From 95c9b107f73bde51e3b010798bc335fbfc96289a Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Fri, 8 May 2026 16:11:23 +0200 Subject: [PATCH] feat(utils): new wrapSetter primitive to wrap the setters of signals and stores --- .changeset/eleven-baths-build.md | 5 ++++ packages/utils/README.md | 35 ++++++++++++++++++++++ packages/utils/package.json | 49 +++++++++++++++++++++++++++++++ packages/utils/src/index.ts | 33 +++++++++++++++++++++ packages/utils/test/index.test.ts | 36 +++++++++++++++++++++-- 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 .changeset/eleven-baths-build.md diff --git a/.changeset/eleven-baths-build.md b/.changeset/eleven-baths-build.md new file mode 100644 index 000000000..7f404ffe8 --- /dev/null +++ b/.changeset/eleven-baths-build.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/utils": minor +--- + +new wrapSetter primitive to wrap the setters of signals and stores diff --git a/packages/utils/README.md b/packages/utils/README.md index 84d039349..19ca15cbe 100644 --- a/packages/utils/README.md +++ b/packages/utils/README.md @@ -115,6 +115,41 @@ const { data } = createSSE(url, { transform: safe(json) }); - **`safe(transform, fallback?)`** - Wrap any transform in a `try/catch`; returns `fallback` instead of throwing - **`pipe(a, b)`** - Compose two transforms into one +## wrapSetter + +It is a typical use case to react on setting a new value; this is especially cumbersome for stores, where you otherwise need the `deep` package to make effects subscribe to all changes. A more performant and simple approach is to wrap the setter of your signal or store. To simplify this approach, we provide a `wrapSetter` function: + +```ts +import { createStore } from "solid-js"; +import { wrapSetter } from "@solid-primitives/utils"; + +const [state, setState] = wrapSetter( + createStore( + localStorage.getItem('persistedState') + ? JSON.parse(localStorage.getItem('persistedState')) + : initialState + ), + (setter) => { + const output = setState(); + localStorage.setItem('persistedState', latest(() => JSON.stringify(state))); + return output; + } +); +``` + +If the signal or store is destructured into a tuple and augmented with additional values, those are left intact in the output. For the TS types to work, you need to `as const` the new tuple: + +```ts +import { createSignal } from "solid-js"; +import { wrapSetter } from "@solid-primitives/utils"; + +const augmentedSignal = [...createSignal(0), { extra: "data" }] as const; +const [count, setCount, data] = wrapSetter( + augmented, + (setter) => (next) => (console.log(next), setter(next)) +); +``` + ## Changelog See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/utils/package.json b/packages/utils/package.json index 4fe598e02..e8210afad 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -60,6 +60,55 @@ "solid", "primitives" ], + "primitive": { + "name": "utils", + "stage": 2, + "list": [ + "shallowArrayCopy", + "shallowObjectCopy", + "shallowCopy", + "withArrayCopy", + "withObjectCopy", + "withCopy", + "push", + "drop", + "dropRight", + "filterOut", + "filter", + "sort", + "sortBy", + "map", + "slice", + "splice", + "fill", + "concat", + "remove", + "removeItems", + "flatten", + "filterInstance", + "filterOutInstance", + "omit", + "pick", + "split", + "merge", + "get", + "update", + "add", + "substract", + "multiply", + "divide", + "power", + "clamp", + "json", + "ndjson", + "lines", + "number", + "safe", + "pipe", + "wrapSetter" + ], + "category": "Utilities" + }, "peerDependencies": { "@solidjs/web": "^2.0.0-beta.10", "solid-js": "^2.0.0-beta.10" diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 7c5e33cb1..f34490970 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -2,11 +2,16 @@ import { getOwner, onCleanup, createSignal, + createStore, type Accessor, untrack, type EffectFunction, type NoInfer, + type Setter, type SignalOptions, + type Signal, + type Store, + type StoreSetter, sharedConfig, onSettled, DEV, @@ -396,3 +401,31 @@ export function safe( export function pipe(a: (raw: string) => A, b: (a: A) => B): (raw: string) => B { return (raw: string): B => b(a(raw)); } + +/** + * Wraps a setter function of any signal or store + * + * ```ts + * const [data, setData] = wrapSetter( + * createSignal(initialData), + * (setter) => (next) => { console.log(next); return setter(next); }, + * ); + * ``` + * If you destructure signal or store in a longer tuple, you need to use a const assertion for the types to work. + */ +export function wrapSetter(signal: Signal, wrapper: (setter: Setter) => Setter): Signal; +export function wrapSetter(store: [Store, StoreSetter], wrapper: (setter: StoreSetter) => StoreSetter): [Store, StoreSetter]; +export function wrapSetter | [Store, StoreSetter] | [...Signal, ...any[]] | [Store, StoreSetter, ...any[]]>( + signalOrStore: S, + wrapper: (setter: S[1]) => S[1] +): S; +export function wrapSetter | [Store, StoreSetter] | readonly [...Signal, ...any[]] | readonly [Store, StoreSetter, ...any[]]>( + signalOrStore: S, + wrapper: (setter: S[1]) => S[1] +): S; +export function wrapSetter | [Store, StoreSetter] | [...Signal, ...any[]] | [Store, StoreSetter, ...any[]]>( + signalOrStore: S, + wrapper: (setter: S[1]) => S[1] +): S { + return [signalOrStore[0], wrapper(signalOrStore[1]), ...signalOrStore.slice(2)] as S; +} diff --git a/packages/utils/test/index.test.ts b/packages/utils/test/index.test.ts index 59506224f..3c7d9310b 100644 --- a/packages/utils/test/index.test.ts +++ b/packages/utils/test/index.test.ts @@ -1,5 +1,6 @@ -import { describe, test, expect, assert } from "vitest"; -import { handleDiffArray, arrayEquals, createHydratableSignal } from "../src/index.js"; +import { describe, test, expect, assert, vi } from "vitest"; +import { createSignal, createStore, flush, type Signal } from "solid-js"; +import { handleDiffArray, arrayEquals, createHydratableSignal, wrapSetter } from "../src/index.js"; describe("handleDiffArray", () => { test("handleAdded called for new array", () => { @@ -102,3 +103,34 @@ describe("createHydratableSignal", () => { expect(setState).toBeInstanceOf(Function); }); }); + +describe("wrapSetter", () => { + test("wraps a signal", () => { + const wrapped = vi.fn((x) => x); + const [state, setState] = wrapSetter(createSignal(0), (setter) => (next) => wrapped(setter(next))); + setState(1); + flush(); + expect(state()).toBe(1); + expect(wrapped).toHaveBeenCalledWith(1); + setState(c => c + 1); + flush(); + expect(state()).toBe(2); + }); + test("wraps a store", () => { + const wrapped = vi.fn((x) => x); + const [state, setState] = wrapSetter(createStore({ on: false }), (setter) => (next) => wrapped(setter(next))); + setState((s) => { s.on = !s.on; }); + flush(); + expect(state.on).toBe(true); + expect(wrapped).toHaveBeenCalled(); + }); + test("leaves additional values in the new tuple", () => { + const wrapped = vi.fn((x) => x); + const modifiedSignal = [...createSignal(0), {} as Record, [] as string[]] as const; + const wrappedSignal = wrapSetter(modifiedSignal, (setter) => (next) => wrapped(setter(next))); + expect(wrappedSignal[0]).toBe(modifiedSignal[0]); + expect(wrappedSignal[2]).toBe(modifiedSignal[2]); + expect(wrappedSignal[3]).toBe(modifiedSignal[3]); + expect(wrappedSignal).toHaveLength(modifiedSignal.length); + }); +}); \ No newline at end of file