diff --git a/src/lib/util/persist.svelte.test.ts b/src/lib/util/persist.svelte.test.ts new file mode 100644 index 00000000..3776bb54 --- /dev/null +++ b/src/lib/util/persist.svelte.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { persisted, readJSON, writeJSON } from './persist.svelte'; + +beforeEach(() => { + window.localStorage.clear(); +}); + +describe('readJSON', () => { + it('returns the fallback when the key is missing', () => { + expect(readJSON('missing', 'fallback')).toBe('fallback'); + }); + + it('returns the parsed value when present', () => { + window.localStorage.setItem('key', '{"a":1}'); + expect(readJSON<{ a: number }>('key', { a: 0 })).toEqual({ a: 1 }); + }); + + it('returns the fallback when the stored value is corrupt', () => { + window.localStorage.setItem('corrupt', '{oops'); + expect(readJSON('corrupt', 'fallback')).toBe('fallback'); + // The previous persistence layer could write the literal string "undefined". + window.localStorage.setItem('legacy', 'undefined'); + expect(readJSON('legacy', 'fallback')).toBe('fallback'); + }); +}); + +describe('writeJSON', () => { + it('round-trips values through localStorage as JSON', () => { + writeJSON('key', { nested: { value: 2 } }); + expect(window.localStorage.getItem('key')).toBe('{"nested":{"value":2}}'); + expect(readJSON('key', {})).toEqual({ nested: { value: 2 } }); + }); +}); + +describe('persisted', () => { + it('initialises from storage when a value exists', () => { + window.localStorage.setItem('counter', '5'); + const counter = persisted('counter', 0); + expect(counter.value).toBe(5); + }); + + it('uses the initial value when storage is empty, without writing it', () => { + const counter = persisted('counter', 7); + expect(counter.value).toBe(7); + expect(window.localStorage.getItem('counter')).toBeNull(); + }); + + it('persists on assignment and exposes the new value', () => { + const counter = persisted('counter', 0); + counter.value = 42; + expect(counter.value).toBe(42); + expect(window.localStorage.getItem('counter')).toBe('42'); + }); +}); diff --git a/src/lib/util/persist.svelte.ts b/src/lib/util/persist.svelte.ts new file mode 100644 index 00000000..5204af25 --- /dev/null +++ b/src/lib/util/persist.svelte.ts @@ -0,0 +1,46 @@ +/** + * Runes-based localStorage persistence, shared by the persisted state in + * `state.svelte.ts`, `migrations.svelte.ts`, `promo.svelte.ts` and History. + * + * Values are stored as plain JSON. Reads of missing or corrupt values fall + * back to the provided default, so values written by older versions of the + * editor (which serialized plain objects to the same JSON shape) stay loadable. + */ + +const hasStorage = (): boolean => typeof window !== 'undefined' && !!window.localStorage; + +export const readJSON = (key: string, fallback: T): T => { + if (!hasStorage()) { + return fallback; + } + try { + const raw = window.localStorage.getItem(key); + return raw === null ? fallback : (JSON.parse(raw) as T); + } catch { + return fallback; + } +}; + +export const writeJSON = (key: string, value: unknown): void => { + if (hasStorage()) { + window.localStorage.setItem(key, JSON.stringify(value)); + } +}; + +export interface Persisted { + value: T; +} + +// A localStorage-backed reactive value. Reads on init, writes on every set. +export const persisted = (key: string, initial: T): Persisted => { + let value = $state(readJSON(key, initial)); + return { + get value() { + return value; + }, + set value(next: T) { + value = next; + writeJSON(key, next); + } + }; +};