From f1b08f6c8b4dca91b8736e46416cf70a63800538 Mon Sep 17 00:00:00 2001 From: kraktus Date: Fri, 27 Feb 2026 13:37:25 +0100 Subject: [PATCH] feat(PersistedState): compile-time guard preventing `undefined` as a valid value in the stored type `undefined` is already used internally by `PersistedState` as a special case to indicate that the value is not present in storage, and in case of deserialisation failure. This make using `undefined` in user-code a footgun, because for example `persistentState.current = undefined` is not working as expected, being a NOOP instead of emptying the state. While this is technically a breaking-change, I consider it a patch because the user-code impacted is buggy anyway. Alternatively another approach would be allow undefined state. A draft PR is available here: https://github.com/svecosystem/runed/pull/408. I did not get to the end of it, because the issue is that undefined has no valid JSON value, so it would require additional (de)serialisation logic, especially for custom ones. --- .changeset/pretty-ends-sing.md | 5 +++ .../persisted-state/persisted-state.svelte.ts | 32 +++++++++++-------- 2 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 .changeset/pretty-ends-sing.md diff --git a/.changeset/pretty-ends-sing.md b/.changeset/pretty-ends-sing.md new file mode 100644 index 00000000..1d64687d --- /dev/null +++ b/.changeset/pretty-ends-sing.md @@ -0,0 +1,5 @@ +--- +"runed": patch +--- + +: PersistedState: compile-time guard to prevent `undefined` as a valid value in the stored type 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..c8ef1ee4 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 @@ -2,9 +2,11 @@ import { defaultWindow, type ConfigurableWindow } from "$lib/internal/configurab import { on } from "svelte/events"; import { createSubscriber } from "svelte/reactivity"; +type NoUndefined = undefined extends T ? never : T; + type Serializer = { serialize: (value: T) => string; - deserialize: (value: string) => T | undefined; + deserialize: (value: string) => NoUndefined | undefined; }; type StorageType = "local" | "session"; @@ -55,18 +57,18 @@ type PersistedStateOptions = { function proxy( value: unknown, - root: T | undefined, + root: NoUndefined | undefined, proxies: WeakMap, subscribe: VoidFunction | undefined, update: VoidFunction | undefined, - serialize: (root?: T | undefined) => void -): T { + serialize: (root?: NoUndefined | undefined) => void +): NoUndefined { if (value === null || typeof value !== "object") { - return value as T; + return value as NoUndefined; } const proto = Object.getPrototypeOf(value); if (proto !== null && proto !== Object.prototype && !Array.isArray(value)) { - return value as T; + return value as NoUndefined; } let p = proxies.get(value); if (!p) { @@ -84,11 +86,13 @@ function proxy( }); proxies.set(value, p); } - return p as T; + return p as NoUndefined; } /** * Creates reactive state that is persisted and synchronized across browser sessions and tabs using Web Storage. + * The stored type must not include `undefined` as a valid value, if you with to have a empty value, use `null` instead. + * * @param key The unique key used to store the state in the storage. * @param initialValue The initial value of the state if not already present in the storage. * @param options Configuration options including storage type, serializer for complex data types, and whether to sync state changes across tabs. @@ -96,7 +100,7 @@ function proxy( * @see {@link https://runed.dev/docs/utilities/persisted-state} */ export class PersistedState { - #current: T | undefined; + #current: NoUndefined | undefined; #key: string; #serializer: Serializer; #storage?: Storage; @@ -109,7 +113,7 @@ export class PersistedState { #syncTabs: boolean; #storageType: StorageType; - constructor(key: string, initialValue: T, options: PersistedStateOptions = {}) { + constructor(key: string, initialValue: NoUndefined, options: PersistedStateOptions = {}) { const { storage: storageType = "local", serializer = { serialize: JSON.stringify, deserialize: JSON.parse }, @@ -141,10 +145,10 @@ export class PersistedState { this.#setupStorageListener(); } - get current(): T { + get current(): NoUndefined { this.#subscribe?.(); - let root: T | undefined; + let root: NoUndefined | 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); @@ -163,7 +167,7 @@ export class PersistedState { ); } - set current(newValue: T) { + set current(newValue: NoUndefined) { this.#serialize(newValue); this.#update?.(); } @@ -174,7 +178,7 @@ export class PersistedState { this.#update?.(); }; - #deserialize(value: string): T | undefined { + #deserialize(value: string): NoUndefined | undefined { try { return this.#serializer.deserialize(value); } catch (error) { @@ -183,7 +187,7 @@ export class PersistedState { } } - #serialize(value: T | undefined): void { + #serialize(value: NoUndefined | undefined): void { if (!this.#connected) { // when we're not connected to storage, we only update the value in memory this.#current = value;