diff --git a/.changeset/crazy-rabbits-hang.md b/.changeset/crazy-rabbits-hang.md new file mode 100644 index 00000000..92328035 --- /dev/null +++ b/.changeset/crazy-rabbits-hang.md @@ -0,0 +1,5 @@ +--- +"runed": minor +--- + +feat(PersistedState): add `connect` and `disconnect` methods to control synchronization to storage diff --git a/.changeset/quiet-emus-hope.md b/.changeset/quiet-emus-hope.md new file mode 100644 index 00000000..f91d69c6 --- /dev/null +++ b/.changeset/quiet-emus-hope.md @@ -0,0 +1,5 @@ +--- +"runed": minor +--- + +feat(PersistedState): allow `null` values diff --git a/packages/runed/package.json b/packages/runed/package.json index dc7bd18b..e6a8eaaf 100644 --- a/packages/runed/package.json +++ b/packages/runed/package.json @@ -41,6 +41,7 @@ "build": "pnpm package", "package": "svelte-kit sync && svelte-package && publint", "test": "vitest --run && playwright test", + "test:unit": "vitest --watch", "test:watch": "vitest --watch", "test:ui": "vitest --watch --ui", "test:integration": "playwright test", diff --git a/packages/runed/src/lib/utilities/persisted-state/persisted-state.svelte.ts b/packages/runed/src/lib/utilities/persisted-state/persisted-state.svelte.ts index d0c43330..6d03c747 100644 --- a/packages/runed/src/lib/utilities/persisted-state/persisted-state.svelte.ts +++ b/packages/runed/src/lib/utilities/persisted-state/persisted-state.svelte.ts @@ -19,12 +19,38 @@ function getStorage(storageType: StorageType, window: Window & typeof globalThis } type PersistedStateOptions = { - /** The storage type to use. Defaults to `local`. */ + /** + * The storage type to use. + * + * @default "local" + */ storage?: StorageType; - /** The serializer to use. Defaults to `JSON.stringify` and `JSON.parse`. */ + + /** + * The serializer to use. + * + * @default { serialize: JSON.stringify, deserialize: JSON.parse } + */ serializer?: Serializer; - /** Whether to sync with the state changes from other tabs. Defaults to `true`. */ + + /** + * Whether to sync with the state changes from other tabs. + * + * @default true + */ syncTabs?: boolean; + + /** + * Whether to connect to storage on initialization, which means that updates to the state will + * be persisted to storage and reads from the state will be read from storage. + * + * When `connected` is `false`, the state is not connected to storage and any changes to the state will + * not be persisted to storage and any changes to storage will not be reflected in the state until + * `.connect()` is called. + * + * @default true + */ + connected?: boolean; } & ConfigurableWindow; function proxy( @@ -77,18 +103,28 @@ export class PersistedState { #subscribe?: VoidFunction; #update: VoidFunction | undefined; #proxies = new WeakMap(); + #connected: boolean; + #storageCleanup?: VoidFunction; + #window?: Window & typeof globalThis; + #syncTabs: boolean; + #storageType: StorageType; constructor(key: string, initialValue: T, options: PersistedStateOptions = {}) { const { storage: storageType = "local", serializer = { serialize: JSON.stringify, deserialize: JSON.parse }, syncTabs = true, + connected = true, } = options; const window = "window" in options ? options.window : defaultWindow; // window is not mockable to be undefined without this, because JavaScript will fill undefined with `= default` this.#current = initialValue; this.#key = key; this.#serializer = serializer; + this.#connected = connected; + this.#window = window; + this.#syncTabs = syncTabs; + this.#storageType = storageType; if (window === undefined) return; @@ -98,28 +134,25 @@ export class PersistedState { const existingValue = storage.getItem(key); if (existingValue !== null) { this.#current = this.#deserialize(existingValue); - } else { + } else if (connected) { this.#serialize(initialValue); } - this.#subscribe = createSubscriber((update) => { - this.#update = update; - const cleanup = - syncTabs && storageType === "local" - ? on(window, "storage", this.#handleStorageEvent) - : null; - return () => { - cleanup?.(); - this.#update = undefined; - }; - }); + this.#setupStorageListener(); } get current(): T { this.#subscribe?.(); - const storageItem = this.#storage?.getItem(this.#key); - const root = storageItem ? this.#deserialize(storageItem) : this.#current; + let root: T | undefined; + if (this.#connected) { + // when we're connected to storage, we use storage as the source of truth + const storageItem = this.#storage?.getItem(this.#key); + root = storageItem ? this.#deserialize(storageItem) : this.#current; + } else { + // when we're not connected to storage, we use the current value in memory + root = this.#current; + } return proxy( root, root, @@ -151,8 +184,14 @@ export class PersistedState { } #serialize(value: T | undefined): void { + if (!this.#connected) { + // when we're not connected to storage, we only update the value in memory + this.#current = value; + return; + } + try { - if (value != undefined) { + if (value !== undefined) { this.#storage?.setItem(this.#key, this.#serializer.serialize(value)); } } catch (error) { @@ -162,4 +201,71 @@ export class PersistedState { ); } } + + #setupStorageListener(): void { + if (!this.#window || !this.#connected) return; + this.#subscribe = createSubscriber((update) => { + this.#update = update; + this.#storageCleanup = + this.#connected && this.#syncTabs && this.#storageType === "local" + ? on(this.#window!, "storage", this.#handleStorageEvent) + : undefined; + + return () => { + this.#storageCleanup?.(); + this.#storageCleanup = undefined; + this.#update = undefined; + }; + }); + } + + #teardownStorageListener(): void { + this.#storageCleanup?.(); + this.#storageCleanup = undefined; + this.#subscribe = undefined; + } + + /** + * Returns whether the state is currently connected to storage. + * + * When `connected` is `false`, the state is not connected to storage and any + * changes to the state will not be persisted to storage and any changes to storage + * will not be reflected in the state. + */ + get connected(): boolean { + return this.#connected; + } + + /** + * Disconnects the state from storage, preventing updates to storage and stopping + * cross-tab synchronization. The current value in storage is removed. + * + * Call `.connect()` to re-enable storage persistence. + */ + disconnect(): void { + if (!this.#connected) return; + // capture current value from storage before removing + const storageItem = this.#storage?.getItem(this.#key); + if (storageItem) { + this.#current = this.#deserialize(storageItem); + } + this.#connected = false; + this.#storage?.removeItem(this.#key); + this.#teardownStorageListener(); + } + + /** + * Reconnects the state to storage, enabling storage persistence and cross-tab + * synchronization. The current value is immediately persisted to storage. + * + * **NOTE**: By default, the state is already connected to storage and this method is + * only useful to re-enable storage persistence after calling `disconnect()` + * or starting with `connected: false` as an option. + */ + connect(): void { + if (this.#connected) return; + this.#connected = true; + this.#serialize(this.#current); + this.#setupStorageListener(); + } } diff --git a/packages/runed/src/lib/utilities/persisted-state/persisted-state.test.svelte.ts b/packages/runed/src/lib/utilities/persisted-state/persisted-state.test.svelte.ts index 8b1f129a..bfc0e74a 100644 --- a/packages/runed/src/lib/utilities/persisted-state/persisted-state.test.svelte.ts +++ b/packages/runed/src/lib/utilities/persisted-state/persisted-state.test.svelte.ts @@ -327,4 +327,181 @@ describe("PersistedState", async () => { expect(persistedState.current.foo.prop).toBe(303); expect(localStorage.getItem(key)).toBe(JSON.stringify({ foo: { prop: 303 } })); }); + + describe("null handling", () => { + testWithEffect("allows null as a valid value", () => { + const persistedState = new PersistedState(key, initialValue); + persistedState.current = null; + expect(persistedState.current).toBe(null); + expect(localStorage.getItem(key)).toBe(JSON.stringify(null)); + }); + + testWithEffect("can retrieve null from localStorage", () => { + localStorage.setItem(key, JSON.stringify(null)); + const persistedState = new PersistedState(key, initialValue); + expect(persistedState.current).toBe(null); + }); + + testWithEffect("can set null then set a new value", () => { + const persistedState = new PersistedState(key, initialValue); + persistedState.current = null; + expect(persistedState.current).toBe(null); + + persistedState.current = newValue; + expect(persistedState.current).toBe(newValue); + expect(localStorage.getItem(key)).toBe(JSON.stringify(newValue)); + }); + + testWithEffect("triggers reactivity when set to null", () => { + const values: (string | null)[] = []; + const persistedState = new PersistedState(key, initialValue); + $effect(() => { + values.push(persistedState.current); + }); + flushSync(); + expect(values).toStrictEqual([initialValue]); + + flushSync(() => { + persistedState.current = null; + }); + expect(values).toStrictEqual([initialValue, null]); + }); + }); + + describe("disconnect/connect", () => { + testWithEffect("disconnect prevents storage updates", () => { + const persistedState = new PersistedState(key, initialValue); + expect(localStorage.getItem(key)).toBe(JSON.stringify(initialValue)); + + persistedState.disconnect(); + expect(localStorage.getItem(key)).toBeNull(); + + persistedState.current = newValue; + expect(persistedState.current).toBe(newValue); + expect(localStorage.getItem(key)).toBeNull(); + }); + + testWithEffect("connect re-enables storage updates", () => { + const persistedState = new PersistedState(key, initialValue); + persistedState.disconnect(); + + persistedState.current = newValue; + expect(localStorage.getItem(key)).toBeNull(); + + persistedState.connect(); + expect(localStorage.getItem(key)).toBe(JSON.stringify(newValue)); + }); + + testWithEffect("connected getter returns correct state", () => { + const persistedState = new PersistedState(key, initialValue); + expect(persistedState.connected).toBe(true); + + persistedState.disconnect(); + expect(persistedState.connected).toBe(false); + + persistedState.connect(); + expect(persistedState.connected).toBe(true); + }); + + testWithEffect("disconnect stops cross-tab sync", () => { + const persistedState = new PersistedState(key, initialValue); + expect(persistedState.current).toBe(initialValue); + + persistedState.disconnect(); + + localStorage.setItem(key, JSON.stringify(newValue)); + window.dispatchEvent( + new StorageEvent("storage", { + key, + oldValue: null, + newValue: JSON.stringify(newValue), + }) + ); + + expect(persistedState.current).toBe(initialValue); + }); + + testWithEffect("connect re-enables cross-tab sync", () => { + const persistedState = new PersistedState(key, initialValue); + persistedState.disconnect(); + persistedState.connect(); + + localStorage.setItem(key, JSON.stringify(newValue)); + window.dispatchEvent( + new StorageEvent("storage", { + key, + oldValue: null, + newValue: JSON.stringify(newValue), + }) + ); + + expect(persistedState.current).toBe(newValue); + }); + + testWithEffect("can start disconnected via option", () => { + const persistedState = new PersistedState(key, initialValue, { + connected: false, + }); + expect(persistedState.connected).toBe(false); + expect(localStorage.getItem(key)).toBeNull(); + + persistedState.current = newValue; + expect(localStorage.getItem(key)).toBeNull(); + }); + + testWithEffect("works with sessionStorage", () => { + const persistedState = new PersistedState(key, initialValue, { + storage: "session", + }); + expect(sessionStorage.getItem(key)).toBe(JSON.stringify(initialValue)); + + persistedState.disconnect(); + expect(sessionStorage.getItem(key)).toBeNull(); + + persistedState.current = newValue; + expect(sessionStorage.getItem(key)).toBeNull(); + + persistedState.connect(); + expect(sessionStorage.getItem(key)).toBe(JSON.stringify(newValue)); + }); + + testWithEffect("disconnect is idempotent", () => { + const persistedState = new PersistedState(key, initialValue); + persistedState.disconnect(); + persistedState.disconnect(); + expect(persistedState.connected).toBe(false); + }); + + testWithEffect("connect is idempotent", () => { + const persistedState = new PersistedState(key, initialValue); + persistedState.connect(); + persistedState.connect(); + expect(persistedState.connected).toBe(true); + }); + + testWithEffect("value persists through disconnect/connect cycle", () => { + const persistedState = new PersistedState(key, initialValue); + persistedState.current = newValue; + + persistedState.disconnect(); + expect(persistedState.current).toBe(newValue); + + persistedState.connect(); + expect(persistedState.current).toBe(newValue); + expect(localStorage.getItem(key)).toBe(JSON.stringify(newValue)); + }); + + testWithEffect("works when window is undefined", () => { + const persistedState = new PersistedState(key, initialValue, { + window: undefined, + }); + expect(persistedState.connected).toBe(true); + + persistedState.disconnect(); + expect(persistedState.connected).toBe(false); + + persistedState.connect(); + expect(persistedState.connected).toBe(true); + }); + }); }); diff --git a/sites/docs/src/content/utilities/persisted-state.md b/sites/docs/src/content/utilities/persisted-state.md index 3716993b..f3b868f8 100644 --- a/sites/docs/src/content/utilities/persisted-state.md +++ b/sites/docs/src/content/utilities/persisted-state.md @@ -80,6 +80,9 @@ const state = new PersistedState("user-preferences", initialValue, { // Disable cross-tab synchronization (default: true) syncTabs: false, + // Start disconnected from storage (default: true) + connected: false, + // Custom serialization handlers serializer: { serialize: superjson.stringify, @@ -98,6 +101,42 @@ const state = new PersistedState("user-preferences", initialValue, { When `syncTabs` is enabled (default), changes are automatically synchronized across all browser tabs using the storage event. +### Connection Control + +By default, the state is connected to storage on initialization and any changes to the state will +persist to storage and reads from the state will be read from storage. + +For more control, you can control when the state connects to storage using the `connected` option +and/or the `.connect()` and `.disconnect()` methods: + +```ts +// Start disconnected from storage +const state = new PersistedState("temp-data", initialValue, { + connected: false +}); + +// State changes are kept in memory only +state.current = "new value"; + +// Connect to storage when ready +state.connect(); // Now persists to storage + +// Check connection status +console.log(state.connected); // true + +// Disconnect from storage +state.disconnect(); // Removes from storage, keeps value in memory +``` + +When disconnected: + +- State changes are kept in memory only +- Storage changes are not reflected in the state +- Cross-tab synchronization is disabled + +Calling `disconnect()` removes the current value from storage but preserves it in memory. Calling +`connect()` immediately persists the current in-memory value to storage. + ### Custom Serialization Provide custom `serialize` and `deserialize` functions to handle complex data types: