diff --git a/.changeset/storage-sync-cleanup.md b/.changeset/storage-sync-cleanup.md new file mode 100644 index 000000000..8b53c8622 --- /dev/null +++ b/.changeset/storage-sync-cleanup.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/storage": patch +--- + +Return an explicit disposer as the fourth value from `makePersisted` to unsubscribe sync listeners and cancel pending initialization. Preserve writes made before deferred initialization. diff --git a/packages/storage/README.md b/packages/storage/README.md index 17cdc952f..435a00cc5 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -56,6 +56,15 @@ type PersistedOptions = { the output of `createSignal` or `createStore` - using `makePersisted` in an SSR scenario where you have different values on the server and the client might lead to hydration mismatches; use the `hydrated` option to defer the initialization so both values are the same +The fourth returned value is a `dispose` function. Call it to unsubscribe from sync events and cancel pending initialization. Cleanup is explicit; to tie it to a Solid owner, register it with `onCleanup`: + +```ts +const [value, setValue, init, dispose] = makePersisted(createSignal("initial")); +onCleanup(dispose); +``` + +Custom sync subscriptions can return an unsubscribe function, which `dispose` will call. + ### Using `makePersisted` with Loading In case you are using an asynchronous storage and want the initialisation mesh into Suspense instead of mixing it with Show, we provide the output of the initialisation as third part of the returned tuple: diff --git a/packages/storage/src/persisted.ts b/packages/storage/src/persisted.ts index b5fdb9db1..8403255c1 100644 --- a/packages/storage/src/persisted.ts +++ b/packages/storage/src/persisted.ts @@ -51,8 +51,8 @@ export type PersistenceSyncData = { export type PersistenceSyncCallback = (data: PersistenceSyncData) => void; export type PersistenceSyncAPI = [ - /** subscribes to sync */ - subscribe: (subscriber: PersistenceSyncCallback) => void, + /** Subscribes to sync and optionally returns an unsubscribe function. */ + subscribe: (subscriber: PersistenceSyncCallback) => void | (() => void), update: (key: string, value: string | null | undefined) => void, ]; @@ -74,7 +74,7 @@ export type PersistenceOptions< storageOptions?: O; }); -export type PersistedState | string | null> = S extends [any, any] ? [...S, I] : never; +export type PersistedState | string | null> = S extends [any, any] ? [...S, I, () => void] : never; export type StoreTuple = [Store, StoreSetter]; @@ -139,7 +139,7 @@ export function makePersisted< (signal as any)[1] = action(actionFn) as unknown as S[1]; } if (!storage) { - return [signal[0], signal[1], null] as unknown as PersistedState; + return [signal[0], signal[1], null, () => {}] as unknown as PersistedState; } const storageOptions = (options as unknown as { storageOptions: O }).storageOptions; const serialize: (data: T) => string = options.serialize || JSON.stringify.bind(JSON); @@ -166,29 +166,29 @@ export function makePersisted< } }; let unchanged = true; + let timer: ReturnType | undefined; if (init instanceof Promise) init.then(data => unchanged && data && set(data)); else if (init) // in case of hydration mismatches due to the server lacking the same initial value, // we want to defer the initialization by a short amount so the same state can be used // during hydration - if (options.hydrated) setTimeout(() => set(init), 45); - else set(init); + if (options.hydrated) { + timer = setTimeout(() => unchanged && set(init), 45); + } else set(init); const getter: () => T = isSignal ? (signal[0] as () => T) : () => snapshot(signal[0] as T); - if (typeof options.sync?.[0] === "function") { - options.sync[0]((data: PersistenceSyncData) => { - if ( - data.key !== name || - (!isServer && (data.url || globalThis.location.href) !== globalThis.location.href) || - data.newValue === serialize(untrack(getter)) - ) { - return; - } - set(data.newValue as string); - }); - } + const unsubscribe = options.sync?.[0]((data: PersistenceSyncData) => { + if ( + data.key !== name || + (!isServer && (data.url || globalThis.location.href) !== globalThis.location.href) || + data.newValue === serialize(untrack(getter)) + ) { + return; + } + set(data.newValue as string); + }); const persistValue = (next: T | undefined) => { if (next == null) { @@ -217,6 +217,11 @@ export function makePersisted< return output instanceof Promise ? output.then(async (result) => { await Promise.resolve(); persist(); return result; }, (err) => { persist(); throw err; }) : output; }), init, + () => { + unchanged = false; + clearTimeout(timer); + unsubscribe?.(); + }, ] as unknown as PersistedState; } @@ -226,8 +231,11 @@ export function makePersisted< * If you wish to use e.g. cookieStorage, you may use a different sync method */ export const storageSync: PersistenceSyncAPI = [ - (subscriber: PersistenceSyncCallback) => - window.addEventListener("storage", ev => subscriber(ev as PersistenceSyncData)), + (subscriber: PersistenceSyncCallback) => { + const handler = (ev: StorageEvent) => subscriber(ev as PersistenceSyncData); + window.addEventListener("storage", handler); + return () => window.removeEventListener("storage", handler); + }, () => { /*storage already sends events fulfilling the PersistenceSyncData conditions*/ }, @@ -237,10 +245,13 @@ export const storageSync: PersistenceSyncAPI = [ * messageSync - synchronize over post message or broadcast channel API */ export const messageSync = (channel: Window | BroadcastChannel = window, url: string | undefined = globalThis.location?.href): PersistenceSyncAPI => [ - (subscriber: PersistenceSyncCallback) => - channel.addEventListener("message", ev => { + (subscriber: PersistenceSyncCallback) => { + const handler = (ev: Event) => { subscriber((ev as MessageEvent).data); - }), + }; + channel.addEventListener("message", handler); + return () => channel.removeEventListener("message", handler); + }, (key, newValue) => channel.postMessage( { key, newValue, timeStamp: Date.now(), url }, @@ -252,8 +263,8 @@ export const messageSync = (channel: Window | BroadcastChannel = window, url: st * wsSync - syncronize persisted storage via web socket */ export const wsSync = (ws: WebSocket, warnOnError: boolean = !!DEV, url: string | undefined = globalThis.location?.href): PersistenceSyncAPI => [ - (subscriber: PersistenceSyncCallback) => - ws.addEventListener("message", (ev: MessageEvent) => { + (subscriber: PersistenceSyncCallback) => { + const handler = (ev: MessageEvent) => { try { const data = JSON.parse(ev.data); if (["key", "newValue", "timeStamp"].every(item => Object.hasOwn(data, item))) { @@ -263,7 +274,10 @@ export const wsSync = (ws: WebSocket, warnOnError: boolean = !!DEV, url: string // oxlint-disable-next-line no-console if (warnOnError) console.warn(e); } - }), + }; + ws.addEventListener("message", handler); + return () => ws.removeEventListener("message", handler); + }, (key, newValue) => ws.send( JSON.stringify({ @@ -283,6 +297,9 @@ export const wsSync = (ws: WebSocket, warnOnError: boolean = !!DEV, url: string * ``` */ export const multiplexSync = (...syncAPIs: PersistenceSyncAPI[]): PersistenceSyncAPI => [ - subscriber => syncAPIs.forEach(([subscribe]) => subscribe(subscriber)), + subscriber => { + const unsubscribers = syncAPIs.map(([subscribe]) => subscribe(subscriber)); + return () => unsubscribers.forEach(unsubscribe => unsubscribe?.()); + }, (key, value) => syncAPIs.forEach(([_, updater]) => updater(key, value)), ]; diff --git a/packages/storage/test/persisted.test.ts b/packages/storage/test/persisted.test.ts index 3dcea89aa..66561fa99 100644 --- a/packages/storage/test/persisted.test.ts +++ b/packages/storage/test/persisted.test.ts @@ -9,7 +9,7 @@ import { latest, refresh, } from "solid-js"; -import { makePersisted, messageSync, storageSync, wsSync, type PersistenceSyncData } from "../src/persisted.js"; +import { makePersisted, messageSync, multiplexSync, storageSync, wsSync, type PersistenceSyncData } from "../src/persisted.js"; import { type AsyncStorage } from "../src/index.js"; describe("makePersisted", () => { @@ -207,6 +207,44 @@ describe("makePersisted", () => { expect(init).toBe(promise); }); + it("keeps early writes and explicitly disposes sync and deferred init", async () => { + mockStorage.setItem("cleanup", '"init"'); + const [value, setValue, , dispose] = createRoot(disposeRoot => { + const persisted = makePersisted(createSignal("default"), { + storage: mockStorage, + name: "cleanup", + hydrated: true, + sync: multiplexSync(messageSync(window), storageSync), + }); + disposeRoot(); + return persisted; + }); + const send = (newValue: string) => window.dispatchEvent(new MessageEvent("message", { + data: { key: "cleanup", newValue: JSON.stringify(newValue) }, + })); + send("synced"); + flush(); + expect(value()).toBe("synced"); + window.dispatchEvent(new StorageEvent("storage", { key: "cleanup", newValue: '"stored"' })); + flush(); + expect(value()).toBe("stored"); + setValue("user write"); + const [cancelled, , , cancel] = makePersisted(createSignal("default"), { + storage: mockStorage, + name: "cleanup", + hydrated: true, + }); + cancel(); + await new Promise(r => setTimeout(r, 100)); + flush(); + expect(value()).toBe("user write"); + expect(cancelled()).toBe("default"); + dispose(); + send("ignored"); + window.dispatchEvent(new StorageEvent("storage", { key: "cleanup", newValue: '"ignored"' })); + flush(); + expect(value()).toBe("user write"); + }); it("defers the initialization if the hydrated option is set", async () => { const anotherMockStorage = { ...mockStorage }; anotherMockStorage.setItem("test18", '"init"');