diff --git a/packages/runed/src/lib/utilities/PersistedState/PersistedState.svelte.ts b/packages/runed/src/lib/utilities/PersistedState/PersistedState.svelte.ts index 54b539f5..34896e77 100644 --- a/packages/runed/src/lib/utilities/PersistedState/PersistedState.svelte.ts +++ b/packages/runed/src/lib/utilities/PersistedState/PersistedState.svelte.ts @@ -6,34 +6,64 @@ type Serializer = { deserialize: (value: string) => T; }; -interface StorageAdapter { - getItem: (key: string) => Promise; - setItem: (key: string, value: string) => Promise; - subscribe?: (callback: (key: string, newValue: string | null) => void) => () => void; +type GetItemResult = + | { + found: false; + value: null; + } + | { + found: true; + value: T; + }; + +interface StorageAdapter { + getItem: (key: string) => Promise>; + setItem: (key: string, value: T) => Promise; + subscribe?: (callback: (key: string, newValue: GetItemResult) => void) => () => void; } -class BrowserStorageAdapter implements StorageAdapter { +export class WebStorageAdapter implements StorageAdapter { #storage: Storage; + #serializer: Serializer; - constructor(storage: Storage) { + constructor({ + storage, + serializer = { + serialize: JSON.stringify, + deserialize: JSON.parse, + }, + }: { + storage: Storage; + serializer?: Serializer; + }) { this.#storage = storage; + this.#serializer = serializer; } - async getItem(key: string): Promise { - return this.#storage.getItem(key); + async getItem(key: string): Promise> { + const value = this.#storage.getItem(key); + return value !== null + ? { found: true, value: this.#serializer.deserialize(value) } + : { found: false, value: null }; } - async setItem(key: string, value: string): Promise { - this.#storage.setItem(key, value); + async setItem(key: string, value: T): Promise { + const serializedValue = this.#serializer.serialize(value); + this.#storage.setItem(key, serializedValue); } - subscribe(callback: (key: string, newValue: string | null) => void): () => void { + subscribe(callback: (key: string, newValue: GetItemResult) => void): () => void { const listener = (event: StorageEvent) => { if (event.key === null) { return; } - callback(event.key, event.newValue); + const result: GetItemResult = + event.newValue !== null + ? { found: true, value: this.#serializer.deserialize(event.newValue) } + : { found: false, value: null }; + + callback(event.key, result); }; const unsubscribe = addEventListener(window, "storage", listener.bind(this)); @@ -44,65 +74,21 @@ class BrowserStorageAdapter implements StorageAdapter { } } -type GetValueFromStorageResult = - | { - found: true; - value: T; - } - | { - found: false; - value: null; - }; - -async function getValueFromStorage({ - key, - storage, - serializer, -}: { - key: string; - storage: StorageAdapter; - serializer: Serializer; -}): Promise> { - if (!storage) { - return { found: false, value: null }; - } - - const value = await storage.getItem(key); - if (value === null) { - return { found: false, value: null }; - } - - try { - return { - found: true, - value: serializer.deserialize(value), - }; - } catch (e) { - console.error(`Error when parsing ${value} from persisted store "${key}"`, e); - return { - found: false, - value: null, - }; - } -} - async function setValueToStorage({ key, value, storage, - serializer, }: { key: string; value: T; - storage: StorageAdapter | null; - serializer: Serializer; + storage: StorageAdapter | null; }) { if (!storage) { return; } try { - await storage.setItem(key, serializer.serialize(value)); + await storage.setItem(key, value); } catch (e) { console.error( `Error when writing value from persisted store "${key}" to ${storage.constructor.name}`, @@ -111,126 +97,24 @@ async function setValueToStorage({ } } -// type StorageType = "local" | "session"; - -// function getStorage(storageType: StorageType): Storage | null { -// if (typeof window === "undefined") { -// return null; -// } - -// const storageByStorageType = { -// local: localStorage, -// session: sessionStorage, -// } satisfies Record; - -// return storageByStorageType[storageType]; -// } - -// type PersistedStateOptions = { -// /** The storage type to use. Defaults to `local`. */ -// storage?: StorageType; -// /** The serializer to use. Defaults to `JSON.stringify` and `JSON.parse`. */ -// serializer?: Serializer; -// /** Whether to sync with the state changes from other tabs. Defaults to `true`. */ -// syncTabs?: boolean; -// }; - -// /** -// * Creates reactive state that is persisted and synchronized across browser sessions and tabs using Web Storage. -// * @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. -// * -// * @see {@link https://runed.dev/docs/utilities/persisted-state} -// */ -// export class Persisted { -// #current = $state() as T; -// #key: string; -// #storage: Storage | null; -// #serializer: Serializer; - -// constructor(key: string, initialValue: T, options: PersistedStateOptions = {}) { -// const { -// storage: storageType = "local", -// serializer = { serialize: JSON.stringify, deserialize: JSON.parse }, -// syncTabs = true, -// } = options; - -// this.#key = key; -// this.#storage = getStorage(storageType); -// this.#serializer = serializer; - -// const valueFromStorage = getValueFromStorage({ -// key: this.#key, -// storage: this.#storage, -// serializer: this.#serializer, -// }); - -// this.#current = valueFromStorage.found ? valueFromStorage.value : initialValue; - -// $effect(() => { -// setValueToStorage({ -// key: this.#key, -// value: this.#current, -// storage: this.#storage, -// serializer: this.#serializer, -// }); -// }); - -// $effect(() => { -// if (!syncTabs) { -// return; -// } - -// return addEventListener(window, "storage", this.#handleStorageEvent.bind(this)); -// }); -// } - -// #handleStorageEvent(event: StorageEvent) { -// if (event.key !== this.#key || !this.#storage) { -// return; -// } - -// const valueFromStorage = getValueFromStorage({ -// key: this.#key, -// storage: this.#storage, -// serializer: this.#serializer, -// }); - -// if (valueFromStorage.found) { -// this.#current = valueFromStorage.value; -// } -// } - -// get current(): T { -// return this.#current; -// } - -// set current(newValue: T) { -// this.#current = newValue; -// } -// } - -function getStorageAdapterForStorageType(storageType: StorageType): StorageAdapter | null { +function getWebStorageAdapterForStorageType(storageType: StorageType): StorageAdapter | null { if (typeof window === "undefined") { return null; } - const storageAdapterByStorageType = { - local: new BrowserStorageAdapter(localStorage), - session: new BrowserStorageAdapter(sessionStorage), + const webStorageAdapterByStorageType = { + local: new WebStorageAdapter({ storage: localStorage }), + session: new WebStorageAdapter({ storage: sessionStorage }), }; - return storageAdapterByStorageType[storageType]; + return webStorageAdapterByStorageType[storageType]; } type StorageType = "local" | "session"; type PersistedStateOptions = { /** The storage type to use. Defaults to `local`. */ - storage?: StorageType | StorageAdapter; - /** The serializer to use. Defaults to `JSON.stringify` and `JSON.parse`. */ - serializer?: Serializer; + storage?: StorageType | StorageAdapter; /** Whether to sync with the state changes from other tabs. Defaults to `true`. */ syncTabs?: boolean; }; @@ -248,21 +132,15 @@ export class Persisted { #isInitialized = $state(false); #initialValue: T; #key: string; - #storageAdapter: StorageAdapter | null; - #serializer: Serializer; + #storageAdapter: StorageAdapter | null; constructor(key: string, initialValue: T, options: PersistedStateOptions = {}) { - const { - storage = "local", - serializer = { serialize: JSON.stringify, deserialize: JSON.parse }, - syncTabs = true, - } = options; + const { storage = "local", syncTabs = true } = options; this.#key = key; this.#initialValue = initialValue; this.#storageAdapter = - typeof storage === "string" ? getStorageAdapterForStorageType(storage) : storage; - this.#serializer = serializer; + typeof storage === "string" ? getWebStorageAdapterForStorageType(storage) : storage; $effect(() => { if (!this.#isInitialized) { @@ -273,7 +151,6 @@ export class Persisted { key: this.#key, value: this.#current, storage: this.#storageAdapter, - serializer: this.#serializer, }); }); @@ -286,9 +163,11 @@ export class Persisted { const unsubscribe = this.#storageAdapter .subscribe(async (key, newValue) => { - if (key === this.#key && newValue !== null) { - this.#current = this.#serializer.deserialize(newValue); + if (key !== this.#key || !newValue.found) { + return; } + + this.#current = newValue.value; }) .bind(this); @@ -305,11 +184,7 @@ export class Persisted { return; } - const valueFromStorage = await getValueFromStorage({ - key: this.#key, - storage: this.#storageAdapter, - serializer: this.#serializer, - }); + const valueFromStorage = await this.#storageAdapter.getItem(this.#key); if (!valueFromStorage.found) { return; }