diff --git a/.changeset/spotty-impalas-grow.md b/.changeset/spotty-impalas-grow.md new file mode 100644 index 0000000..d1aba28 --- /dev/null +++ b/.changeset/spotty-impalas-grow.md @@ -0,0 +1,5 @@ +--- +"@codebelt/classy-store": patch +--- + +core improvements and tests diff --git a/examples/angular/src/store.ts b/examples/angular/src/store.ts index 4bcfe9f..9aa9c21 100644 --- a/examples/angular/src/store.ts +++ b/examples/angular/src/store.ts @@ -36,6 +36,7 @@ export const counterStore = createClassyStore(new CounterStore()); export const persistHandle = persist(counterStore, { name: 'classy-angular-example', + storage: localStorage, properties: ['count', 'step', 'label'], }); diff --git a/examples/nextjs/src/stores/_storage.ts b/examples/nextjs/src/stores/_storage.ts new file mode 100644 index 0000000..5d4732a --- /dev/null +++ b/examples/nextjs/src/stores/_storage.ts @@ -0,0 +1,27 @@ +/** + * SSR-safe storage adapter. Uses real `localStorage` in the browser; falls + * back to a per-process in-memory map on the server so module-init + * `persist()` calls never throw. + */ +const memory = new Map(); + +export const ssrSafeLocalStorage = { + getItem(key: string): string | null { + if (typeof localStorage !== 'undefined') return localStorage.getItem(key); + return memory.get(key) ?? null; + }, + setItem(key: string, value: string): void { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(key, value); + return; + } + memory.set(key, value); + }, + removeItem(key: string): void { + if (typeof localStorage !== 'undefined') { + localStorage.removeItem(key); + return; + } + memory.delete(key); + }, +}; diff --git a/examples/nextjs/src/stores/planner-store.ts b/examples/nextjs/src/stores/planner-store.ts index 0efb490..46c184b 100644 --- a/examples/nextjs/src/stores/planner-store.ts +++ b/examples/nextjs/src/stores/planner-store.ts @@ -1,5 +1,6 @@ import {createClassyStore} from '@codebelt/classy-store'; import {devtools, persist} from '@codebelt/classy-store/utils'; +import {ssrSafeLocalStorage} from './_storage'; export type MealSlot = { recipeId: string | null; @@ -82,6 +83,7 @@ export const plannerStore = createClassyStore(new PlannerStore()); export const plannerPersist = persist(plannerStore, { name: 'classy-meal-planner', + storage: ssrSafeLocalStorage, properties: ['days'], debounce: 500, version: 2, diff --git a/examples/nextjs/src/stores/settings-store.ts b/examples/nextjs/src/stores/settings-store.ts index 49c25bb..4b38a62 100644 --- a/examples/nextjs/src/stores/settings-store.ts +++ b/examples/nextjs/src/stores/settings-store.ts @@ -1,5 +1,6 @@ import {createClassyStore} from '@codebelt/classy-store'; import {devtools, persist} from '@codebelt/classy-store/utils'; +import {ssrSafeLocalStorage} from './_storage'; class SettingsStore { theme = 'system' as 'light' | 'dark' | 'system'; @@ -14,8 +15,8 @@ export const settingsStore = createClassyStore(new SettingsStore()); export const settingsPersist = persist(settingsStore, { name: 'classy-kitchen-settings', + storage: ssrSafeLocalStorage, debounce: 300, - merge: 'replace', }); devtools(settingsStore, {name: 'Settings Store'}); diff --git a/examples/nextjs/src/stores/shopping-store.ts b/examples/nextjs/src/stores/shopping-store.ts index 4f0c9eb..5c8086b 100644 --- a/examples/nextjs/src/stores/shopping-store.ts +++ b/examples/nextjs/src/stores/shopping-store.ts @@ -1,6 +1,7 @@ import {createClassyStore} from '@codebelt/classy-store'; import type {PropertyTransform} from '@codebelt/classy-store/utils'; import {devtools, persist, subscribeKey} from '@codebelt/classy-store/utils'; +import {ssrSafeLocalStorage} from './_storage'; export type ShoppingItem = { id: string; @@ -84,6 +85,7 @@ const itemsTransform: PropertyTransform = { export const shoppingPersist = persist(shoppingStore, { name: 'classy-shopping-list', + storage: ssrSafeLocalStorage, properties: [itemsTransform, 'lastAction'], merge: (persisted, current) => ({ ...current, diff --git a/examples/react/src/demos/persist/KitchenSinkPersistDemo.tsx b/examples/react/src/demos/persist/KitchenSinkPersistDemo.tsx index 09f26d2..4ae8573 100644 --- a/examples/react/src/demos/persist/KitchenSinkPersistDemo.tsx +++ b/examples/react/src/demos/persist/KitchenSinkPersistDemo.tsx @@ -20,6 +20,7 @@ const STORE_CODE = `class KitchenSinkStore { persist(store, { name: 'kitchen-sink', + storage: localStorage, debounce: 300, version: 1, merge: 'shallow', diff --git a/examples/react/src/demos/persist/SimplePersistDemo.tsx b/examples/react/src/demos/persist/SimplePersistDemo.tsx index 65792ae..6127984 100644 --- a/examples/react/src/demos/persist/SimplePersistDemo.tsx +++ b/examples/react/src/demos/persist/SimplePersistDemo.tsx @@ -32,7 +32,7 @@ const STORE_CODE = `class PreferencesStore { } const store = createClassyStore(new PreferencesStore()); -persist(store, { name: 'preferences' });`; +persist(store, { name: 'preferences', storage: localStorage });`; function ThemeToggle() { const theme = useClassyStore(preferencesStore, (state) => state.theme); @@ -183,7 +183,7 @@ export function SimplePersistDemo() { return (
diff --git a/examples/react/src/pages/PersistPage.tsx b/examples/react/src/pages/PersistPage.tsx index 607f00c..338add1 100644 --- a/examples/react/src/pages/PersistPage.tsx +++ b/examples/react/src/pages/PersistPage.tsx @@ -23,9 +23,9 @@ export function PersistPage() { - The simplest setup is just{' '} + The simplest setup is{' '} - persist(store, {'{'} name: 'key' {'}'}) + persist(store, {'{'} name: 'key', storage: localStorage {'}'}) . Add{' '} properties{' '} diff --git a/examples/react/src/stores/persistStores.ts b/examples/react/src/stores/persistStores.ts index 79317e6..02e5f35 100644 --- a/examples/react/src/stores/persistStores.ts +++ b/examples/react/src/stores/persistStores.ts @@ -48,6 +48,7 @@ export const preferencesStore = createClassyStore(new PreferencesStore()); export const preferencesHandle = persist(preferencesStore, { name: 'preferences', + storage: localStorage, }); // ── Kitchen Sink Store ────────────────────────────────────────────────────── @@ -150,6 +151,7 @@ export const kitchenSinkStore = createClassyStore(new KitchenSinkStore()); export const kitchenSinkHandle = persist(kitchenSinkStore, { name: 'kitchen-sink', + storage: localStorage, debounce: 300, version: 1, merge: 'shallow', diff --git a/examples/solid/src/store.ts b/examples/solid/src/store.ts index 7999262..6224b54 100644 --- a/examples/solid/src/store.ts +++ b/examples/solid/src/store.ts @@ -36,6 +36,7 @@ export const counterStore = createClassyStore(new CounterStore()); export const persistHandle = persist(counterStore, { name: 'classy-solid-example', + storage: localStorage, properties: ['count', 'step', 'label'], }); diff --git a/examples/svelte/src/store.ts b/examples/svelte/src/store.ts index ab99b99..b361213 100644 --- a/examples/svelte/src/store.ts +++ b/examples/svelte/src/store.ts @@ -37,6 +37,7 @@ export const counterStore = createClassyStore(new CounterStore()); export const persistHandle = persist(counterStore, { name: 'classy-svelte-example', + storage: localStorage, properties: ['count', 'step', 'label'], }); diff --git a/examples/vue/src/store.ts b/examples/vue/src/store.ts index 27816eb..a017c09 100644 --- a/examples/vue/src/store.ts +++ b/examples/vue/src/store.ts @@ -36,6 +36,7 @@ export const counterStore = createClassyStore(new CounterStore()); export const persistHandle = persist(counterStore, { name: 'classy-vue-example', + storage: localStorage, properties: ['count', 'step', 'label'], }); diff --git a/packages/classy-store/src/collections/collections.test.ts b/packages/classy-store/src/collections/collections.test.ts index f60c0c4..5902617 100644 --- a/packages/classy-store/src/collections/collections.test.ts +++ b/packages/classy-store/src/collections/collections.test.ts @@ -336,6 +336,46 @@ describe('reactiveMap() — edge cases', () => { ['y', 20], ]); }); + + test('keys() / values() / entries() return snapshot iterators, not live iterators', () => { + const m = reactiveMap([ + ['a', 1], + ['b', 2], + ['c', 3], + ]); + const keysIter = m.keys(); + const valuesIter = m.values(); + const entriesIter = m.entries(); + + m.set('d', 4); + m.delete('a'); + + expect([...keysIter]).toEqual(['a', 'b', 'c']); + expect([...valuesIter]).toEqual([1, 2, 3]); + expect([...entriesIter]).toEqual([ + ['a', 1], + ['b', 2], + ['c', 3], + ]); + }); + + test('large initial iterable with duplicates dedupes correctly (last value wins)', () => { + const initial: [number, string][] = []; + for (let i = 0; i < 1000; i++) { + initial.push([i, `v${i}`]); + } + // Add duplicates that overwrite the first half with new values. + for (let i = 0; i < 500; i++) { + initial.push([i, `dup${i}`]); + } + + const m = reactiveMap(initial); + expect(m.size).toBe(1000); + expect(m.get(0)).toBe('dup0'); + expect(m.get(499)).toBe('dup499'); + expect(m.get(500)).toBe('v500'); + expect(m.get(999)).toBe('v999'); + }); }); // ── ReactiveSet — additional edge cases ──────────────────────────────────── diff --git a/packages/classy-store/src/collections/collections.ts b/packages/classy-store/src/collections/collections.ts index 14962ad..d2f04e5 100644 --- a/packages/classy-store/src/collections/collections.ts +++ b/packages/classy-store/src/collections/collections.ts @@ -24,11 +24,14 @@ export class ReactiveMap { /** Deduplicates initial entries by key (last value wins, matching native `Map`). */ constructor(initial?: Iterable<[K, V]>) { if (initial) { + // Track key → index for O(1) dedupe instead of O(n) linear scan. + const indexByKey = new Map(); for (const [k, v] of initial) { - const index = this._entries.findIndex(([ek]) => Object.is(ek, k)); - if (index !== -1) { - this._entries[index] = [k, v]; + const existing = indexByKey.get(k); + if (existing !== undefined) { + this._entries[existing] = [k, v]; } else { + indexByKey.set(k, this._entries.length); this._entries.push([k, v]); } } @@ -75,19 +78,64 @@ export class ReactiveMap { this._entries.splice(0, this._entries.length); } - /** Returns an iterator over the keys. */ + /** + * Returns an iterator over the keys. + * Snapshot semantics: the iterator reflects the entries at call time and + * is unaffected by subsequent mutations. + */ keys(): IterableIterator { - return this._entries.map(([k]) => k)[Symbol.iterator](); + const snap = this._entries.slice(); + let i = 0; + return { + next: () => + i < snap.length + ? {value: snap[i++][0], done: false} + : {value: undefined as unknown as K, done: true}, + [Symbol.iterator]() { + return this; + }, + }; } - /** Returns an iterator over the values. */ + /** + * Returns an iterator over the values. + * Snapshot semantics: the iterator reflects the entries at call time and + * is unaffected by subsequent mutations. + */ values(): IterableIterator { - return this._entries.map(([, v]) => v)[Symbol.iterator](); + const snap = this._entries.slice(); + let i = 0; + return { + next: () => + i < snap.length + ? {value: snap[i++][1], done: false} + : {value: undefined as unknown as V, done: true}, + [Symbol.iterator]() { + return this; + }, + }; } - /** Returns an iterator over [key, value] pairs. */ + /** + * Returns an iterator over [key, value] pairs. + * Snapshot semantics: the iterator reflects the entries at call time and + * is unaffected by subsequent mutations. + */ entries(): IterableIterator<[K, V]> { - return this._entries.map(([k, v]) => [k, v] as [K, V])[Symbol.iterator](); + const snap = this._entries.slice(); + let i = 0; + return { + next: () => { + if (i >= snap.length) { + return {value: undefined as unknown as [K, V], done: true}; + } + const e = snap[i++]; + return {value: [e[0], e[1]] as [K, V], done: false}; + }, + [Symbol.iterator]() { + return this; + }, + }; } /** Calls `callback` for each entry, matching the native `Map.forEach` signature. */ @@ -127,8 +175,11 @@ export class ReactiveSet { /** Deduplicates initial values using `Object.is` comparison. */ constructor(initial?: Iterable) { if (initial) { + // Use a Set for O(1) dedupe (Set uses SameValueZero — NaN-aware, like Object.is for our purposes). + const seen = new Set(); for (const v of initial) { - if (!this._items.some((item) => Object.is(item, v))) { + if (!seen.has(v)) { + seen.add(v); this._items.push(v); } } @@ -166,19 +217,54 @@ export class ReactiveSet { this._items.splice(0, this._items.length); } - /** Returns an iterator over the values (same as `values()`, matching Set API). */ + /** + * Returns an iterator over the values (same as `values()`, matching Set API). + * Snapshot semantics: the iterator reflects the items at call time and + * is unaffected by subsequent mutations. + */ keys(): IterableIterator { - return this._items.map((v) => v)[Symbol.iterator](); + return this.values(); } - /** Returns an iterator over the values. */ + /** + * Returns an iterator over the values. + * Snapshot semantics: the iterator reflects the items at call time and + * is unaffected by subsequent mutations. + */ values(): IterableIterator { - return this._items.map((v) => v)[Symbol.iterator](); + const snap = this._items.slice(); + let i = 0; + return { + next: () => + i < snap.length + ? {value: snap[i++], done: false} + : {value: undefined as unknown as T, done: true}, + [Symbol.iterator]() { + return this; + }, + }; } - /** Returns an iterator over [value, value] pairs, matching the native Set API. */ + /** + * Returns an iterator over [value, value] pairs, matching the native Set API. + * Snapshot semantics: the iterator reflects the items at call time and + * is unaffected by subsequent mutations. + */ entries(): IterableIterator<[T, T]> { - return this._items.map((v) => [v, v] as [T, T])[Symbol.iterator](); + const snap = this._items.slice(); + let i = 0; + return { + next: () => { + if (i >= snap.length) { + return {value: undefined as unknown as [T, T], done: true}; + } + const v = snap[i++]; + return {value: [v, v] as [T, T], done: false}; + }, + [Symbol.iterator]() { + return this; + }, + }; } /** Calls `callback` for each item, matching the native `Set.forEach` signature. */ diff --git a/packages/classy-store/src/core/core.test.ts b/packages/classy-store/src/core/core.test.ts index 372e138..29111d0 100644 --- a/packages/classy-store/src/core/core.test.ts +++ b/packages/classy-store/src/core/core.test.ts @@ -747,4 +747,118 @@ describe('createClassyStore() — core reactivity', () => { expect(secondListener).toHaveBeenCalledTimes(1); }); }); + + describe('bound method cache invalidation', () => { + it('reassigning a method returns the new function on next access', async () => { + class Store { + count = 0; + bump() { + this.count++; + } + } + const s = createClassyStore(new Store()); + + // Cache the original bound method by accessing it. + const original = s.bump; + expect(s.bump).toBe(original); // cached + + // Reassign. + const replacement = function (this: Store) { + this.count += 100; + }; + (s as unknown as {bump: () => void}).bump = replacement; + await flush(); + + // New access must NOT return the stale cached binding. + expect(s.bump).not.toBe(original); + s.bump(); + expect(s.count).toBe(100); + }); + + it('deleting a reassigned own method clears the bound cache; re-adding returns a fresh binding', async () => { + const s = createClassyStore({ + count: 0, + bump(this: {count: number}) { + this.count++; + }, + }); + + // Cache the original binding. + const original = s.bump; + expect(s.bump).toBe(original); + + // Delete the own property. + delete (s as Partial).bump; + await flush(); + expect(s.bump).toBeUndefined(); + + // Re-add — must return a fresh binding, not the cached old one. + (s as {bump?: () => void}).bump = function (this: {count: number}) { + this.count += 5; + }; + await flush(); + + expect(s.bump).not.toBe(original); + s.bump?.(); + expect(s.count).toBe(5); + }); + }); + + describe('computed getter error handling', () => { + it('a getter that throws propagates the error and leaves the store usable', async () => { + class Store { + count = 0; + get bad(): number { + if (this.count === 0) throw new Error('not ready'); + return this.count * 2; + } + } + const s = createClassyStore(new Store()); + + expect(() => s.bad).toThrow('not ready'); + + // Store still mutable + reactive after throw. + const listener = mock(() => {}); + subscribe(s, listener); + s.count = 3; + await flush(); + expect(listener).toHaveBeenCalledTimes(1); + expect(s.bad).toBe(6); + }); + + it('throws a clear error on circular getter dependency (A → B → A)', () => { + class Store { + get a(): number { + return (this as unknown as Store).b + 1; + } + get b(): number { + return (this as unknown as Store).a + 1; + } + } + const s = createClassyStore(new Store()); + + expect(() => s.a).toThrow(/circular computed getter dependency/); + expect(() => s.a).toThrow(/a → b → a/); + }); + + it('throws a clear error on self-referencing getter', () => { + class Store { + get loop(): number { + return (this as unknown as Store).loop + 1; + } + } + const s = createClassyStore(new Store()); + expect(() => s.loop).toThrow(/circular computed getter dependency/); + }); + }); + + describe('deep version propagation', () => { + it('mutation 4 levels deep bumps the root version', async () => { + const s = createClassyStore({a: {b: {c: {d: 0}}}}); + const v0 = getVersion(s); + s.a.b.c.d = 99; + await flush(); + expect(getVersion(s)).toBeGreaterThan(v0); + }); + }); }); diff --git a/packages/classy-store/src/core/core.ts b/packages/classy-store/src/core/core.ts index da92828..3b435da 100644 --- a/packages/classy-store/src/core/core.ts +++ b/packages/classy-store/src/core/core.ts @@ -16,7 +16,11 @@ const internalsMap = new WeakMap(); * a getter reads during evaluation. A stack (not a single variable) is needed * because getter A can read getter B, which pushes a second tracker. */ -const trackerStack: {internal: StoreInternal; deps: DepEntry[]}[] = []; +const trackerStack: { + internal: StoreInternal; + prop: string | symbol; + deps: DepEntry[]; +}[] = []; /** Returns the tracker currently recording deps, or `null` if none is active. */ function activeTracker() { @@ -120,8 +124,23 @@ function evaluateComputed( return cached.value; } + // Cycle detection: if this (internal, prop) pair is already being + // evaluated higher in the stack, the getter chain is recursive. + for (const existing of trackerStack) { + if (existing.internal === internal && existing.prop === prop) { + const cycle = trackerStack + .slice(trackerStack.indexOf(existing)) + .map((f) => String(f.prop)) + .concat(String(prop)) + .join(' → '); + throw new Error( + `@codebelt/classy-store: circular computed getter dependency: ${cycle}`, + ); + } + } + // Push a new tracker frame for this getter evaluation. - const frame = {internal, deps: [] as DepEntry[]}; + const frame = {internal, prop, deps: [] as DepEntry[]}; trackerStack.push(frame); try { const value = getterFn.call(receiver); @@ -232,6 +251,9 @@ function createStoreProxy( internal.childInternals.delete(prop); } + // Drop any cached bound method — the previous binding is dead now. + boundMethods.delete(prop); + Reflect.set(_target, prop, value); scheduleNotify(internal); return true; @@ -293,6 +315,7 @@ function createStoreProxy( internal.childProxies.delete(prop); internal.childInternals.delete(prop); } + boundMethods.delete(prop); const deleted = Reflect.deleteProperty(_target, prop); if (deleted) { scheduleNotify(internal); diff --git a/packages/classy-store/src/frameworks/angular/angular.test.ts b/packages/classy-store/src/frameworks/angular/angular.test.ts new file mode 100644 index 0000000..8e052a2 --- /dev/null +++ b/packages/classy-store/src/frameworks/angular/angular.test.ts @@ -0,0 +1,55 @@ +import {describe, expect, it} from 'bun:test'; +import {Injector, runInInjectionContext} from '@angular/core'; +import {createClassyStore} from '../../core/core'; +import {injectStore} from './angular'; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +/** + * Run `fn` inside an Angular injection context with a fresh injector that + * exposes a `DestroyRef`. Returns whatever `fn` returns plus a `destroy` + * function to trigger lifecycle teardown. + */ +function withInjector(fn: () => T): {result: T; destroy: () => void} { + const injector = Injector.create({providers: []}); + const result = runInInjectionContext(injector, fn); + return {result, destroy: () => injector.destroy()}; +} + +describe('angular injectStore', () => { + it('throws when given a non-store object', () => { + const {destroy} = withInjector(() => { + expect(() => injectStore({count: 0})).toThrow(/not a store proxy/); + }); + destroy(); + }); + + it('returns a Signal with the current snapshot', () => { + const s = createClassyStore({count: 11}); + const {result: state, destroy} = withInjector(() => injectStore(s)); + expect(state()).toEqual({count: 11}); + destroy(); + }); + + it('signal updates when the store mutates', async () => { + const s = createClassyStore({count: 0}); + const {result: state, destroy} = withInjector(() => injectStore(s)); + + s.count = 33; + await flush(); + expect(state()).toEqual({count: 33}); + destroy(); + }); + + it('unsubscribes on injector destroy', async () => { + const s = createClassyStore({count: 0}); + const {result: state, destroy} = withInjector(() => injectStore(s)); + + const before = state(); + destroy(); + + s.count = 999; + await flush(); + expect(state()).toBe(before); + }); +}); diff --git a/packages/classy-store/src/frameworks/angular/angular.ts b/packages/classy-store/src/frameworks/angular/angular.ts index f7006e0..a04d311 100644 --- a/packages/classy-store/src/frameworks/angular/angular.ts +++ b/packages/classy-store/src/frameworks/angular/angular.ts @@ -5,13 +5,16 @@ import { signal, type WritableSignal, } from '@angular/core'; -import {subscribe} from '../../core/core'; +import {getInternal, subscribe} from '../../core/core'; import {snapshot} from '../../snapshot/snapshot'; import type {Snapshot} from '../../types'; export function injectStore( proxyStore: T, ): Signal> { + // Validate up front so users get a clear error instead of an opaque trace. + getInternal(proxyStore); + const state: WritableSignal> = signal(snapshot(proxyStore)); const destroyRef = inject(DestroyRef); diff --git a/packages/classy-store/src/frameworks/react/react.test.tsx b/packages/classy-store/src/frameworks/react/react.test.tsx index d1f4b6a..46b1ee9 100644 --- a/packages/classy-store/src/frameworks/react/react.test.tsx +++ b/packages/classy-store/src/frameworks/react/react.test.tsx @@ -683,4 +683,82 @@ describe('useClassyStore — selector edge cases', () => { expect(container.textContent).toBe('Jane Doe'); }); + + it('selector returning undefined twice in a row does not over-invoke selector', async () => { + // Pre-fix, the fast path used `resultRef.current !== undefined` which + // skipped memoization whenever the selector legitimately returned + // `undefined`. With `hasResultRef` the fast path engages. + const s = createClassyStore({maybe: undefined as string | undefined}); + const renderCount = mock(() => {}); + const selector = mock((state: {maybe: string | undefined}) => state.maybe); + + function Display() { + const v = useClassyStore(s, selector); + renderCount(); + return
{v ?? 'none'}
; + } + + setup(); + render(); + expect(container.textContent).toBe('none'); + const initialRenders = renderCount.mock.calls.length; + const initialSelectorCalls = selector.mock.calls.length; + + // Mutate an unrelated absent property — but this object only has `maybe`, + // so to force a re-evaluation, change to undefined explicitly. + await act(async () => { + s.maybe = undefined; + await flush(); + }); + + // No notification should fire (same value), so no extra renders. + expect(renderCount).toHaveBeenCalledTimes(initialRenders); + expect(selector.mock.calls.length).toBe(initialSelectorCalls); + }); + + it('selector transitioning from undefined to defined triggers a re-render', async () => { + const s = createClassyStore({maybe: undefined as string | undefined}); + + function Display() { + const v = useClassyStore(s, (state) => state.maybe); + return
{v ?? 'none'}
; + } + + setup(); + render(); + expect(container.textContent).toBe('none'); + + await act(async () => { + s.maybe = 'hello'; + await flush(); + }); + expect(container.textContent).toBe('hello'); + }); + + it('selector returning null is stable across mutations', async () => { + const s = createClassyStore({count: 0}); + const renderCount = mock(() => {}); + + function Display() { + // Always returns null regardless of state. + const v = useClassyStore(s, (_state) => null); + renderCount(); + return
{v === null ? 'null' : String(v)}
; + } + + setup(); + render(); + expect(container.textContent).toBe('null'); + const before = renderCount.mock.calls.length; + + await act(async () => { + s.count = 1; + await flush(); + s.count = 2; + await flush(); + }); + + // Selector returns the same `null` each time → no re-renders. + expect(renderCount).toHaveBeenCalledTimes(before); + }); }); diff --git a/packages/classy-store/src/frameworks/react/react.ts b/packages/classy-store/src/frameworks/react/react.ts index 72cd92a..c0773e5 100644 --- a/packages/classy-store/src/frameworks/react/react.ts +++ b/packages/classy-store/src/frameworks/react/react.ts @@ -57,6 +57,9 @@ export function useClassyStore( // Selector mode refs const snapRef = useRef | undefined>(undefined); const resultRef = useRef(undefined); + // Tracks whether `resultRef` holds a real prior result (so a selector that + // legitimately returns `undefined` still benefits from the fast path). + const hasResultRef = useRef(false); // Auto-track mode refs const affected = useRef(new WeakMap()).current; @@ -68,7 +71,14 @@ export function useClassyStore( const getSnapshot = (): Snapshot | S => selector - ? getSelectorSnapshot(proxyStore, snapRef, resultRef, selector, isEqual) + ? getSelectorSnapshot( + proxyStore, + snapRef, + resultRef, + hasResultRef, + selector, + isEqual, + ) : getAutoTrackSnapshot( proxyStore, affected, @@ -96,14 +106,15 @@ function getSelectorSnapshot( proxyStore: T, snapRef: React.RefObject | undefined>, resultRef: React.RefObject, + hasResultRef: React.RefObject, selector: (snap: Snapshot) => S, isEqual?: (a: S, b: S) => boolean, ): S { const nextSnap = snapshot(proxyStore); // Fast path: same snapshot reference → same result. - if (snapRef.current === nextSnap && resultRef.current !== undefined) { - return resultRef.current; + if (snapRef.current === nextSnap && hasResultRef.current) { + return resultRef.current as S; } const nextResult = selector(nextSnap); @@ -111,15 +122,16 @@ function getSelectorSnapshot( // Check equality with previous result. if ( - resultRef.current !== undefined && + hasResultRef.current && (isEqual - ? isEqual(resultRef.current, nextResult) - : Object.is(resultRef.current, nextResult)) + ? isEqual(resultRef.current as S, nextResult) + : Object.is(resultRef.current as S, nextResult)) ) { - return resultRef.current; + return resultRef.current as S; } resultRef.current = nextResult; + hasResultRef.current = true; return nextResult; } diff --git a/packages/classy-store/src/frameworks/solid/solid.test.ts b/packages/classy-store/src/frameworks/solid/solid.test.ts new file mode 100644 index 0000000..2fc6ccb --- /dev/null +++ b/packages/classy-store/src/frameworks/solid/solid.test.ts @@ -0,0 +1,56 @@ +import {describe, expect, it} from 'bun:test'; +import {createRoot} from 'solid-js'; +import {createClassyStore} from '../../core/core'; +import {useClassyStore} from './solid'; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('solid useClassyStore', () => { + it('throws when given a non-store object', () => { + createRoot((dispose) => { + expect(() => useClassyStore({count: 0})).toThrow(/not a store proxy/); + dispose(); + }); + }); + + it('returns a signal accessor with the current snapshot', () => { + const s = createClassyStore({count: 5}); + createRoot((dispose) => { + const get = useClassyStore(s); + expect(get()).toEqual({count: 5}); + dispose(); + }); + }); + + it('signal updates when the store mutates', async () => { + const s = createClassyStore({count: 0}); + let getter!: () => unknown; + let dispose!: () => void; + createRoot((d) => { + getter = useClassyStore(s); + dispose = d; + }); + + s.count = 7; + await flush(); + expect(getter()).toEqual({count: 7}); + dispose(); + }); + + it('cleans up subscription on root dispose', async () => { + const s = createClassyStore({count: 0}); + let getter!: () => unknown; + let dispose!: () => void; + createRoot((d) => { + getter = useClassyStore(s); + dispose = d; + }); + + const before = getter(); + dispose(); + + s.count = 999; + await flush(); + expect(getter()).toBe(before); + }); +}); diff --git a/packages/classy-store/src/frameworks/solid/solid.ts b/packages/classy-store/src/frameworks/solid/solid.ts index a5049db..6792a1a 100644 --- a/packages/classy-store/src/frameworks/solid/solid.ts +++ b/packages/classy-store/src/frameworks/solid/solid.ts @@ -1,11 +1,14 @@ import {createSignal, onCleanup} from 'solid-js'; -import {subscribe} from '../../core/core'; +import {getInternal, subscribe} from '../../core/core'; import {snapshot} from '../../snapshot/snapshot'; import type {Snapshot} from '../../types'; export function useClassyStore( proxyStore: T, ): () => Snapshot { + // Validate up front so users get a clear error instead of an opaque trace. + getInternal(proxyStore); + const [state, setState] = createSignal>(snapshot(proxyStore)); const unsubscribe = subscribe(proxyStore, () => { diff --git a/packages/classy-store/src/frameworks/svelte/svelte.test.ts b/packages/classy-store/src/frameworks/svelte/svelte.test.ts new file mode 100644 index 0000000..2e03ae8 --- /dev/null +++ b/packages/classy-store/src/frameworks/svelte/svelte.test.ts @@ -0,0 +1,42 @@ +import {describe, expect, it, mock} from 'bun:test'; +import {createClassyStore} from '../../core/core'; +import {toSvelteStore} from './svelte'; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('toSvelteStore', () => { + it('throws when given a non-store object', () => { + expect(() => toSvelteStore({count: 0})).toThrow(/not a store proxy/); + }); + + it('calls the run callback immediately with the current snapshot', () => { + const s = createClassyStore({count: 7}); + const store = toSvelteStore(s); + const run = mock((_v: unknown) => {}); + store.subscribe(run); + expect(run).toHaveBeenCalledTimes(1); + expect(run.mock.calls[0][0]).toEqual({count: 7}); + }); + + it('calls run again with a new snapshot when the store mutates', async () => { + const s = createClassyStore({count: 0}); + const store = toSvelteStore(s); + const run = mock((_v: unknown) => {}); + store.subscribe(run); + s.count = 1; + await flush(); + expect(run).toHaveBeenCalledTimes(2); + expect(run.mock.calls[1][0]).toEqual({count: 1}); + }); + + it('returns an unsubscribe function that stops further calls', async () => { + const s = createClassyStore({count: 0}); + const store = toSvelteStore(s); + const run = mock((_v: unknown) => {}); + const unsub = store.subscribe(run); + unsub(); + s.count = 99; + await flush(); + expect(run).toHaveBeenCalledTimes(1); // only the initial sync call + }); +}); diff --git a/packages/classy-store/src/frameworks/svelte/svelte.ts b/packages/classy-store/src/frameworks/svelte/svelte.ts index b459cea..cc33140 100644 --- a/packages/classy-store/src/frameworks/svelte/svelte.ts +++ b/packages/classy-store/src/frameworks/svelte/svelte.ts @@ -1,4 +1,4 @@ -import {subscribe as coreSubscribe} from '../../core/core'; +import {subscribe as coreSubscribe, getInternal} from '../../core/core'; import {snapshot} from '../../snapshot/snapshot'; import type {Snapshot} from '../../types'; @@ -9,6 +9,9 @@ export interface ClassyReadable { export function toSvelteStore( proxyStore: T, ): ClassyReadable> { + // Validate up front so users get a clear error instead of an opaque trace. + getInternal(proxyStore); + return { subscribe(run: (value: Snapshot) => void): () => void { // Svelte contract: call immediately with current value diff --git a/packages/classy-store/src/frameworks/vue/vue.test.ts b/packages/classy-store/src/frameworks/vue/vue.test.ts new file mode 100644 index 0000000..94e941e --- /dev/null +++ b/packages/classy-store/src/frameworks/vue/vue.test.ts @@ -0,0 +1,63 @@ +import {describe, expect, it} from 'bun:test'; +import {effectScope} from 'vue'; +import {createClassyStore} from '../../core/core'; +import {useClassyStore} from './vue'; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('vue useClassyStore', () => { + it('throws when given a non-store object', () => { + const scope = effectScope(); + try { + scope.run(() => { + expect(() => useClassyStore({count: 0})).toThrow(/not a store proxy/); + }); + } finally { + scope.stop(); + } + }); + + it('returns a ShallowRef holding the current snapshot', () => { + const s = createClassyStore({count: 5}); + const scope = effectScope(); + try { + scope.run(() => { + const state = useClassyStore(s); + expect(state.value).toEqual({count: 5}); + }); + } finally { + scope.stop(); + } + }); + + it('updates the ref when the store mutates', async () => { + const s = createClassyStore({count: 0}); + const scope = effectScope(); + let stateRef!: ReturnType>; + scope.run(() => { + stateRef = useClassyStore(s); + }); + + s.count = 42; + await flush(); + expect(stateRef.value).toEqual({count: 42}); + scope.stop(); + }); + + it('unsubscribes when the effect scope is stopped', async () => { + const s = createClassyStore({count: 0}); + const scope = effectScope(); + let stateRef!: ReturnType>; + scope.run(() => { + stateRef = useClassyStore(s); + }); + + scope.stop(); + + const before = stateRef.value; + s.count = 999; + await flush(); + // After unmount, ref should not update further. + expect(stateRef.value).toBe(before); + }); +}); diff --git a/packages/classy-store/src/frameworks/vue/vue.ts b/packages/classy-store/src/frameworks/vue/vue.ts index bbe5579..d78687f 100644 --- a/packages/classy-store/src/frameworks/vue/vue.ts +++ b/packages/classy-store/src/frameworks/vue/vue.ts @@ -1,11 +1,14 @@ -import {onUnmounted, type ShallowRef, shallowRef} from 'vue'; -import {subscribe} from '../../core/core'; +import {onScopeDispose, type ShallowRef, shallowRef} from 'vue'; +import {getInternal, subscribe} from '../../core/core'; import {snapshot} from '../../snapshot/snapshot'; import type {Snapshot} from '../../types'; export function useClassyStore( proxyStore: T, ): ShallowRef> { + // Validate up front so users get a clear error instead of an opaque trace. + getInternal(proxyStore); + const state = shallowRef(snapshot(proxyStore)) as unknown as ShallowRef< Snapshot >; @@ -14,7 +17,9 @@ export function useClassyStore( state.value = snapshot(proxyStore); }); - onUnmounted(unsubscribe); + // `onScopeDispose` runs both on component unmount (setup() runs inside a + // scope) and on standalone `effectScope().stop()`. + onScopeDispose(unsubscribe); return state; } diff --git a/packages/classy-store/src/snapshot/snapshot.test.ts b/packages/classy-store/src/snapshot/snapshot.test.ts index 92612ee..ca7ec3e 100644 --- a/packages/classy-store/src/snapshot/snapshot.test.ts +++ b/packages/classy-store/src/snapshot/snapshot.test.ts @@ -452,5 +452,34 @@ describe('snapshot()', () => { const snap = snapshot(s); expect(snap.count).toBe(42); }); + + it('throws a clear error when the store contains a circular reference', () => { + const raw: {self: unknown; data: {parent: unknown}} = { + self: null, + data: {parent: null}, + }; + raw.self = raw; + raw.data.parent = raw.data; + const s = createClassyStore(raw); + expect(() => snapshot(s)).toThrow(/circular reference/); + }); + + it('throws on a self-referencing array (tracked sub-tree)', () => { + const raw: {items: unknown[]} = {items: []}; + raw.items.push(raw.items); + const s = createClassyStore(raw); + expect(() => snapshot(s)).toThrow(/circular reference/); + }); + + it('throws on a circular reference inside an untracked nested plain object', () => { + // An untracked path triggers when a plain nested object has never been + // read through the proxy (no child internal exists), so snapshot() goes + // through deepFreezeClone for it. We freeze the snapshot at the root + // BEFORE accessing inner refs, so `outer` is untracked at that point. + const inner: {self: unknown} = {self: null}; + inner.self = inner; + const s = createClassyStore({outer: {nested: inner}}); + expect(() => snapshot(s)).toThrow(/circular reference/); + }); }); }); diff --git a/packages/classy-store/src/snapshot/snapshot.ts b/packages/classy-store/src/snapshot/snapshot.ts index 2e4177d..80d1e65 100644 --- a/packages/classy-store/src/snapshot/snapshot.ts +++ b/packages/classy-store/src/snapshot/snapshot.ts @@ -168,13 +168,18 @@ function snapshotValue( value: unknown, parentInternal: StoreInternal, key: string | symbol, + inProgress: WeakSet, ): unknown { const childInternal = parentInternal.childInternals.get(key); if (childInternal) { - return createSnapshotRecursive(childInternal.target, childInternal); + return createSnapshotRecursive( + childInternal.target, + childInternal, + inProgress, + ); } if (canProxy(value)) { - return deepFreezeClone(value as object); + return deepFreezeClone(value as object, inProgress); } return value; } @@ -183,9 +188,15 @@ function snapshotValue( * Deep-clone and freeze a plain object or array that is NOT tracked by a proxy. * Cached by raw object identity for structural sharing across snapshots. */ -function deepFreezeClone(value: object): object { +function deepFreezeClone(value: object, inProgress: WeakSet): object { const cached = untrackedCache.get(value); if (cached) return cached; + if (inProgress.has(value)) { + throw new Error( + '@codebelt/classy-store: circular reference detected in snapshot', + ); + } + inProgress.add(value); let clone: Record | unknown[]; @@ -194,7 +205,7 @@ function deepFreezeClone(value: object): object { for (let i = 0; i < value.length; i++) { const item = value[i]; (clone as unknown[])[i] = canProxy(item) - ? deepFreezeClone(item as object) + ? deepFreezeClone(item as object, inProgress) : item; } } else { @@ -204,13 +215,14 @@ function deepFreezeClone(value: object): object { if (!desc || !('value' in desc)) continue; const item = desc.value; (clone as Record)[key] = canProxy(item) - ? deepFreezeClone(item as object) + ? deepFreezeClone(item as object, inProgress) : item; } } Object.freeze(clone); untrackedCache.set(value, clone); + inProgress.delete(value); return clone; } @@ -284,6 +296,7 @@ function installMemoizedGetters( function createSnapshotRecursive( target: T, internal: StoreInternal, + inProgress: WeakSet = new WeakSet(), ): T { // Cache hit: version unchanged → return the same frozen snapshot reference. const cached = snapshotCache.get(target); @@ -291,12 +304,24 @@ function createSnapshotRecursive( return cached[1] as T; } + if (inProgress.has(target)) { + throw new Error( + '@codebelt/classy-store: circular reference detected in snapshot', + ); + } + inProgress.add(target); + let snap: Record | unknown[]; if (Array.isArray(target)) { snap = []; for (let i = 0; i < target.length; i++) { - (snap as unknown[])[i] = snapshotValue(target[i], internal, String(i)); + (snap as unknown[])[i] = snapshotValue( + target[i], + internal, + String(i), + inProgress, + ); } } else { // Preserve the prototype chain and install memoized getters on the snapshot. @@ -312,6 +337,7 @@ function createSnapshotRecursive( desc.value, internal, key, + inProgress, ); } @@ -322,6 +348,7 @@ function createSnapshotRecursive( Object.freeze(snap); // Cache AFTER populating + freezing. The reference is stable. snapshotCache.set(target, [internal.version, snap]); + inProgress.delete(target); return snap as T; } diff --git a/packages/classy-store/src/utils/devtools/devtools.test.ts b/packages/classy-store/src/utils/devtools/devtools.test.ts index 4663c2e..eb56ea2 100644 --- a/packages/classy-store/src/utils/devtools/devtools.test.ts +++ b/packages/classy-store/src/utils/devtools/devtools.test.ts @@ -370,29 +370,6 @@ describe('devtools', () => { expect(ext.connect).toHaveBeenCalledWith({name: 'ClassyStore'}); }); - it('handles connection.subscribe returning {unsubscribe} object', async () => { - const unsubMock = mock(() => {}); - const conn = createMockConnection(); - // Override subscribe to return an object with unsubscribe method - conn.subscribe = mock((listener: (message: unknown) => void) => { - conn._listener = listener; - return {unsubscribe: unsubMock}; - }); - const ext = createMockExtension(conn); - setExtension(ext); - - class Store { - count = 0; - } - - const store = createClassyStore(new Store()); - const dispose = devtools(store); - - dispose(); - - expect(unsubMock).toHaveBeenCalledTimes(1); - }); - it('sends multiple state updates for sequential mutations', async () => { const conn = createMockConnection(); const ext = createMockExtension(conn); @@ -477,4 +454,40 @@ describe('devtools', () => { // The partial state change should NOT be sent as a STORE_UPDATE expect(conn.send.mock.calls.length).toBe(sendCountBefore); }); + + it('JUMP_TO_ACTION followed by user mutation does not loop', async () => { + const conn = createMockConnection(); + setExtension(createMockExtension(conn)); + + class Store { + count = 0; + } + const store = createClassyStore(new Store()); + devtools(store); + + store.count = 5; + await tick(); + + // Time-travel back to count=0. + conn._listener?.({ + type: 'DISPATCH', + payload: {type: 'JUMP_TO_ACTION'}, + state: JSON.stringify({count: 0}), + }); + await tick(); + + const sendCountAfterJump = conn.send.mock.calls.length; + + // User mutates after the time-travel. + store.count = 99; + await tick(); + + // Exactly ONE additional send (the user mutation), not multiple. + expect(conn.send.mock.calls.length).toBe(sendCountAfterJump + 1); + + // Subsequent mutation also single send. + store.count = 100; + await tick(); + expect(conn.send.mock.calls.length).toBe(sendCountAfterJump + 2); + }); }); diff --git a/packages/classy-store/src/utils/devtools/devtools.ts b/packages/classy-store/src/utils/devtools/devtools.ts index 358c8bb..1929694 100644 --- a/packages/classy-store/src/utils/devtools/devtools.ts +++ b/packages/classy-store/src/utils/devtools/devtools.ts @@ -13,9 +13,7 @@ type DevToolsMessage = { type DevToolsConnection = { init: (state: unknown) => void; send: (action: string | {type: string}, state: unknown) => void; - subscribe: ( - listener: (message: DevToolsMessage) => void, - ) => (() => void) | {unsubscribe: () => void}; + subscribe: (listener: (message: DevToolsMessage) => void) => () => void; }; type DevToolsExtension = { @@ -118,13 +116,6 @@ export function devtools( // Return dispose function return () => { unsubscribeFromStore(); - if (typeof devToolsUnsub === 'function') { - devToolsUnsub(); - } else if ( - devToolsUnsub && - typeof devToolsUnsub.unsubscribe === 'function' - ) { - devToolsUnsub.unsubscribe(); - } + devToolsUnsub(); }; } diff --git a/packages/classy-store/src/utils/history/history.test.ts b/packages/classy-store/src/utils/history/history.test.ts index 1fa97b9..c47aee6 100644 --- a/packages/classy-store/src/utils/history/history.test.ts +++ b/packages/classy-store/src/utils/history/history.test.ts @@ -175,6 +175,41 @@ describe('withHistory', () => { h.dispose(); }); + it('undo() does not silently un-pause an explicitly paused recorder', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.count = 1; + await tick(); + store.count = 2; + await tick(); + + // User pauses recording, then performs an undo. The undo internally + // toggles `paused` while applying the snapshot — but it must restore + // the prior (user-set) paused state, not unconditionally clear it. + h.pause(); + h.undo(); + expect(store.count).toBe(1); + + // Wait past the microtask that restores `paused`. + await tick(); + + // A subsequent mutation should NOT be recorded — recorder is still paused. + store.count = 99; + await tick(); + + // Redo should still go to 2 (the recorded state), proving the + // mutation to 99 was not added to history. + h.redo(); + expect(store.count).toBe(2); + + h.dispose(); + }); + it('skips getters during state restoration', async () => { class Store { count = 5; diff --git a/packages/classy-store/src/utils/history/history.ts b/packages/classy-store/src/utils/history/history.ts index f99fc15..ba998e6 100644 --- a/packages/classy-store/src/utils/history/history.ts +++ b/packages/classy-store/src/utils/history/history.ts @@ -95,12 +95,13 @@ export function withHistory( undo() { if (pointer <= 0) return; pointer--; + const prevPaused = paused; paused = true; try { applySnapshot(history[pointer]); } finally { queueMicrotask(() => { - paused = false; + paused = prevPaused; }); } }, @@ -108,12 +109,13 @@ export function withHistory( redo() { if (pointer >= history.length - 1) return; pointer++; + const prevPaused = paused; paused = true; try { applySnapshot(history[pointer]); } finally { queueMicrotask(() => { - paused = false; + paused = prevPaused; }); } }, diff --git a/packages/classy-store/src/utils/internal/internal.test.ts b/packages/classy-store/src/utils/internal/internal.test.ts index e83cf67..4ec5313 100644 --- a/packages/classy-store/src/utils/internal/internal.test.ts +++ b/packages/classy-store/src/utils/internal/internal.test.ts @@ -239,4 +239,26 @@ describe('canProxy — additional', () => { } expect(canProxy(new Child())).toBe(true); }); + + it('is not fooled by an own `constructor` data field on a plain object', () => { + // User data with its own `constructor` field must not be checked against + // PROXYABLE on that field — the check must read constructor from the prototype. + const tricky = {constructor: {[PROXYABLE]: true}, value: 1}; + // Plain objects are still proxyable, so this should be true — but for the + // RIGHT reason (isPlainObject), not because the fake constructor was honored. + expect(canProxy(tricky)).toBe(true); + + // A non-plain object with an own `constructor` data field must not be proxied. + class Opaque { + value = 1; + } + const opaque = new Opaque(); + Object.defineProperty(opaque, 'constructor', { + value: {[PROXYABLE]: true}, + enumerable: true, + writable: true, + configurable: true, + }); + expect(canProxy(opaque)).toBe(false); + }); }); diff --git a/packages/classy-store/src/utils/internal/internal.ts b/packages/classy-store/src/utils/internal/internal.ts index 1b37494..f9d18c5 100644 --- a/packages/classy-store/src/utils/internal/internal.ts +++ b/packages/classy-store/src/utils/internal/internal.ts @@ -32,7 +32,10 @@ export function canProxy(value: unknown): value is object { if (typeof value !== 'object' || value === null) return false; if (Array.isArray(value)) return true; // Allow class instances that opt-in via the PROXYABLE symbol. - const ctor = (value as {constructor?: unknown}).constructor; + // Read constructor from the prototype, NOT the instance, so that user data + // with a `constructor` field of its own can't trick the check. + const proto = Object.getPrototypeOf(value); + const ctor = proto?.constructor; if (ctor && (ctor as Record)[PROXYABLE]) { return true; } diff --git a/packages/classy-store/src/utils/persist/persist.test.ts b/packages/classy-store/src/utils/persist/persist.test.ts index 03a9410..b26a461 100644 --- a/packages/classy-store/src/utils/persist/persist.test.ts +++ b/packages/classy-store/src/utils/persist/persist.test.ts @@ -433,42 +433,6 @@ describe('persist()', () => { expect(s.sidebar).toBe(true); // kept (not in storage) }); - it('replace merge: only uses persisted keys, drops missing defaults', async () => { - const storage = createMockStorage(); - storage.data.set( - 'test', - JSON.stringify({version: 0, state: {theme: 'dark'}}), - ); - - const s = createClassyStore({theme: 'light', fontSize: 14}); - const handle = persist(s, {name: 'test', storage, merge: 'replace'}); - await handle.hydrated; - - expect(s.theme).toBe('dark'); - // With 'replace', fontSize is not in persisted state so it should NOT be applied - // (only persisted keys are used). The store keeps its default since 'fontSize' - // is not in the merged result and the apply loop skips keys not in merged. - expect(s.fontSize).toBe(14); - }); - - it('replace merge: does not spread current defaults into merged state', async () => { - const storage = createMockStorage(); - // Persist only has 'a', the store has 'a' and 'b' - storage.data.set( - 'test', - JSON.stringify({version: 0, state: {a: 'from-storage'}}), - ); - - const s = createClassyStore({a: 'default-a', b: 'default-b'}); - const handle = persist(s, {name: 'test', storage, merge: 'replace'}); - await handle.hydrated; - - expect(s.a).toBe('from-storage'); - // 'b' is not in persisted state and replace doesn't merge current defaults, - // so 'b' stays at its default because the apply loop checks `key in merged` - expect(s.b).toBe('default-b'); - }); - it('custom merge function', async () => { const storage = createMockStorage(); storage.data.set( @@ -779,6 +743,32 @@ describe('persist()', () => { handle.unsubscribe(); }); + + it('does not write back to storage when applying a remote tab event (no ping-pong loop)', async () => { + const storage = createMockStorage(); + const s = createClassyStore({count: 0}); + const handle = persist(s, {name: 'test', storage, syncTabs: true}); + await handle.hydrated; + + const setItemSpy = mock(storage.setItem); + storage.setItem = setItemSpy; + + const event = new StorageEvent('storage', { + key: 'test', + newValue: JSON.stringify({version: 0, state: {count: 42}}), + }); + globalThis.dispatchEvent(event); + + // Wait for the microtask that resets the hydrating flag, plus any + // queued write-back that should NOT happen. + await tick(); + await tick(); + + expect(s.count).toBe(42); + expect(setItemSpy).not.toHaveBeenCalled(); + + handle.unsubscribe(); + }); }); // ── expireIn / TTL ───────────────────────────────────────────────────── @@ -1001,30 +991,12 @@ describe('persist()', () => { expect(s.count).toBe(0); // invalid envelope skipped }); - it('returns a dormant handle when no storage adapter is available (SSR)', () => { - // Override the localStorage getter so getDefaultStorage() catches - // and returns undefined, triggering the dormant handle path. - const desc = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); - Object.defineProperty(globalThis, 'localStorage', { - get() { - throw new Error('no localStorage'); - }, - configurable: true, - }); - try { - const s = createClassyStore({count: 0}); - const handle = persist(s, {name: 'test'}); - - // Should not throw — returns a dormant handle. - expect(handle).toBeDefined(); - expect(handle.isHydrated).toBe(true); - expect(handle.isExpired).toBe(false); - expect(handle.hydrated).toBeInstanceOf(Promise); - } finally { - if (desc) { - Object.defineProperty(globalThis, 'localStorage', desc); - } - } + it('returns a working handle when storage adapter is provided', () => { + const storage = createMockStorage(); + const s = createClassyStore({count: 0}); + const handle = persist(s, {name: 'test', storage}); + expect(handle).toBeDefined(); + expect(handle.hydrated).toBeInstanceOf(Promise); }); it('handles null state in envelope gracefully', async () => { @@ -1163,128 +1135,6 @@ describe('persist()', () => { expect(stored?.state).not.toHaveProperty('label'); }); - it('dormant handle no-ops do not throw', async () => { - const desc = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); - Object.defineProperty(globalThis, 'localStorage', { - get() { - throw new Error('no localStorage'); - }, - configurable: true, - }); - try { - const s = createClassyStore({count: 0}); - const handle = persist(s, {name: 'test'}); - - // All methods should be safe to call. - await handle.save(); // no-op - await handle.clear(); // no-op - handle.unsubscribe(); // no-op - } finally { - if (desc) { - Object.defineProperty(globalThis, 'localStorage', desc); - } - } - }); - - it('dormant handle hydrated promise resolves immediately', async () => { - const desc = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); - Object.defineProperty(globalThis, 'localStorage', { - get() { - throw new Error('no localStorage'); - }, - configurable: true, - }); - try { - const s = createClassyStore({count: 0}); - const handle = persist(s, {name: 'test'}); - - // hydrated resolves immediately for dormant handles. - await handle.hydrated; - expect(handle.isHydrated).toBe(true); - expect(s.count).toBe(0); // keeps default - } finally { - if (desc) { - Object.defineProperty(globalThis, 'localStorage', desc); - } - } - }); - - it('dormant handle rehydrate() activates when storage becomes available', async () => { - const storage = createMockStorage(); - storage.data.set( - 'ssr-test', - JSON.stringify({version: 0, state: {count: 42}}), - ); - - // Use a mutable options object. Initially storage is undefined and - // localStorage is blocked, producing a dormant handle. Then we set - // options.storage before calling rehydrate(), simulating storage - // becoming available on the client. - const opts: Parameters[1] = { - name: 'ssr-test', - storage: undefined, - syncTabs: false, - }; - - const desc = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); - Object.defineProperty(globalThis, 'localStorage', { - get() { - throw new Error('no localStorage'); - }, - configurable: true, - }); - - const s = createClassyStore({count: 0}); - let handle: ReturnType | undefined; - - try { - handle = persist(s, opts); - expect(handle.isHydrated).toBe(true); // dormant - expect(s.count).toBe(0); - } finally { - if (desc) { - Object.defineProperty(globalThis, 'localStorage', desc); - } - } - - // Simulate client mount: storage is now available via options. - opts.storage = storage; - - // rehydrate() re-checks options.storage and bootstraps full lifecycle. - await handle?.rehydrate(); - expect(s.count).toBe(42); - - // After activation, mutations should persist. - s.count = 100; - await tick(); - const stored = parseStored(storage, 'ssr-test'); - expect(stored?.state.count).toBe(100); - - handle?.unsubscribe(); - }); - - it('dormant rehydrate() is a no-op when storage is still unavailable', async () => { - const desc = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); - Object.defineProperty(globalThis, 'localStorage', { - get() { - throw new Error('no localStorage'); - }, - configurable: true, - }); - try { - const s = createClassyStore({count: 0}); - const handle = persist(s, {name: 'test'}); - - // rehydrate() on dormant handle with no storage still available — no-op. - await handle.rehydrate(); - expect(s.count).toBe(0); - } finally { - if (desc) { - Object.defineProperty(globalThis, 'localStorage', desc); - } - } - }); - it('multiple persists on the same store with different keys', async () => { const storage1 = createMockStorage(); const storage2 = createMockStorage(); @@ -1314,4 +1164,177 @@ describe('persist()', () => { expect(stored2?.state).not.toHaveProperty('count'); }); }); + + // ── onError callback ──────────────────────────────────────────────────── + + describe('onError callback', () => { + it('reports a setItem failure with operation "write" and keeps the store usable', async () => { + const storage = createMockStorage(); + storage.setItem = () => { + throw new Error('quota exceeded'); + }; + const onError = mock((_e: unknown, _op: string) => {}); + const s = createClassyStore({count: 0}); + const handle = persist(s, {name: 'test', storage, onError}); + await handle.hydrated; + + s.count = 1; + await tick(); + + expect(onError).toHaveBeenCalledTimes(1); + const [err, op] = onError.mock.calls[0]; + expect((err as Error).message).toBe('quota exceeded'); + expect(op).toBe('write'); + + // Store still mutable. + s.count = 2; + await tick(); + expect(s.count).toBe(2); + }); + + it('reports an async setItem rejection (no unhandled rejection)', async () => { + const storage = createAsyncMockStorage(); + storage.setItem = () => Promise.reject(new Error('disk full')); + const onError = mock((_e: unknown, _op: string) => {}); + const s = createClassyStore({count: 0}); + const handle = persist(s, {name: 'test', storage, onError}); + await handle.hydrated; + + s.count = 1; + await tick(); + await tick(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][1]).toBe('write'); + }); + + it('reports a getItem failure with operation "read"', async () => { + const storage = createMockStorage(); + storage.getItem = () => { + throw new Error('cannot read'); + }; + const onError = mock((_e: unknown, _op: string) => {}); + const s = createClassyStore({count: 0}); + persist(s, {name: 'test', storage, onError}); + await tick(); + await tick(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][1]).toBe('read'); + }); + + it('reports a JSON parse failure with operation "parse"', async () => { + const storage = createMockStorage(); + storage.data.set('test', '{not valid json'); + const onError = mock((_e: unknown, _op: string) => {}); + const s = createClassyStore({count: 0}); + const handle = persist(s, {name: 'test', storage, onError}); + await handle.hydrated; + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][1]).toBe('parse'); + }); + + it('reports a removeItem failure during clear() with operation "remove"', async () => { + const storage = createMockStorage(); + storage.removeItem = () => { + throw new Error('locked'); + }; + const onError = mock((_e: unknown, _op: string) => {}); + const s = createClassyStore({count: 0}); + const handle = persist(s, {name: 'test', storage, onError}); + await handle.hydrated; + + await handle.clear(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][1]).toBe('remove'); + }); + + it('a throwing onError callback does not crash subsequent operations', async () => { + const storage = createMockStorage(); + storage.setItem = () => { + throw new Error('first failure'); + }; + const s = createClassyStore({count: 0}); + const handle = persist(s, { + name: 'test', + storage, + onError: () => { + throw new Error('user callback exploded'); + }, + }); + await handle.hydrated; + + s.count = 1; + await tick(); + + // Should not throw — and store remains mutable. + s.count = 2; + await tick(); + expect(s.count).toBe(2); + }); + }); + + // ── Race conditions ──────────────────────────────────────────────────── + + describe('race conditions', () => { + it('rehydrate cancels a pending debounced write so loaded state wins', async () => { + const storage = createMockStorage(); + storage.data.set( + 'test', + JSON.stringify({version: 0, state: {count: 100}}), + ); + const s = createClassyStore({count: 0}); + // Mutate first; the debounce timer holds the (locally) newer value. + const handle = persist(s, { + name: 'test', + storage, + debounce: 1000, + skipHydration: true, + }); + s.count = 5; + await tick(); + // Debounce timer is now armed with count=5. + + await handle.rehydrate(); + + // After rehydration: store reflects loaded value, and the pending + // debounce timer must NOT fire and overwrite storage with count=5. + expect(s.count).toBe(100); + + await wait(1100); + const storedNow = JSON.parse(storage.data.get('test') ?? '{}'); + expect(storedNow.state.count).toBe(100); + }); + + it('cross-tab event cancels a pending debounced write', async () => { + const storage = createMockStorage(); + const s = createClassyStore({count: 0}); + const handle = persist(s, { + name: 'test', + storage, + debounce: 1000, + syncTabs: true, + }); + await handle.hydrated; + + s.count = 5; + await tick(); + // Debounce armed with count=5. + + const event = new StorageEvent('storage', { + key: 'test', + newValue: JSON.stringify({version: 0, state: {count: 77}}), + }); + globalThis.dispatchEvent(event); + + expect(s.count).toBe(77); + + await wait(1100); + // Pending write must have been cancelled — storage still empty (no setItem ran). + expect(storage.data.has('test')).toBe(false); + + handle.unsubscribe(); + }); + }); }); diff --git a/packages/classy-store/src/utils/persist/persist.ts b/packages/classy-store/src/utils/persist/persist.ts index 92a6c3d..8e12c2d 100644 --- a/packages/classy-store/src/utils/persist/persist.ts +++ b/packages/classy-store/src/utils/persist/persist.ts @@ -41,11 +41,11 @@ export type PersistOptions = { name: string; /** - * Storage adapter. Defaults to `globalThis.localStorage`. + * Storage adapter. Required. * Any object with getItem/setItem/removeItem (sync or async). * Works with: localStorage, sessionStorage, AsyncStorage, localForage, etc. */ - storage?: StorageAdapter; + storage: StorageAdapter; /** * Which properties to persist. @@ -88,8 +88,6 @@ export type PersistOptions = { * * - `'shallow'` (default): persisted values overwrite current values one key at a time. * Properties not in storage keep their current value. - * - `'replace'`: same behavior as `'shallow'` for flat stores — only stored keys are assigned. - * For nested objects, the entire object is replaced rather than merged. * - Custom function: receives `(persistedState, currentState)` and returns merged state. * Enables deep merge or any custom logic. * @@ -98,7 +96,6 @@ export type PersistOptions = { */ merge?: | 'shallow' - | 'replace' | (( persisted: Record, current: Record, @@ -116,10 +113,10 @@ export type PersistOptions = { * When another tab writes to the same storage key, this tab automatically * re-hydrates from the new value. * - * Only works with `localStorage` (storage events don't fire for sessionStorage - * or async adapters). + * Only meaningful for `localStorage` — `storage` events don't fire for + * `sessionStorage` or async adapters. * - * Default: `true` when storage is `localStorage`, `false` otherwise. + * Default: `false`. */ syncTabs?: boolean; @@ -136,8 +133,20 @@ export type PersistOptions = { * but left in storage). */ clearOnExpire?: boolean; + + /** + * Called when a storage operation fails. Use this to surface quota + * exhaustion, private-mode restrictions, network errors from async + * adapters, or corrupted-data parse failures. + * + * The store keeps working — failures do not throw. + */ + onError?: (error: unknown, operation: PersistOperation) => void; }; +/** Operation that can produce an `onError` callback invocation. */ +export type PersistOperation = 'write' | 'read' | 'remove' | 'parse'; + /** * Handle returned by `persist()`. Provides control over the persist lifecycle. */ @@ -220,38 +229,6 @@ function resolveProperties( return result; } -/** - * Detect if the given storage adapter is `globalThis.localStorage`. - */ -function isLocalStorage(storage: StorageAdapter): boolean { - try { - return ( - typeof globalThis !== 'undefined' && - typeof globalThis.localStorage !== 'undefined' && - storage === (globalThis.localStorage as unknown as StorageAdapter) - ); - } catch { - return false; - } -} - -/** - * Get the default storage adapter (`localStorage`), or `undefined` if unavailable. - */ -function getDefaultStorage(): StorageAdapter | undefined { - try { - if ( - typeof globalThis !== 'undefined' && - typeof globalThis.localStorage !== 'undefined' - ) { - return globalThis.localStorage as unknown as StorageAdapter; - } - } catch { - // SSR or restricted environment — no localStorage. - } - return undefined; -} - // ── Main implementation ────────────────────────────────────────────────────── /** @@ -262,11 +239,6 @@ function getDefaultStorage(): StorageAdapter | undefined { * On init (or manual rehydrate), reads from storage and applies the state back * to the store proxy. * - * When no storage adapter is available (e.g. during SSR), returns a dormant - * handle instead of throwing. Calling `rehydrate()` on the dormant handle will - * bootstrap the full persist lifecycle if storage has become available (e.g. - * after client-side mount). - * * @param proxyStore - A reactive proxy created by `createClassyStore()`. * @param options - Persistence configuration. * @returns A handle with lifecycle controls (unsubscribe, save, clear, rehydrate, hydrated). @@ -277,26 +249,37 @@ export function persist( ): PersistHandle { const { name, + storage, properties: propertiesOption, debounce: debounceMs = 0, version = 0, migrate, merge = 'shallow', skipHydration = false, - syncTabs: syncTabsOption, + syncTabs = false, expireIn, clearOnExpire = false, + onError, } = options; + /** Route a failure through `onError` (or swallow silently if not provided). */ + function reportError(error: unknown, operation: PersistOperation): void { + if (onError) { + try { + onError(error, operation); + } catch { + // User callback failed — nothing more we can do. + } + } + } + // ── Mutable state shared by handle closures ───────────────────────────── let disposed = false; - let active = false; let hydrating = false; let debounceTimer: ReturnType | null = null; let hydratedFlag = false; let expiredFlag = false; - let unsubscribeFromStore: (() => void) | null = null; // Hydration promise + resolver. let resolveHydrated!: () => void; @@ -306,62 +289,15 @@ export function persist( rejectHydrated = reject; }); - // These are initialized by setup() — declared here so closures can reference them. - let resolvedProps: Array<{key: string; transform?: PropertyTransform}> = - []; - let transformMap = new Map>(); - let propKeys: string[] = []; - let storage!: StorageAdapter; - - // ── Setup (core persist lifecycle) ────────────────────────────────────── - - function setup(storageAdapter: StorageAdapter): void { - if (active) return; - active = true; - storage = storageAdapter; - - resolvedProps = resolveProperties(proxyStore, propertiesOption); - - // Build a map of key → transform for fast lookup during save/restore. - transformMap = new Map>(); - for (const prop of resolvedProps) { - if (prop.transform) { - transformMap.set(prop.key, prop.transform); - } - } - - propKeys = resolvedProps.map((p) => p.key); - - // Cross-tab sync. - const shouldSyncTabs = - syncTabsOption !== undefined - ? syncTabsOption - : isLocalStorage(storageAdapter); - - if ( - shouldSyncTabs && - typeof globalThis !== 'undefined' && - typeof globalThis.addEventListener === 'function' - ) { - globalThis.addEventListener('storage', onStorageEvent); - } - - // Subscribe to store mutations. - unsubscribeFromStore = subscribe(proxyStore, scheduleWrite); - - // Kick off initial hydration (unless skipped). - if (!skipHydration) { - void hydrateFromStorage() - .then(() => { - hydratedFlag = true; - resolveHydrated(); - }) - .catch((error) => { - hydratedFlag = true; - rejectHydrated(error); - }); + const resolvedProps = resolveProperties(proxyStore, propertiesOption); + // Build a map of key → transform for fast lookup during save/restore. + const transformMap = new Map>(); + for (const prop of resolvedProps) { + if (prop.transform) { + transformMap.set(prop.key, prop.transform); } } + const propKeys = resolvedProps.map((p) => p.key); // ── Save logic ───────────────────────────────────────────────────────── @@ -389,8 +325,12 @@ export function persist( /** Write the current state to storage. */ async function writeToStorage(): Promise { if (disposed) return; - const json = serializeState(); - await storage.setItem(name, json); + try { + const json = serializeState(); + await storage.setItem(name, json); + } catch (error) { + reportError(error, 'write'); + } } /** Schedule a debounced write (or write immediately if debounce is 0). */ @@ -421,8 +361,8 @@ export function persist( let envelope: PersistEnvelope; try { envelope = JSON.parse(raw) as PersistEnvelope; - } catch { - // Corrupted data — skip. + } catch (error) { + reportError(error, 'parse'); return; } @@ -441,7 +381,11 @@ export function persist( Date.now() >= envelope.expiresAt ) { expiredFlag = true; - if (clearOnExpire) void storage.removeItem(name); + if (clearOnExpire) { + void Promise.resolve(storage.removeItem(name)).catch((error) => { + reportError(error, 'remove'); + }); + } return; } @@ -471,9 +415,6 @@ export function persist( let merged: Record; if (typeof merge === 'function') { merged = merge(state, currentState); - } else if (merge === 'replace') { - // Only use persisted keys — new defaults not in storage are dropped. - merged = state; } else { // 'shallow': persisted values overwrite current, but properties not // in storage keep their current (default) value. @@ -490,8 +431,21 @@ export function persist( /** Read from storage and apply to the store. */ async function hydrateFromStorage(): Promise { - const raw = await storage.getItem(name); + let raw: string | null; + try { + raw = await storage.getItem(name); + } catch (error) { + reportError(error, 'read'); + return; + } if (raw !== null) { + // A debounced local write may be pending from a mutation issued before + // we got around to hydrating; the freshly-loaded state would be + // overwritten when that timer fires. Cancel it before applying. + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } hydrating = true; applyPersistedState(raw); // Reset after microtask so the batched subscription callback @@ -508,21 +462,44 @@ export function persist( if (disposed) return; if (event.key !== name) return; if (event.newValue === null) return; // cleared - applyPersistedState(event.newValue); + // A pending local debounced write would otherwise overwrite the remote + // state once its timer fires. + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + // Suppress writes triggered by mutations from this apply, otherwise + // tabs ping-pong storage events forever. + hydrating = true; + try { + applyPersistedState(event.newValue); + } finally { + queueMicrotask(() => { + hydrating = false; + }); + } } // ── Initialize ───────────────────────────────────────────────────────── - const maybeStorage = options.storage ?? getDefaultStorage(); + if (syncTabs) { + globalThis.addEventListener('storage', onStorageEvent); + } + + // Subscribe to store mutations. + const unsubscribeFromStore = subscribe(proxyStore, scheduleWrite); - if (maybeStorage) { - // Storage available — set up immediately. - setup(maybeStorage); - } else { - // No storage (SSR / restricted environment). - // Resolve hydrated immediately — the store keeps its defaults. - hydratedFlag = true; - resolveHydrated(); + // Kick off initial hydration (unless skipped). + if (!skipHydration) { + void hydrateFromStorage() + .then(() => { + hydratedFlag = true; + resolveHydrated(); + }) + .catch((error) => { + hydratedFlag = true; + rejectHydrated(error); + }); } // ── Build handle ─────────────────────────────────────────────────────── @@ -549,22 +526,16 @@ export function persist( } // Unsubscribe from store mutations. - if (unsubscribeFromStore) { - unsubscribeFromStore(); - } + unsubscribeFromStore(); // Remove cross-tab sync listener. - if ( - active && - typeof globalThis !== 'undefined' && - typeof globalThis.removeEventListener === 'function' - ) { + if (syncTabs) { globalThis.removeEventListener('storage', onStorageEvent); } }, async save() { - if (disposed || !active) return; + if (disposed) return; // Cancel pending debounce and write immediately. if (debounceTimer !== null) { clearTimeout(debounceTimer); @@ -574,29 +545,16 @@ export function persist( }, async clear() { - if (disposed || !active) return; - await storage.removeItem(name); + if (disposed) return; + try { + await storage.removeItem(name); + } catch (error) { + reportError(error, 'remove'); + } }, async rehydrate() { - if (!active) { - // Try to activate with storage that may now be available (e.g. client mount). - const s = options.storage ?? getDefaultStorage(); - if (s) { - // Reset hydration state so setup's hydration path works correctly. - hydratedFlag = false; - setup(s); - // If skipHydration was false, setup already kicked off hydration. - // If skipHydration was true, we need to hydrate manually below. - if (!skipHydration) { - // setup() already started hydration — just wait for it. - await hydratedPromise; - return; - } - } else { - return; // Still no storage — nothing to do. - } - } + if (disposed) return; expiredFlag = false; await hydrateFromStorage(); if (!hydratedFlag) { diff --git a/website/docs/PERSIST_ARCHITECTURE.md b/website/docs/PERSIST_ARCHITECTURE.md index 30ba996..3d83c03 100644 --- a/website/docs/PERSIST_ARCHITECTURE.md +++ b/website/docs/PERSIST_ARCHITECTURE.md @@ -175,7 +175,7 @@ Both `'shallow'` and `'replace'` produce the same result for flat stores. The di ## Cross-Tab Sync -When `syncTabs` is enabled (default for `localStorage`), the utility listens for the `window.storage` event: +When `syncTabs: true` is set, the utility listens for the `window.storage` event: ```mermaid sequenceDiagram diff --git a/website/docs/PERSIST_TUTORIAL.md b/website/docs/PERSIST_TUTORIAL.md index f3324e3..8f89ed5 100644 --- a/website/docs/PERSIST_TUTORIAL.md +++ b/website/docs/PERSIST_TUTORIAL.md @@ -172,7 +172,7 @@ persist(uiStore, { name: 'ui-state', storage: sessionStorage, properties: ['sidebarOpen', 'activeTab'], - // syncTabs defaults to false for sessionStorage + // syncTabs defaults to false; opt in only for localStorage }); ``` @@ -194,12 +194,13 @@ await handle.hydrated; ### `syncTabs` (Cross-Tab Synchronization) -When using `localStorage`, state syncs automatically across browser tabs. If a user logs out in Tab A, Tab B picks up the change immediately: +Opt in to keep `localStorage`-backed state in sync across browser tabs. When another tab writes to the same key, this tab re-hydrates from the new value: ```ts persist(authStore, { name: 'auth', - // syncTabs: true -- default for localStorage + storage: localStorage, + syncTabs: true, }); // Tab A: user logs out @@ -266,14 +267,27 @@ persist(sessionStore, { ### `skipHydration` (SSR / Next.js Support) -`persist()` is SSR-safe out of the box. When `localStorage` is unavailable (server-side rendering, restricted environments), it returns a **dormant handle** instead of throwing. The store keeps its class defaults on the server, and you activate persistence on the client via `rehydrate()`. - -This means you can call `persist()` at module scope -- no `typeof window` guards needed: +`persist()` requires a `storage` adapter. For SSR (Next.js, etc.) where module init runs on the server, supply an adapter that falls back to in-memory when `localStorage` is undefined and gate hydration on the client with `skipHydration: true`: ```ts +// _storage.ts -- shared SSR-safe adapter +const memory = new Map(); +export const ssrSafeLocalStorage = { + getItem: (k: string) => (typeof localStorage !== 'undefined' ? localStorage.getItem(k) : memory.get(k) ?? null), + setItem: (k: string, v: string) => { + if (typeof localStorage !== 'undefined') localStorage.setItem(k, v); + else memory.set(k, v); + }, + removeItem: (k: string) => { + if (typeof localStorage !== 'undefined') localStorage.removeItem(k); + else memory.delete(k); + }, +}; + // store.ts -- runs on both server and client export const handle = persist(todoStore, { name: 'todo-store', + storage: ssrSafeLocalStorage, skipHydration: true, }); ``` @@ -423,6 +437,7 @@ class TodoStore { ```ts persist(todoStore, { name: 'todo-store', + storage: localStorage, merge: 'shallow', }); @@ -432,20 +447,6 @@ persist(todoStore, { // tags → ["work", "personal"] (kept class default) ``` -**`'replace'`** — only persisted keys are assigned. New properties not in storage are dropped. For nested objects, the entire object is replaced rather than merged: - -```ts -persist(todoStore, { - name: 'todo-store', - merge: 'replace', -}); - -// Result after hydration: -// filter → "done" (from storage) -// todos → [{text: "Buy milk", done: false}] (from storage) -// tags → undefined (not in storage, dropped) -``` - **Custom function** — for full control, pass a function that receives `(persisted, current)` and returns the merged state: ```ts