diff --git a/.changeset/light-pianos-tie.md b/.changeset/light-pianos-tie.md new file mode 100644 index 00000000..1777769d --- /dev/null +++ b/.changeset/light-pianos-tie.md @@ -0,0 +1,5 @@ +--- +"runed": minor +--- + +Add `PersistedState` diff --git a/packages/runed/src/lib/utilities/PersistedState/PersistedState.svelte.ts b/packages/runed/src/lib/utilities/PersistedState/PersistedState.svelte.ts new file mode 100644 index 00000000..ebdb1330 --- /dev/null +++ b/packages/runed/src/lib/utilities/PersistedState/PersistedState.svelte.ts @@ -0,0 +1,198 @@ +import { untrack } from "svelte"; +import { BROWSER, DEV } from "esm-env"; +import { addEventListener } from "$lib/internal/utils/event.js"; + +type Serializer = { + serialize: (value: T) => string; + deserialize: (value: string) => T; +}; + +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; +} + +export class WebStorageAdapter implements StorageAdapter { + #storage: Storage; + #serializer: Serializer; + + constructor({ + storage, + serializer = { + serialize: JSON.stringify, + deserialize: JSON.parse, + }, + }: { + storage: Storage; + serializer?: Serializer; + }) { + this.#storage = storage; + this.#serializer = serializer; + } + + 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: T): Promise { + const serializedValue = this.#serializer.serialize(value); + this.#storage.setItem(key, serializedValue); + } + + subscribe(callback: (key: string, newValue: GetItemResult) => void): () => void { + const listener = (event: StorageEvent) => { + if (event.key === null) return; + + 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)); + + return () => { + unsubscribe(); + }; + } +} + +async function setValueToStorage({ + key, + value, + storage, +}: { + key: string; + value: T; + storage: StorageAdapter | null; +}) { + if (!storage) return; + + try { + await storage.setItem(key, value); + } catch (e) { + if (!DEV) return; + console.error( + `Error when writing value from persisted store "${key}" to ${storage.constructor.name}`, + e + ); + } +} + +function getWebStorageAdapterForStorageType(storageType: StorageType): StorageAdapter | null { + if (!BROWSER) return null; + + const webStorageAdapterByStorageType = { + local: new WebStorageAdapter({ storage: localStorage }), + session: new WebStorageAdapter({ storage: sessionStorage }), + }; + + return webStorageAdapterByStorageType[storageType]; +} + +type StorageType = "local" | "session"; + +type PersistedStateOptions = { + /** + * The storage type to use. + * @defaultValue `local` + */ + storage?: StorageType | StorageAdapter; + /** + * Whether to synchronize the state with changes detected via the `subscribe` callback. + * This allows the state to be updated in response to changes originating from various sources, + * including other browser tabs or sessions when using web storage like localStorage or + * sessionStorage. + * + * @defaultValue `true` + */ + syncChanges?: 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; + #isInitialized = $state(false); + #initialValue: T; + #key: string; + #storageAdapter: StorageAdapter | null; + + constructor(key: string, initialValue: T, options: PersistedStateOptions = {}) { + const { storage = "local", syncChanges = true } = options; + + this.#key = key; + this.#initialValue = initialValue; + this.#storageAdapter = + typeof storage === "string" ? getWebStorageAdapterForStorageType(storage) : storage; + + $effect(() => { + if (!this.#isInitialized) return; + + setValueToStorage({ + key: this.#key, + value: this.#current, + storage: this.#storageAdapter, + }); + }); + + if (syncChanges) { + $effect(() => { + return untrack(() => { + if (!this.#storageAdapter?.subscribe) return; + + const unsubscribe = this.#storageAdapter + .subscribe(async (key, newValue) => { + if (key !== this.#key || !newValue.found) return; + this.#current = newValue.value; + }) + .bind(this); + + return unsubscribe; + }); + }); + } + + this.init(); + } + + async init() { + if (!this.#storageAdapter) return; + + const valueFromStorage = await this.#storageAdapter.getItem(this.#key); + + this.#current = valueFromStorage.found ? valueFromStorage.value : this.#initialValue; + this.#isInitialized = true; + } + + get current(): T { + 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 new file mode 100644 index 00000000..f2edf143 --- /dev/null +++ b/packages/runed/src/lib/utilities/PersistedState/PersistedState.test.svelte.ts @@ -0,0 +1,112 @@ +import { describe, expect } from "vitest"; + +import { Persisted, WebStorageAdapter } from "./index.js"; +import { testWithEffect } from "$lib/test/util.svelte.js"; + +const key = "test-key"; +const initialValue = "test-value"; +const existingValue = "existing-value"; + +describe("PersistedState", () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + describe("localStorage", () => { + testWithEffect("uses initial value if no persisted value is found", () => { + 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 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 Persisted(key, initialValue); + expect(persistedState.current).toBe(initialValue); + persistedState.current = "new-value"; + expect(persistedState.current).toBe("new-value"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(localStorage.getItem(key)).toBe(JSON.stringify("new-value")); + }); + }); + + describe("sessionStorage", () => { + testWithEffect("uses initial value if no persisted value is found", () => { + 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 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 Persisted(key, initialValue, { storage: "session" }); + expect(persistedState.current).toBe(initialValue); + persistedState.current = "new-value"; + expect(persistedState.current).toBe("new-value"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(sessionStorage.getItem(key)).toBe(JSON.stringify("new-value")); + }); + }); + + describe("serializer", () => { + testWithEffect("uses provided serializer", async () => { + const isoDate = "2024-01-01T00:00:00.000Z"; + const date = new Date(isoDate); + + const serializer = { + serialize: (value: Date) => { + return value.toISOString(); + }, + deserialize: (value: string) => { + return new Date(value); + }, + }; + const persistedState = new Persisted(key, date, { + storage: new WebStorageAdapter({ + storage: localStorage, + serializer, + }), + }); + // persistedState.current = date; + // await new Promise((resolve) => setTimeout(resolve, 50)); + expect(persistedState.current).toBe(date); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(persistedState.current).toBe(date); + expect(localStorage.getItem(key)).toBe(isoDate); + }); + }); + + describe.skip("syncTabs", () => { + testWithEffect("updates persisted value when local storage changes independently", async () => { + // TODO: figure out why this test is failing even though it works in the browser. maybe jsdom doesn't emit storage events? + // expect(true).toBe(true); + // const persistedState = new PersistedState(key, initialValue); + // localStorage.setItem(key, JSON.stringify("new-value")); + // await new Promise((resolve) => setTimeout(resolve, 0)); + // expect(persistedState.current).toBe("new-value"); + }); + + // TODO: this test passes, but likely only because the storage event is not being emitted either way from jsdom + testWithEffect( + "does not update persisted value when local storage changes independently if syncTabs is false", + async () => { + const persistedState = new Persisted(key, initialValue, { syncChanges: false }); + localStorage.setItem(key, JSON.stringify("new-value")); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(persistedState.current).toBe(initialValue); + } + ); + }); +}); diff --git a/packages/runed/src/lib/utilities/PersistedState/index.ts b/packages/runed/src/lib/utilities/PersistedState/index.ts new file mode 100644 index 00000000..715881b9 --- /dev/null +++ b/packages/runed/src/lib/utilities/PersistedState/index.ts @@ -0,0 +1 @@ +export * from "./PersistedState.svelte.js"; \ No newline at end of file diff --git a/packages/runed/src/lib/utilities/index.ts b/packages/runed/src/lib/utilities/index.ts index e9614b7d..b5f8165f 100644 --- a/packages/runed/src/lib/utilities/index.ts +++ b/packages/runed/src/lib/utilities/index.ts @@ -20,3 +20,4 @@ export * from "./AnimationFrames/index.js"; export * from "./useIntersectionObserver/index.js"; export * from "./IsFocusWithin/index.js"; export * from "./FiniteStateMachine/index.js"; +export * from "./PersistedState/index.js"; \ No newline at end of file diff --git a/sites/docs/content/utilities/persisted-state.md b/sites/docs/content/utilities/persisted-state.md new file mode 100644 index 00000000..a00c3781 --- /dev/null +++ b/sites/docs/content/utilities/persisted-state.md @@ -0,0 +1,49 @@ +--- +title: Persisted +description: + Create reactive state that is persisted and synchronized across browser sessions and tabs using + Web Storage. +category: State +--- + + + +## Demo + + + +## Usage + +`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 + + +
+ + + +

Count: {count.current}

+
+``` + +`Persisted` also includes an `options` object. + +```ts +{ + storage: 'session', // Specifies whether to use local or session storage. Default is 'local'. + syncTabs: false, // Indicates if changes should sync across tabs. Default is true. + serializer: { + serialize: superjson.stringify, // Custom serialization function. Default is JSON.stringify. + deserialize: superjson.parse // Custom deserialization function. Default is JSON.parse. + } +} +``` diff --git a/sites/docs/src/lib/components/demos/persisted-state.svelte b/sites/docs/src/lib/components/demos/persisted-state.svelte new file mode 100644 index 00000000..c14d8d2b --- /dev/null +++ b/sites/docs/src/lib/components/demos/persisted-state.svelte @@ -0,0 +1,20 @@ + + + + + + +
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. + +