From 7eff30ef7ee6820142feeed1dbb3159d2f9d5407 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Thu, 21 May 2026 11:18:18 -0400 Subject: [PATCH 1/4] Migrate to Solid 2 beta 14 --- .changeset/props-solid2-migration.md | 25 ++++++++ packages/props/README.md | 8 +-- packages/props/dev/index.tsx | 3 +- packages/props/package.json | 6 +- packages/props/src/combineProps.ts | 44 ++++++++------ packages/props/src/filterProps.ts | 1 - packages/props/test/combineProps.test.ts | 73 +++++++++--------------- packages/props/test/filterProps.test.ts | 62 ++++++++++---------- packages/props/test/server.test.ts | 45 +++++++++++++++ pnpm-lock.yaml | 35 ++++-------- 10 files changed, 175 insertions(+), 127 deletions(-) create mode 100644 .changeset/props-solid2-migration.md create mode 100644 packages/props/test/server.test.ts diff --git a/.changeset/props-solid2-migration.md b/.changeset/props-solid2-migration.md new file mode 100644 index 000000000..881923ed5 --- /dev/null +++ b/.changeset/props-solid2-migration.md @@ -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)`. diff --git a/packages/props/README.md b/packages/props/README.md index 833744776..8738fb43a 100644 --- a/packages/props/README.md +++ b/packages/props/README.md @@ -29,9 +29,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 @@ -54,7 +54,7 @@ const MyButton: Component = 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. @@ -117,7 +117,7 @@ https://codesandbox.io/s/combineprops-demo-ytw247?file=/index.tsx 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. diff --git a/packages/props/dev/index.tsx b/packages/props/dev/index.tsx index f0e72e0d6..521b16338 100644 --- a/packages/props/dev/index.tsx +++ b/packages/props/dev/index.tsx @@ -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"; diff --git a/packages/props/package.json b/packages/props/package.json index 59a0b66e6..d1d70fdf3 100644 --- a/packages/props/package.json +++ b/packages/props/package.json @@ -51,10 +51,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": {} } diff --git a/packages/props/src/combineProps.ts b/packages/props/src/combineProps.ts index 2412ddf22..30f00f349 100644 --- a/packages/props/src/combineProps.ts +++ b/packages/props/src/combineProps.ts @@ -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"; @@ -53,9 +54,8 @@ export function combineStyle( } type PropsInput = { - class?: string; + class?: string | JSX.ClassList; className?: string; - classList?: Record; style?: JSX.CSSProperties | string; ref?: Element | ((el: any) => void); } & Record; @@ -104,17 +104,17 @@ export type CombinePropsOptions = { export function combineProps[]>( sources: T, options?: CombinePropsOptions, -): MergeProps; +): Merge; export function combineProps[]>( ...sources: T -): MergeProps; +): Merge; export function combineProps[]>( ...args: T | [sources: T, options?: CombinePropsOptions] -): MergeProps { +): Merge { const restArgs = Array.isArray(args[0]); const sources = (restArgs ? args[0] : args) as T; - if (sources.length === 1) return sources[0] as MergeProps; + if (sources.length === 1) return sources[0] as Merge; const chainFn = restArgs && (args[1] as CombinePropsOptions | undefined)?.reverseEventHandlers @@ -149,12 +149,12 @@ export function combineProps[]>( } } - const merge = mergeProps(...sources) as unknown as MergeProps; + const merged = merge(...sources) as unknown as Merge; 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); @@ -172,23 +172,29 @@ export function combineProps[]>( // 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, diff --git a/packages/props/src/filterProps.ts b/packages/props/src/filterProps.ts index f1d7819b4..c22db3bb3 100644 --- a/packages/props/src/filterProps.ts +++ b/packages/props/src/filterProps.ts @@ -63,7 +63,6 @@ export function createPropsPredicate( Object.keys(props); return {}; }, - undefined, { equals: false }, ); return key => { diff --git a/packages/props/test/combineProps.test.ts b/packages/props/test/combineProps.test.ts index f0183ecb2..33841515b 100644 --- a/packages/props/test/combineProps.test.ts +++ b/packages/props/test/combineProps.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { createComputed, createRoot, createSignal, mergeProps } from "solid-js"; +import { createRoot, createSignal, flush, merge } from "solid-js"; import { combineProps } from "../src/index.js"; describe("combineProps", () => { @@ -195,27 +195,14 @@ describe("combineProps", () => { }); }); - it("combines css classList", async () => { + it("combines css class objects", async () => { createRoot(async dispose => { - const classList1 = { - primary: true, - outline: true, - compact: true, - }; - - const classList2 = { - large: true, - compact: false, - }; + const classObj1 = { primary: true, outline: true, compact: true }; + const classObj2 = { large: true, compact: false }; - const combinedProps = combineProps({ classList: classList1 }, { classList: classList2 }); + const combinedProps = combineProps({ class: classObj1 }, { class: classObj2 }); - expect(combinedProps.classList).toEqual({ - primary: true, - outline: true, - large: true, - compact: false, - }); + expect(combinedProps.class).toEqual([classObj1, classObj2]); dispose(); }); @@ -273,11 +260,11 @@ describe("combineProps", () => { dispose(); })); - it("works with mergeProps", () => { + it("works with merge", () => { const cb1 = vi.fn(); const cb2 = vi.fn(); const combined = combineProps({ onClick: cb1 }, { onClick: cb2 }); - const merged = mergeProps(combined); + const merged = merge(combined); merged.onClick("foo"); @@ -288,38 +275,30 @@ describe("combineProps", () => { }); it("accepts function sources", () => { - createRoot(() => { - const [signal, setSignal] = createSignal({ - class: "primary", - style: { - margin: "10px", - }, - }); + const [signal, setSignal] = createSignal({ + class: "primary", + style: { + margin: "10px", + }, + }); - const combinedProps = combineProps( + let combinedProps: any; + createRoot(() => { + combinedProps = combineProps( signal, { class: "secondary" }, { style: { padding: "10px" } }, ); - let i = 0; - - createComputed(() => { - if (i === 0) { - expect(combinedProps.class).toBe("primary secondary"); - expect(combinedProps.style).toEqual({ - margin: "10px", - padding: "10px", - }); - i++; - } else { - expect(combinedProps.class).toBe("tertiary secondary"); - expect(combinedProps.style).toEqual({ padding: "10px" }); - expect(combinedProps.foo).toEqual("bar"); - } - }); - - setSignal({ class: "tertiary", foo: "bar" }); + expect(combinedProps.class).toBe("primary secondary"); + expect(combinedProps.style).toEqual({ margin: "10px", padding: "10px" }); }); + + setSignal({ class: "tertiary", foo: "bar" }); + flush(); + + expect(combinedProps.class).toBe("tertiary secondary"); + expect(combinedProps.style).toEqual({ padding: "10px" }); + expect(combinedProps.foo).toBe("bar"); }); }); diff --git a/packages/props/test/filterProps.test.ts b/packages/props/test/filterProps.test.ts index 0c381ed49..543ce280a 100644 --- a/packages/props/test/filterProps.test.ts +++ b/packages/props/test/filterProps.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "vitest"; -import { createComputed, createRoot, createSignal, mergeProps } from "solid-js"; +import { createEffect, createRoot, createSignal, flush, merge } from "solid-js"; import { filterProps, createPropsPredicate } from "../src/index.js"; describe("filterProps", () => { @@ -35,23 +35,25 @@ describe("filterProps", () => { expect(checked).toEqual(["a", "b", "c", "d"]); }); - test("supports dynamic props", () => + test("supports dynamic props", () => { + const [props, setProps] = createSignal>({ a: 1, b: 2, c: 3 }); + let captured: any; + createRoot(dispose => { - const [props, setProps] = createSignal>({ a: 1, b: 2, c: 3 }); - const proxy = mergeProps(props); + const proxy = merge(props); const filtered = filterProps(proxy, key => key !== "b" && key !== "d"); - let captured: any; - createComputed(() => { - captured = { ...filtered }; - }); + createEffect( + () => ({ ...filtered }), + v => { captured = v; }, + ); + flush(); expect(captured).toEqual({ a: 1, c: 3 }); + }); - setProps({ a: 1, b: 2, c: 3, d: 4, e: 5 }); - - expect(captured).toEqual({ a: 1, c: 3, e: 5 }); - - dispose(); - })); + setProps({ a: 1, b: 2, c: 3, d: 4, e: 5 }); + flush(); + expect(captured).toEqual({ a: 1, c: 3, e: 5 }); + }); }); describe("filterProps + createPropsPredicate", () => { @@ -95,11 +97,13 @@ describe("filterProps + createPropsPredicate", () => { dispose(); })); - test("supports dynamic props", () => + test("supports dynamic props", () => { + const checked: string[] = []; + const [props, setProps] = createSignal>({ a: 1, b: 2, c: 3 }); + let captured: any; + createRoot(dispose => { - const checked: string[] = []; - const [props, setProps] = createSignal>({ a: 1, b: 2, c: 3 }); - const proxy = mergeProps(props); + const proxy = merge(props); const filtered = filterProps( proxy, createPropsPredicate(proxy, key => { @@ -107,19 +111,19 @@ describe("filterProps + createPropsPredicate", () => { return key !== "b" && key !== "d"; }), ); - let captured: any; - createComputed(() => { - captured = { ...filtered }; - }); + createEffect( + () => ({ ...filtered }), + v => { captured = v; }, + ); + flush(); expect(captured).toEqual({ a: 1, c: 3 }); expect(checked).toEqual(["a", "b", "c"]); checked.length = 0; + }); - setProps({ a: 1, b: 2, c: 3, d: 4, e: 5 }); - - expect(captured).toEqual({ a: 1, c: 3, e: 5 }); - expect(checked).toEqual(["a", "b", "c", "d", "e"]); - - dispose(); - })); + setProps({ a: 1, b: 2, c: 3, d: 4, e: 5 }); + flush(); + expect(captured).toEqual({ a: 1, c: 3, e: 5 }); + expect(checked).toEqual(["a", "b", "c", "d", "e"]); + }); }); diff --git a/packages/props/test/server.test.ts b/packages/props/test/server.test.ts new file mode 100644 index 000000000..f63dacda5 --- /dev/null +++ b/packages/props/test/server.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { combineProps, filterProps, createPropsPredicate } from "../src/index.js"; + +describe("combineProps SSR", () => { + it("combines handlers", () => { + let called = 0; + const combined = combineProps({ onClick: () => called++ }, { onClick: () => called++ }); + combined.onClick(); + expect(called).toBe(2); + }); + + it("combines classes", () => { + const combined = combineProps({ class: "a" }, { class: "b" }); + expect(combined.class).toBe("a b"); + }); + + it("combines styles", () => { + const combined = combineProps({ style: { margin: "4px" } }, { style: { padding: "4px" } }); + expect(combined.style).toEqual({ margin: "4px", padding: "4px" }); + }); +}); + +describe("filterProps SSR", () => { + it("filters props", () => { + const props = { a: 1, b: 2, c: 3 }; + const filtered = filterProps(props, key => key !== "b"); + expect(filtered).toEqual({ a: 1, c: 3 }); + }); + + it("createPropsPredicate caches in server context", () => { + const props = { a: 1, b: 2, c: 3 }; + const checked: string[] = []; + const filtered = filterProps( + props, + createPropsPredicate(props, key => { + checked.push(key); + return key !== "b"; + }), + ); + filtered.a; + filtered.a; + expect(checked).toEqual(["a"]); + expect(filtered).toEqual({ a: 1, c: 3 }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7a083eac..195b4e19e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -791,9 +791,12 @@ importers: specifier: workspace:^ version: link:../utils devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.13 + version: 2.0.0-beta.13(solid-js@2.0.0-beta.13) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.13 + version: 2.0.0-beta.13 packages/raf: dependencies: @@ -5267,12 +5270,6 @@ packages: peerDependencies: seroval: ^1.0 - seroval-plugins@1.5.2: - resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} - engines: {node: '>=10'} - peerDependencies: - seroval: ^1.0 - seroval-plugins@1.5.4: resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} engines: {node: '>=10'} @@ -5283,10 +5280,6 @@ packages: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} - seroval@1.5.2: - resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} - engines: {node: '>=10'} - seroval@1.5.4: resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} @@ -7761,14 +7754,14 @@ snapshots: '@solidjs/web@2.0.0-beta.13(solid-js@2.0.0-beta.10)': dependencies: - seroval: 1.5.2 - seroval-plugins: 1.5.2(seroval@1.5.2) + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) solid-js: 2.0.0-beta.10 '@solidjs/web@2.0.0-beta.13(solid-js@2.0.0-beta.13)': dependencies: - seroval: 1.5.2 - seroval-plugins: 1.5.2(seroval@1.5.2) + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) solid-js: 2.0.0-beta.13 '@supabase/auth-js@2.67.3': @@ -10744,18 +10737,12 @@ snapshots: dependencies: seroval: 1.3.2 - seroval-plugins@1.5.2(seroval@1.5.2): - dependencies: - seroval: 1.5.2 - seroval-plugins@1.5.4(seroval@1.5.4): dependencies: seroval: 1.5.4 seroval@1.3.2: {} - seroval@1.5.2: {} - seroval@1.5.4: {} set-blocking@2.0.0: {} @@ -10829,8 +10816,8 @@ snapshots: dependencies: '@solidjs/signals': 2.0.0-beta.13 csstype: 3.1.3 - seroval: 1.5.2 - seroval-plugins: 1.5.2(seroval@1.5.2) + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) solid-refresh@0.8.0-next.7(solid-js@2.0.0-beta.10): dependencies: From 5eb147aab20ae35b6e94fbbabf6dc59c8a68e3b2 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Thu, 21 May 2026 11:23:11 -0400 Subject: [PATCH 2/4] Documentation cleanup --- packages/props/src/combineProps.ts | 30 ++---------------------- packages/props/test/combineProps.test.ts | 24 +++++++++---------- 2 files changed, 14 insertions(+), 40 deletions(-) diff --git a/packages/props/src/combineProps.ts b/packages/props/src/combineProps.ts index 30f00f349..3c4fe9f65 100644 --- a/packages/props/src/combineProps.ts +++ b/packages/props/src/combineProps.ts @@ -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 @@ -201,29 +201,3 @@ export function combineProps[]>( ) 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; diff --git a/packages/props/test/combineProps.test.ts b/packages/props/test/combineProps.test.ts index 33841515b..4b89fcbff 100644 --- a/packages/props/test/combineProps.test.ts +++ b/packages/props/test/combineProps.test.ts @@ -18,8 +18,8 @@ describe("combineProps", () => { dispose(); })); - it("combines handlers", async () => { - createRoot(async dispose => { + it("combines handlers", () => { + createRoot(dispose => { const mockFn = vi.fn(); const message1 = "click1"; const message2 = "click2"; @@ -69,8 +69,8 @@ describe("combineProps", () => { }); }); - it("event handlers can be overwritten", async () => { - createRoot(async dispose => { + it("event handlers can be overwritten", () => { + createRoot(dispose => { const mockFn = vi.fn(); const message1 = "click1"; const message2 = "click2"; @@ -92,8 +92,8 @@ describe("combineProps", () => { }); }); - it("last value overwrites the event-listeners", async () => { - createRoot(async dispose => { + it("last value overwrites the event-listeners", () => { + createRoot(dispose => { const mockFn = vi.fn(); const message1 = "click1"; const message2 = "click2"; @@ -134,8 +134,8 @@ describe("combineProps", () => { dispose(); })); - it("merges props with different keys", async () => { - createRoot(async dispose => { + it("merges props with different keys", () => { + createRoot(dispose => { const mockFn = vi.fn(); const click1 = "click1"; const click2 = "click2"; @@ -169,8 +169,8 @@ describe("combineProps", () => { }); }); - it("combines css classes", async () => { - createRoot(async dispose => { + it("combines css classes", () => { + createRoot(dispose => { const className1 = "primary"; const className2 = "hover"; const className3 = "focus"; @@ -195,8 +195,8 @@ describe("combineProps", () => { }); }); - it("combines css class objects", async () => { - createRoot(async dispose => { + it("combines css class objects", () => { + createRoot(dispose => { const classObj1 = { primary: true, outline: true, compact: true }; const classObj2 = { large: true, compact: false }; From bf6ec8e6ecf791dd88e00e7a0ea53cb9a8111235 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Thu, 21 May 2026 12:37:39 -0400 Subject: [PATCH 3/4] Added combineHandlers and partitionProps --- packages/props/README.md | 48 ++++++++++++++- packages/props/package.json | 4 +- packages/props/src/combineProps.ts | 28 +++++++++ packages/props/src/filterProps.ts | 31 ++++++++++ packages/props/test/combineProps.test.ts | 55 ++++++++++++++++- packages/props/test/filterProps.test.ts | 76 +++++++++++++++++++++++- 6 files changed, 238 insertions(+), 4 deletions(-) diff --git a/packages/props/README.md b/packages/props/README.md index 8738fb43a..7d3687d6b 100644 --- a/packages/props/README.md +++ b/packages/props/README.md @@ -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 @@ -113,6 +115,27 @@ 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 = props => { + // Merge an internal handler with whatever the consumer provides + return ; +}; +``` + +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) diff --git a/packages/props/package.json b/packages/props/package.json index d1d70fdf3..58032bd70 100644 --- a/packages/props/package.json +++ b/packages/props/package.json @@ -15,7 +15,9 @@ "stage": 3, "list": [ "combineProps", - "filterProps" + "combineHandlers", + "filterProps", + "partitionProps" ], "category": "Utilities" }, diff --git a/packages/props/src/combineProps.ts b/packages/props/src/combineProps.ts index 3c4fe9f65..bed37eeff 100644 --- a/packages/props/src/combineProps.ts +++ b/packages/props/src/combineProps.ts @@ -201,3 +201,31 @@ export function combineProps[]>( ) as any; } +/** + * 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 + * ; + * + * // With caching for an expensive predicate: + * const pred = createPropsPredicate(props, key => expensiveCheck(key)); + * const [ownProps, htmlProps] = partitionProps(props, pred); + * ``` + */ +export function partitionProps( + props: T, + predicate: (key: keyof T) => boolean, +): [T, T] { + return [filterProps(props, predicate), filterProps(props, key => !predicate(key))]; +} diff --git a/packages/props/test/combineProps.test.ts b/packages/props/test/combineProps.test.ts index 4b89fcbff..e781133a4 100644 --- a/packages/props/test/combineProps.test.ts +++ b/packages/props/test/combineProps.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { createRoot, createSignal, flush, merge } from "solid-js"; -import { combineProps } from "../src/index.js"; +import { combineProps, combineHandlers } from "../src/index.js"; describe("combineProps", () => { it("handles one argument", () => @@ -302,3 +302,56 @@ describe("combineProps", () => { expect(combinedProps.foo).toBe("bar"); }); }); + +describe("combineHandlers", () => { + it("chains handlers left-to-right", () => { + const order: number[] = []; + const combined = combineHandlers( + () => order.push(1), + () => order.push(2), + () => order.push(3), + )!; + combined(); + expect(order).toEqual([1, 2, 3]); + }); + + it("passes all arguments to every handler", () => { + const mock1 = vi.fn(); + const mock2 = vi.fn(); + const combined = combineHandlers(mock1, mock2)!; + combined("a", "b"); + expect(mock1).toHaveBeenCalledWith("a", "b"); + expect(mock2).toHaveBeenCalledWith("a", "b"); + }); + + it("skips null, undefined, and false", () => { + const mock = vi.fn(); + const combined = combineHandlers(null, undefined, false, mock, undefined)!; + combined("arg"); + expect(mock).toHaveBeenCalledOnce(); + expect(mock).toHaveBeenCalledWith("arg"); + }); + + it("supports conditional handlers", () => { + const mock1 = vi.fn(); + const mock2 = vi.fn(); + + const inactive = combineHandlers(mock1, false ? mock2 : null)!; + inactive("x"); + expect(mock1).toHaveBeenCalledWith("x"); + expect(mock2).not.toHaveBeenCalled(); + + const active = combineHandlers(mock1, mock2)!; + active("y"); + expect(mock2).toHaveBeenCalledWith("y"); + }); + + it("returns undefined when all handlers are absent", () => { + expect(combineHandlers(null, undefined, false)).toBeUndefined(); + }); + + it("returns the single handler unchanged (no wrapping)", () => { + const fn = vi.fn(); + expect(combineHandlers(fn)).toBe(fn); + }); +}); diff --git a/packages/props/test/filterProps.test.ts b/packages/props/test/filterProps.test.ts index 543ce280a..004d3dee2 100644 --- a/packages/props/test/filterProps.test.ts +++ b/packages/props/test/filterProps.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; import { createEffect, createRoot, createSignal, flush, merge } from "solid-js"; -import { filterProps, createPropsPredicate } from "../src/index.js"; +import { filterProps, createPropsPredicate, partitionProps } from "../src/index.js"; describe("filterProps", () => { test("filters props", () => { @@ -127,3 +127,77 @@ describe("filterProps + createPropsPredicate", () => { expect(checked).toEqual(["a", "b", "c", "d", "e"]); }); }); + +describe("partitionProps", () => { + test("splits props into matched and rest", () => { + const props = { a: 1, b: 2, c: 3, d: 4 }; + const [matched, rest] = partitionProps(props, key => key === "a" || key === "c"); + + expect(matched).toEqual({ a: 1, c: 3 }); + expect(rest).toEqual({ b: 2, d: 4 }); + expect(Object.keys(matched)).toEqual(["a", "c"]); + expect(Object.keys(rest)).toEqual(["b", "d"]); + }); + + test("both views are lazy — predicate runs per read", () => { + const checked: string[] = []; + const props = { a: 1, b: 2, c: 3 }; + const [matched, rest] = partitionProps(props, key => { + checked.push(key as string); + return key !== "b"; + }); + + expect(checked.length).toBe(0); + + matched.a; + // predicate run once for matched.a + expect(checked).toEqual(["a"]); + + rest.b; + // predicate run once for rest.b (negated — "b" returns false, so rest includes it) + expect(checked).toEqual(["a", "b"]); + }); + + test("both views update with dynamic props", () => { + const [props, setProps] = createSignal>({ a: 1, b: 2, c: 3 }); + let capturedMatched: any; + let capturedRest: any; + + createRoot(dispose => { + const proxy = merge(props); + const [matched, rest] = partitionProps(proxy, key => key !== "b"); + createEffect( + () => [{ ...matched }, { ...rest }], + ([m, r]) => { capturedMatched = m; capturedRest = r; }, + ); + flush(); + expect(capturedMatched).toEqual({ a: 1, c: 3 }); + expect(capturedRest).toEqual({ b: 2 }); + }); + + setProps({ a: 10, b: 20, c: 30, d: 40 }); + flush(); + expect(capturedMatched).toEqual({ a: 10, c: 30, d: 40 }); + expect(capturedRest).toEqual({ b: 20 }); + }); + + test("works with createPropsPredicate to share cache across both views", () => + createRoot(dispose => { + const props = { a: 1, b: 2, c: 3, d: 4 }; + const checked: string[] = []; + const pred = createPropsPredicate(props, key => { + checked.push(key as string); + return key !== "b"; + }); + const [matched, rest] = partitionProps(props, pred); + + matched.a; + matched.a; // cache hit — predicate should not run again + rest.b; + rest.b; // cache hit + + expect(checked).toEqual(["a", "b"]); + + dispose(); + })); +}); From 1e74307a429b39717f53e24b6f4001c244089496 Mon Sep 17 00:00:00 2001 From: David Di Biase <1168397+davedbase@users.noreply.github.com> Date: Sat, 23 May 2026 16:47:22 -0400 Subject: [PATCH 4/4] Version bump, doc fix and use JSX.ClassValue --- packages/props/README.md | 2 +- packages/props/package.json | 8 ++++---- packages/props/src/combineProps.ts | 13 ++++++------- pnpm-lock.yaml | 27 +++++++-------------------- 4 files changed, 18 insertions(+), 32 deletions(-) diff --git a/packages/props/README.md b/packages/props/README.md index 7d3687d6b..50e0251f8 100644 --- a/packages/props/README.md +++ b/packages/props/README.md @@ -140,7 +140,7 @@ Conditional handlers can be passed inline — `null`/`false` entries are skipped A helper that creates a new props object with only the property names that match the predicate. -An alternative primitive to Solid's `omit` that will split the props eagerly, without letting you change the omitted keys afterwards. +An alternative primitive to Solid's `omit` that splits props lazily (per-read) — the predicate is not evaluated upfront, so the set of included keys can change dynamically. The `predicate` is run for every property read lazily — any signal accessed within the `predicate` will be tracked, and `predicate` re-executed if changed. diff --git a/packages/props/package.json b/packages/props/package.json index 58032bd70..5baa02fd1 100644 --- a/packages/props/package.json +++ b/packages/props/package.json @@ -53,12 +53,12 @@ "@solid-primitives/utils": "workspace:^" }, "devDependencies": { - "@solidjs/web": "2.0.0-beta.13", - "solid-js": "2.0.0-beta.13" + "@solidjs/web": "2.0.0-beta.14", + "solid-js": "2.0.0-beta.14" }, "peerDependencies": { - "@solidjs/web": "^2.0.0-beta.13", - "solid-js": "^2.0.0-beta.13" + "@solidjs/web": "^2.0.0-beta.14", + "solid-js": "^2.0.0-beta.14" }, "typesVersions": {} } diff --git a/packages/props/src/combineProps.ts b/packages/props/src/combineProps.ts index bed37eeff..b91c628b0 100644 --- a/packages/props/src/combineProps.ts +++ b/packages/props/src/combineProps.ts @@ -54,7 +54,7 @@ export function combineStyle( } type PropsInput = { - class?: string | JSX.ClassList; + class?: JSX.ClassValue; className?: string; style?: JSX.CSSProperties | string; ref?: Element | ((el: any) => void); @@ -105,9 +105,7 @@ export function combineProps[]>( sources: T, options?: CombinePropsOptions, ): Merge; -export function combineProps[]>( - ...sources: T -): Merge; +export function combineProps[]>(...sources: T): Merge; export function combineProps[]>( ...args: T | [sources: T, options?: CombinePropsOptions] ): Merge { @@ -177,7 +175,7 @@ export function combineProps[]>( // Combine class or className values if (key === "class" || key === "className") { - const parts: (string | JSX.ClassList)[] = []; + const parts: JSX.ClassValue[] = []; for (const s of sources) { const v = access(s)[key]; if (v !== undefined) parts.push(v); @@ -226,6 +224,7 @@ export function combineHandlers void>( 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; + return ((...args: any[]) => { + for (const fn of fns) fn(...args); + }) as T; } - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee1923c5f..ddce401ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -791,9 +791,12 @@ importers: specifier: workspace:^ version: link:../utils devDependencies: + '@solidjs/web': + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14(solid-js@2.0.0-beta.14) solid-js: - specifier: ^1.9.7 - version: 1.9.7 + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14 packages/raf: dependencies: @@ -5262,12 +5265,6 @@ packages: peerDependencies: seroval: ^1.0 - seroval-plugins@1.5.2: - resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} - engines: {node: '>=10'} - peerDependencies: - seroval: ^1.0 - seroval-plugins@1.5.4: resolution: {integrity: sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==} engines: {node: '>=10'} @@ -5278,10 +5275,6 @@ packages: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} - seroval@1.5.2: - resolution: {integrity: sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==} - engines: {node: '>=10'} - seroval@1.5.4: resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} @@ -7738,8 +7731,8 @@ snapshots: '@solidjs/web@2.0.0-beta.13(solid-js@2.0.0-beta.14)': dependencies: - seroval: 1.5.2 - seroval-plugins: 1.5.2(seroval@1.5.2) + seroval: 1.5.4 + seroval-plugins: 1.5.4(seroval@1.5.4) solid-js: 2.0.0-beta.14 '@solidjs/web@2.0.0-beta.14(solid-js@2.0.0-beta.14)': @@ -10704,18 +10697,12 @@ snapshots: dependencies: seroval: 1.3.2 - seroval-plugins@1.5.2(seroval@1.5.2): - dependencies: - seroval: 1.5.2 - seroval-plugins@1.5.4(seroval@1.5.4): dependencies: seroval: 1.5.4 seroval@1.3.2: {} - seroval@1.5.2: {} - seroval@1.5.4: {} set-blocking@2.0.0: {}