Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/storage-sync-cleanup.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions packages/storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ type PersistedOptions<Type, StorageOptions> = {
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:
Expand Down
71 changes: 44 additions & 27 deletions packages/storage/src/persisted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];

Expand All @@ -74,7 +74,7 @@ export type PersistenceOptions<
storageOptions?: O;
});

export type PersistedState<S, I = Promise<string> | string | null> = S extends [any, any] ? [...S, I] : never;
export type PersistedState<S, I = Promise<string> | string | null> = S extends [any, any] ? [...S, I, () => void] : never;

export type StoreTuple<T> = [Store<T>, StoreSetter<T>];

Expand Down Expand Up @@ -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<S>;
return [signal[0], signal[1], null, () => {}] as unknown as PersistedState<S>;
}
const storageOptions = (options as unknown as { storageOptions: O }).storageOptions;
const serialize: (data: T) => string = options.serialize || JSON.stringify.bind(JSON);
Expand All @@ -166,29 +166,29 @@ export function makePersisted<
}
};
let unchanged = true;
let timer: ReturnType<typeof setTimeout> | 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) {
Expand Down Expand Up @@ -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<S>;
}

Expand All @@ -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<T> conditions*/
},
Expand All @@ -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 },
Expand All @@ -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))) {
Expand All @@ -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({
Expand All @@ -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)),
];
40 changes: 39 additions & 1 deletion packages/storage/test/persisted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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"');
Expand Down
Loading