Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/props-solid2-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@solid-primitives/props": major
---

Migrate to Solid.js v2.0 (beta.13)

## Breaking Changes

**Peer dependencies**: `solid-js@^2.0.0-beta.13` and `@solidjs/web@^2.0.0-beta.13` are now required.

**`classList` support removed**: Solid 2.0 removes the `classList` JSX prop in favour of the `class` prop accepting objects and arrays. `combineProps` no longer handles a `classList` key. Use `class` with an object or array instead:

```tsx
// Before
combineProps(props, { classList: { active: isActive() } })

// After
combineProps(props, { class: { active: isActive() } })
```

**`class` combining updated**: When all combined `class` values are strings they are joined with a space (unchanged). When any value is a `ClassList` object or array, the result is a flat array accepted by Solid 2.0's `class` prop.

**`merge` replaces `mergeProps`**: The internal call to `mergeProps` has been updated to Solid 2.0's `merge`. Non-special props now follow `merge` semantics — an explicit `undefined` in a later source overrides earlier values (previously `undefined` was skipped).

**`createMemo` second argument**: `createPropsPredicate` used `createMemo(fn, undefined, options)` — the removed `initialValue` arg. It now correctly passes `createMemo(fn, options)`.
56 changes: 51 additions & 5 deletions packages/props/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
Library of primitives focused around component props.

- [`combineProps`](#combineprops) - Reactively merges multiple props objects together while smartly combining some of Solid's JSX/DOM attributes.
- [`combineHandlers`](#combinehandlers) - Chains multiple event handlers into a single handler.
- [`filterProps`](#filterprops) - Create a new props object with only the property names that match the predicate.
- [`partitionProps`](#partitionprops) - Split a props object into two reactive views based on a predicate.

## Installation

Expand All @@ -29,9 +31,9 @@ A helper that reactively merges multiple props objects together while smartly co

Event handlers _(onClick, onclick, onMouseMove, onSomething)_, and refs _(props.ref)_ are chained.

`class`, `className`, `classList` and `style` are combined.
`class`, `className`, and `style` are combined.

For all other props, the last prop object overrides all previous ones. Similarly to Solid's [mergeProps](https://www.solidjs.com/docs/latest/api#mergeprops).
For all other props, the last prop object overrides all previous ones. Similarly to Solid's `merge`.

### How to use it

Expand All @@ -54,7 +56,7 @@ const MyButton: Component<ButtonProps> = props => {

#### Chaining of event listeners

Every [function/tuple](https://www.solidjs.com/docs/latest/api#on___) property with `on___` name get's chained. That could potentially include properties that are not actually event-listeners such as `only` or `once`. Hence you should remove them from the props (with [splitProps](https://www.solidjs.com/docs/latest/api#splitprops)).
Every function property with `on___` name gets chained. That could potentially include properties that are not actually event-listeners such as `only` or `once`. Hence you should remove them from the props (with Solid's `omit`).

Chained functions will always return `void`. If you want to get the returned value from a callback, you have to split those props and handle them yourself.

Expand Down Expand Up @@ -113,11 +115,32 @@ styles; // { margin: "2rem", border: "1px solid #121212", padding: "16px" }

https://codesandbox.io/s/combineprops-demo-ytw247?file=/index.tsx

## `combineHandlers`

Chains multiple event handlers into a single handler that calls each in order. Handlers that are `null`, `undefined`, or `false` are silently skipped.

When used inline in JSX, reads from Solid's reactive props proxy are tracked through the render context automatically — no explicit signal unwrapping is needed. For a standalone signal holding a handler, read it before passing (`handler()`) or wrap the whole call in a `createMemo`.

```tsx
import { combineHandlers } from "@solid-primitives/props";

const MyButton: Component<ButtonProps> = props => {
// Merge an internal handler with whatever the consumer provides
return <button onClick={combineHandlers(props.onClick, () => console.log("clicked"))} />;
};
```

Conditional handlers can be passed inline — `null`/`false` entries are skipped safely:

```tsx
<div onKeyDown={combineHandlers(props.onKeyDown, isOpen() ? closeOnEsc : null)} />
```

## `filterProps`

A helper that creates a new props object with only the property names that match the predicate.

An alternative primitive to Solid's [splitProps](https://www.solidjs.com/docs/latest/api#splitprops) that will split the props eagerly, without letting you change the omitted keys afterwards.
An alternative primitive to Solid's `omit` that will split the props eagerly, without letting you change the omitted keys afterwards.

The `predicate` is run for every property read lazily — any signal accessed within the `predicate` will be tracked, and `predicate` re-executed if changed.

Expand Down Expand Up @@ -146,7 +169,7 @@ Creates a predicate function that can be used to filter props by the prop name d

The provided `predicate` function get's wrapped with a cache layer to prevent unnecessary re-evaluation. If one property is requested multiple times, the `predicate` will only be evaluated once.

The cache is only cleared when the keys of the props object change. _(when spreading props from a singal)_ This also means that any signal accessed within the `predicate` won't be tracked.
The cache is only cleared when the keys of the props object change. _(when spreading props from a signal)_ This also means that any signal accessed within the `predicate` won't be tracked.

```tsx
import { filterProps, createPropsPredicate } from "@solid-primitives/props";
Expand All @@ -159,6 +182,29 @@ const MyComponent = props => {
};
```

## `partitionProps`

Splits a props object into two reactive views: one containing only the keys that match the predicate, and one containing the rest. Both views are lazy proxies — the predicate runs per property read, not eagerly.

```tsx
import { partitionProps } from "@solid-primitives/props";

const MyButton = (props: ButtonProps & JSX.HTMLAttributes<HTMLButtonElement>) => {
const [ownProps, htmlProps] = partitionProps(props,
key => ["label", "variant", "size"].includes(key as string)
);

return <button {...htmlProps}>{ownProps.label}</button>;
};
```

For an expensive predicate, pass a [`createPropsPredicate`](#createpropspredicate) result to share a single cache across both views:

```tsx
const pred = createPropsPredicate(props, key => expensiveCheck(key));
const [ownProps, htmlProps] = partitionProps(props, pred);
```

## Changelog

See [CHANGELOG.md](./CHANGELOG.md)
3 changes: 2 additions & 1 deletion packages/props/dev/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type Component, type ComponentProps, createSignal, Show } from "solid-js";
import { type Component, createSignal, Show } from "solid-js";
import type { ComponentProps } from "@solidjs/web";

import { combineProps } from "../src/index.js";

Expand Down
10 changes: 7 additions & 3 deletions packages/props/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
"stage": 3,
"list": [
"combineProps",
"filterProps"
"combineHandlers",
"filterProps",
"partitionProps"
],
"category": "Utilities"
},
Expand Down Expand Up @@ -51,10 +53,12 @@
"@solid-primitives/utils": "workspace:^"
},
"devDependencies": {
"solid-js": "^1.9.7"
"@solidjs/web": "2.0.0-beta.13",
"solid-js": "2.0.0-beta.13"
},
"peerDependencies": {
"solid-js": "^1.6.12"
"@solidjs/web": "^2.0.0-beta.13",
"solid-js": "^2.0.0-beta.13"
},
"typesVersions": {}
}
102 changes: 55 additions & 47 deletions packages/props/src/combineProps.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type JSX, mergeProps, type MergeProps } from "solid-js";
import { merge, type Merge } from "solid-js";
import type { JSX } from "@solidjs/web";
import { access, chain, reverseChain, type MaybeAccessor } from "@solid-primitives/utils";
import { propTraps } from "./propTraps.js";

Expand Down Expand Up @@ -53,9 +54,8 @@ export function combineStyle(
}

type PropsInput = {
class?: string;
class?: string | JSX.ClassList;
className?: string;
classList?: Record<string, boolean | undefined>;
style?: JSX.CSSProperties | string;
ref?: Element | ((el: any) => void);
} & Record<string, any>;
Expand Down Expand Up @@ -86,8 +86,8 @@ export type CombinePropsOptions = {
/**
* A helper that reactively merges multiple props objects together while smartly combining some of Solid's JSX/DOM attributes.
*
* Event handlers and refs are chained, class, classNames and styles are combined.
* For all other props, the last prop object overrides all previous ones. Similarly to {@link mergeProps}
* Event handlers and refs are chained, `class` and `style` are combined.
* For all other props, the last prop object overrides all previous ones. Similarly to `merge`.
* @param sources - Multiple sets of props to combine together.
* @example
* ```tsx
Expand All @@ -104,17 +104,17 @@ export type CombinePropsOptions = {
export function combineProps<T extends [] | MaybeAccessor<PropsInput>[]>(
sources: T,
options?: CombinePropsOptions,
): MergeProps<T>;
): Merge<T>;
export function combineProps<T extends [] | MaybeAccessor<PropsInput>[]>(
...sources: T
): MergeProps<T>;
): Merge<T>;
export function combineProps<T extends MaybeAccessor<PropsInput>[]>(
...args: T | [sources: T, options?: CombinePropsOptions]
): MergeProps<T> {
): Merge<T> {
const restArgs = Array.isArray(args[0]);
const sources = (restArgs ? args[0] : args) as T;

if (sources.length === 1) return sources[0] as MergeProps<T>;
if (sources.length === 1) return sources[0] as Merge<T>;

const chainFn =
restArgs && (args[1] as CombinePropsOptions | undefined)?.reverseEventHandlers
Expand Down Expand Up @@ -149,12 +149,12 @@ export function combineProps<T extends MaybeAccessor<PropsInput>[]>(
}
}

const merge = mergeProps(...sources) as unknown as MergeProps<T>;
const merged = merge(...sources) as unknown as Merge<T>;

return new Proxy(
{
get(key) {
if (typeof key !== "string") return Reflect.get(merge, key);
if (typeof key !== "string") return Reflect.get(merged, key);

// Combine style prop
if (key === "style") return reduce(sources, "style", combineStyle);
Expand All @@ -172,52 +172,60 @@ export function combineProps<T extends MaybeAccessor<PropsInput>[]>(
// Chain event listeners
if (key[0] === "o" && key[1] === "n" && key[2]) {
const callbacks = listeners[key.toLowerCase()];
return callbacks ? chainFn(callbacks) : Reflect.get(merge, key);
return callbacks ? chainFn(callbacks) : Reflect.get(merged, key);
}

// Merge classes or classNames
if (key === "class" || key === "className")
return reduce(sources, key, (a, b) => `${a} ${b}`);

// Merge classList objects, keys in the last object overrides all previous ones.
if (key === "classList") return reduce(sources, key, (a, b) => ({ ...a, ...b }));
// Combine class or className values
if (key === "class" || key === "className") {
const parts: (string | JSX.ClassList)[] = [];
for (const s of sources) {
const v = access(s)[key];
if (v !== undefined) parts.push(v);
}
if (parts.length === 0) return undefined;
if (parts.length === 1) return parts[0];
if (parts.every((v): v is string => typeof v === "string")) return parts.join(" ");
return parts;
}

return Reflect.get(merge, key);
return Reflect.get(merged, key);
},
has(key) {
return Reflect.has(merge, key);
return Reflect.has(merged, key);
},
keys() {
return Object.keys(merge);
return Object.keys(merged);
},
},
propTraps,
) as any;
}

// type check

// const com = combineProps(
// {
// onSomething: 123,
// onWheel: (e: WheelEvent) => 213,
// something: "foo",
// style: { margin: "24px" },
// once: true,
// onMount: (fn: VoidFunction) => undefined
// },
// {
// onSomething: [(n: number, s: string) => "fo", 123],
// once: "ovv"
// },
// {
// onWheel: false,
// onMount: (n: number) => void 0
// }
// );
// com.onSomething; // (s: string) => void;
// com.once; // string;
// com.onWheel; // false;
// com.onMount; // ((fn: VoidFunction) => undefined) & ((n: number) => undefined);
// com.something; // string;
// com.style; // string | JSX.CSSProperties;
/**
* Chains multiple event handlers into a single handler that calls each in order.
* Handlers that are `null`, `undefined`, or `false` are silently skipped, making
* it safe to pass conditional handlers directly.
*
* When used inline in JSX, reads from Solid's reactive props proxy are tracked
* through the surrounding render context — no explicit signal unwrapping needed.
* For a standalone signal holding a handler, read it before passing:
* `combineHandlers(handler(), base)` or wrap the whole call in a `createMemo`.
*
* @example
* ```tsx
* // Inline — props.onClick is tracked via Solid's reactive props proxy
* <button onClick={combineHandlers(props.onClick, internalHandler)} />
*
* // Conditional handler — null/false are safely skipped
* <div onKeyDown={combineHandlers(props.onKeyDown, isOpen() ? closeOnEsc : null)} />
* ```
*/
export function combineHandlers<T extends (...args: any[]) => void>(
...handlers: (T | null | undefined | false)[]
): T | undefined {
const fns = handlers.filter((h): h is T => typeof h === "function");
if (fns.length === 0) return undefined;
if (fns.length === 1) return fns[0];
return ((...args: any[]) => { for (const fn of fns) fn(...args); }) as T;
}

32 changes: 31 additions & 1 deletion packages/props/src/filterProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ export function createPropsPredicate<T extends object>(
Object.keys(props);
return {};
},
undefined,
{ equals: false },
);
return key => {
Expand All @@ -75,3 +74,34 @@ export function createPropsPredicate<T extends object>(
return v;
};
}

/**
* Splits a props object into two reactive views: one containing only the keys
* that match the predicate, and one containing the rest.
*
* Both returned objects are lazy proxies — the predicate runs per property read,
* not eagerly. For expensive predicates, pass a {@link createPropsPredicate}
* result to share a single cache across both views.
*
* @param props - The props object to partition.
* @param predicate - Returns `true` for keys that belong in the first view.
* @returns A tuple `[matched, rest]` of reactive props objects.
*
* @example
* ```tsx
* const [ownProps, htmlProps] = partitionProps(props,
* key => ["label", "variant", "size"].includes(key as string)
* );
* return <button {...htmlProps}>{ownProps.label}</button>;
*
* // With caching for an expensive predicate:
* const pred = createPropsPredicate(props, key => expensiveCheck(key));
* const [ownProps, htmlProps] = partitionProps(props, pred);
* ```
*/
export function partitionProps<T extends object>(
props: T,
predicate: (key: keyof T) => boolean,
): [T, T] {
return [filterProps(props, predicate), filterProps(props, key => !predicate(key))];
}
Loading