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 6d03c747..1893680d 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 @@ -1,4 +1,5 @@ import { defaultWindow, type ConfigurableWindow } from "$lib/internal/configurable-globals.js"; +import { dequal } from "dequal"; import { on } from "svelte/events"; import { createSubscriber } from "svelte/reactivity"; @@ -51,6 +52,14 @@ type PersistedStateOptions = { * @default true */ connected?: boolean; + + /** + * When `true`, writing a value deep-equal to `initialValue` removes the storage key instead of + * persisting it (an absent key reads back as the default), keeping storage clean. + * + * @default false + */ + eraseWhenDefault?: boolean; } & ConfigurableWindow; function proxy( @@ -108,6 +117,8 @@ export class PersistedState { #window?: Window & typeof globalThis; #syncTabs: boolean; #storageType: StorageType; + #initialValue: T; + #eraseWhenDefault: boolean; constructor(key: string, initialValue: T, options: PersistedStateOptions = {}) { const { @@ -115,16 +126,20 @@ export class PersistedState { serializer = { serialize: JSON.stringify, deserialize: JSON.parse }, syncTabs = true, connected = true, + eraseWhenDefault = false, } = 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; + // clone so #current/#initialValue don't alias the caller's object (see #clone) + this.#current = this.#clone(initialValue); this.#connected = connected; this.#window = window; this.#syncTabs = syncTabs; this.#storageType = storageType; + this.#initialValue = this.#clone(initialValue); + this.#eraseWhenDefault = eraseWhenDefault; if (window === undefined) return; @@ -135,7 +150,7 @@ export class PersistedState { if (existingValue !== null) { this.#current = this.#deserialize(existingValue); } else if (connected) { - this.#serialize(initialValue); + this.#serialize(this.#current); } this.#setupStorageListener(); @@ -169,7 +184,15 @@ export class PersistedState { } #handleStorageEvent = (event: StorageEvent): void => { - if (event.key !== this.#key || event.newValue === null) return; + if (event.key !== this.#key) return; + if (event.newValue === null) { + // a removed key resets to the default when erasing defaults; otherwise it's ignored + if (this.#eraseWhenDefault) { + this.#current = this.#clone(this.#initialValue); + this.#update?.(); + } + return; + } this.#current = this.#deserialize(event.newValue); this.#update?.(); }; @@ -184,14 +207,20 @@ export class PersistedState { } #serialize(value: T | undefined): void { + // keep the in-memory value in sync so `current`'s fallback isn't stale when the key is absent + this.#current = value; + 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) return; + if (this.#eraseWhenDefault && dequal(value, this.#initialValue)) { + // an absent key reads back as the default, so clear it instead of persisting the default + this.#storage?.removeItem(this.#key); + } else { this.#storage?.setItem(this.#key, this.#serializer.serialize(value)); } } catch (error) { @@ -202,6 +231,16 @@ export class PersistedState { } } + // deep copy via the serializer so a mutable default isn't aliased: mutating `current` through the + // proxy must not corrupt #initialValue, which `eraseWhenDefault` compares against + #clone(value: T): T { + try { + return this.#serializer.deserialize(this.#serializer.serialize(value)) ?? value; + } catch { + return value; + } + } + #setupStorageListener(): void { if (!this.#window || !this.#connected) return; this.#subscribe = createSubscriber((update) => { 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 bfc0e74a..deb87161 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 @@ -504,4 +504,83 @@ describe("PersistedState", async () => { expect(persistedState.connected).toBe(true); }); }); + + describe("eraseWhenDefault", () => { + testWithEffect("does not persist the initial value on construction", () => { + const persistedState = new PersistedState(key, initialValue, { eraseWhenDefault: true }); + expect(persistedState.current).toBe(initialValue); + expect(localStorage.getItem(key)).toBeNull(); + }); + + testWithEffect("persists non-default values", () => { + const persistedState = new PersistedState(key, initialValue, { eraseWhenDefault: true }); + persistedState.current = newValue; + expect(persistedState.current).toBe(newValue); + expect(localStorage.getItem(key)).toBe(JSON.stringify(newValue)); + }); + + testWithEffect("removes the key when the value returns to the default", () => { + const persistedState = new PersistedState(key, initialValue, { eraseWhenDefault: true }); + persistedState.current = newValue; + expect(localStorage.getItem(key)).toBe(JSON.stringify(newValue)); + + persistedState.current = initialValue; + expect(localStorage.getItem(key)).toBeNull(); + }); + + // Regression: after erasing the key, `current` must report the value that was just written, + // not a stale in-memory value left over from before. + testWithEffect("reads back the default after erasing, even when constructed from a value", () => { + localStorage.setItem(key, JSON.stringify(newValue)); + const persistedState = new PersistedState(key, initialValue, { eraseWhenDefault: true }); + expect(persistedState.current).toBe(newValue); + + persistedState.current = initialValue; + expect(localStorage.getItem(key)).toBeNull(); + expect(persistedState.current).toBe(initialValue); + }); + + // Regression: #current/#initialValue must not alias the caller's object, or mutating the + // reactive value through the proxy would also mutate the default that `dequal` compares against. + testWithEffect("does not alias the caller's initialValue object", () => { + const initial = { nested: { value: 1 } }; + const persistedState = new PersistedState(key, initial, { eraseWhenDefault: true }); + + persistedState.current.nested.value = 2; + expect(initial.nested.value).toBe(1); + expect(localStorage.getItem(key)).not.toBeNull(); + + persistedState.current.nested.value = 1; + expect(localStorage.getItem(key)).toBeNull(); + }); + + testWithEffect("deep-equal objects are treated as the default", () => { + const initial = { nested: { value: 1 } }; + const persistedState = new PersistedState(key, initial, { eraseWhenDefault: true }); + persistedState.current = { nested: { value: 2 } }; + expect(localStorage.getItem(key)).not.toBeNull(); + + persistedState.current = { nested: { value: 1 } }; + expect(localStorage.getItem(key)).toBeNull(); + expect(persistedState.current).toEqual(initial); + }); + + testWithEffect("a cross-tab removal resets current to the default", () => { + $effect(() => { + const persistedState = new PersistedState(key, initialValue, { eraseWhenDefault: true }); + persistedState.current = newValue; + expect(persistedState.current).toBe(newValue); + + localStorage.removeItem(key); + window.dispatchEvent( + new StorageEvent("storage", { + key, + oldValue: JSON.stringify(newValue), + newValue: null, + }) + ); + expect(persistedState.current).toBe(initialValue); + }); + }); + }); }); diff --git a/sites/docs/src/content/utilities/persisted-state.md b/sites/docs/src/content/utilities/persisted-state.md index f3b868f8..0ebcf654 100644 --- a/sites/docs/src/content/utilities/persisted-state.md +++ b/sites/docs/src/content/utilities/persisted-state.md @@ -83,6 +83,9 @@ const state = new PersistedState("user-preferences", initialValue, { // Start disconnected from storage (default: true) connected: false, + // Clear the storage key when the value equals the initial value (default: false) + eraseWhenDefault: true, + // Custom serialization handlers serializer: { serialize: superjson.stringify, @@ -137,6 +140,18 @@ When disconnected: Calling `disconnect()` removes the current value from storage but preserves it in memory. Calling `connect()` immediately persists the current in-memory value to storage. +### Erasing Defaults + +With `eraseWhenDefault: true`, writing a value deep-equal to `initialValue` removes the storage key +instead of persisting it. An empty storage key is treated the same as a storage key equal to `initialValue`. + +```ts +const htmlPreview = new PersistedState("page-123456:html-preview", false, { eraseWhenDefault: true }); + +htmlPreview.current = true; // -> localStorage["page-123456:html-preview"] === "true" +htmlPreview.current = false; // localStorage["page-123456:html-preview"] === undefined -- key removed, reads back as false +``` + ### Custom Serialization Provide custom `serialize` and `deserialize` functions to handle complex data types: