Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -51,6 +52,14 @@ type PersistedStateOptions<T> = {
* @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<T>(
Expand Down Expand Up @@ -108,23 +117,29 @@ export class PersistedState<T> {
#window?: Window & typeof globalThis;
#syncTabs: boolean;
#storageType: StorageType;
#initialValue: T;
#eraseWhenDefault: boolean;

constructor(key: string, initialValue: T, options: PersistedStateOptions<T> = {}) {
const {
storage: storageType = "local",
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;

Expand All @@ -135,7 +150,7 @@ export class PersistedState<T> {
if (existingValue !== null) {
this.#current = this.#deserialize(existingValue);
} else if (connected) {
this.#serialize(initialValue);
this.#serialize(this.#current);
}

this.#setupStorageListener();
Expand Down Expand Up @@ -169,7 +184,15 @@ export class PersistedState<T> {
}

#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?.();
};
Expand All @@ -184,14 +207,20 @@ export class PersistedState<T> {
}

#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) {
Expand All @@ -202,6 +231,16 @@ export class PersistedState<T> {
}
}

// 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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
});
15 changes: 15 additions & 0 deletions sites/docs/src/content/utilities/persisted-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down