Skip to content

Commit f1b08f6

Browse files
committed
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: svecosystem#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.
1 parent a12cd68 commit f1b08f6

File tree

2 files changed

+23
-14
lines changed

2 files changed

+23
-14
lines changed

.changeset/pretty-ends-sing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"runed": patch
3+
---
4+
5+
: PersistedState: compile-time guard to prevent `undefined` as a valid value in the stored type

packages/runed/src/lib/utilities/persisted-state/persisted-state.svelte.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import { defaultWindow, type ConfigurableWindow } from "$lib/internal/configurab
22
import { on } from "svelte/events";
33
import { createSubscriber } from "svelte/reactivity";
44

5+
type NoUndefined<T> = undefined extends T ? never : T;
6+
57
type Serializer<T> = {
68
serialize: (value: T) => string;
7-
deserialize: (value: string) => T | undefined;
9+
deserialize: (value: string) => NoUndefined<T> | undefined;
810
};
911

1012
type StorageType = "local" | "session";
@@ -55,18 +57,18 @@ type PersistedStateOptions<T> = {
5557

5658
function proxy<T>(
5759
value: unknown,
58-
root: T | undefined,
60+
root: NoUndefined<T> | undefined,
5961
proxies: WeakMap<WeakKey, unknown>,
6062
subscribe: VoidFunction | undefined,
6163
update: VoidFunction | undefined,
62-
serialize: (root?: T | undefined) => void
63-
): T {
64+
serialize: (root?: NoUndefined<T> | undefined) => void
65+
): NoUndefined<T> {
6466
if (value === null || typeof value !== "object") {
65-
return value as T;
67+
return value as NoUndefined<T>;
6668
}
6769
const proto = Object.getPrototypeOf(value);
6870
if (proto !== null && proto !== Object.prototype && !Array.isArray(value)) {
69-
return value as T;
71+
return value as NoUndefined<T>;
7072
}
7173
let p = proxies.get(value);
7274
if (!p) {
@@ -84,19 +86,21 @@ function proxy<T>(
8486
});
8587
proxies.set(value, p);
8688
}
87-
return p as T;
89+
return p as NoUndefined<T>;
8890
}
8991

9092
/**
9193
* Creates reactive state that is persisted and synchronized across browser sessions and tabs using Web Storage.
94+
* The stored type must not include `undefined` as a valid value, if you with to have a empty value, use `null` instead.
95+
*
9296
* @param key The unique key used to store the state in the storage.
9397
* @param initialValue The initial value of the state if not already present in the storage.
9498
* @param options Configuration options including storage type, serializer for complex data types, and whether to sync state changes across tabs.
9599
*
96100
* @see {@link https://runed.dev/docs/utilities/persisted-state}
97101
*/
98102
export class PersistedState<T> {
99-
#current: T | undefined;
103+
#current: NoUndefined<T> | undefined;
100104
#key: string;
101105
#serializer: Serializer<T>;
102106
#storage?: Storage;
@@ -109,7 +113,7 @@ export class PersistedState<T> {
109113
#syncTabs: boolean;
110114
#storageType: StorageType;
111115

112-
constructor(key: string, initialValue: T, options: PersistedStateOptions<T> = {}) {
116+
constructor(key: string, initialValue: NoUndefined<T>, options: PersistedStateOptions<T> = {}) {
113117
const {
114118
storage: storageType = "local",
115119
serializer = { serialize: JSON.stringify, deserialize: JSON.parse },
@@ -141,10 +145,10 @@ export class PersistedState<T> {
141145
this.#setupStorageListener();
142146
}
143147

144-
get current(): T {
148+
get current(): NoUndefined<T> {
145149
this.#subscribe?.();
146150

147-
let root: T | undefined;
151+
let root: NoUndefined<T> | undefined;
148152
if (this.#connected) {
149153
// when we're connected to storage, we use storage as the source of truth
150154
const storageItem = this.#storage?.getItem(this.#key);
@@ -163,7 +167,7 @@ export class PersistedState<T> {
163167
);
164168
}
165169

166-
set current(newValue: T) {
170+
set current(newValue: NoUndefined<T>) {
167171
this.#serialize(newValue);
168172
this.#update?.();
169173
}
@@ -174,7 +178,7 @@ export class PersistedState<T> {
174178
this.#update?.();
175179
};
176180

177-
#deserialize(value: string): T | undefined {
181+
#deserialize(value: string): NoUndefined<T> | undefined {
178182
try {
179183
return this.#serializer.deserialize(value);
180184
} catch (error) {
@@ -183,7 +187,7 @@ export class PersistedState<T> {
183187
}
184188
}
185189

186-
#serialize(value: T | undefined): void {
190+
#serialize(value: NoUndefined<T> | undefined): void {
187191
if (!this.#connected) {
188192
// when we're not connected to storage, we only update the value in memory
189193
this.#current = value;

0 commit comments

Comments
 (0)