From df65dd441b44aa2b81c1be00eaea6176deb4a084 Mon Sep 17 00:00:00 2001 From: Jayden Carey Date: Mon, 15 Jul 2024 20:53:45 +0800 Subject: [PATCH] adapter pattern --- .../PersistedState/PersistedState.svelte.ts | 246 +++++++++++++++--- .../PersistedState.test.svelte.ts | 18 +- .../docs/content/utilities/persisted-state.md | 14 +- .../components/demos/persisted-state.svelte | 6 +- 4 files changed, 222 insertions(+), 62 deletions(-) diff --git a/packages/runed/src/lib/utilities/PersistedState/PersistedState.svelte.ts b/packages/runed/src/lib/utilities/PersistedState/PersistedState.svelte.ts index c182239f..54b539f5 100644 --- a/packages/runed/src/lib/utilities/PersistedState/PersistedState.svelte.ts +++ b/packages/runed/src/lib/utilities/PersistedState/PersistedState.svelte.ts @@ -1,3 +1,4 @@ +import { untrack } from "svelte"; import { addEventListener } from "$lib/internal/utils/event.js"; type Serializer = { @@ -5,6 +6,44 @@ 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; +} + +class BrowserStorageAdapter implements StorageAdapter { + #storage: Storage; + + constructor(storage: Storage) { + this.#storage = storage; + } + + async getItem(key: string): Promise { + return this.#storage.getItem(key); + } + + async setItem(key: string, value: string): Promise { + this.#storage.setItem(key, value); + } + + subscribe(callback: (key: string, newValue: string | null) => void): () => void { + const listener = (event: StorageEvent) => { + if (event.key === null) { + return; + } + + callback(event.key, event.newValue); + }; + + const unsubscribe = addEventListener(window, "storage", listener.bind(this)); + + return () => { + unsubscribe(); + }; + } +} + type GetValueFromStorageResult = | { found: true; @@ -14,20 +53,21 @@ type GetValueFromStorageResult = found: false; value: null; }; -function getValueFromStorage({ + +async function getValueFromStorage({ key, storage, serializer, }: { key: string; - storage: Storage | null; + storage: StorageAdapter; serializer: Serializer; -}): GetValueFromStorageResult { +}): Promise> { if (!storage) { return { found: false, value: null }; } - const value = storage.getItem(key); + const value = await storage.getItem(key); if (value === null) { return { found: false, value: null }; } @@ -46,7 +86,7 @@ function getValueFromStorage({ } } -function setValueToStorage({ +async function setValueToStorage({ key, value, storage, @@ -54,7 +94,7 @@ function setValueToStorage({ }: { key: string; value: T; - storage: Storage | null; + storage: StorageAdapter | null; serializer: Serializer; }) { if (!storage) { @@ -62,30 +102,133 @@ function setValueToStorage({ } try { - storage.setItem(key, serializer.serialize(value)); + await storage.setItem(key, serializer.serialize(value)); } catch (e) { - console.error(`Error when writing value from persisted store "${key}" to ${storage}`, e); + console.error( + `Error when writing value from persisted store "${key}" to ${storage.constructor.name}`, + e + ); } } -type StorageType = "local" | "session"; +// 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; -function getStorage(storageType: StorageType): Storage | null { +// $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 { if (typeof window === "undefined") { return null; } - const storageByStorageType = { - local: localStorage, - session: sessionStorage, - } satisfies Record; + const storageAdapterByStorageType = { + local: new BrowserStorageAdapter(localStorage), + session: new BrowserStorageAdapter(sessionStorage), + }; - return storageByStorageType[storageType]; + return storageAdapterByStorageType[storageType]; } +type StorageType = "local" | "session"; + type PersistedStateOptions = { /** The storage type to use. Defaults to `local`. */ - storage?: StorageType; + storage?: StorageType | StorageAdapter; /** 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`. */ @@ -100,70 +243,87 @@ type PersistedStateOptions = { * * @see {@link https://runed.dev/docs/utilities/persisted-state} */ -export class PersistedState { +export class Persisted { #current = $state() as T; + #isInitialized = $state(false); + #initialValue: T; #key: string; - #storage: Storage | null; + #storageAdapter: StorageAdapter | null; #serializer: Serializer; constructor(key: string, initialValue: T, options: PersistedStateOptions = {}) { const { - storage: storageType = "local", + storage = "local", serializer = { serialize: JSON.stringify, deserialize: JSON.parse }, syncTabs = true, } = options; this.#key = key; - this.#storage = getStorage(storageType); + this.#initialValue = initialValue; + this.#storageAdapter = + typeof storage === "string" ? getStorageAdapterForStorageType(storage) : storage; this.#serializer = serializer; - const valueFromStorage = getValueFromStorage({ - key: this.#key, - storage: this.#storage, - serializer: this.#serializer, - }); - - this.#current = valueFromStorage.found ? valueFromStorage.value : initialValue; - $effect(() => { + if (!this.#isInitialized) { + return; + } + setValueToStorage({ key: this.#key, value: this.#current, - storage: this.#storage, + storage: this.#storageAdapter, serializer: this.#serializer, }); }); - $effect(() => { - if (!syncTabs) { - return; - } + if (syncTabs) { + $effect(() => { + return untrack(() => { + if (!this.#storageAdapter?.subscribe) { + return; + } - return addEventListener(window, "storage", this.#handleStorageEvent.bind(this)); - }); + const unsubscribe = this.#storageAdapter + .subscribe(async (key, newValue) => { + if (key === this.#key && newValue !== null) { + this.#current = this.#serializer.deserialize(newValue); + } + }) + .bind(this); + + return unsubscribe; + }); + }); + } + + this.init(); } - #handleStorageEvent(event: StorageEvent) { - if (event.key !== this.#key || !this.#storage) { + async init() { + if (!this.#storageAdapter) { return; } - const valueFromStorage = getValueFromStorage({ + const valueFromStorage = await getValueFromStorage({ key: this.#key, - storage: this.#storage, + storage: this.#storageAdapter, serializer: this.#serializer, }); - - if (valueFromStorage.found) { - this.#current = valueFromStorage.value; + if (!valueFromStorage.found) { + return; } + + this.#current = valueFromStorage.value; + this.#isInitialized = true; } get current(): T { - return this.#current; + return this.#isInitialized ? this.#current : this.#initialValue; } set current(newValue: T) { this.#current = newValue; + this.#isInitialized ||= true; } } diff --git a/packages/runed/src/lib/utilities/PersistedState/PersistedState.test.svelte.ts b/packages/runed/src/lib/utilities/PersistedState/PersistedState.test.svelte.ts index feb8eac6..132d40ea 100644 --- a/packages/runed/src/lib/utilities/PersistedState/PersistedState.test.svelte.ts +++ b/packages/runed/src/lib/utilities/PersistedState/PersistedState.test.svelte.ts @@ -1,6 +1,6 @@ import { describe, expect } from "vitest"; -import { PersistedState } from "./index.js"; +import { Persisted } from "./index.js"; import { testWithEffect } from "$lib/test/util.svelte.js"; const key = "test-key"; @@ -15,19 +15,19 @@ describe("PersistedState", () => { describe("localStorage", () => { testWithEffect("uses initial value if no persisted value is found", () => { - const persistedState = new PersistedState(key, initialValue); + const persistedState = new Persisted(key, initialValue); expect(persistedState.current).toBe(initialValue); }); testWithEffect("uses persisted value if it is found", async () => { localStorage.setItem(key, JSON.stringify(existingValue)); - const persistedState = new PersistedState(key, initialValue); + const persistedState = new Persisted(key, initialValue); await new Promise((resolve) => setTimeout(resolve, 0)); expect(persistedState.current).toBe(existingValue); }); testWithEffect("updates localStorage when current value changes", async () => { - const persistedState = new PersistedState(key, initialValue); + const persistedState = new Persisted(key, initialValue); expect(persistedState.current).toBe(initialValue); persistedState.current = "new-value"; expect(persistedState.current).toBe("new-value"); @@ -38,19 +38,19 @@ describe("PersistedState", () => { describe("sessionStorage", () => { testWithEffect("uses initial value if no persisted value is found", () => { - const persistedState = new PersistedState(key, initialValue, { storage: "session" }); + const persistedState = new Persisted(key, initialValue, { storage: "session" }); expect(persistedState.current).toBe(initialValue); }); testWithEffect("uses persisted value if it is found", async () => { sessionStorage.setItem(key, JSON.stringify(existingValue)); - const persistedState = new PersistedState(key, initialValue, { storage: "session" }); + const persistedState = new Persisted(key, initialValue, { storage: "session" }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(persistedState.current).toBe(existingValue); }); testWithEffect("updates sessionStorage when current value changes", async () => { - const persistedState = new PersistedState(key, initialValue, { storage: "session" }); + const persistedState = new Persisted(key, initialValue, { storage: "session" }); expect(persistedState.current).toBe(initialValue); persistedState.current = "new-value"; expect(persistedState.current).toBe("new-value"); @@ -68,7 +68,7 @@ describe("PersistedState", () => { serialize: (value: Date) => value.toISOString(), deserialize: (value: string) => new Date(value), }; - const persistedState = new PersistedState(key, date, { serializer }); + const persistedState = new Persisted(key, date, { serializer }); expect(persistedState.current).toBe(date); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -91,7 +91,7 @@ describe("PersistedState", () => { testWithEffect( "does not update persisted value when local storage changes independently if syncTabs is false", async () => { - const persistedState = new PersistedState(key, initialValue, { syncTabs: false }); + const persistedState = new Persisted(key, initialValue, { syncTabs: false }); localStorage.setItem(key, JSON.stringify("new-value")); await new Promise((resolve) => setTimeout(resolve, 0)); expect(persistedState.current).toBe(initialValue); diff --git a/sites/docs/content/utilities/persisted-state.md b/sites/docs/content/utilities/persisted-state.md index 7df18a47..a00c3781 100644 --- a/sites/docs/content/utilities/persisted-state.md +++ b/sites/docs/content/utilities/persisted-state.md @@ -1,5 +1,5 @@ --- -title: PersistedState +title: Persisted description: Create reactive state that is persisted and synchronized across browser sessions and tabs using Web Storage. @@ -16,15 +16,15 @@ import Demo from '$lib/components/demos/persisted-state.svelte'; ## Usage -`PersistedState` allows for syncing and persisting state across browser sessions using -`localStorage` or `sessionStorage`. Initialize `PersistedState` by providing a unique key and an -initial value for the state. +`Persisted` allows for syncing and persisting state across browser sessions using `localStorage` or +`sessionStorage`. Initialize `Persisted` by providing a unique key and an initial value for the +state. ```svelte
@@ -35,7 +35,7 @@ initial value for the state.
``` -`PersistedState` also includes an `options` object. +`Persisted` also includes an `options` object. ```ts { diff --git a/sites/docs/src/lib/components/demos/persisted-state.svelte b/sites/docs/src/lib/components/demos/persisted-state.svelte index 4a7fa2ea..c14d8d2b 100644 --- a/sites/docs/src/lib/components/demos/persisted-state.svelte +++ b/sites/docs/src/lib/components/demos/persisted-state.svelte @@ -1,10 +1,10 @@ @@ -12,7 +12,7 @@
Count: {`${count.current}`}
- + You can refresh this page and/or open it in another tab to see the count state being persisted and synchronized across sessions and tabs.