From 79f6636dbf03d6f06006a3e607d8e984b61772bd Mon Sep 17 00:00:00 2001 From: codeBelt Date: Thu, 22 Oct 2026 22:06:23 -0500 Subject: [PATCH 1/2] feat: simplify utility APIs and prepare 0.8.0 --- .changeset/public-utility-status.md | 18 + README.md | 10 +- .../components/dashboard/StoreInspector.tsx | 76 ++- .../src/components/editor/HistoryControls.tsx | 22 +- .../src/components/editor/RecipeEditor.tsx | 2 +- .../src/components/features/FeatureMap.tsx | 9 - .../components/planner/PersistStatusBadge.tsx | 50 +- .../components/planner/PlannerHydrator.tsx | 10 +- .../components/settings/PersistControls.tsx | 32 +- .../src/components/shared/StoreHydrator.tsx | 10 +- .../components/shopping/SnapshotViewer.tsx | 33 +- examples/react/dedupe-react.ts | 3 + .../react/src/demos/history/UndoRedoDemo.tsx | 43 +- .../demos/persist/KitchenSinkPersistDemo.tsx | 61 ++- .../src/demos/persist/SimplePersistDemo.tsx | 14 +- .../EditorStoreProvider.tsx | 12 +- examples/react/tsconfig.json | 3 + examples/react/tsconfig.scoping.json | 3 + ...omputed.test.tsx => core.computed.test.ts} | 256 +--------- ...mory.qa.test.ts => core.ownership.test.ts} | 2 +- packages/classy-store/src/core/core.ts | 5 +- packages/classy-store/src/core/core.types.ts | 55 +++ .../hooks/useClassyStore/useClassyStore.ts | 225 +++++++++ .../useClassyStore/useClassyStore.types.ts | 5 + .../useHistoryStatus/useHistoryStatus.ts | 10 + .../hooks/useLocalStore/useLocalStore.ts | 33 ++ .../usePersistStatus/usePersistStatus.ts | 10 + .../useUtilityStatus/useUtilityStatus.ts | 11 + .../frameworks/react/react.computed.test.tsx | 109 +++++ ...tsx => react.hydration.lifecycle.test.tsx} | 5 +- .../frameworks/react/react.hydration.test.tsx | 9 +- ...qa.test.tsx => react.performance.test.tsx} | 2 +- .../src/frameworks/react/react.ssr.test.tsx | 2 +- .../frameworks/react/react.status.test.tsx | 236 +++++++++ .../src/frameworks/react/react.ts | 325 +------------ ...act.qa.test.tsx => react.updates.test.tsx} | 2 +- .../frameworks/react/react.utilities.test.tsx | 10 +- .../historyStatusView/HistoryStatusView.tsx | 9 + .../persistStatusView/PersistStatusView.tsx | 9 + .../utils/createStoreHook/createStoreHook.ts | 49 ++ ...t.ts => trackingReadMap.lifecycle.test.ts} | 2 +- packages/classy-store/src/index.ts | 2 +- .../src/snapshot/snapshot.computed.test.ts | 156 ++++++ .../classy-store/src/snapshot/snapshot.ts | 3 +- packages/classy-store/src/types.ts | 56 --- .../src/utils/history/history.status.test.ts | 94 ++++ .../classy-store/src/utils/history/history.ts | 54 ++- packages/classy-store/src/utils/index.ts | 12 +- .../src/utils/persist/persist.api.test.ts | 458 ++++++++++++++++++ .../utils/persist/persist.concurrent.test.ts | 23 +- .../src/utils/persist/persist.errors.test.ts | 195 ++++++++ .../src/utils/persist/persist.test.ts | 68 +-- .../classy-store/src/utils/persist/persist.ts | 363 +++++--------- .../src/utils/persist/persist.types.ts | 188 +++++++ .../src/utils/utilityStatus/utilityStatus.ts | 59 +++ .../components/hydrationApp/HydrationApp.tsx | 2 +- .../browser/hydration/hydration.store.ts | 6 +- .../persistenceForm/PersistenceForm.tsx | 2 +- .../browser/{qa.spec.ts => updates.spec.ts} | 0 .../compatibility/consumer.types.fixture.ts | 131 ++++- .../compatibility/entrypoints.fixture.mjs | 49 +- .../tests/compatibility/runCompatibility.mjs | 27 +- ...qa.test.tsx => compiler.behavior.test.tsx} | 4 +- .../tests/compiler/compiler.memory.test.tsx | 10 +- .../tests/compiler/runCompiler.mjs | 4 +- packages/classy-store/tests/next/README.md | 4 +- .../tests/next/checkBoundaries.mjs | 8 +- .../FixtureStoreProvider.tsx | 8 +- packages/classy-store/tests/scoping/README.md | 2 +- ...qa.test.tsx => scoping.isolation.test.tsx} | 2 +- website/docs/ARCHITECTURE.md | 157 +++--- website/docs/HISTORY_TUTORIAL.md | 43 +- website/docs/PERSIST_ARCHITECTURE.md | 56 ++- website/docs/PERSIST_TUTORIAL.md | 89 +++- website/docs/REACT_NEXT.md | 11 +- website/docs/REACT_SCOPING.md | 9 +- website/docs/REACT_SSR.md | 11 +- website/docs/TUTORIAL.md | 4 +- website/docs/UPGRADING_0_8.md | 75 +++ website/docs/index.md | 59 ++- website/sidebars.ts | 1 + website/static/llms-full.txt | 355 +++++++++----- website/static/llms.txt | 7 +- 83 files changed, 3224 insertions(+), 1435 deletions(-) create mode 100644 .changeset/public-utility-status.md rename packages/classy-store/src/core/{computed.test.tsx => core.computed.test.ts} (66%) rename packages/classy-store/src/core/{core.memory.qa.test.ts => core.ownership.test.ts} (98%) create mode 100644 packages/classy-store/src/core/core.types.ts create mode 100644 packages/classy-store/src/frameworks/react/hooks/useClassyStore/useClassyStore.ts create mode 100644 packages/classy-store/src/frameworks/react/hooks/useClassyStore/useClassyStore.types.ts create mode 100644 packages/classy-store/src/frameworks/react/hooks/useHistoryStatus/useHistoryStatus.ts create mode 100644 packages/classy-store/src/frameworks/react/hooks/useLocalStore/useLocalStore.ts create mode 100644 packages/classy-store/src/frameworks/react/hooks/usePersistStatus/usePersistStatus.ts create mode 100644 packages/classy-store/src/frameworks/react/hooks/useUtilityStatus/useUtilityStatus.ts create mode 100644 packages/classy-store/src/frameworks/react/react.computed.test.tsx rename packages/classy-store/src/frameworks/react/{react.hydration.qa.test.tsx => react.hydration.lifecycle.test.tsx} (98%) rename packages/classy-store/src/frameworks/react/{react.performance.qa.test.tsx => react.performance.test.tsx} (99%) create mode 100644 packages/classy-store/src/frameworks/react/react.status.test.tsx rename packages/classy-store/src/frameworks/react/{react.qa.test.tsx => react.updates.test.tsx} (99%) create mode 100644 packages/classy-store/src/frameworks/react/testHelpers/historyStatusView/HistoryStatusView.tsx create mode 100644 packages/classy-store/src/frameworks/react/testHelpers/persistStatusView/PersistStatusView.tsx create mode 100644 packages/classy-store/src/frameworks/react/utils/createStoreHook/createStoreHook.ts rename packages/classy-store/src/frameworks/react/utils/trackingReadMap/{trackingReadMap.qa.test.ts => trackingReadMap.lifecycle.test.ts} (98%) create mode 100644 packages/classy-store/src/snapshot/snapshot.computed.test.ts create mode 100644 packages/classy-store/src/utils/history/history.status.test.ts create mode 100644 packages/classy-store/src/utils/persist/persist.api.test.ts create mode 100644 packages/classy-store/src/utils/persist/persist.errors.test.ts create mode 100644 packages/classy-store/src/utils/persist/persist.types.ts create mode 100644 packages/classy-store/src/utils/utilityStatus/utilityStatus.ts rename packages/classy-store/tests/browser/{qa.spec.ts => updates.spec.ts} (100%) rename packages/classy-store/tests/compiler/{compiler.qa.test.tsx => compiler.behavior.test.tsx} (98%) rename packages/classy-store/tests/scoping/{scoping.qa.test.tsx => scoping.isolation.test.tsx} (99%) create mode 100644 website/docs/UPGRADING_0_8.md diff --git a/.changeset/public-utility-status.md b/.changeset/public-utility-status.md new file mode 100644 index 0000000..3fb0915 --- /dev/null +++ b/.changeset/public-utility-status.md @@ -0,0 +1,18 @@ +--- +'@codebelt/classy-store': minor +--- + +**Upgrade required from 0.7.x.** Add observable utility status and simplify the public API. See the [0.8.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_8) for migration examples. + +- History and persistence handles expose `getSnapshot()`, `getServerSnapshot()`, and `subscribe(listener)`. Use the new `useHistoryStatus(history)` React hook for undo/redo availability and recording state, and `usePersistStatus(handle)` for hydration, expiry, and persistence errors. Both expose disposal status and update even when store data does not change. Each hook accepts its matching handle type and shares an internal subscription implementation. +- Explicit `save()`, `clear()`, and `rehydrate()` calls reject on failure after reporting through `onError` and status. These methods take no options; catch rejected promises in event handlers and effects. Automatic writes and initial hydration handle reported failures internally. Failed calls do not block later queued writes. Calls on disposed handles and explicit queued operations cancelled by disposal reject. +- Persistence cleanup is now `dispose()`. The `unsubscribe()` method has been removed; update effect cleanups and other teardown calls to `handle.dispose()`. Repeated disposal is safe and still stops listeners and timers, ignores pending reads, and skips queued writes that have not started. +- Remove the public `getVersion` export. Use `subscribe()` for notifications or compare `snapshot()` references for change detection. The version helper remains internal for persistence and core bookkeeping. +- Keep the shared `UtilityStatusSource` type internal. Use the exported `HistoryHandle`, `PersistHandle`, `HistoryStatus`, and `PersistStatus` types in consumers. Also export `PersistOperation` from the utilities entry. +- Import `prepareStoreForHydration` from `@codebelt/classy-store/react/server` on both server and client. The duplicate export from `@codebelt/classy-store/react` has been removed. +- Persistence transforms infer their value type from `key`. Serializers receive that property's readonly snapshot; deserializers must return the matching mutable property type. Existing transforms that return another property's type or mutate snapshot inputs now fail TypeScript checks. Persistence flags are readonly in the public types, matching their existing runtime getters. +- Preserve explicit history pause state when undo/redo finishes. Update React and Next.js examples to subscribe to utility status directly. + +`hydrated` and `isHydrated` indicate that a hydration attempt has settled, including failure or an obsolete read. Inspect `lastError`; await explicit operations and handle their errors when success matters. Utility status uses its initial snapshot during SSR/hydration; construct server and client handles with matching initial status. + +Reorganize React hooks and internal types by ownership, split computed tests by layer, and name regression suites by behavior. This structural cleanup preserves runtime behavior and package entry points. diff --git a/README.md b/README.md index b926fea..96b66ad 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@ Visit the **[Documentation Website](https://codebelt.github.io/classy-store/)** for tutorials, API reference, and examples. -## Upgrading to 0.7.0 +## Upgrading to 0.8.0 -React subscriptions now notify synchronously. Remove the React hooks' `sync` option and any `UseClassyStoreOptions` imports. Auto-tracked hooks now require native `WeakRef` support (ES2021). Selector equality options remain available, and ordinary core subscriptions still batch by default. +Replace persistence `unsubscribe()` with `dispose()`, handle rejected promises from explicit persistence operations, and import hydration preparation from `@codebelt/classy-store/react/server`. The public `getVersion` export has been removed. New `useHistoryStatus` and `usePersistStatus` hooks keep utility metadata reactive in React. -See the [0.7.0 upgrade guide and release notes](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7) for before/after examples, SSR and Next.js setup, and persistence changes. +See the [0.8.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_8) for required changes and examples. If upgrading from 0.6.x or earlier, also read the [0.7.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7). ## 🚀 Features @@ -76,7 +76,7 @@ return ( Ordinary `subscribe(store, listener)` callbacks still batch in a microtask. React batches renders within event handlers, while selectors can run after each mutation. -For server-rendered React apps, call `prepareStoreForHydration(store)` from `@codebelt/classy-store/react` on matching server/client stores before persistence starts. Delayed Suspense boundaries then hydrate from the original data and catch up to live state. See the [SSR guide](https://codebelt.github.io/classy-store/docs/REACT_SSR). +For server-rendered React apps, call `prepareStoreForHydration(store)` from `@codebelt/classy-store/react/server` on matching server/client stores before persistence starts. Delayed Suspense boundaries then hydrate from the original data and catch up to live state. See the [SSR guide](https://codebelt.github.io/classy-store/docs/REACT_SSR). For Next.js App Router, construct stores inside Client Providers from serializable initial data. Use `@codebelt/classy-store/react/server` when preparing a store in server-only code. See the [App Router guide](https://codebelt.github.io/classy-store/docs/REACT_NEXT) for package boundaries, streaming, and navigation. @@ -86,6 +86,8 @@ Auto-tracked React subscriptions require native `WeakRef` support (ES2021) so tr For independent forms, nested editors, and request-scoped state, see the [typed context example](https://codebelt.github.io/classy-store/docs/REACT_SCOPING). Providers own their store instances and effects; React keys control intentional resets. +Most components only need `useClassyStore(store)` to render application data. Use [`useHistoryStatus(history)`](https://codebelt.github.io/classy-store/docs/#usehistorystatushistory) for undo/redo controls and recording status, or [`usePersistStatus(handle)`](https://codebelt.github.io/classy-store/docs/#usepersiststatushandle) for hydration, expiry, and persistence errors. Both hooks come from `@codebelt/classy-store/react` and update even when store data is unchanged. Manual `save()`, `clear()`, and `rehydrate()` calls reject on failure after reporting through status and `onError`. Handle these promises in event handlers; automatic persistence reports failures without producing unhandled rejections. + ## 📦 Installation ```bash diff --git a/examples/nextjs/src/components/dashboard/StoreInspector.tsx b/examples/nextjs/src/components/dashboard/StoreInspector.tsx index f3cc05f..61f3c14 100644 --- a/examples/nextjs/src/components/dashboard/StoreInspector.tsx +++ b/examples/nextjs/src/components/dashboard/StoreInspector.tsx @@ -1,56 +1,51 @@ 'use client'; -import {getVersion, snapshot, subscribe} from '@codebelt/classy-store'; -import {useCallback, useEffect, useState} from 'react'; +import {snapshot, subscribe} from '@codebelt/classy-store'; +import {useEffect, useState} from 'react'; import {ApiInfo} from '@/components/shared/ApiInfo'; import {plannerStore} from '@/stores/planner-store'; import {recipeStore} from '@/stores/recipe-store'; import {shoppingStore} from '@/stores/shopping-store'; export function StoreInspector() { - const [mutationCount, setMutationCount] = useState(0); const [lastMutated, setLastMutated] = useState(null); - const [versions, setVersions] = useState({ + const [updateCounts, setUpdateCounts] = useState({ recipe: 0, shopping: 0, planner: 0, }); - const updateVersions = useCallback(() => { - setVersions({ - recipe: getVersion(recipeStore), - shopping: getVersion(shoppingStore), - planner: getVersion(plannerStore), - }); - }, []); - useEffect(() => { - updateVersions(); - const unsubs = [ + let highlightTimer: ReturnType | undefined; + function recordUpdate(store: keyof typeof updateCounts, label: string) { + setUpdateCounts((previousCounts) => ({ + ...previousCounts, + [store]: previousCounts[store] + 1, + })); + setLastMutated(label); + clearTimeout(highlightTimer); + highlightTimer = setTimeout(() => setLastMutated(null), 1000); + } + const subscriptions = [ subscribe(recipeStore, () => { - setMutationCount((c) => c + 1); - setLastMutated('Recipe Store'); - updateVersions(); - setTimeout(() => setLastMutated(null), 1000); + recordUpdate('recipe', 'Recipe Store'); }), subscribe(shoppingStore, () => { - setMutationCount((c) => c + 1); - setLastMutated('Shopping Store'); - updateVersions(); - setTimeout(() => setLastMutated(null), 1000); + recordUpdate('shopping', 'Shopping Store'); }), subscribe(plannerStore, () => { - setMutationCount((c) => c + 1); - setLastMutated('Planner Store'); - updateVersions(); - setTimeout(() => setLastMutated(null), 1000); + recordUpdate('planner', 'Planner Store'); }), ]; return () => { - for (const u of unsubs) u(); + for (const unsubscribe of subscriptions) unsubscribe(); + clearTimeout(highlightTimer); }; - }, [updateVersions]); + }, []); + + const totalUpdates = + updateCounts.recipe + updateCounts.shopping + updateCounts.planner; const recipeSnap = snapshot(recipeStore); const recipeKeys = Object.keys(recipeSnap); @@ -84,11 +79,10 @@ export function StoreInspector() { { - setMutationCount((c) => c + 1); - updateVersions(); + setUpdateCount((previousCount) => previousCount + 1); });`} /> @@ -98,10 +92,10 @@ export function StoreInspector() {

- TOTAL MUTATIONS + OBSERVED UPDATES

- {mutationCount} + {totalUpdates}

{lastMutated && (
@@ -110,15 +104,19 @@ export function StoreInspector() {
{[ - {label: 'Recipe', ver: versions.recipe, color: 'text-blue-400'}, + { + label: 'Recipe', + count: updateCounts.recipe, + color: 'text-blue-400', + }, { label: 'Shopping', - ver: versions.shopping, + count: updateCounts.shopping, color: 'text-emerald-400', }, { label: 'Planner', - ver: versions.planner, + count: updateCounts.planner, color: 'text-orange-400', }, ].map((state) => ( @@ -126,11 +124,11 @@ export function StoreInspector() { key={state.label} className="flex justify-between items-center text-xs" > - {state.label} v: + {state.label}: - {state.ver} + {state.count}
))} diff --git a/examples/nextjs/src/components/editor/HistoryControls.tsx b/examples/nextjs/src/components/editor/HistoryControls.tsx index a475fe2..36fe27a 100644 --- a/examples/nextjs/src/components/editor/HistoryControls.tsx +++ b/examples/nextjs/src/components/editor/HistoryControls.tsx @@ -1,18 +1,11 @@ 'use client'; -import {useClassyStore} from '@codebelt/classy-store/react'; +import {useHistoryStatus} from '@codebelt/classy-store/react'; import type {HistoryHandle} from '@codebelt/classy-store/utils'; import {ApiInfo} from '@/components/shared/ApiInfo'; -export function HistoryControls({ - history, - store, -}: { - history: HistoryHandle; - store: object; -}) { - // Subscribe to store changes so we re-render when canUndo/canRedo changes. - useClassyStore(store); +export function HistoryControls({history}: {history: HistoryHandle}) { + const status = useHistoryStatus(history); return (
@@ -21,7 +14,7 @@ export function HistoryControls({

- canUndo: {String(history.canUndo)} | canRedo:{' '} - {String(history.canRedo)} + canUndo: {String(status.canUndo)} | canRedo: {String(status.canRedo)}

- +
diff --git a/examples/nextjs/src/components/features/FeatureMap.tsx b/examples/nextjs/src/components/features/FeatureMap.tsx index 581a974..4fdd900 100644 --- a/examples/nextjs/src/components/features/FeatureMap.tsx +++ b/examples/nextjs/src/components/features/FeatureMap.tsx @@ -40,15 +40,6 @@ const sections: FeatureSection[] = [ {label: 'Settings / StorageDebugPanel', href: '/settings'}, ], }, - { - api: 'getVersion()', - description: - 'Returns the monotonically increasing version number of a store', - links: [ - {label: 'Dashboard / StoreInspector', href: '/'}, - {label: 'Shopping / SnapshotViewer', href: '/shopping'}, - ], - }, { api: 'shallowEqual', description: diff --git a/examples/nextjs/src/components/planner/PersistStatusBadge.tsx b/examples/nextjs/src/components/planner/PersistStatusBadge.tsx index ac43455..91ecad9 100644 --- a/examples/nextjs/src/components/planner/PersistStatusBadge.tsx +++ b/examples/nextjs/src/components/planner/PersistStatusBadge.tsx @@ -1,47 +1,40 @@ 'use client'; -import {subscribe} from '@codebelt/classy-store'; -import {useEffect, useState} from 'react'; +import {usePersistStatus} from '@codebelt/classy-store/react'; +import {useState} from 'react'; import {ApiInfo} from '@/components/shared/ApiInfo'; -import {plannerPersist, plannerStore} from '@/stores/planner-store'; +import {plannerPersist} from '@/stores/planner-store'; export function PersistStatusBadge() { - const [status, setStatus] = useState({hydrated: false, expired: false}); + const status = usePersistStatus(plannerPersist); + const [actionError, setActionError] = useState(null); - useEffect(() => { - const check = () => { - setStatus({ - hydrated: plannerPersist.isHydrated, - expired: plannerPersist.isExpired, - }); - }; - check(); - // Re-check after hydration completes - plannerPersist.hydrated.then(check); - // Re-check after any store mutation (persist writes happen after) - const unsub = subscribe(plannerStore, () => { - setTimeout(check, 100); - }); - return unsub; - }, []); + async function handleAction(action: () => Promise) { + setActionError(null); + try { + await action(); + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)); + } + } return (
- {status.hydrated ? 'Hydrated' : 'Pending'} + {status.isHydrated ? 'Hydrated' : 'Pending'} - {status.expired && ( + {status.isExpired && ( Expired @@ -50,19 +43,24 @@ export function PersistStatusBadge() {
+ {(actionError || status.lastError) && ( +

+ {actionError ?? `Persistence failed: ${status.lastError?.operation}`} +

+ )}
); } diff --git a/examples/nextjs/src/components/planner/PlannerHydrator.tsx b/examples/nextjs/src/components/planner/PlannerHydrator.tsx index ffb2b90..bfcf6c0 100644 --- a/examples/nextjs/src/components/planner/PlannerHydrator.tsx +++ b/examples/nextjs/src/components/planner/PlannerHydrator.tsx @@ -8,7 +8,13 @@ export function PlannerHydrator() { const [hydrated, setHydrated] = useState(false); useEffect(() => { - plannerPersist.rehydrate().then(() => setHydrated(true)); + void plannerPersist.rehydrate().then( + () => setHydrated(true), + (error) => { + console.error('Could not restore planner data', error); + setHydrated(true); + }, + ); }, []); if (!hydrated) { @@ -25,7 +31,7 @@ export function PlannerHydrator() { description="SSR-safe pattern: store skips auto-hydration, component calls rehydrate() in useEffect on client." code={`persist(plannerStore, { skipHydration: true, ... }); // in useEffect: -plannerPersist.rehydrate();`} +void plannerPersist.rehydrate().catch(console.error);`} /> ); } diff --git a/examples/nextjs/src/components/settings/PersistControls.tsx b/examples/nextjs/src/components/settings/PersistControls.tsx index 7725660..5e7c43b 100644 --- a/examples/nextjs/src/components/settings/PersistControls.tsx +++ b/examples/nextjs/src/components/settings/PersistControls.tsx @@ -1,18 +1,33 @@ 'use client'; +import {useState} from 'react'; import {ApiInfo} from '@/components/shared/ApiInfo'; import {settingsPersist} from '@/stores/settings-store'; export function PersistControls() { + const [errorMessage, setErrorMessage] = useState(null); + + async function handleAction(action: () => Promise) { + setErrorMessage(null); + try { + await action(); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : String(error)); + } + } + return (

Persist Controls

PersistHandle: save(), clear(), rehydrate() @@ -20,26 +35,31 @@ settingsPersist.rehydrate();`}

+ {errorMessage && ( +

+ {errorMessage} +

+ )}
); } diff --git a/examples/nextjs/src/components/shared/StoreHydrator.tsx b/examples/nextjs/src/components/shared/StoreHydrator.tsx index fa9fd79..fc727cc 100644 --- a/examples/nextjs/src/components/shared/StoreHydrator.tsx +++ b/examples/nextjs/src/components/shared/StoreHydrator.tsx @@ -10,8 +10,14 @@ export function StoreHydrator({children}: {children: ReactNode}) { const [hydrated, setHydrated] = useState(false); useEffect(() => { - Promise.all(persists.map((p) => p.rehydrate())).then(() => - setHydrated(true), + void Promise.allSettled(persists.map((handle) => handle.rehydrate())).then( + (results) => { + for (const result of results) { + if (result.status === 'rejected') + console.error('Could not restore persisted state', result.reason); + } + setHydrated(true); + }, ); }, []); diff --git a/examples/nextjs/src/components/shopping/SnapshotViewer.tsx b/examples/nextjs/src/components/shopping/SnapshotViewer.tsx index 50ab75a..8ac21e2 100644 --- a/examples/nextjs/src/components/shopping/SnapshotViewer.tsx +++ b/examples/nextjs/src/components/shopping/SnapshotViewer.tsx @@ -1,37 +1,29 @@ 'use client'; -import {getVersion, snapshot} from '@codebelt/classy-store'; +import {snapshot} from '@codebelt/classy-store'; import {useState} from 'react'; import {ApiInfo} from '@/components/shared/ApiInfo'; import {shoppingStore} from '@/stores/shopping-store'; export function SnapshotViewer() { - const [captured, setCaptured] = useState<{ - version: number; - json: string; - } | null>(null); + const [captured, setCaptured] = useState(null); const handleCapture = () => { const snap = snapshot(shoppingStore); - setCaptured({ - version: getVersion(shoppingStore), - json: JSON.stringify(snap, null, 2), - }); + setCaptured(JSON.stringify(snap, null, 2)); }; return (

Snapshot Viewer

Manual{' '} - snapshot() +{' '} - getVersion(){' '} + snapshot(){' '} capture

); diff --git a/examples/react/dedupe-react.ts b/examples/react/dedupe-react.ts index 8ed9606..a3e1860 100644 --- a/examples/react/dedupe-react.ts +++ b/examples/react/dedupe-react.ts @@ -18,6 +18,9 @@ const dedupeReact: BunPlugin = { if (!subpath || subpath === '/') { return {path: path.join(pkgSrc, 'index.ts')}; } + if (subpath === '/react/server') { + return {path: path.join(pkgSrc, 'frameworks/react/server.ts')}; + } const name = subpath.slice(1); // 'react', 'vue', 'utils', etc. const inFrameworks = path.join(pkgSrc, 'frameworks', name, `${name}.ts`); if (existsSync(inFrameworks)) return {path: inFrameworks}; diff --git a/examples/react/src/demos/history/UndoRedoDemo.tsx b/examples/react/src/demos/history/UndoRedoDemo.tsx index 029b6bd..916d1cd 100644 --- a/examples/react/src/demos/history/UndoRedoDemo.tsx +++ b/examples/react/src/demos/history/UndoRedoDemo.tsx @@ -1,5 +1,4 @@ -import {useClassyStore} from '@codebelt/classy-store/react'; -import {useState} from 'react'; +import {useClassyStore, useHistoryStatus} from '@codebelt/classy-store/react'; import {Button} from '../../components/Button'; import {DemoContainer} from '../../components/DemoContainer'; import {RenderBadge} from '../../components/RenderBadge'; @@ -25,15 +24,16 @@ const COMPONENT_CODE = `// Undo/redo controls history.undo(); history.redo(); -// Check availability -history.canUndo; // boolean -history.canRedo; // boolean +// Subscribe to status inside a React component +const {canUndo, canRedo, isPaused} = useHistoryStatus(history); -// Batch multiple edits into one history entry -history.pause(); +// Synchronous edits naturally share one history entry store.setTitle('New Title'); store.setContent('New content'); -history.resume(); // single history entry`; + +// Control recording separately +history.pause(); // skip notifications delivered while paused +history.resume(); // record future notifications`; function TitleEditor() { const title = useClassyStore(textEditorStore, (state) => state.title); @@ -80,13 +80,10 @@ function ContentEditor() { } function HistoryControls() { - const snap = useClassyStore(textEditorStore); - const [paused, setPaused] = useState(false); + const status = useHistoryStatus(history); + const paused = status.isPaused; const renders = useRenderCount(); - // Reading snap to force re-render so canUndo/canRedo update - void snap; - return (
@@ -99,7 +96,7 @@ function HistoryControls() {
@@ -166,7 +161,7 @@ export function UndoRedoDemo() { return ( { - if (hydrated) return; - kitchenSinkHandle.hydrated.then(() => setHydrated(true)); - }, [hydrated]); + const {isHydrated: hydrated} = usePersistStatus(kitchenSinkHandle); return ( { - await kitchenSinkHandle.save(); - showStatus('Saved!'); + try { + await kitchenSinkHandle.save(); + showStatus('Saved!'); + } catch (error) { + showStatus( + error instanceof Error ? error.message : String(error), + ); + } }} className="!bg-teal-600 hover:!bg-teal-500" > @@ -370,8 +371,14 @@ function ControlPanel() {
+ {lastError && ( +

Could not persist preferences: {lastError.operation}

+ )} ); } diff --git a/examples/react/src/demos/scoped-store/store/components/editorStoreProvider/EditorStoreProvider.tsx b/examples/react/src/demos/scoped-store/store/components/editorStoreProvider/EditorStoreProvider.tsx index 0a1b457..5465600 100644 --- a/examples/react/src/demos/scoped-store/store/components/editorStoreProvider/EditorStoreProvider.tsx +++ b/examples/react/src/demos/scoped-store/store/components/editorStoreProvider/EditorStoreProvider.tsx @@ -1,7 +1,5 @@ -import { - prepareStoreForHydration, - useLocalStore, -} from '@codebelt/classy-store/react'; +import {useLocalStore} from '@codebelt/classy-store/react'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; import {persist, type StorageAdapter} from '@codebelt/classy-store/utils'; import {type ReactNode, useEffect} from 'react'; import {type EditorData, EditorStore} from '../../editorStore'; @@ -33,8 +31,10 @@ export function EditorStoreProvider({ properties: ['title', 'body'], skipHydration: true, }); - void handle.rehydrate(); - return () => handle.unsubscribe(); + void handle + .rehydrate() + .catch((error) => console.error('Could not restore editor data', error)); + return () => handle.dispose(); }, [store, name, storage]); return ( diff --git a/examples/react/tsconfig.json b/examples/react/tsconfig.json index a34c468..e23ff1e 100644 --- a/examples/react/tsconfig.json +++ b/examples/react/tsconfig.json @@ -25,6 +25,9 @@ "@codebelt/classy-store/react": [ "../../packages/classy-store/src/frameworks/react/react.ts" ], + "@codebelt/classy-store/react/server": [ + "../../packages/classy-store/src/frameworks/react/server.ts" + ], "@codebelt/classy-store/utils": [ "../../packages/classy-store/src/utils/index.ts" ] diff --git a/examples/react/tsconfig.scoping.json b/examples/react/tsconfig.scoping.json index 2b24eba..036486e 100644 --- a/examples/react/tsconfig.scoping.json +++ b/examples/react/tsconfig.scoping.json @@ -12,6 +12,9 @@ "@codebelt/classy-store/react": [ "../../packages/classy-store/src/frameworks/react/react.ts" ], + "@codebelt/classy-store/react/server": [ + "../../packages/classy-store/src/frameworks/react/server.ts" + ], "@codebelt/classy-store/utils": [ "../../packages/classy-store/src/utils/index.ts" ] diff --git a/packages/classy-store/src/core/computed.test.tsx b/packages/classy-store/src/core/core.computed.test.ts similarity index 66% rename from packages/classy-store/src/core/computed.test.tsx rename to packages/classy-store/src/core/core.computed.test.ts index 7738095..d3ecdc5 100644 --- a/packages/classy-store/src/core/computed.test.tsx +++ b/packages/classy-store/src/core/core.computed.test.ts @@ -1,9 +1,5 @@ -import {afterEach, describe, expect, it, mock} from 'bun:test'; -import {act, type ReactNode} from 'react'; -import {createRoot} from 'react-dom/client'; -import {useClassyStore} from '../frameworks/react/react'; +import {describe, expect, it} from 'bun:test'; import {snapshot} from '../snapshot/snapshot'; -import type {Snapshot} from '../types'; import {createClassyStore} from './core'; /** Helper: flush the queueMicrotask-based batching. */ @@ -532,253 +528,3 @@ describe('computed getters — write proxy memoization', () => { expect(callCount).toBe(2); }); }); - -// ── Snapshot memoization tests ────────────────────────────────────────────── - -describe('computed getters — snapshot memoization', () => { - // 9. Snapshot getter computes only once - it('snapshot getter computes only once (verified by call counter)', () => { - let callCount = 0; - - class Store { - items = ['a', 'b', 'c']; - - get count() { - callCount++; - return this.items.length; - } - } - - const s = createClassyStore(new Store()); - const snap = snapshot(s); - - expect(snap.count).toBe(3); - expect(snap.count).toBe(3); - expect(snap.count).toBe(3); - - expect(callCount).toBe(1); // computed only once on the snapshot - }); - - // 10. Snapshot getter returns same reference on repeated access - it('snapshot getter returns the same reference on repeated access', () => { - class Store { - items = ['a', 'b', 'c']; - - get filtered() { - return this.items.filter((item) => item !== 'b'); - } - } - - const s = createClassyStore(new Store()); - const snap = snapshot(s); - - const result1 = snap.filtered; - const result2 = snap.filtered; - - expect(result1).toBe(result2); // same reference — memoized - expect(result1).toEqual(['a', 'c']); - }); - - // 10b. Cross-snapshot reference stability when deps unchanged - it('returns same reference across snapshots when getter deps have not changed', async () => { - class TodoStore { - todos = [{text: 'Buy milk', done: false}]; - theme = 'dark'; - - get activeTodos() { - return this.todos.filter((todo) => !todo.done); - } - - setTheme(value: string) { - this.theme = value; - } - } - - const s = createClassyStore(new TodoStore()); - - const snap1 = snapshot(s); - const result1 = snap1.activeTodos; - - s.setTheme('light'); - await flush(); - - const snap2 = snapshot(s); - expect(snap2).not.toBe(snap1); // different snapshot - - const result2 = snap2.activeTodos; - expect(result1).toBe(result2); // SAME reference — cross-snapshot memo - }); - - // 11. Different snapshots compute independently - it('different snapshots compute independently', async () => { - class Store { - count = 5; - - get doubled() { - return this.count * 2; - } - - setCount(value: number) { - this.count = value; - } - } - - const s = createClassyStore(new Store()); - - const snap1 = snapshot(s); - expect(snap1.doubled).toBe(10); - - s.setCount(10); - await flush(); - - const snap2 = snapshot(s); - expect(snap2.doubled).toBe(20); - - // snap1 still returns the old value - expect(snap1.doubled).toBe(10); - }); - - // 11c. Snapshot getter recomputes when a sub-tree dep is replaced - it('snapshot getter returns new value when dep sub-tree is replaced', async () => { - class Store { - items = [1, 2, 3]; - - get count() { - return this.items.length; - } - - setItems(newItems: number[]) { - this.items = newItems; - } - } - - const s = createClassyStore(new Store()); - - const snap1 = snapshot(s); - expect(snap1.count).toBe(3); - - s.setItems([10]); - await flush(); - - const snap2 = snapshot(s); - expect(snap2.count).toBe(1); // must reflect the new items, not stale cache - }); - - // 12. Snapshot getter reads frozen snapshot data correctly - it('snapshot getter reads frozen data correctly', () => { - class Store { - firstName = 'John'; - lastName = 'Doe'; - - get fullName() { - return `${this.firstName} ${this.lastName}`; - } - } - - const s = createClassyStore(new Store()); - const snap = snapshot(s); - - expect(snap.fullName).toBe('John Doe'); - expect(Object.isFrozen(snap)).toBe(true); - }); -}); - -// ── Integration tests with useClassyStore ────────────────────────────────────── - -describe('computed getters — useClassyStore integration', () => { - let container: HTMLDivElement; - - function setup(): void { - container = document.createElement('div'); - document.body.appendChild(container); - } - - function teardown(): void { - document.body.removeChild(container); - } - - function render(element: ReactNode): void { - const root = createRoot(container); - act(() => { - root.render(element); - }); - } - - afterEach(teardown); - - // 13. useClassyStore selector with getter returns stable reference - it('selector with getter returns stable reference — no re-render on unrelated change', async () => { - class TodoStore { - todos = [{text: 'Buy milk', done: false}]; - theme = 'dark'; - - get activeTodos() { - return this.todos.filter((todo) => !todo.done); - } - - setTheme(value: string) { - this.theme = value; - } - } - - const todoStore = createClassyStore(new TodoStore()); - const renderCount = mock(() => {}); - - function ActiveList() { - const active = useClassyStore( - todoStore, - (snap: Snapshot) => snap.activeTodos, - ); - renderCount(); - return
{active.length}
; - } - - setup(); - render(); - expect(container.textContent).toBe('1'); - expect(renderCount).toHaveBeenCalledTimes(1); - - // Change `theme` — activeTodos getter doesn't depend on it. - // The snapshot getter is memoized, so it returns the same reference. - await act(async () => { - todoStore.setTheme('light'); - await flush(); - }); - - // Should NOT re-render because the getter result is the same reference. - expect(renderCount).toHaveBeenCalledTimes(1); - }); - - // 14. Auto-tracked mode with getter access - it('auto-tracked mode works with computed getters', async () => { - class Store { - count = 5; - - get doubled() { - return this.count * 2; - } - - setCount(value: number) { - this.count = value; - } - } - - const s = createClassyStore(new Store()); - - function Display() { - const snap = useClassyStore(s); - return
{snap.doubled}
; - } - - setup(); - render(); - expect(container.textContent).toBe('10'); - - await act(async () => { - s.setCount(20); - await flush(); - }); - - expect(container.textContent).toBe('40'); - }); -}); diff --git a/packages/classy-store/src/core/core.memory.qa.test.ts b/packages/classy-store/src/core/core.ownership.test.ts similarity index 98% rename from packages/classy-store/src/core/core.memory.qa.test.ts rename to packages/classy-store/src/core/core.ownership.test.ts index a1c55c7..0ed2fbb 100644 --- a/packages/classy-store/src/core/core.memory.qa.test.ts +++ b/packages/classy-store/src/core/core.ownership.test.ts @@ -1,7 +1,7 @@ import {describe, expect, test} from 'bun:test'; import {createClassyStore, getInternal, subscribe} from './core'; -describe('createClassyStore memory ownership QA', () => { +describe('createClassyStore memory ownership', () => { test.each([ {description: 'replacement', remove: 'replace'}, {description: 'deletion', remove: 'delete'}, diff --git a/packages/classy-store/src/core/core.ts b/packages/classy-store/src/core/core.ts index 64ddab8..623530f 100644 --- a/packages/classy-store/src/core/core.ts +++ b/packages/classy-store/src/core/core.ts @@ -1,5 +1,6 @@ -import type {DepEntry, StoreInternal, SubscribeOptions} from '../types'; +import type {SubscribeOptions} from '../types'; import {canProxy, findGetterDescriptor} from '../utils/internal/internal'; +import type {DepEntry, StoreInternal} from './core.types'; // ── Global state ────────────────────────────────────────────────────────────── @@ -424,7 +425,7 @@ export function subscribe( } /** - * Returns the current version number of a store proxy. + * @internal Returns the current version number of a store proxy. * * Versions are monotonically increasing and bump on any mutation in the * store's subtree (child mutations propagate up to the root). Useful for diff --git a/packages/classy-store/src/core/core.types.ts b/packages/classy-store/src/core/core.types.ts new file mode 100644 index 0000000..ff7252a --- /dev/null +++ b/packages/classy-store/src/core/core.types.ts @@ -0,0 +1,55 @@ +// ── Computed getter memoization types ───────────────────────────────────────── + +/** + * A single dependency recorded during getter execution. + * - `version`: dependency is a tracked sub-tree — compare `internal.version`. + * - `value`: dependency is a primitive — compare via `Object.is`. + */ +export type DepEntry = + | { + kind: 'version'; + internal: StoreInternal; + version: number; + /** Parent target — used to detect when the property is replaced entirely. */ + parentTarget: object; + /** Property name on the parent. */ + prop: string | symbol; + } + | {kind: 'value'; target: object; prop: string | symbol; value: unknown} + | { + /** Dependency on a computed getter — validated via the computed cache, not raw getter re-execution. */ + kind: 'computed'; + internal: StoreInternal; + prop: string | symbol; + value: unknown; + }; + +/** Cached result of a memoized computed getter, together with its dependencies. */ +export type ComputedEntry = { + value: unknown; + deps: DepEntry[]; +}; + +// ── Store internal bookkeeping ─────────────────────────────────────────────── + +/** Internal bookkeeping for a store proxy, stored in a WeakMap keyed by the proxy. */ +export type StoreInternal = { + /** The raw (un-proxied) class instance. */ + target: object; + /** Monotonically increasing version counter. */ + version: number; + /** Subscriber callbacks notified once per batched mutation turn. */ + batchedListeners: Set<() => void>; + /** Subscriber callbacks notified immediately on each mutation. */ + syncListeners: Set<() => void>; + /** Cached child proxies for nested plain objects/arrays (keyed by property name). */ + childProxies: Map; + /** Cached child internals for propagating version bumps up the tree. */ + childInternals: Map; + /** Reference to the parent internal (if this is a child proxy). */ + parent: StoreInternal | null; + /** Whether a microtask notification is already scheduled. */ + notifyScheduled: boolean; + /** Memoized computed getter cache, keyed by getter property name. */ + computedCache: Map; +}; diff --git a/packages/classy-store/src/frameworks/react/hooks/useClassyStore/useClassyStore.ts b/packages/classy-store/src/frameworks/react/hooks/useClassyStore/useClassyStore.ts new file mode 100644 index 0000000..19810ad --- /dev/null +++ b/packages/classy-store/src/frameworks/react/hooks/useClassyStore/useClassyStore.ts @@ -0,0 +1,225 @@ +import {createProxy, isChanged} from 'proxy-compare'; +import {useEffect, useMemo, useRef, useSyncExternalStore} from 'react'; +import {subscribe as coreSubscribe, getInternal} from '../../../../core/core'; +import {snapshot} from '../../../../snapshot/snapshot'; +import type {Snapshot} from '../../../../types'; +import {hydrationSnapshots} from '../../utils/hydrationSnapshots/hydrationSnapshots'; +import {SnapshotTargetCache} from '../../utils/snapshotTargetCache/snapshotTargetCache'; +import {TrackingReadMap} from '../../utils/trackingReadMap/trackingReadMap'; +import type { + EqualityFn, + UseClassyStoreSelectorOptions, +} from './useClassyStore.types'; + +// A frozen snapshot's backing copy is identical for every consumer. Without +// this cache, each row rescans and copies the entire containing list on access. +const snapshotTargets = new SnapshotTargetCache(); + +// ── Overloads ───────────────────────────────────────────────────────────────── + +/** + * Subscribe to a store proxy with an explicit selector. + * + * Re-renders only when the selected value changes (compared via `Object.is` + * by default, or `options.isEqual`). + * + * @param proxyStore - A reactive proxy created by `createClassyStore()`. + * @param selector - Picks data from the immutable snapshot. + * @param options - Controls selected-value equality. + */ +export function useClassyStore( + proxyStore: T, + selector: (snap: Snapshot) => S, + options?: UseClassyStoreSelectorOptions, +): S; + +/** + * Subscribe to a store proxy **without** a selector (auto-tracked mode). + * + * Returns a `proxy-compare` tracking proxy over the immutable snapshot. + * The component only re-renders when a property it actually read changes. + * + * @param proxyStore - A reactive proxy created by `createClassyStore()`. + */ +export function useClassyStore(proxyStore: T): Snapshot; + +// ── Implementation ──────────────────────────────────────────────────────────── + +export function useClassyStore( + proxyStore: T, + selector?: (snap: Snapshot) => S, + selectorOptions?: UseClassyStoreSelectorOptions, +): Snapshot | S { + // Validate that the argument is actually a store proxy (throws if not). + getInternal(proxyStore); + + const isEqual = selectorOptions?.isEqual; + + // React must observe mutations during the event that changes a controlled + // input. Ordinary core subscriptions retain their default microtask batching. + const subscribe = useMemo(() => createSubscription(proxyStore), [proxyStore]); + + // ── Refs used by both modes (always allocated to satisfy Rules of Hooks) ── + + // Keep only committed selections shared between selector instances. + // Each selector gets its own snapshot cache so concurrent renders do not + // overwrite one another's cached snapshot or selected result. + const committedSelectionRef = useRef<{value: S} | undefined>(undefined); + const initialSnapshot = hydrationSnapshots.get(proxyStore) as + | Snapshot + | undefined; + const {getSelectedSnapshot, getServerSnapshot} = useMemo( + () => + createSnapshotGetters( + proxyStore, + selector, + isEqual, + committedSelectionRef, + initialSnapshot, + ), + [proxyStore, selector, isEqual, initialSnapshot], + ); + + // Each render owns its read set, seeded from committed reads of the same + // immutable objects. Memoization can skip those reads on subsequent renders. + // Weak keys release old dependencies as their snapshots become unreachable. + const committedReads = useRef(new WeakMap()).current; + const affected = new TrackingReadMap(committedReads); + const proxyCache = useRef(new WeakMap()).current; + let previousAutoSnapshot: Snapshot | undefined; + const getSnapshot = (): Snapshot | S => { + if (getSelectedSnapshot) return getSelectedSnapshot(); + const nextSnapshot = snapshot(proxyStore); + if ( + previousAutoSnapshot && + !isChanged(previousAutoSnapshot, nextSnapshot, affected) + ) { + return previousAutoSnapshot; + } + previousAutoSnapshot = nextSnapshot; + return nextSnapshot; + }; + + const readServerSnapshot = (): Snapshot | S => { + const serverResult = getServerSnapshot + ? getServerSnapshot() + : getSnapshot(); + // This render's read set can filter the first live update, too. Seed only + // when React hydrates so client-only mounts always start from current data. + if (!selector) previousAutoSnapshot = serverResult as Snapshot; + return serverResult; + }; + + const result = useSyncExternalStore( + subscribe, + getSnapshot, + readServerSnapshot, + ); + + useEffect(() => { + committedSelectionRef.current = selector ? {value: result as S} : undefined; + affected.commit(); + }, [result, selector, affected]); + + return selector + ? result + : (createProxy( + result as Snapshot, + affected, + proxyCache, + snapshotTargets, + ) as Snapshot); +} + +// ── Selector mode logic (pure function, no hooks) ───────────────────────────── + +// Construct stable callbacks outside the hook's lexical scope. Engines can +// share a closure context with render-local values, otherwise keeping the first +// result and auto-tracked snapshot alive for the entire mounted subscription. +function createSubscription(proxyStore: T) { + return (onStoreChange: () => void) => + coreSubscribe(proxyStore, onStoreChange, {sync: true}); +} + +function createSnapshotGetters( + proxyStore: T, + selector: ((snap: Snapshot) => S) | undefined, + isEqual: EqualityFn | undefined, + committedSelectionRef: {current: {value: S} | undefined}, + initialSnapshot: Snapshot | undefined, +) { + let hydrationSelection: {value: S; snapshot: Snapshot} | undefined; + const readServerSelection = + initialSnapshot && selector + ? createSelectorSnapshotGetter( + () => initialSnapshot, + selector, + () => undefined, + isEqual, + ) + : undefined; + + return { + getSelectedSnapshot: selector + ? createSelectorSnapshotGetter( + () => snapshot(proxyStore), + selector, + () => hydrationSelection ?? committedSelectionRef.current, + isEqual, + ) + : undefined, + getServerSnapshot: initialSnapshot + ? () => { + if (!readServerSelection) return initialSnapshot; + const value = readServerSelection(); + // Seed only this selector's live cache, and only when React actually + // hydrates. Never overwrite committed data from an abandoned render. + hydrationSelection = {value, snapshot: initialSnapshot}; + return value; + } + : undefined, + }; +} + +/** + * Create a cached `getSnapshot` for one store, selector, and equality function. + * + * Re-evaluates when any of those inputs change, even if the store did not. + * Repeated reads of the same snapshot return the cached result, including + * `undefined`. Equal selections retain their reference across selector changes. + */ +function createSelectorSnapshotGetter( + getStoreSnapshot: () => Snapshot, + selector: (snap: Snapshot) => S, + getPreviousSelection: () => {value: S; snapshot?: Snapshot} | undefined, + isEqual?: EqualityFn, +): () => S { + let previousSnapshot: Snapshot | undefined; + let selection: {value: S; snapshot?: Snapshot} | undefined; + + return () => { + const nextSnapshot = getStoreSnapshot(); + if (previousSnapshot === nextSnapshot && selection) { + return selection.value; + } + + const previousSelection = selection ?? getPreviousSelection(); + // The same immutable input and selector can reuse the hydration result + // directly, including selectors that allocate an object without isEqual. + const nextResult = + !selection && previousSelection?.snapshot === nextSnapshot + ? previousSelection.value + : selector(nextSnapshot); + + selection = + previousSelection && + (isEqual + ? isEqual(previousSelection.value, nextResult) + : Object.is(previousSelection.value, nextResult)) + ? previousSelection + : {value: nextResult}; + previousSnapshot = nextSnapshot; + + return selection.value; + }; +} diff --git a/packages/classy-store/src/frameworks/react/hooks/useClassyStore/useClassyStore.types.ts b/packages/classy-store/src/frameworks/react/hooks/useClassyStore/useClassyStore.types.ts new file mode 100644 index 0000000..03f12aa --- /dev/null +++ b/packages/classy-store/src/frameworks/react/hooks/useClassyStore/useClassyStore.types.ts @@ -0,0 +1,5 @@ +export type EqualityFn = (a: T, b: T) => boolean; + +export type UseClassyStoreSelectorOptions = { + isEqual?: EqualityFn; +}; diff --git a/packages/classy-store/src/frameworks/react/hooks/useHistoryStatus/useHistoryStatus.ts b/packages/classy-store/src/frameworks/react/hooks/useHistoryStatus/useHistoryStatus.ts new file mode 100644 index 0000000..d67a632 --- /dev/null +++ b/packages/classy-store/src/frameworks/react/hooks/useHistoryStatus/useHistoryStatus.ts @@ -0,0 +1,10 @@ +import type { + HistoryHandle, + HistoryStatus, +} from '../../../../utils/history/history'; +import {useUtilityStatus} from '../useUtilityStatus/useUtilityStatus'; + +/** Subscribe to undo/redo availability, recording state, and history disposal. */ +export function useHistoryStatus(history: HistoryHandle): HistoryStatus { + return useUtilityStatus(history); +} diff --git a/packages/classy-store/src/frameworks/react/hooks/useLocalStore/useLocalStore.ts b/packages/classy-store/src/frameworks/react/hooks/useLocalStore/useLocalStore.ts new file mode 100644 index 0000000..7553079 --- /dev/null +++ b/packages/classy-store/src/frameworks/react/hooks/useLocalStore/useLocalStore.ts @@ -0,0 +1,33 @@ +import {useState} from 'react'; +import {createClassyStore} from '../../../../core/core'; + +/** + * Create a component-scoped reactive store that lives for the lifetime of the + * component. Unmounting releases React's reference; the store can be collected + * when no external references remain. Clean up application subscriptions and + * other effects separately; this hook does not call a store's dispose method. + * + * The factory is a `useState` initializer and must be pure. React StrictMode + * may invoke it more than once in development. + * Each component instance gets its own isolated store. + * + * Use the returned proxy with `useClassyStore()` to read state in the same component + * or pass it down via props/context to share within a subtree. + * + * @param factory - A function that returns a class instance (or plain object). + * Used as the component state initializer. + * @returns A reactive store proxy scoped to the component's lifetime. + * + * @example + * ```tsx + * function Counter() { + * const store = useLocalStore(() => new CounterStore()); + * const count = useClassyStore(store, (state) => state.count); + * return ; + * } + * ``` + */ +export function useLocalStore(factory: () => T): T { + const [store] = useState(() => createClassyStore(factory())); + return store; +} diff --git a/packages/classy-store/src/frameworks/react/hooks/usePersistStatus/usePersistStatus.ts b/packages/classy-store/src/frameworks/react/hooks/usePersistStatus/usePersistStatus.ts new file mode 100644 index 0000000..c6fb311 --- /dev/null +++ b/packages/classy-store/src/frameworks/react/hooks/usePersistStatus/usePersistStatus.ts @@ -0,0 +1,10 @@ +import type { + PersistHandle, + PersistStatus, +} from '../../../../utils/persist/persist.types'; +import {useUtilityStatus} from '../useUtilityStatus/useUtilityStatus'; + +/** Subscribe to persistence hydration, expiry, errors, and disposal. */ +export function usePersistStatus(handle: PersistHandle): PersistStatus { + return useUtilityStatus(handle); +} diff --git a/packages/classy-store/src/frameworks/react/hooks/useUtilityStatus/useUtilityStatus.ts b/packages/classy-store/src/frameworks/react/hooks/useUtilityStatus/useUtilityStatus.ts new file mode 100644 index 0000000..40927e5 --- /dev/null +++ b/packages/classy-store/src/frameworks/react/hooks/useUtilityStatus/useUtilityStatus.ts @@ -0,0 +1,11 @@ +import {useSyncExternalStore} from 'react'; +import type {UtilityStatusSource} from '../../../../utils/utilityStatus/utilityStatus'; + +/** Internal subscription shared by the public history and persistence hooks. */ +export function useUtilityStatus(handle: UtilityStatusSource): T { + return useSyncExternalStore( + handle.subscribe, + handle.getSnapshot, + handle.getServerSnapshot, + ); +} diff --git a/packages/classy-store/src/frameworks/react/react.computed.test.tsx b/packages/classy-store/src/frameworks/react/react.computed.test.tsx new file mode 100644 index 0000000..8bf4266 --- /dev/null +++ b/packages/classy-store/src/frameworks/react/react.computed.test.tsx @@ -0,0 +1,109 @@ +import {afterEach, describe, expect, it, mock} from 'bun:test'; +import {act, type ReactNode} from 'react'; +import {createRoot} from 'react-dom/client'; +import {createClassyStore} from '../../core/core'; +import type {Snapshot} from '../../types'; +import {useClassyStore} from './react'; + +/** Helper: flush the queueMicrotask-based batching. */ +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// ── Integration tests with useClassyStore ────────────────────────────────────── + +describe('computed getters — useClassyStore integration', () => { + let container: HTMLDivElement; + + function setup(): void { + container = document.createElement('div'); + document.body.appendChild(container); + } + + function teardown(): void { + document.body.removeChild(container); + } + + function render(element: ReactNode): void { + const root = createRoot(container); + act(() => { + root.render(element); + }); + } + + afterEach(teardown); + + // 13. useClassyStore selector with getter returns stable reference + it('selector with getter returns stable reference — no re-render on unrelated change', async () => { + class TodoStore { + todos = [{text: 'Buy milk', done: false}]; + theme = 'dark'; + + get activeTodos() { + return this.todos.filter((todo) => !todo.done); + } + + setTheme(value: string) { + this.theme = value; + } + } + + const todoStore = createClassyStore(new TodoStore()); + const renderCount = mock(() => {}); + + function ActiveList() { + const active = useClassyStore( + todoStore, + (snap: Snapshot) => snap.activeTodos, + ); + renderCount(); + return
{active.length}
; + } + + setup(); + render(); + expect(container.textContent).toBe('1'); + expect(renderCount).toHaveBeenCalledTimes(1); + + // Change `theme` — activeTodos getter doesn't depend on it. + // The snapshot getter is memoized, so it returns the same reference. + await act(async () => { + todoStore.setTheme('light'); + await flush(); + }); + + // Should NOT re-render because the getter result is the same reference. + expect(renderCount).toHaveBeenCalledTimes(1); + }); + + // 14. Auto-tracked mode with getter access + it('auto-tracked mode works with computed getters', async () => { + class Store { + count = 5; + + get doubled() { + return this.count * 2; + } + + setCount(value: number) { + this.count = value; + } + } + + const s = createClassyStore(new Store()); + + function Display() { + const snap = useClassyStore(s); + return
{snap.doubled}
; + } + + setup(); + render(); + expect(container.textContent).toBe('10'); + + await act(async () => { + s.setCount(20); + await flush(); + }); + + expect(container.textContent).toBe('40'); + }); +}); diff --git a/packages/classy-store/src/frameworks/react/react.hydration.qa.test.tsx b/packages/classy-store/src/frameworks/react/react.hydration.lifecycle.test.tsx similarity index 98% rename from packages/classy-store/src/frameworks/react/react.hydration.qa.test.tsx rename to packages/classy-store/src/frameworks/react/react.hydration.lifecycle.test.tsx index 0a3b305..5c5550a 100644 --- a/packages/classy-store/src/frameworks/react/react.hydration.qa.test.tsx +++ b/packages/classy-store/src/frameworks/react/react.hydration.lifecycle.test.tsx @@ -4,7 +4,8 @@ import {createRoot, hydrateRoot, type Root} from 'react-dom/client'; import {renderToString} from 'react-dom/server'; import {createClassyStore, getInternal} from '../../core/core'; import type {Snapshot} from '../../types'; -import {prepareStoreForHydration, useClassyStore} from './react'; +import {useClassyStore} from './react'; +import {prepareStoreForHydration} from './server'; import {DelayedHydrationView} from './testHelpers/delayedHydrationView/DelayedHydrationView'; import type {HydrationStore} from './testHelpers/hydration.types'; @@ -66,7 +67,7 @@ afterEach(async () => { mounted.clear(); }); -describe('prepareStoreForHydration independent QA', () => { +describe('prepareStoreForHydration lifecycle', () => { test('validates proxies, preserves identity, and does not subscribe', () => { expect(() => prepareStoreForHydration(new HydrationQaStore('invalid')), diff --git a/packages/classy-store/src/frameworks/react/react.hydration.test.tsx b/packages/classy-store/src/frameworks/react/react.hydration.test.tsx index d0b4017..c71d391 100644 --- a/packages/classy-store/src/frameworks/react/react.hydration.test.tsx +++ b/packages/classy-store/src/frameworks/react/react.hydration.test.tsx @@ -4,11 +4,8 @@ import {hydrateRoot, type Root} from 'react-dom/client'; import {renderToString} from 'react-dom/server'; import {createClassyStore, getInternal} from '../../core/core'; import {persist} from '../../utils/persist/persist'; -import { - createStoreHook, - prepareStoreForHydration, - useClassyStore, -} from './react'; +import {createStoreHook, useClassyStore} from './react'; +import {prepareStoreForHydration} from './server'; import {DelayedHydrationView} from './testHelpers/delayedHydrationView/DelayedHydrationView'; import type { HydrationGate, @@ -151,7 +148,7 @@ describe('React delayed Suspense hydration', () => { let rehydration: Promise | undefined; const onMount = () => { rehydration = persistence.rehydrate(); - return () => persistence.unsubscribe(); + return () => persistence.dispose(); }; let root!: Root; diff --git a/packages/classy-store/src/frameworks/react/react.performance.qa.test.tsx b/packages/classy-store/src/frameworks/react/react.performance.test.tsx similarity index 99% rename from packages/classy-store/src/frameworks/react/react.performance.qa.test.tsx rename to packages/classy-store/src/frameworks/react/react.performance.test.tsx index 0de328f..0a172bb 100644 --- a/packages/classy-store/src/frameworks/react/react.performance.qa.test.tsx +++ b/packages/classy-store/src/frameworks/react/react.performance.test.tsx @@ -50,7 +50,7 @@ afterEach(() => { mounted.clear(); }); -describe('useClassyStore performance independent QA', () => { +describe('useClassyStore render counts', () => { test('keeps shared snapshot targets independent across many consumer read sets', () => { const store = createStore(128); const {counts, states, onCommit} = createCommits(); diff --git a/packages/classy-store/src/frameworks/react/react.ssr.test.tsx b/packages/classy-store/src/frameworks/react/react.ssr.test.tsx index b592469..b002f20 100644 --- a/packages/classy-store/src/frameworks/react/react.ssr.test.tsx +++ b/packages/classy-store/src/frameworks/react/react.ssr.test.tsx @@ -147,7 +147,7 @@ describe('React server rendering and hydration', () => { skipHydration: true, }); rehydration = persistence.rehydrate(); - return () => persistence.unsubscribe(); + return () => persistence.dispose(); }; expect(storage.getItem).not.toHaveBeenCalled(); diff --git a/packages/classy-store/src/frameworks/react/react.status.test.tsx b/packages/classy-store/src/frameworks/react/react.status.test.tsx new file mode 100644 index 0000000..07b6c17 --- /dev/null +++ b/packages/classy-store/src/frameworks/react/react.status.test.tsx @@ -0,0 +1,236 @@ +import {afterEach, describe, expect, mock, test} from 'bun:test'; +import {act, StrictMode} from 'react'; +import {createRoot, hydrateRoot} from 'react-dom/client'; +import {renderToString} from 'react-dom/server'; +import {createClassyStore} from '../../core/core'; +import {persist, withHistory} from '../../utils/index'; +import {HistoryStatusView} from './testHelpers/historyStatusView/HistoryStatusView'; +import {PersistStatusView} from './testHelpers/persistStatusView/PersistStatusView'; + +const cleanups: Array<() => void> = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0).reverse()) cleanup(); +}); + +function mountContainer() { + const container = document.createElement('div'); + document.body.appendChild(container); + cleanups.push(() => container.remove()); + return container; +} + +describe('useHistoryStatus', () => { + test('renders history availability and pause changes without subscribing to store data', async () => { + const store = createClassyStore({count: 0}); + const history = withHistory(store); + cleanups.push(history.dispose); + const container = mountContainer(); + const root = createRoot(container); + cleanups.push(() => act(() => root.unmount())); + act(() => root.render()); + expect(JSON.parse(container.textContent ?? '').canUndo).toBe(false); + await act(async () => { + store.count++; + }); + expect(JSON.parse(container.textContent ?? '').canUndo).toBe(true); + act(() => history.pause()); + expect(JSON.parse(container.textContent ?? '').isPaused).toBe(true); + act(() => history.resume()); + await act(async () => history.undo()); + expect(JSON.parse(container.textContent ?? '')).toMatchObject({ + canUndo: false, + canRedo: true, + isPaused: false, + }); + act(() => history.dispose()); + expect(JSON.parse(container.textContent ?? '').isDisposed).toBe(true); + }); + + test('switches sources and cleans up subscriptions under Strict Mode', () => { + const first = withHistory(createClassyStore({count: 0})); + const second = withHistory(createClassyStore({count: 0})); + cleanups.push(first.dispose, second.dispose); + let subscriptions = 0; + const observed = { + ...first, + subscribe(listener: () => void) { + subscriptions++; + const stop = first.subscribe(listener); + return () => { + subscriptions--; + stop(); + }; + }, + }; + const container = mountContainer(); + const root = createRoot(container); + act(() => + root.render( + + + , + ), + ); + expect(subscriptions).toBe(1); + act(() => + root.render( + + + , + ), + ); + expect(subscriptions).toBe(0); + act(() => first.pause()); + expect(JSON.parse(container.textContent ?? '').isPaused).toBe(false); + act(() => second.pause()); + expect(JSON.parse(container.textContent ?? '').isPaused).toBe(true); + act(() => + root.render( + + + , + ), + ); + expect(subscriptions).toBe(1); + act(() => root.unmount()); + expect(subscriptions).toBe(0); + }); + + test('hydrates initial status then catches up when the handle changes before hydration', async () => { + const server = withHistory(createClassyStore({count: 0})); + const client = withHistory(createClassyStore({count: 0})); + cleanups.push(server.dispose, client.dispose); + const container = mountContainer(); + container.innerHTML = renderToString(); + const originalOutput = container.firstChild; + client.pause(); + const recoverable = mock(() => {}); + let root!: ReturnType; + await act(async () => { + root = hydrateRoot(container, , { + onRecoverableError: recoverable, + }); + }); + cleanups.push(() => act(() => root.unmount())); + expect(recoverable).not.toHaveBeenCalled(); + expect(container.firstChild).toBe(originalOutput); + expect(JSON.parse(container.textContent ?? '').isPaused).toBe(true); + }); +}); + +describe('usePersistStatus', () => { + function createPersistence() { + const handle = persist(createClassyStore({count: 0}), { + name: 'status-lifecycle', + skipHydration: true, + storage: { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }, + }); + cleanups.push(handle.dispose); + return handle; + } + + test('switches handles and cleans up subscriptions under Strict Mode', async () => { + const first = createPersistence(); + const second = createPersistence(); + let subscriptions = 0; + const observed = { + ...first, + subscribe(listener: () => void) { + subscriptions++; + const stop = first.subscribe(listener); + return () => { + subscriptions--; + stop(); + }; + }, + }; + const container = mountContainer(); + const root = createRoot(container); + act(() => + root.render( + + + , + ), + ); + expect(subscriptions).toBe(1); + act(() => + root.render( + + + , + ), + ); + expect(subscriptions).toBe(0); + await act(async () => first.rehydrate()); + expect(JSON.parse(container.textContent ?? '').isHydrated).toBe(false); + await act(async () => second.rehydrate()); + expect(JSON.parse(container.textContent ?? '').isHydrated).toBe(true); + act(() => + root.render( + + + , + ), + ); + expect(subscriptions).toBe(1); + act(() => root.unmount()); + expect(subscriptions).toBe(0); + }); + + test('hydrates initial status then catches up when persistence settles before hydration', async () => { + const server = createPersistence(); + const client = createPersistence(); + const container = mountContainer(); + container.innerHTML = renderToString(); + const originalOutput = container.firstChild; + await client.rehydrate(); + const recoverable = mock(() => {}); + let root!: ReturnType; + await act(async () => { + root = hydrateRoot(container, , { + onRecoverableError: recoverable, + }); + }); + cleanups.push(() => act(() => root.unmount())); + expect(recoverable).not.toHaveBeenCalled(); + expect(container.firstChild).toBe(originalOutput); + expect(JSON.parse(container.textContent ?? '').isHydrated).toBe(true); + }); + + test('renders hydration, storage failure and recovery without a data mutation', async () => { + let failing = true; + const handle = persist(createClassyStore({count: 0}), { + name: 'status', + skipHydration: true, + storage: { + getItem: () => null, + setItem: () => { + if (failing) throw new Error('quota'); + }, + removeItem: () => {}, + }, + }); + cleanups.push(handle.dispose); + const container = mountContainer(); + const root = createRoot(container); + cleanups.push(() => act(() => root.unmount())); + act(() => root.render()); + expect(JSON.parse(container.textContent ?? '').isHydrated).toBe(false); + await act(async () => handle.rehydrate()); + expect(JSON.parse(container.textContent ?? '').isHydrated).toBe(true); + await act(async () => { + await expect(handle.save()).rejects.toThrow('quota'); + }); + expect(JSON.parse(container.textContent ?? '').lastError.operation).toBe( + 'write', + ); + failing = false; + await act(async () => handle.save()); + expect(JSON.parse(container.textContent ?? '').lastError).toBeNull(); + }); +}); diff --git a/packages/classy-store/src/frameworks/react/react.ts b/packages/classy-store/src/frameworks/react/react.ts index 5cf651a..f99d288 100644 --- a/packages/classy-store/src/frameworks/react/react.ts +++ b/packages/classy-store/src/frameworks/react/react.ts @@ -1,321 +1,8 @@ 'use client'; -import {createProxy, isChanged} from 'proxy-compare'; -import { - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from 'react'; -import { - subscribe as coreSubscribe, - createClassyStore, - getInternal, -} from '../../core/core'; -import {snapshot} from '../../snapshot/snapshot'; -import type {Snapshot} from '../../types'; -import {hydrationSnapshots} from './utils/hydrationSnapshots/hydrationSnapshots'; -import {SnapshotTargetCache} from './utils/snapshotTargetCache/snapshotTargetCache'; -import {TrackingReadMap} from './utils/trackingReadMap/trackingReadMap'; - -export {prepareStoreForHydration} from './server'; - -type EqualityFn = (a: T, b: T) => boolean; - -export type UseClassyStoreSelectorOptions = { - isEqual?: EqualityFn; -}; - -// A frozen snapshot's backing copy is identical for every consumer. Without -// this cache, each row rescans and copies the entire containing list on access. -const snapshotTargets = new SnapshotTargetCache(); - -// ── Overloads ───────────────────────────────────────────────────────────────── - -/** - * Subscribe to a store proxy with an explicit selector. - * - * Re-renders only when the selected value changes (compared via `Object.is` - * by default, or `options.isEqual`). - * - * @param proxyStore - A reactive proxy created by `createClassyStore()`. - * @param selector - Picks data from the immutable snapshot. - * @param options - Controls selected-value equality. - */ -export function useClassyStore( - proxyStore: T, - selector: (snap: Snapshot) => S, - options?: UseClassyStoreSelectorOptions, -): S; - -/** - * Subscribe to a store proxy **without** a selector (auto-tracked mode). - * - * Returns a `proxy-compare` tracking proxy over the immutable snapshot. - * The component only re-renders when a property it actually read changes. - * - * @param proxyStore - A reactive proxy created by `createClassyStore()`. - */ -export function useClassyStore(proxyStore: T): Snapshot; - -// ── Implementation ──────────────────────────────────────────────────────────── - -export function useClassyStore( - proxyStore: T, - selector?: (snap: Snapshot) => S, - selectorOptions?: UseClassyStoreSelectorOptions, -): Snapshot | S { - // Validate that the argument is actually a store proxy (throws if not). - getInternal(proxyStore); - - const isEqual = selectorOptions?.isEqual; - - // React must observe mutations during the event that changes a controlled - // input. Ordinary core subscriptions retain their default microtask batching. - const subscribe = useMemo(() => createSubscription(proxyStore), [proxyStore]); - - // ── Refs used by both modes (always allocated to satisfy Rules of Hooks) ── - - // Keep only committed selections shared between selector instances. - // Each selector gets its own snapshot cache so concurrent renders do not - // overwrite one another's cached snapshot or selected result. - const committedSelectionRef = useRef<{value: S} | undefined>(undefined); - const initialSnapshot = hydrationSnapshots.get(proxyStore) as - | Snapshot - | undefined; - const {getSelectedSnapshot, getServerSnapshot} = useMemo( - () => - createSnapshotGetters( - proxyStore, - selector, - isEqual, - committedSelectionRef, - initialSnapshot, - ), - [proxyStore, selector, isEqual, initialSnapshot], - ); - - // Each render owns its read set, seeded from committed reads of the same - // immutable objects. Memoization can skip those reads on subsequent renders. - // Weak keys release old dependencies as their snapshots become unreachable. - const committedReads = useRef(new WeakMap()).current; - const affected = new TrackingReadMap(committedReads); - const proxyCache = useRef(new WeakMap()).current; - let previousAutoSnapshot: Snapshot | undefined; - const getSnapshot = (): Snapshot | S => { - if (getSelectedSnapshot) return getSelectedSnapshot(); - const nextSnapshot = snapshot(proxyStore); - if ( - previousAutoSnapshot && - !isChanged(previousAutoSnapshot, nextSnapshot, affected) - ) { - return previousAutoSnapshot; - } - previousAutoSnapshot = nextSnapshot; - return nextSnapshot; - }; - - const readServerSnapshot = (): Snapshot | S => { - const serverResult = getServerSnapshot - ? getServerSnapshot() - : getSnapshot(); - // This render's read set can filter the first live update, too. Seed only - // when React hydrates so client-only mounts always start from current data. - if (!selector) previousAutoSnapshot = serverResult as Snapshot; - return serverResult; - }; - - const result = useSyncExternalStore( - subscribe, - getSnapshot, - readServerSnapshot, - ); - - useEffect(() => { - committedSelectionRef.current = selector ? {value: result as S} : undefined; - affected.commit(); - }, [result, selector, affected]); - - return selector - ? result - : (createProxy( - result as Snapshot, - affected, - proxyCache, - snapshotTargets, - ) as Snapshot); -} - -// ── Selector mode logic (pure function, no hooks) ───────────────────────────── - -// Construct stable callbacks outside the hook's lexical scope. Engines can -// share a closure context with render-local values, otherwise keeping the first -// result and auto-tracked snapshot alive for the entire mounted subscription. -function createSubscription(proxyStore: T) { - return (onStoreChange: () => void) => - coreSubscribe(proxyStore, onStoreChange, {sync: true}); -} - -function createSnapshotGetters( - proxyStore: T, - selector: ((snap: Snapshot) => S) | undefined, - isEqual: EqualityFn | undefined, - committedSelectionRef: {current: {value: S} | undefined}, - initialSnapshot: Snapshot | undefined, -) { - let hydrationSelection: {value: S; snapshot: Snapshot} | undefined; - const readServerSelection = - initialSnapshot && selector - ? createSelectorSnapshotGetter( - () => initialSnapshot, - selector, - () => undefined, - isEqual, - ) - : undefined; - - return { - getSelectedSnapshot: selector - ? createSelectorSnapshotGetter( - () => snapshot(proxyStore), - selector, - () => hydrationSelection ?? committedSelectionRef.current, - isEqual, - ) - : undefined, - getServerSnapshot: initialSnapshot - ? () => { - if (!readServerSelection) return initialSnapshot; - const value = readServerSelection(); - // Seed only this selector's live cache, and only when React actually - // hydrates. Never overwrite committed data from an abandoned render. - hydrationSelection = {value, snapshot: initialSnapshot}; - return value; - } - : undefined, - }; -} - -/** - * Create a cached `getSnapshot` for one store, selector, and equality function. - * - * Re-evaluates when any of those inputs change, even if the store did not. - * Repeated reads of the same snapshot return the cached result, including - * `undefined`. Equal selections retain their reference across selector changes. - */ -function createSelectorSnapshotGetter( - getStoreSnapshot: () => Snapshot, - selector: (snap: Snapshot) => S, - getPreviousSelection: () => {value: S; snapshot?: Snapshot} | undefined, - isEqual?: EqualityFn, -): () => S { - let previousSnapshot: Snapshot | undefined; - let selection: {value: S; snapshot?: Snapshot} | undefined; - - return () => { - const nextSnapshot = getStoreSnapshot(); - if (previousSnapshot === nextSnapshot && selection) { - return selection.value; - } - - const previousSelection = selection ?? getPreviousSelection(); - // The same immutable input and selector can reuse the hydration result - // directly, including selectors that allocate an object without isEqual. - const nextResult = - !selection && previousSelection?.snapshot === nextSnapshot - ? previousSelection.value - : selector(nextSnapshot); - - selection = - previousSelection && - (isEqual - ? isEqual(previousSelection.value, nextResult) - : Object.is(previousSelection.value, nextResult)) - ? previousSelection - : {value: nextResult}; - previousSnapshot = nextSnapshot; - - return selection.value; - }; -} - -// ── Bound store hook factory ───────────────────────────────────────────────── - -/** - * Create a pre-bound React hook for a specific store proxy. - * - * Eliminates the boilerplate of writing a wrapper around `useClassyStore` - * for every store instance: - * - * ```ts - * // Before: - * export const catalogStore = createClassyStore(new CatalogStore()); - * export function useCatalogStore(selector: (s: Snapshot) => S) { - * return useClassyStore(catalogStore, selector); - * } - * - * // After: - * export const catalogStore = createClassyStore(new CatalogStore()); - * export const useCatalogStore = createStoreHook(catalogStore); - * ``` - * - * The returned hook supports both selector mode and auto-tracked (selectorless) - * mode — identical to `useClassyStore`, but with the store already bound. - * - * @param proxyStore - A reactive proxy created by `createClassyStore()`. - */ -export function createStoreHook(proxyStore: T) { - // Fail fast at creation time rather than on first render. - getInternal(proxyStore); - - function useStore(): Snapshot; - function useStore( - selector: (snap: Snapshot) => S, - options?: UseClassyStoreSelectorOptions, - ): S; - function useStore( - selector?: (snap: Snapshot) => S, - options?: UseClassyStoreSelectorOptions, - ): Snapshot | S { - return useClassyStore( - proxyStore, - selector as (snap: Snapshot) => S, - options, - ); - } - return useStore; -} - -// ── Component-scoped store ──────────────────────────────────────────────────── - -/** - * Create a component-scoped reactive store that lives for the lifetime of the - * component. Unmounting releases React's reference; the store can be collected - * when no external references remain. Clean up application subscriptions and - * other effects separately; this hook does not call a store's dispose method. - * - * The factory is a `useState` initializer and must be pure. React StrictMode - * may invoke it more than once in development. - * Each component instance gets its own isolated store. - * - * Use the returned proxy with `useClassyStore()` to read state in the same component - * or pass it down via props/context to share within a subtree. - * - * @param factory - A function that returns a class instance (or plain object). - * Used as the component state initializer. - * @returns A reactive store proxy scoped to the component's lifetime. - * - * @example - * ```tsx - * function Counter() { - * const store = useLocalStore(() => new CounterStore()); - * const count = useClassyStore(store, (state) => state.count); - * return ; - * } - * ``` - */ -export function useLocalStore(factory: () => T): T { - const [store] = useState(() => createClassyStore(factory())); - return store; -} +export {useClassyStore} from './hooks/useClassyStore/useClassyStore'; +export type {UseClassyStoreSelectorOptions} from './hooks/useClassyStore/useClassyStore.types'; +export {useHistoryStatus} from './hooks/useHistoryStatus/useHistoryStatus'; +export {useLocalStore} from './hooks/useLocalStore/useLocalStore'; +export {usePersistStatus} from './hooks/usePersistStatus/usePersistStatus'; +export {createStoreHook} from './utils/createStoreHook/createStoreHook'; diff --git a/packages/classy-store/src/frameworks/react/react.qa.test.tsx b/packages/classy-store/src/frameworks/react/react.updates.test.tsx similarity index 99% rename from packages/classy-store/src/frameworks/react/react.qa.test.tsx rename to packages/classy-store/src/frameworks/react/react.updates.test.tsx index fefc029..9808d57 100644 --- a/packages/classy-store/src/frameworks/react/react.qa.test.tsx +++ b/packages/classy-store/src/frameworks/react/react.updates.test.tsx @@ -32,7 +32,7 @@ afterEach(() => { mountedRoots.clear(); }); -describe('useClassyStore independent QA', () => { +describe('useClassyStore update consistency', () => { test('preserves nested snapshot identity across local state renders', () => { const store = createClassyStore({items: ['First']}); const effect = mock(() => {}); diff --git a/packages/classy-store/src/frameworks/react/react.utilities.test.tsx b/packages/classy-store/src/frameworks/react/react.utilities.test.tsx index b611760..adbc5f6 100644 --- a/packages/classy-store/src/frameworks/react/react.utilities.test.tsx +++ b/packages/classy-store/src/frameworks/react/react.utilities.test.tsx @@ -88,7 +88,7 @@ describe('core and React utility happy paths', () => { const storage = createStorage(); const store = createClassyStore({count: 0}); const persistence = persist(store, {name: 'draft', storage}); - cleanups.push(persistence.unsubscribe); + cleanups.push(persistence.dispose); await persistence.hydrated; const history = withHistory(store); cleanups.push(history.dispose); @@ -110,7 +110,7 @@ describe('core and React utility happy paths', () => { const restored = createClassyStore({count: 0}); const restoredPersistence = persist(restored, {name: 'draft', storage}); - cleanups.push(restoredPersistence.unsubscribe); + cleanups.push(restoredPersistence.dispose); await restoredPersistence.hydrated; expect(restored.count).toBe(1); }); @@ -173,7 +173,7 @@ describe('core and React utility happy paths', () => { ], }; const persistence = persist(store, options); - cleanups.push(persistence.unsubscribe); + cleanups.push(persistence.dispose); await persistence.hydrated; store.users.set('ada', 'Ada'); store.tags.add('saved'); @@ -182,7 +182,7 @@ describe('core and React utility happy paths', () => { const restored = createStore(); const restoredPersistence = persist(restored, options); - cleanups.push(restoredPersistence.unsubscribe); + cleanups.push(restoredPersistence.dispose); await restoredPersistence.hydrated; expect(restored.users.get('ada')).toBe('Ada'); expect(restored.tags.has('saved')).toBe(true); @@ -212,7 +212,7 @@ describe('core and React utility happy paths', () => { const extension = installDevtools(); const store = createClassyStore({count: 0}); const persistence = persist(store, {name: 'draft', storage}); - cleanups.push(persistence.unsubscribe); + cleanups.push(persistence.dispose); await persistence.hydrated; const history = withHistory(store); cleanups.push(history.dispose, devtools(store)); diff --git a/packages/classy-store/src/frameworks/react/testHelpers/historyStatusView/HistoryStatusView.tsx b/packages/classy-store/src/frameworks/react/testHelpers/historyStatusView/HistoryStatusView.tsx new file mode 100644 index 0000000..4fbff57 --- /dev/null +++ b/packages/classy-store/src/frameworks/react/testHelpers/historyStatusView/HistoryStatusView.tsx @@ -0,0 +1,9 @@ +import type {HistoryHandle} from '@codebelt/classy-store/utils'; +import {useHistoryStatus} from '../../react'; + +type Props = {handle: HistoryHandle}; + +export function HistoryStatusView({handle}: Props) { + const status = useHistoryStatus(handle); + return {JSON.stringify(status)}; +} diff --git a/packages/classy-store/src/frameworks/react/testHelpers/persistStatusView/PersistStatusView.tsx b/packages/classy-store/src/frameworks/react/testHelpers/persistStatusView/PersistStatusView.tsx new file mode 100644 index 0000000..73cc3f5 --- /dev/null +++ b/packages/classy-store/src/frameworks/react/testHelpers/persistStatusView/PersistStatusView.tsx @@ -0,0 +1,9 @@ +import type {PersistHandle} from '@codebelt/classy-store/utils'; +import {usePersistStatus} from '../../react'; + +type Props = {handle: PersistHandle}; + +export function PersistStatusView({handle}: Props) { + const status = usePersistStatus(handle); + return {JSON.stringify(status)}; +} diff --git a/packages/classy-store/src/frameworks/react/utils/createStoreHook/createStoreHook.ts b/packages/classy-store/src/frameworks/react/utils/createStoreHook/createStoreHook.ts new file mode 100644 index 0000000..420bda0 --- /dev/null +++ b/packages/classy-store/src/frameworks/react/utils/createStoreHook/createStoreHook.ts @@ -0,0 +1,49 @@ +import {getInternal} from '../../../../core/core'; +import type {Snapshot} from '../../../../types'; +import {useClassyStore} from '../../hooks/useClassyStore/useClassyStore'; +import type {UseClassyStoreSelectorOptions} from '../../hooks/useClassyStore/useClassyStore.types'; + +/** + * Create a pre-bound React hook for a specific store proxy. + * + * Eliminates the boilerplate of writing a wrapper around `useClassyStore` + * for every store instance: + * + * ```ts + * // Before: + * export const catalogStore = createClassyStore(new CatalogStore()); + * export function useCatalogStore(selector: (s: Snapshot) => S) { + * return useClassyStore(catalogStore, selector); + * } + * + * // After: + * export const catalogStore = createClassyStore(new CatalogStore()); + * export const useCatalogStore = createStoreHook(catalogStore); + * ``` + * + * The returned hook supports both selector mode and auto-tracked (selectorless) + * mode — identical to `useClassyStore`, but with the store already bound. + * + * @param proxyStore - A reactive proxy created by `createClassyStore()`. + */ +export function createStoreHook(proxyStore: T) { + // Fail fast at creation time rather than on first render. + getInternal(proxyStore); + + function useStore(): Snapshot; + function useStore( + selector: (snap: Snapshot) => S, + options?: UseClassyStoreSelectorOptions, + ): S; + function useStore( + selector?: (snap: Snapshot) => S, + options?: UseClassyStoreSelectorOptions, + ): Snapshot | S { + return useClassyStore( + proxyStore, + selector as (snap: Snapshot) => S, + options, + ); + } + return useStore; +} diff --git a/packages/classy-store/src/frameworks/react/utils/trackingReadMap/trackingReadMap.qa.test.ts b/packages/classy-store/src/frameworks/react/utils/trackingReadMap/trackingReadMap.lifecycle.test.ts similarity index 98% rename from packages/classy-store/src/frameworks/react/utils/trackingReadMap/trackingReadMap.qa.test.ts rename to packages/classy-store/src/frameworks/react/utils/trackingReadMap/trackingReadMap.lifecycle.test.ts index 16677a3..dec0c93 100644 --- a/packages/classy-store/src/frameworks/react/utils/trackingReadMap/trackingReadMap.qa.test.ts +++ b/packages/classy-store/src/frameworks/react/utils/trackingReadMap/trackingReadMap.lifecycle.test.ts @@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'; import {createProxy, isChanged} from 'proxy-compare'; import {TrackingReadMap} from './trackingReadMap'; -describe('TrackingReadMap lifecycle QA', () => { +describe('TrackingReadMap lifecycle', () => { test('isolates nested reads from an abandoned render after another render commits', () => { const committed = new WeakMap(); const nested = Object.freeze({first: 1, second: 2, third: 3}); diff --git a/packages/classy-store/src/index.ts b/packages/classy-store/src/index.ts index 18b9fe0..07720b7 100644 --- a/packages/classy-store/src/index.ts +++ b/packages/classy-store/src/index.ts @@ -5,7 +5,7 @@ * * @module @codebelt/classy-store */ -export {createClassyStore, getVersion, subscribe} from './core/core'; +export {createClassyStore, subscribe} from './core/core'; export {snapshot} from './snapshot/snapshot'; export type {Snapshot, SubscribeOptions} from './types'; export {shallowEqual} from './utils/equality/equality'; diff --git a/packages/classy-store/src/snapshot/snapshot.computed.test.ts b/packages/classy-store/src/snapshot/snapshot.computed.test.ts new file mode 100644 index 0000000..91e7a3d --- /dev/null +++ b/packages/classy-store/src/snapshot/snapshot.computed.test.ts @@ -0,0 +1,156 @@ +import {describe, expect, it} from 'bun:test'; +import {createClassyStore} from '../core/core'; +import {snapshot} from './snapshot'; + +/** Helper: flush the queueMicrotask-based batching. */ +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// ── Snapshot memoization tests ────────────────────────────────────────────── + +describe('computed getters — snapshot memoization', () => { + // 9. Snapshot getter computes only once + it('snapshot getter computes only once (verified by call counter)', () => { + let callCount = 0; + + class Store { + items = ['a', 'b', 'c']; + + get count() { + callCount++; + return this.items.length; + } + } + + const s = createClassyStore(new Store()); + const snap = snapshot(s); + + expect(snap.count).toBe(3); + expect(snap.count).toBe(3); + expect(snap.count).toBe(3); + + expect(callCount).toBe(1); // computed only once on the snapshot + }); + + // 10. Snapshot getter returns same reference on repeated access + it('snapshot getter returns the same reference on repeated access', () => { + class Store { + items = ['a', 'b', 'c']; + + get filtered() { + return this.items.filter((item) => item !== 'b'); + } + } + + const s = createClassyStore(new Store()); + const snap = snapshot(s); + + const result1 = snap.filtered; + const result2 = snap.filtered; + + expect(result1).toBe(result2); // same reference — memoized + expect(result1).toEqual(['a', 'c']); + }); + + // 10b. Cross-snapshot reference stability when deps unchanged + it('returns same reference across snapshots when getter deps have not changed', async () => { + class TodoStore { + todos = [{text: 'Buy milk', done: false}]; + theme = 'dark'; + + get activeTodos() { + return this.todos.filter((todo) => !todo.done); + } + + setTheme(value: string) { + this.theme = value; + } + } + + const s = createClassyStore(new TodoStore()); + + const snap1 = snapshot(s); + const result1 = snap1.activeTodos; + + s.setTheme('light'); + await flush(); + + const snap2 = snapshot(s); + expect(snap2).not.toBe(snap1); // different snapshot + + const result2 = snap2.activeTodos; + expect(result1).toBe(result2); // SAME reference — cross-snapshot memo + }); + + // 11. Different snapshots compute independently + it('different snapshots compute independently', async () => { + class Store { + count = 5; + + get doubled() { + return this.count * 2; + } + + setCount(value: number) { + this.count = value; + } + } + + const s = createClassyStore(new Store()); + + const snap1 = snapshot(s); + expect(snap1.doubled).toBe(10); + + s.setCount(10); + await flush(); + + const snap2 = snapshot(s); + expect(snap2.doubled).toBe(20); + + // snap1 still returns the old value + expect(snap1.doubled).toBe(10); + }); + + // 11c. Snapshot getter recomputes when a sub-tree dep is replaced + it('snapshot getter returns new value when dep sub-tree is replaced', async () => { + class Store { + items = [1, 2, 3]; + + get count() { + return this.items.length; + } + + setItems(newItems: number[]) { + this.items = newItems; + } + } + + const s = createClassyStore(new Store()); + + const snap1 = snapshot(s); + expect(snap1.count).toBe(3); + + s.setItems([10]); + await flush(); + + const snap2 = snapshot(s); + expect(snap2.count).toBe(1); // must reflect the new items, not stale cache + }); + + // 12. Snapshot getter reads frozen snapshot data correctly + it('snapshot getter reads frozen data correctly', () => { + class Store { + firstName = 'John'; + lastName = 'Doe'; + + get fullName() { + return `${this.firstName} ${this.lastName}`; + } + } + + const s = createClassyStore(new Store()); + const snap = snapshot(s); + + expect(snap.fullName).toBe('John Doe'); + expect(Object.isFrozen(snap)).toBe(true); + }); +}); diff --git a/packages/classy-store/src/snapshot/snapshot.ts b/packages/classy-store/src/snapshot/snapshot.ts index 2f906ac..73834a4 100644 --- a/packages/classy-store/src/snapshot/snapshot.ts +++ b/packages/classy-store/src/snapshot/snapshot.ts @@ -1,6 +1,7 @@ import {markToTrack} from 'proxy-compare'; import {getInternal} from '../core/core'; -import type {Snapshot, StoreInternal} from '../types'; +import type {StoreInternal} from '../core/core.types'; +import type {Snapshot} from '../types'; import {canProxy, findGetterDescriptor} from '../utils/internal/internal'; // ── Caches ──────────────────────────────────────────────────────────────────── diff --git a/packages/classy-store/src/types.ts b/packages/classy-store/src/types.ts index ba6c439..0855363 100644 --- a/packages/classy-store/src/types.ts +++ b/packages/classy-store/src/types.ts @@ -43,64 +43,8 @@ export type Snapshot = T extends SnapshotLeaf ? {readonly [K in keyof T]: Snapshot} : T; -// ── Computed getter memoization types ───────────────────────────────────────── - -/** - * A single dependency recorded during getter execution. - * - `version`: dependency is a tracked sub-tree — compare `internal.version`. - * - `value`: dependency is a primitive — compare via `Object.is`. - */ -export type DepEntry = - | { - kind: 'version'; - internal: StoreInternal; - version: number; - /** Parent target — used to detect when the property is replaced entirely. */ - parentTarget: object; - /** Property name on the parent. */ - prop: string | symbol; - } - | {kind: 'value'; target: object; prop: string | symbol; value: unknown} - | { - /** Dependency on a computed getter — validated via the computed cache, not raw getter re-execution. */ - kind: 'computed'; - internal: StoreInternal; - prop: string | symbol; - value: unknown; - }; - -/** Cached result of a memoized computed getter, together with its dependencies. */ -export type ComputedEntry = { - value: unknown; - deps: DepEntry[]; -}; - /** Options that control how store subscribers are notified. */ export type SubscribeOptions = { /** Notify this subscriber immediately instead of waiting for the batched microtask. */ sync?: boolean; }; - -// ── Store internal bookkeeping ─────────────────────────────────────────────── - -/** Internal bookkeeping for a store proxy, stored in a WeakMap keyed by the proxy. */ -export type StoreInternal = { - /** The raw (un-proxied) class instance. */ - target: object; - /** Monotonically increasing version counter. */ - version: number; - /** Subscriber callbacks notified once per batched mutation turn. */ - batchedListeners: Set<() => void>; - /** Subscriber callbacks notified immediately on each mutation. */ - syncListeners: Set<() => void>; - /** Cached child proxies for nested plain objects/arrays (keyed by property name). */ - childProxies: Map; - /** Cached child internals for propagating version bumps up the tree. */ - childInternals: Map; - /** Reference to the parent internal (if this is a child proxy). */ - parent: StoreInternal | null; - /** Whether a microtask notification is already scheduled. */ - notifyScheduled: boolean; - /** Memoized computed getter cache, keyed by getter property name. */ - computedCache: Map; -}; diff --git a/packages/classy-store/src/utils/history/history.status.test.ts b/packages/classy-store/src/utils/history/history.status.test.ts new file mode 100644 index 0000000..c3b5800 --- /dev/null +++ b/packages/classy-store/src/utils/history/history.status.test.ts @@ -0,0 +1,94 @@ +import {describe, expect, mock, test} from 'bun:test'; +import {createClassyStore} from '../../core/core'; +import {withHistory} from './history'; + +const tick = () => new Promise((resolve) => queueMicrotask(resolve)); + +describe('withHistory status', () => { + test('publishes stable readonly snapshots as undo and redo availability changes', async () => { + const store = createClassyStore({count: 0}); + const history = withHistory(store); + const listener = mock(() => {}); + history.subscribe(listener); + try { + const initial = history.getSnapshot(); + expect(Object.isFrozen(initial)).toBe(true); + expect(history.getSnapshot()).toBe(initial); + store.count = 1; + await tick(); + const recorded = history.getSnapshot(); + expect(recorded.canUndo).toBe(true); + expect(initial.canUndo).toBe(false); + expect(history.getServerSnapshot()).toBe(initial); + store.count = 2; + await tick(); + expect(history.getSnapshot()).toBe(recorded); + expect(listener).toHaveBeenCalledTimes(1); + history.undo(); + await tick(); + expect(history.getSnapshot()).toMatchObject({ + canUndo: true, + canRedo: true, + }); + history.redo(); + await tick(); + expect(history.getSnapshot()).toMatchObject({ + canUndo: true, + canRedo: false, + }); + } finally { + history.dispose(); + } + }); + + test('notifies for pause and resume without a store mutation and supports unsubscribe', () => { + const history = withHistory(createClassyStore({count: 0})); + const listener = mock(() => {}); + const unsubscribe = history.subscribe(listener); + history.pause(); + history.pause(); + expect(history.getSnapshot().isPaused).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + history.resume(); + expect(history.getSnapshot().isPaused).toBe(false); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + history.pause(); + expect(listener).toHaveBeenCalledTimes(2); + history.dispose(); + }); + + test('keeps an explicit pause made during undo and emits one final disposal status', async () => { + const store = createClassyStore({count: 0}); + const history = withHistory(store); + store.count++; + await tick(); + history.undo(); + history.pause(); + await tick(); + store.count = 5; + await tick(); + expect(history.getSnapshot()).toMatchObject({ + isPaused: true, + canUndo: false, + canRedo: true, + }); + const listener = mock(() => {}); + history.subscribe(listener); + history.dispose(); + history.dispose(); + history.resume(); + history.undo(); + store.count++; + await tick(); + expect(listener).toHaveBeenCalledTimes(1); + expect(history.getSnapshot()).toMatchObject({ + isDisposed: true, + canUndo: false, + canRedo: false, + }); + const retiredListener = mock(() => {}); + history.subscribe(retiredListener)(); + expect(retiredListener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/classy-store/src/utils/history/history.ts b/packages/classy-store/src/utils/history/history.ts index 478058b..bb65fa2 100644 --- a/packages/classy-store/src/utils/history/history.ts +++ b/packages/classy-store/src/utils/history/history.ts @@ -3,6 +3,10 @@ import {snapshot} from '../../snapshot/snapshot'; import type {Snapshot} from '../../types'; import {cloneSnapshotValue} from '../cloneSnapshot/cloneSnapshot'; import {findGetterDescriptor} from '../internal/internal'; +import { + createUtilityStatus, + type UtilityStatusSource, +} from '../utilityStatus/utilityStatus'; // ── Types ──────────────────────────────────────────────────────────────────── @@ -11,7 +15,14 @@ export type HistoryOptions = { limit?: number; }; -export type HistoryHandle = { +export type HistoryStatus = { + readonly canUndo: boolean; + readonly canRedo: boolean; + readonly isPaused: boolean; + readonly isDisposed: boolean; +}; + +export type HistoryHandle = UtilityStatusSource & { /** Restore the previous state. */ undo: () => void; /** Restore the next state (after an undo). */ @@ -51,6 +62,20 @@ export function withHistory( const history: Snapshot[] = [snapshot(proxyStore)]; let pointer = 0; let paused = false; + let restoring = 0; + let disposed = false; + const status = createUtilityStatus({ + canUndo: false, + canRedo: false, + isPaused: false, + isDisposed: false, + }); + const readStatus = (): HistoryStatus => ({ + canUndo: pointer > 0, + canRedo: pointer < history.length - 1, + isPaused: paused, + isDisposed: disposed, + }); /** * Apply a snapshot's data properties back to the store proxy. @@ -76,7 +101,7 @@ export function withHistory( // Subscribe to store mutations const unsubscribeFromStore = subscribe(proxyStore, () => { - if (paused) return; + if (paused || restoring > 0 || disposed) return; const snap = snapshot(proxyStore); @@ -94,34 +119,36 @@ export function withHistory( history.shift(); pointer = history.length - 1; } + status.update(readStatus()); }); const handle: HistoryHandle = { + ...status.source, undo() { - if (pointer <= 0) return; + if (disposed || pointer <= 0) return; pointer--; - const prevPaused = paused; - paused = true; + restoring++; try { applySnapshot(history[pointer]); } finally { queueMicrotask(() => { - paused = prevPaused; + restoring--; }); + status.update(readStatus()); } }, redo() { - if (pointer >= history.length - 1) return; + if (disposed || pointer >= history.length - 1) return; pointer++; - const prevPaused = paused; - paused = true; + restoring++; try { applySnapshot(history[pointer]); } finally { queueMicrotask(() => { - paused = prevPaused; + restoring--; }); + status.update(readStatus()); } }, @@ -134,17 +161,24 @@ export function withHistory( }, pause() { + if (disposed) return; paused = true; + status.update(readStatus()); }, resume() { + if (disposed) return; paused = false; + status.update(readStatus()); }, dispose() { + if (disposed) return; + disposed = true; unsubscribeFromStore(); history.length = 0; pointer = 0; + status.dispose(readStatus()); }, }; diff --git a/packages/classy-store/src/utils/index.ts b/packages/classy-store/src/utils/index.ts index 3a9cd0a..d08d22a 100644 --- a/packages/classy-store/src/utils/index.ts +++ b/packages/classy-store/src/utils/index.ts @@ -9,13 +9,19 @@ export type {SubscribeOptions} from '../types'; export type {DevtoolsOptions} from './devtools/devtools'; export {devtools} from './devtools/devtools'; -export type {HistoryHandle, HistoryOptions} from './history/history'; +export type { + HistoryHandle, + HistoryOptions, + HistoryStatus, +} from './history/history'; export {withHistory} from './history/history'; +export {persist} from './persist/persist'; export type { PersistHandle, + PersistOperation, PersistOptions, + PersistStatus, PropertyTransform, StorageAdapter, -} from './persist/persist'; -export {persist} from './persist/persist'; +} from './persist/persist.types'; export {subscribeKey} from './subscribe-key/subscribe-key'; diff --git a/packages/classy-store/src/utils/persist/persist.api.test.ts b/packages/classy-store/src/utils/persist/persist.api.test.ts new file mode 100644 index 0000000..cfe2802 --- /dev/null +++ b/packages/classy-store/src/utils/persist/persist.api.test.ts @@ -0,0 +1,458 @@ +import {afterEach, describe, expect, mock, test} from 'bun:test'; +import {createClassyStore} from '../../core/core'; +import {persist} from './persist'; +import type {PersistHandle, StorageAdapter} from './persist.types'; + +const handles: PersistHandle[] = []; +const storage: StorageAdapter = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, +}; +const raw = JSON.stringify({version: 0, state: {count: 1}}); +afterEach(() => { + for (const handle of handles.splice(0)) handle.dispose(); +}); + +describe('persist public API', () => { + test.each([ + {description: 'read', operation: 'read'}, + {description: 'decoding', operation: 'parse'}, + {description: 'expired-data removal', operation: 'remove'}, + ])('automatic hydration reports $description failures and settles without rejecting', async ({ + operation, + }) => { + const failure = new Error(operation); + const onError = mock(() => {}); + const handle = persist(createClassyStore({count: 0}), { + name: 'automatic-hydration-error', + clearOnExpire: true, + storage: { + ...storage, + getItem: async () => { + if (operation === 'read') throw failure; + return operation === 'parse' + ? raw + : JSON.stringify({version: 0, state: {}, expiresAt: 1}); + }, + removeItem: async () => { + throw failure; + }, + }, + properties: [ + { + key: 'count', + serialize: (value) => value, + deserialize: () => { + throw failure; + }, + }, + ], + onError, + }); + handles.push(handle); + await expect(handle.hydrated).resolves.toBeUndefined(); + expect(handle.getSnapshot()).toMatchObject({ + isHydrated: true, + lastError: {error: failure, operation}, + }); + expect(onError).toHaveBeenCalledTimes(1); + }); + + test.each([ + {description: 'batched', debounce: 0}, + {description: 'debounced', debounce: 5}, + ])('automatic $description writes report rejection and recover on the next edit', async ({ + debounce, + }) => { + const failure = new Error('quota'); + let failing = true; + const onError = mock(() => {}); + const setItem = mock(async () => { + if (failing) throw failure; + }); + const store = createClassyStore({count: 0}); + const handle = persist(store, { + name: 'automatic-write-error', + storage: {...storage, setItem}, + debounce, + skipHydration: true, + onError, + }); + handles.push(handle); + store.count = 1; + await new Promise((resolve) => setTimeout(resolve, debounce + 20)); + expect(onError).toHaveBeenCalledTimes(1); + expect(handle.getSnapshot().lastError).toEqual({ + error: failure, + operation: 'write', + }); + failing = false; + store.count = 2; + await new Promise((resolve) => setTimeout(resolve, debounce + 20)); + expect(setItem).toHaveBeenCalledTimes(2); + expect(handle.getSnapshot().lastError).toBeNull(); + }); + + test('automatic tab expiration reports asynchronous removal failures', async () => { + const failure = new Error('remove expired'); + const onError = mock(() => {}); + const handle = persist(createClassyStore({count: 0}), { + name: 'tab-expiration-error', + storage: { + ...storage, + removeItem: async () => { + throw failure; + }, + }, + skipHydration: true, + syncTabs: true, + clearOnExpire: true, + onError, + }); + handles.push(handle); + globalThis.dispatchEvent( + new StorageEvent('storage', { + key: 'tab-expiration-error', + newValue: JSON.stringify({version: 0, state: {}, expiresAt: 1}), + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(onError).toHaveBeenCalledTimes(1); + expect(handle.getSnapshot()).toMatchObject({ + isExpired: true, + lastError: {error: failure, operation: 'remove'}, + }); + }); + + test('keeps auto-saving local edits while expired-data removal is pending', async () => { + let finishRemoval!: () => void; + const pending = new Promise((resolve) => { + finishRemoval = resolve; + }); + const setItem = mock((_name: string, _value: string) => {}); + const store = createClassyStore({count: 0}); + const handle = persist(store, { + name: 'expiry-race', + skipHydration: true, + clearOnExpire: true, + storage: { + getItem: () => + JSON.stringify({version: 0, state: {}, expiresAt: Date.now() - 1}), + removeItem: () => pending, + setItem, + }, + }); + handles.push(handle); + const hydration = handle.rehydrate(); + await new Promise((resolve) => setTimeout(resolve, 0)); + store.count = 2; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(setItem).not.toHaveBeenCalled(); + finishRemoval(); + await hydration; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(setItem).toHaveBeenCalledTimes(1); + expect(JSON.parse(setItem.mock.calls[0][1]).state.count).toBe(2); + }); + + test.each([ + {description: 'save', method: 'save' as const}, + {description: 'clear', method: 'clear' as const}, + ])('rejects a queued $description skipped by disposal without invoking its adapter', async ({ + method, + }) => { + let finishWrite!: () => void; + const pending = new Promise((resolve) => { + finishWrite = resolve; + }); + const setItem = mock(() => pending); + const removeItem = mock(() => {}); + const handle = persist(createClassyStore({count: 0}), { + name: 'queued-disposal', + storage: {...storage, setItem, removeItem}, + skipHydration: true, + }); + handles.push(handle); + const issued = handle.save(); + const queued = handle[method]().catch((error: unknown) => error); + handle.dispose(); + finishWrite(); + await issued; + expect(await queued).toBeInstanceOf(Error); + expect(setItem).toHaveBeenCalledTimes(1); + expect(removeItem).not.toHaveBeenCalled(); + }); + + test('automatic queued writes cancelled by disposal settle without reporting errors', async () => { + let finishWrite!: () => void; + const pending = new Promise((resolve) => { + finishWrite = resolve; + }); + const setItem = mock(() => pending); + const onError = mock(() => {}); + const store = createClassyStore({count: 0}); + const handle = persist(store, { + name: 'automatic-disposal', + storage: {...storage, setItem}, + skipHydration: true, + onError, + }); + handles.push(handle); + store.count = 1; + await new Promise((resolve) => setTimeout(resolve, 0)); + store.count = 2; + await new Promise((resolve) => setTimeout(resolve, 0)); + handle.dispose(); + finishWrite(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(setItem).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + expect(handle.getSnapshot()).toMatchObject({ + isDisposed: true, + lastError: null, + }); + }); + + test.each([ + { + description: 'read', + method: 'rehydrate' as const, + adapterMethod: 'getItem' as const, + operation: 'read', + }, + { + description: 'write', + method: 'save' as const, + adapterMethod: 'setItem' as const, + operation: 'write', + }, + { + description: 'remove', + method: 'clear' as const, + adapterMethod: 'removeItem' as const, + operation: 'remove', + }, + ])('rejects $description failures and reports each error once', async ({ + method, + adapterMethod, + operation, + }) => { + const failure = new Error(`${operation} failed`); + const onError = mock(() => {}); + let failing = true; + const adapter = { + ...storage, + [adapterMethod]: async () => { + if (failing) throw failure; + return null; + }, + }; + const handle = persist(createClassyStore({count: 0}), { + name: 'api', + storage: adapter, + skipHydration: true, + onError, + }); + handles.push(handle); + await expect(handle[method]()).rejects.toBe(failure); + expect(onError).toHaveBeenCalledTimes(1); + expect(handle.getSnapshot().lastError).toEqual({error: failure, operation}); + failing = false; + await handle[method](); + expect(handle.getSnapshot().lastError).toBeNull(); + if (method === 'rehydrate') { + await handle.hydrated; + expect(handle.isHydrated).toBe(true); + } + }); + + test.each([ + {description: 'JSON', stage: 'json'}, + {description: 'transform', stage: 'transform'}, + {description: 'migration', stage: 'migration'}, + {description: 'merge', stage: 'merge'}, + ])('rejects a $description hydration failure once and settles initial hydration', async ({ + stage, + }) => { + const failure = new Error(stage); + const onError = mock(() => {}); + const handle = persist(createClassyStore({count: 0}), { + name: 'parse', + storage: { + ...storage, + getItem: () => (stage === 'json' ? '{broken' : raw), + }, + skipHydration: true, + onError, + version: 1, + migrate: (value) => { + if (stage === 'migration') throw failure; + return value; + }, + merge: (value) => { + if (stage === 'merge') throw failure; + return value; + }, + properties: [ + { + key: 'count', + serialize: (value) => value, + deserialize: (value) => { + if (stage === 'transform') throw failure; + return Number(value); + }, + }, + ], + }); + handles.push(handle); + await expect(handle.rehydrate()).rejects.toBeInstanceOf(Error); + await handle.hydrated; + expect(handle.getSnapshot()).toMatchObject({ + isHydrated: true, + lastError: {operation: 'parse'}, + }); + expect(onError).toHaveBeenCalledTimes(1); + await expect(handle.rehydrate()).rejects.toBeInstanceOf(Error); + expect(onError).toHaveBeenCalledTimes(2); + }); + + test('reports serialization failures and preserves the original error if onError throws', async () => { + const failure = new Error('serialize'); + const setItem = mock(() => {}); + const handle = persist(createClassyStore({count: 0}), { + name: 'serialize', + storage: {...storage, setItem}, + skipHydration: true, + properties: [ + { + key: 'count', + serialize: () => { + throw failure; + }, + deserialize: Number, + }, + ], + onError: () => { + throw new Error('callback'); + }, + }); + handles.push(handle); + await expect(handle.save()).rejects.toBe(failure); + expect(handle.getSnapshot().lastError?.operation).toBe('write'); + expect(setItem).not.toHaveBeenCalled(); + }); + + test('a rejected explicit save does not poison queued saves', async () => { + let rejectFirst!: (error: Error) => void; + const first = new Promise((_resolve, reject) => { + rejectFirst = reject; + }); + const failure = new Error('first save'); + const setItem = mock(async () => { + if (setItem.mock.calls.length === 1) await first; + }); + const handle = persist(createClassyStore({count: 0}), { + name: 'queue', + storage: {...storage, setItem}, + skipHydration: true, + }); + handles.push(handle); + const failedSave = handle.save().catch((error: unknown) => error); + const nextSave = handle.save(); + expect(setItem).toHaveBeenCalledTimes(1); + rejectFirst(failure); + expect(await failedSave).toBe(failure); + await nextSave; + expect(setItem).toHaveBeenCalledTimes(2); + expect(handle.getSnapshot().lastError).toBeNull(); + }); + + test('publishes hydration and expiry changes even when store data never changes', async () => { + let saved: string | null = null; + const handle = persist(createClassyStore({count: 0}), { + name: 'status', + storage: {...storage, getItem: () => saved}, + skipHydration: true, + }); + handles.push(handle); + const initial = handle.getSnapshot(); + expect(Object.isFrozen(initial)).toBe(true); + const listener = mock(() => {}); + const stop = handle.subscribe(listener); + await handle.rehydrate(); + expect(handle.getSnapshot().isHydrated).toBe(true); + expect(handle.getServerSnapshot()).toBe(initial); + expect(initial.isHydrated).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + const hydrated = handle.getSnapshot(); + await handle.save(); + expect(handle.getSnapshot()).toBe(hydrated); + saved = JSON.stringify({ + version: 0, + state: {count: 9}, + expiresAt: Date.now() - 1, + }); + await handle.rehydrate(); + expect(handle.getSnapshot().isExpired).toBe(true); + saved = null; + await handle.rehydrate(); + expect(handle.getSnapshot().isExpired).toBe(false); + stop(); + listener.mockClear(); + handle.dispose(); + expect(listener).not.toHaveBeenCalled(); + }); + + test('rehydration includes expired-data removal failures', async () => { + const failure = new Error('remove expired'); + const handle = persist(createClassyStore({count: 0}), { + name: 'expired', + storage: { + ...storage, + getItem: () => + JSON.stringify({version: 0, state: {}, expiresAt: Date.now() - 1}), + removeItem: () => { + throw failure; + }, + }, + skipHydration: true, + clearOnExpire: true, + }); + handles.push(handle); + await expect(handle.rehydrate()).rejects.toBe(failure); + expect(handle.getSnapshot()).toMatchObject({ + isExpired: true, + lastError: {operation: 'remove'}, + }); + }); + + test('dispose is the only cleanup method and rejects operations after cleanup', async () => { + let resolveRead!: (raw: string | null) => void; + const pending = new Promise((resolve) => { + resolveRead = resolve; + }); + const store = createClassyStore({count: 0}); + const handle = persist(store, { + name: 'dispose', + storage: {...storage, getItem: () => pending}, + }); + handles.push(handle); + const listener = mock(() => {}); + handle.subscribe(listener); + expect('unsubscribe' in handle).toBe(false); + handle.dispose(); + handle.dispose(); + resolveRead(raw); + await handle.hydrated; + expect(store.count).toBe(0); + expect(listener).toHaveBeenCalledTimes(1); + expect(handle.getSnapshot()).toMatchObject({ + isDisposed: true, + isHydrated: handle.isHydrated, + }); + await expect(handle.save()).rejects.toThrow('disposed'); + await expect(handle.clear()).rejects.toThrow('disposed'); + await expect(handle.rehydrate()).rejects.toThrow('disposed'); + }); +}); diff --git a/packages/classy-store/src/utils/persist/persist.concurrent.test.ts b/packages/classy-store/src/utils/persist/persist.concurrent.test.ts index 7771b0d..dbac4d3 100644 --- a/packages/classy-store/src/utils/persist/persist.concurrent.test.ts +++ b/packages/classy-store/src/utils/persist/persist.concurrent.test.ts @@ -1,6 +1,7 @@ import {afterEach, describe, expect, mock, test} from 'bun:test'; import {createClassyStore} from '../../core/core'; -import {type PersistHandle, persist} from './persist'; +import {persist} from './persist'; +import type {PersistHandle} from './persist.types'; function deferred() { let resolve!: (value: T) => void; @@ -17,7 +18,7 @@ const envelope = (count: number) => JSON.stringify({version: 0, state: {count}}); const handles: PersistHandle[] = []; afterEach(() => { - for (const handle of handles.splice(0)) handle.unsubscribe(); + for (const handle of handles.splice(0)) handle.dispose(); }); describe('persist async operation ordering', () => { @@ -57,9 +58,13 @@ describe('persist async operation ordering', () => { try { expect(storage.setItem).toHaveBeenCalledTimes(1); - if (fails) firstWrite.reject(new Error('Write failed')); - else firstWrite.resolve(); - await first; + if (fails) { + firstWrite.reject(new Error('Write failed')); + await expect(first).rejects.toThrow('Write failed'); + } else { + firstWrite.resolve(); + await first; + } await tick(); expect(storage.setItem).toHaveBeenCalledTimes(2); secondWrite.resolve(); @@ -69,11 +74,11 @@ describe('persist async operation ordering', () => { } finally { firstWrite.resolve(); secondWrite.resolve(); - await Promise.all([first, second]); + await Promise.allSettled([first, second]); } }); - test('drops queued saves after unsubscribe while an issued write can finish', async () => { + test('drops queued saves after dispose while an issued write can finish', async () => { const pending = deferred(); const storage = { getItem: () => null, @@ -89,9 +94,9 @@ describe('persist async operation ordering', () => { handles.push(handle); const first = handle.save(); const second = handle.save(); - handle.unsubscribe(); + handle.dispose(); pending.resolve(); - await Promise.all([first, second]); + await Promise.all([first, expect(second).rejects.toThrow('disposed')]); expect(storage.setItem).toHaveBeenCalledTimes(1); }); diff --git a/packages/classy-store/src/utils/persist/persist.errors.test.ts b/packages/classy-store/src/utils/persist/persist.errors.test.ts new file mode 100644 index 0000000..88b1bbe --- /dev/null +++ b/packages/classy-store/src/utils/persist/persist.errors.test.ts @@ -0,0 +1,195 @@ +import {afterEach, describe, expect, mock, test} from 'bun:test'; +import {createClassyStore} from '../../core/core'; +import {persist} from './persist'; +import type {PersistHandle, StorageAdapter} from './persist.types'; + +const handles: PersistHandle[] = []; +const storage: StorageAdapter = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, +}; +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return {promise, resolve, reject}; +} + +afterEach(() => { + for (const handle of handles.splice(0)) handle.dispose(); +}); + +describe('persistence error propagation', () => { + test.each([ + { + description: 'save', + method: 'save' as const, + adapter: 'setItem', + operation: 'write', + }, + { + description: 'clear', + method: 'clear' as const, + adapter: 'removeItem', + operation: 'remove', + }, + { + description: 'rehydrate', + method: 'rehydrate' as const, + adapter: 'getItem', + operation: 'read', + }, + ])('$description preserves a synchronous adapter failure when onError throws', async ({ + method, + adapter, + operation, + }) => { + const failure = new Error('adapter failure'); + const onError = mock(() => { + throw new Error('reporting failure'); + }); + const handle = persist(createClassyStore({count: 0}), { + name: 'qa-synchronous-error', + skipHydration: true, + storage: { + ...storage, + [adapter]: () => { + throw failure; + }, + }, + onError, + }); + handles.push(handle); + await expect(handle[method]()).rejects.toBe(failure); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(failure, operation); + expect(handle.getSnapshot().lastError).toEqual({error: failure, operation}); + }); + + test('an issued save rejects after disposal without reporting or starting queued work', async () => { + const pending = deferred(); + const failure = new Error('late adapter failure'); + const onError = mock(() => {}); + const removeItem = mock(() => {}); + const handle = persist(createClassyStore({count: 0}), { + name: 'qa-disposed-error', + skipHydration: true, + storage: {...storage, setItem: () => pending.promise, removeItem}, + onError, + }); + handles.push(handle); + const saving = handle.save().catch((error: unknown) => error); + const clearing = handle.clear().catch((error: unknown) => error); + handle.dispose(); + const disposedStatus = handle.getSnapshot(); + pending.reject(failure); + expect(await saving).toBe(failure); + expect(await clearing).toBeInstanceOf(Error); + expect(handle.getSnapshot()).toBe(disposedStatus); + expect(removeItem).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + + test('a failed expiration removal rejects hydration and releases a queued save', async () => { + const removal = deferred(); + const removalStarted = deferred(); + const failure = new Error('expiration removal failed'); + const onError = mock(() => {}); + const setItem = mock((_name: string, _value: string) => {}); + const store = createClassyStore({count: 0}); + const handle = persist(store, { + name: 'qa-expiration-queue', + skipHydration: true, + clearOnExpire: true, + debounce: 1000, + storage: { + getItem: () => JSON.stringify({version: 0, state: {}, expiresAt: 1}), + setItem, + removeItem: () => { + removalStarted.resolve(); + return removal.promise; + }, + }, + onError, + }); + handles.push(handle); + const hydration = handle.rehydrate().catch((error: unknown) => error); + await removalStarted.promise; + await tick(); + store.count = 3; + await tick(); + const saving = handle.save(); + expect(setItem).not.toHaveBeenCalled(); + removal.reject(failure); + expect(await hydration).toBe(failure); + await saving; + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(failure, 'remove'); + expect(setItem).toHaveBeenCalledTimes(1); + expect(JSON.parse(setItem.mock.calls[0][1]).state.count).toBe(3); + expect(handle.getSnapshot().lastError).toBeNull(); + }); + + test('a read failure superseded by clear settles without overwriting newer status', async () => { + const reading = deferred(); + const onError = mock(() => {}); + const store = createClassyStore({count: 3}); + const handle = persist(store, { + name: 'qa-obsolete-error', + skipHydration: true, + storage: {...storage, getItem: () => reading.promise}, + onError, + }); + handles.push(handle); + const hydration = handle.rehydrate(); + await handle.clear(); + reading.reject(new Error('obsolete read')); + await expect(hydration).resolves.toBeUndefined(); + expect(onError).not.toHaveBeenCalled(); + expect(handle.getSnapshot()).toMatchObject({ + isHydrated: true, + lastError: null, + }); + expect(store.count).toBe(3); + }); + + test('automatic serialization failure reports once and a later explicit save recovers', async () => { + let failing = true; + const failure = new Error('serialization failed'); + const setItem = mock(() => {}); + const onError = mock(() => {}); + const store = createClassyStore({count: 0}); + const handle = persist(store, { + name: 'qa-automatic-serialization', + skipHydration: true, + storage: {...storage, setItem}, + properties: [ + { + key: 'count', + serialize: (value) => { + if (failing) throw failure; + return value; + }, + deserialize: Number, + }, + ], + onError, + }); + handles.push(handle); + store.count = 4; + await tick(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(failure, 'write'); + expect(setItem).not.toHaveBeenCalled(); + failing = false; + await handle.save(); + expect(setItem).toHaveBeenCalledTimes(1); + expect(handle.getSnapshot().lastError).toBeNull(); + }); +}); diff --git a/packages/classy-store/src/utils/persist/persist.test.ts b/packages/classy-store/src/utils/persist/persist.test.ts index b416f1e..f1bd4b2 100644 --- a/packages/classy-store/src/utils/persist/persist.test.ts +++ b/packages/classy-store/src/utils/persist/persist.test.ts @@ -4,8 +4,8 @@ import {reactiveMap, reactiveSet} from '../../collections/collections'; import {createClassyStore} from '../../core/core'; import {snapshot} from '../../snapshot/snapshot'; import {PROXYABLE} from '../internal/internal'; -import type {StorageAdapter} from './persist'; import {persist} from './persist'; +import type {StorageAdapter} from './persist.types'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -474,7 +474,7 @@ describe('persist()', () => { expect(previous.preferences.display.compact).toBe(false); } } finally { - handle.unsubscribe(); + handle.dispose(); } }); @@ -535,7 +535,7 @@ describe('persist()', () => { expect(beforeHydration.users.size).toBe(1); expect(beforeHydration.tags.size).toBe(1); } finally { - handle.unsubscribe(); + handle.dispose(); } }); @@ -571,7 +571,7 @@ describe('persist()', () => { store.profile.name = 'Alicia'; expect(store.profile.greeting).toBe('Hello, Alicia'); } finally { - handle.unsubscribe(); + handle.dispose(); } }); @@ -681,10 +681,10 @@ describe('persist()', () => { }); }); - // ── Unsubscribe ───────────────────────────────────────────────────────── + // ── Dispose ───────────────────────────────────────────────────────────── - describe('unsubscribe', () => { - it('stops writing to storage after unsubscribe()', async () => { + describe('dispose', () => { + it('stops writing to storage after dispose()', async () => { const storage = createMockStorage(); const s = createClassyStore({count: 0}); const handle = persist(s, {name: 'test', storage}); @@ -693,23 +693,23 @@ describe('persist()', () => { await tick(); expect(parseStored(storage, 'test')?.state.count).toBe(1); - handle.unsubscribe(); + handle.dispose(); s.count = 999; await tick(); - // Should still be 1 — no write after unsubscribe. + // Should still be 1 — no write after dispose. expect(parseStored(storage, 'test')?.state.count).toBe(1); }); - it('cancels pending debounce on unsubscribe()', async () => { + it('cancels pending debounce on dispose()', async () => { const storage = createMockStorage(); const s = createClassyStore({count: 0}); const handle = persist(s, {name: 'test', storage, debounce: 100}); s.count = 42; await tick(); - handle.unsubscribe(); + handle.dispose(); await wait(150); @@ -852,7 +852,7 @@ describe('persist()', () => { expect(s.count).toBe(123); - handle.unsubscribe(); + handle.dispose(); }); it('ignores storage events for other keys', async () => { @@ -869,16 +869,16 @@ describe('persist()', () => { expect(s.count).toBe(0); // unchanged - handle.unsubscribe(); + handle.dispose(); }); - it('stops listening after unsubscribe()', async () => { + it('stops listening after dispose()', async () => { const storage = createMockStorage(); const s = createClassyStore({count: 0}); const handle = persist(s, {name: 'test', storage, syncTabs: true}); await handle.hydrated; - handle.unsubscribe(); + handle.dispose(); const event = new StorageEvent('storage', { key: 'test', @@ -903,7 +903,7 @@ describe('persist()', () => { expect(s.count).toBe(0); // unchanged - handle.unsubscribe(); + handle.dispose(); }); it('does not write back to storage when applying a remote tab event (no ping-pong loop)', async () => { @@ -929,7 +929,7 @@ describe('persist()', () => { expect(s.count).toBe(42); expect(setItemSpy).not.toHaveBeenCalled(); - handle.unsubscribe(); + handle.dispose(); }); }); @@ -1041,7 +1041,7 @@ describe('persist()', () => { expect(s.count).toBe(0); // expired — rejected expect(handle.isExpired).toBe(true); - handle.unsubscribe(); + handle.dispose(); }); it('data without expiresAt hydrates normally when expireIn is set', async () => { @@ -1183,32 +1183,32 @@ describe('persist()', () => { expect(s.count).toBe(0); // invalid envelope skipped }); - it('unsubscribe is idempotent (calling twice does not throw)', async () => { + it('dispose is idempotent (calling twice does not throw)', async () => { const storage = createMockStorage(); const s = createClassyStore({count: 0}); const handle = persist(s, {name: 'test', storage}); await handle.hydrated; - handle.unsubscribe(); - handle.unsubscribe(); // should not throw + handle.dispose(); + handle.dispose(); // should not throw }); - it('save after unsubscribe is a no-op', async () => { + it('save after dispose rejects without writing', async () => { const storage = createMockStorage(); const s = createClassyStore({count: 0}); const handle = persist(s, {name: 'test', storage}); await handle.hydrated; - handle.unsubscribe(); + handle.dispose(); s.count = 999; - await handle.save(); // should not throw or write + await expect(handle.save()).rejects.toThrow('disposed'); const stored = parseStored(storage, 'test'); // Should not have count=999 expect(stored?.state.count ?? 0).not.toBe(999); }); - it('clear after unsubscribe is a no-op', async () => { + it('clear after dispose rejects without removing data', async () => { const storage = createMockStorage(); const s = createClassyStore({count: 0}); const handle = persist(s, {name: 'test', storage}); @@ -1216,8 +1216,8 @@ describe('persist()', () => { s.count = 5; await tick(); - handle.unsubscribe(); - await handle.clear(); // should be no-op + handle.dispose(); + await expect(handle.clear()).rejects.toThrow('disposed'); // Storage should still have the data expect(storage.data.has('test')).toBe(true); @@ -1331,7 +1331,7 @@ describe('persist()', () => { it.each([ false, true, - ])('ignores a pending read after unsubscribe (skipHydration=%s)', async (skipHydration) => { + ])('ignores a pending read after dispose (skipHydration=%s)', async (skipHydration) => { let resolveRead!: (value: string | null) => void; const pendingRead = new Promise((resolve) => { resolveRead = resolve; @@ -1344,7 +1344,7 @@ describe('persist()', () => { const store = createClassyStore({title: 'Current'}); const handle = persist(store, {name: 'editor', storage, skipHydration}); const completion = skipHydration ? handle.rehydrate() : handle.hydrated; - handle.unsubscribe(); + handle.dispose(); resolveRead(JSON.stringify({version: 0, state: {title: 'Retired'}})); await completion; await handle.hydrated; @@ -1358,7 +1358,7 @@ describe('persist()', () => { it.each([ false, true, - ])('settles a failed pending read without callbacks after unsubscribe (skipHydration=%s)', async (skipHydration) => { + ])('settles a failed pending read without callbacks after dispose (skipHydration=%s)', async (skipHydration) => { let rejectRead!: (error: Error) => void; const pendingRead = new Promise((_resolve, reject) => { rejectRead = reject; @@ -1377,7 +1377,7 @@ describe('persist()', () => { onError, }); const completion = skipHydration ? handle.rehydrate() : handle.hydrated; - handle.unsubscribe(); + handle.dispose(); rejectRead(new Error('Late storage failure')); await completion; await handle.hydrated; @@ -1403,7 +1403,7 @@ describe('persist()', () => { storage, clearOnExpire: true, }); - handle.unsubscribe(); + handle.dispose(); resolveRead( JSON.stringify({version: 0, expiresAt: 0, state: {title: 'Expired'}}), ); @@ -1494,7 +1494,7 @@ describe('persist()', () => { const handle = persist(s, {name: 'test', storage, onError}); await handle.hydrated; - await handle.clear(); + await expect(handle.clear()).rejects.toThrow('locked'); expect(onError).toHaveBeenCalledTimes(1); expect(onError.mock.calls[0][1]).toBe('remove'); }); @@ -1583,7 +1583,7 @@ describe('persist()', () => { // Pending write must have been cancelled — storage still empty (no setItem ran). expect(storage.data.has('test')).toBe(false); - handle.unsubscribe(); + handle.dispose(); }); }); }); diff --git a/packages/classy-store/src/utils/persist/persist.ts b/packages/classy-store/src/utils/persist/persist.ts index 53a9b2e..e7bbe7f 100644 --- a/packages/classy-store/src/utils/persist/persist.ts +++ b/packages/classy-store/src/utils/persist/persist.ts @@ -2,181 +2,15 @@ import {getVersion, subscribe} from '../../core/core'; import {snapshot} from '../../snapshot/snapshot'; import {cloneSnapshotValue} from '../cloneSnapshot/cloneSnapshot'; import {findGetterDescriptor} from '../internal/internal'; +import {createUtilityStatus} from '../utilityStatus/utilityStatus'; -// ── Types ──────────────────────────────────────────────────────────────────── - -/** - * Storage adapter interface. Compatible with `localStorage`, `sessionStorage`, - * `AsyncStorage`, `localForage`, or any custom implementation. - * - * Methods may return synchronously or asynchronously — `persist` handles both. - */ -export type StorageAdapter = { - getItem: (name: string) => string | null | Promise; - setItem: (name: string, value: string) => void | Promise; - removeItem: (name: string) => void | Promise; -}; - -/** - * Describes a per-property serialization transform. - * - * Use this when a property's value is not JSON-serializable (Date, ReactiveMap, etc.) - * and needs custom conversion to/from a storable format. - */ -export type PropertyTransform = { - /** The property key on the store class. */ - key: keyof T; - - /** Transform the value BEFORE saving to storage (after snapshot, before JSON.stringify). */ - serialize: (value: T[keyof T]) => unknown; - - /** Transform the value AFTER loading from storage (after JSON.parse, before applying to store). */ - deserialize: (stored: unknown) => T[keyof T]; -}; - -/** - * Options for `persist()`. - */ -export type PersistOptions = { - /** Unique storage key. Required. */ - name: string; - - /** - * Storage adapter. Required. - * Any object with getItem/setItem/removeItem (sync or async). - * Works with: localStorage, sessionStorage, AsyncStorage, localForage, etc. - */ - storage: StorageAdapter; - - /** - * Which properties to persist. - * Each entry is either a plain key (string) or a transform descriptor. - * Defaults to all own enumerable data properties. - * - * **Getters and methods are always excluded.** Class getters (e.g., `get remaining()`) - * are computed/derived values — they are not source-of-truth state. They recompute - * automatically from the persisted data properties when accessed. Persisting a getter - * result would be redundant and could produce stale values on restore. - */ - properties?: Array>; - - /** - * Debounce writes to storage (milliseconds). - * Multiple rapid mutations coalesce into one write. - * Default: 0 (write after every batched mutation). - */ - debounce?: number; - - /** - * Schema version number. Stored alongside the data. - * When the stored version differs from this value, `migrate` is called. - * Default: 0. - */ - version?: number; - - /** - * Migration function. Called when the stored version does not match `version`. - * Receives the raw parsed state and the old version number. - * Must return the state in the current shape. - */ - migrate?: ( - persistedState: Record, - oldVersion: number, - ) => Record; - - /** - * How to merge persisted state with current store state during hydration. - * - * - `'shallow'` (default): persisted values overwrite current values one key at a time. - * Properties not in storage keep their current value. - * - Custom function: receives `(persistedState, currentState)` and returns merged state. - * Enables deep merge or any custom logic. - * - * This matters when the store adds new properties that don't exist in old - * persisted data. `'shallow'` preserves the new defaults. - */ - merge?: - | 'shallow' - | (( - persisted: Record, - current: Record, - ) => Record); - - /** - * If true, do NOT hydrate automatically on init. - * You must call `handle.rehydrate()` manually (e.g., in a `useEffect` for SSR). - * Default: false. - */ - skipHydration?: boolean; - - /** - * Sync state across browser tabs via the `window.storage` event. - * When another tab writes to the same storage key, this tab automatically - * re-hydrates from the new value. - * - * Only meaningful for `localStorage` — `storage` events don't fire for - * `sessionStorage` or async adapters. - * - * Default: `false`. - */ - syncTabs?: boolean; - - /** - * Time-to-live in milliseconds. After this duration, stored data is - * considered expired and skipped during hydration. The TTL resets on - * every write (active sessions stay fresh as long as mutations happen). - */ - expireIn?: number; - - /** - * When `true`, automatically remove the storage key if data is found - * expired during hydration. Default: `false` (expired data is skipped - * 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. - */ -export type PersistHandle = { - /** - * Stop persisting, cancel pending debounce, and remove the storage listener. - * Pending read results/errors and unstarted queued writes are ignored. - * Their promises still settle; already-issued adapter I/O is not cancelled. - */ - unsubscribe: () => void; - - /** Promise that resolves when initial hydration from storage is complete. */ - hydrated: Promise; - - /** Whether hydration from storage has completed. */ - isHydrated: boolean; - - /** Save current state, bypassing debounce and waiting behind earlier writes. */ - save: () => Promise; - - /** Cancel pending auto-saves and remove persisted data after earlier writes. */ - clear: () => Promise; - - /** Re-read after prior writes; ignore the result if a newer read/edit supersedes it. */ - rehydrate: () => Promise; - - /** True if the last hydration found expired data (requires `expireIn`). */ - isExpired: boolean; -}; +import type { + PersistHandle, + PersistOperation, + PersistOptions, + PersistStatus, + PropertyTransform, +} from './persist.types'; // ── Storage envelope ───────────────────────────────────────────────────────── @@ -246,7 +80,7 @@ function resolveProperties( * * @param proxyStore - A reactive proxy created by `createClassyStore()`. * @param options - Persistence configuration. - * @returns A handle with lifecycle controls (unsubscribe, save, clear, rehydrate, hydrated). + * @returns A handle with lifecycle controls (dispose, save, clear, rehydrate, hydrated). */ export function persist( proxyStore: T, @@ -269,6 +103,9 @@ export function persist( /** Route a failure through `onError` (or swallow silently if not provided). */ function reportError(error: unknown, operation: PersistOperation): void { + if (disposed) return; + lastError = Object.freeze({error, operation}); + status.update(readStatus()); if (onError) { try { onError(error, operation); @@ -288,13 +125,34 @@ export function persist( let hydrationRequest = 0; let storageQueue: Promise | undefined; let clearedVersion: number | undefined; + let lastError: PersistStatus['lastError'] = null; + const readStatus = (): PersistStatus => ({ + isHydrated: hydratedFlag, + isExpired: expiredFlag, + isDisposed: disposed, + lastError, + }); + const status = createUtilityStatus(readStatus()); + + function fail(error: unknown, operation: PersistOperation): never { + reportError(error, operation); + throw error; + } + + function assertActive() { + if (disposed) throw new Error('Persistence handle has been disposed'); + } + + function succeeded() { + if (disposed) return; + lastError = null; + status.update(readStatus()); + } // Hydration promise + resolver. let resolveHydrated!: () => void; - let rejectHydrated!: (error: unknown) => void; - const hydratedPromise = new Promise((resolve, reject) => { + const hydratedPromise = new Promise((resolve) => { resolveHydrated = resolve; - rejectHydrated = reject; }); const resolvedProps = resolveProperties(proxyStore, propertiesOption); @@ -313,20 +171,24 @@ export function persist( kind: 'write' | 'remove', ): Promise { const execute = async () => { - if (disposed) return; + assertActive(); try { await operation(); + succeeded(); } catch (error) { - reportError(error, kind); + fail(error, kind); } }; // Start the first operation immediately for synchronous storage adapters. const pending = storageQueue ? storageQueue.then(execute) : execute(); - const completion = pending.finally(() => { - if (storageQueue === completion) storageQueue = undefined; - }); + // A failed operation rejects its caller without blocking later queued work. + const completion = pending + .catch(() => {}) + .finally(() => { + if (storageQueue === completion) storageQueue = undefined; + }); storageQueue = completion; - return completion; + return pending; } // ── Save logic ───────────────────────────────────────────────────────── @@ -340,7 +202,8 @@ export function persist( let value = snap[key]; const transform = transformMap.get(key); if (transform) { - value = transform.serialize(value as T[keyof T]); + // The transform and value are paired by the same resolved property key. + value = transform.serialize(value as never); } state[key] = value; } @@ -354,15 +217,16 @@ export function persist( /** Write the current state to storage. */ async function writeToStorage(): Promise { - if (disposed) return; + assertActive(); hydrationRequest++; + let json: string; try { // Capture now; a queued save must represent the state requested by its caller. - const json = serializeState(); - await enqueueStorageMutation(() => storage.setItem(name, json), 'write'); + json = serializeState(); } catch (error) { - reportError(error, 'write'); + fail(error, 'write'); } + await enqueueStorageMutation(() => storage.setItem(name, json), 'write'); } /** Schedule a debounced write (or write immediately if debounce is 0). */ @@ -372,7 +236,8 @@ export function persist( if (getVersion(proxyStore) === clearedVersion) return; if (debounceMs <= 0) { - void writeToStorage(); + // Background writes already report failures through status and onError. + void writeToStorage().catch(() => {}); return; } @@ -381,7 +246,7 @@ export function persist( } debounceTimer = setTimeout(() => { debounceTimer = null; - void writeToStorage(); + void writeToStorage().catch(() => {}); }, debounceMs); } @@ -389,16 +254,10 @@ export function persist( /** * Parse a raw JSON string from storage, apply migration and transforms, - * and merge the result into the store proxy. + * and merge the result into the store proxy. Returns whether data expired. */ - function applyPersistedState(raw: string): void { - let envelope: PersistEnvelope; - try { - envelope = JSON.parse(raw) as PersistEnvelope; - } catch (error) { - reportError(error, 'parse'); - return; - } + function applyPersistedState(raw: string): boolean { + const envelope = JSON.parse(raw) as PersistEnvelope; if ( !envelope || @@ -406,7 +265,7 @@ export function persist( envelope.state === null || typeof envelope.state !== 'object' ) { - return; + return false; } // Expiry check — skip hydration if data has expired. @@ -415,10 +274,7 @@ export function persist( Date.now() >= envelope.expiresAt ) { expiredFlag = true; - if (clearOnExpire) { - void enqueueStorageMutation(() => storage.removeItem(name), 'remove'); - } - return; + return true; } let {state} = envelope; @@ -465,6 +321,7 @@ export function persist( ); } } + return false; } /** Read from storage and apply to the store. */ @@ -482,7 +339,7 @@ export function persist( if (!isCurrent()) return; raw = await storage.getItem(name); } catch (error) { - if (isCurrent()) reportError(error, 'read'); + if (isCurrent()) fail(error, 'read'); return; } // Cleanup, a newer request, or a local edit can supersede an in-flight read. @@ -497,13 +354,34 @@ export function persist( debounceTimer = null; } hydrating = true; + let removal: Promise | undefined; try { - applyPersistedState(raw); + // Parse/transform/merge failures belong to this read's decoding stage. + // Handle reporting here so thrown callbacks cannot be reported twice. + let expired: boolean; + try { + expired = applyPersistedState(raw); + } catch (error) { + fail(error, 'parse'); + } + if (expired && clearOnExpire) { + removal = enqueueStorageMutation( + () => storage.removeItem(name), + 'remove', + ); + } else { + succeeded(); + } } finally { // Keep the batched notification from writing restored state back. await new Promise((resolve) => queueMicrotask(resolve)); hydrating = false; } + // Adapter I/O must not suppress auto-saves for later local edits. + status.update(readStatus()); + if (removal) await removal; + } else { + succeeded(); } } @@ -529,7 +407,17 @@ export function persist( // tabs ping-pong storage events forever. hydrating = true; try { - applyPersistedState(event.newValue); + if (applyPersistedState(event.newValue) && clearOnExpire) { + void enqueueStorageMutation( + () => storage.removeItem(name), + 'remove', + ).catch(() => {}); + status.update(readStatus()); + } else { + succeeded(); + } + } catch (error) { + reportError(error, 'parse'); } finally { queueMicrotask(() => { hydrating = false; @@ -549,19 +437,30 @@ export function persist( // Kick off initial hydration (unless skipped). if (!skipHydration) { void hydrateFromStorage() - .then(() => { + .catch(() => {}) + .finally(() => { hydratedFlag = true; + status.update(readStatus()); resolveHydrated(); - }) - .catch((error) => { - hydratedFlag = true; - rejectHydrated(error); }); } // ── Build handle ─────────────────────────────────────────────────────── + function dispose() { + if (disposed) return; + disposed = true; + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + unsubscribeFromStore(); + if (syncTabs) globalThis.removeEventListener('storage', onStorageEvent); + status.dispose(readStatus()); + } + const handle: PersistHandle = { + ...status.source, get isHydrated() { return hydratedFlag; }, @@ -572,27 +471,10 @@ export function persist( hydrated: hydratedPromise, - unsubscribe() { - if (disposed) return; - disposed = true; - - // Cancel pending debounce. - if (debounceTimer !== null) { - clearTimeout(debounceTimer); - debounceTimer = null; - } - - // Unsubscribe from store mutations. - unsubscribeFromStore(); - - // Remove cross-tab sync listener. - if (syncTabs) { - globalThis.removeEventListener('storage', onStorageEvent); - } - }, + dispose, async save() { - if (disposed) return; + assertActive(); // Cancel pending debounce and write immediately. if (debounceTimer !== null) { clearTimeout(debounceTimer); @@ -602,7 +484,7 @@ export function persist( }, async clear() { - if (disposed) return; + assertActive(); hydrationRequest++; clearedVersion = getVersion(proxyStore); if (debounceTimer !== null) { @@ -613,12 +495,17 @@ export function persist( }, async rehydrate() { - if (disposed) return; + assertActive(); expiredFlag = false; - await hydrateFromStorage(); - if (!hydratedFlag) { - hydratedFlag = true; - resolveHydrated(); + status.update(readStatus()); + try { + await hydrateFromStorage(); + } finally { + if (!hydratedFlag) { + hydratedFlag = true; + resolveHydrated(); + } + status.update(readStatus()); } }, }; diff --git a/packages/classy-store/src/utils/persist/persist.types.ts b/packages/classy-store/src/utils/persist/persist.types.ts new file mode 100644 index 0000000..774795f --- /dev/null +++ b/packages/classy-store/src/utils/persist/persist.types.ts @@ -0,0 +1,188 @@ +import type {Snapshot} from '../../types'; +import type {UtilityStatusSource} from '../utilityStatus/utilityStatus'; + +/** + * Storage adapter interface. Compatible with `localStorage`, `sessionStorage`, + * `AsyncStorage`, `localForage`, or any custom implementation. + * + * Methods may return synchronously or asynchronously — `persist` handles both. + */ +export type StorageAdapter = { + getItem: (name: string) => string | null | Promise; + setItem: (name: string, value: string) => void | Promise; + removeItem: (name: string) => void | Promise; +}; + +/** + * Describes a per-property serialization transform. + * + * Use this when a property's value is not JSON-serializable (Date, ReactiveMap, etc.) + * and needs custom conversion to/from a storable format. + */ +export type PropertyTransform = { + [P in K]-?: { + /** The property key determines both transform value types. */ + key: P; + /** Serialize this property's readonly snapshot value. */ + serialize: (value: Snapshot) => unknown; + /** Validate unknown stored data and restore this property's mutable value. */ + deserialize: (stored: unknown) => T[P]; + }; +}[K]; + +/** + * Options for `persist()`. + */ +export type PersistOptions = { + /** Unique storage key. Required. */ + name: string; + + /** + * Storage adapter. Required. + * Any object with getItem/setItem/removeItem (sync or async). + * Works with: localStorage, sessionStorage, AsyncStorage, localForage, etc. + */ + storage: StorageAdapter; + + /** + * Which properties to persist. + * Each entry is either a plain key (string) or a transform descriptor. + * Defaults to all own enumerable data properties. + * + * **Getters and methods are always excluded.** Class getters (e.g., `get remaining()`) + * are computed/derived values — they are not source-of-truth state. They recompute + * automatically from the persisted data properties when accessed. Persisting a getter + * result would be redundant and could produce stale values on restore. + */ + properties?: Array>; + + /** + * Debounce writes to storage (milliseconds). + * Multiple rapid mutations coalesce into one write. + * Default: 0 (write after every batched mutation). + */ + debounce?: number; + + /** + * Schema version number. Stored alongside the data. + * When the stored version differs from this value, `migrate` is called. + * Default: 0. + */ + version?: number; + + /** + * Migration function. Called when the stored version does not match `version`. + * Receives the raw parsed state and the old version number. + * Must return the state in the current shape. + */ + migrate?: ( + persistedState: Record, + oldVersion: number, + ) => Record; + + /** + * How to merge persisted state with current store state during hydration. + * + * - `'shallow'` (default): persisted values overwrite current values one key at a time. + * Properties not in storage keep their current value. + * - Custom function: receives `(persistedState, currentState)` and returns merged state. + * Enables deep merge or any custom logic. + * + * This matters when the store adds new properties that don't exist in old + * persisted data. `'shallow'` preserves the new defaults. + */ + merge?: + | 'shallow' + | (( + persisted: Record, + current: Record, + ) => Record); + + /** + * If true, do NOT hydrate automatically on init. + * You must call `handle.rehydrate()` manually (e.g., in a `useEffect` for SSR). + * Default: false. + */ + skipHydration?: boolean; + + /** + * Sync state across browser tabs via the `window.storage` event. + * When another tab writes to the same storage key, this tab automatically + * re-hydrates from the new value. + * + * Only meaningful for `localStorage` — `storage` events don't fire for + * `sessionStorage` or async adapters. + * + * Default: `false`. + */ + syncTabs?: boolean; + + /** + * Time-to-live in milliseconds. After this duration, stored data is + * considered expired and skipped during hydration. The TTL resets on + * every write (active sessions stay fresh as long as mutations happen). + */ + expireIn?: number; + + /** + * When `true`, automatically remove the storage key if data is found + * expired during hydration. Default: `false` (expired data is skipped + * 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. + * + * Automatic operations report errors without rejecting. Explicit save, + * clear, and rehydrate calls also reject their promise with the original error. + */ + onError?: (error: unknown, operation: PersistOperation) => void; +}; + +/** Operation that can produce an `onError` callback invocation. */ +export type PersistOperation = 'write' | 'read' | 'remove' | 'parse'; + +export type PersistStatus = { + readonly isHydrated: boolean; + readonly isExpired: boolean; + readonly isDisposed: boolean; + /** Latest failure; cleared by the next successful operation. */ + readonly lastError: Readonly<{ + error: unknown; + operation: PersistOperation; + }> | null; +}; + +/** + * Handle returned by `persist()`. Provides control over the persist lifecycle. + */ +export type PersistHandle = UtilityStatusSource & { + /** + * Stop persisting, cancel pending debounce, and remove the storage listener. + * Pending read results/errors are ignored; unstarted writes are cancelled. + * Cancelled explicit writes/removals reject. Already-issued adapter I/O is not cancelled. + * Safe to call repeatedly. + */ + dispose: () => void; + + /** Resolves when the initial hydration attempt settles; inspect status for errors. */ + hydrated: Promise; + + /** Whether a hydration attempt has settled, including failures or skipped results. */ + readonly isHydrated: boolean; + + /** Save current state after earlier writes, bypassing debounce. Rejects on failure or disposal. */ + save: () => Promise; + + /** Cancel auto-saves and remove data after earlier writes. Rejects on failure or disposal. */ + clear: () => Promise; + + /** Re-read after prior writes. Rejects on failure or disposal; ignores superseded reads. */ + rehydrate: () => Promise; + + /** True if the last hydration found expired data (requires `expireIn`). */ + readonly isExpired: boolean; +}; diff --git a/packages/classy-store/src/utils/utilityStatus/utilityStatus.ts b/packages/classy-store/src/utils/utilityStatus/utilityStatus.ts new file mode 100644 index 0000000..3f1aaf7 --- /dev/null +++ b/packages/classy-store/src/utils/utilityStatus/utilityStatus.ts @@ -0,0 +1,59 @@ +/** Internal external-store contract for a utility handle's readonly status. */ +export type UtilityStatusSource = { + /** Read a cached snapshot. Its identity changes only when status changes. */ + getSnapshot: () => T; + /** Read the initial status for server rendering and client hydration. */ + getServerSnapshot: () => T; + /** Subscribe to status changes. Returns an unsubscribe function. */ + subscribe: (listener: () => void) => () => void; +}; + +/** Internal status publisher shared by utility handles. */ +export function createUtilityStatus(initial: T) { + const initialSnapshot = Object.freeze(initial); + let current = initialSnapshot; + let disposed = false; + const listeners = new Set<() => void>(); + const source: UtilityStatusSource> = { + getSnapshot: () => current, + getServerSnapshot: () => initialSnapshot, + subscribe(listener) { + if (disposed) return () => {}; + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; + + function update(next: T) { + if ( + (Object.keys(next) as Array).every((key) => + Object.is(current[key], next[key]), + ) + ) { + return; + } + current = Object.freeze(next); + if (disposed) return; + for (const listener of Array.from(listeners)) { + if (!listeners.has(listener)) continue; + try { + listener(); + } catch (error) { + // A UI listener must not interrupt persistence or history bookkeeping. + console.error(error); + } + } + } + + return { + source, + update, + dispose(final: T) { + update(final); + disposed = true; + listeners.clear(); + }, + }; +} diff --git a/packages/classy-store/tests/browser/hydration/components/hydrationApp/HydrationApp.tsx b/packages/classy-store/tests/browser/hydration/components/hydrationApp/HydrationApp.tsx index 65d088f..167dceb 100644 --- a/packages/classy-store/tests/browser/hydration/components/hydrationApp/HydrationApp.tsx +++ b/packages/classy-store/tests/browser/hydration/components/hydrationApp/HydrationApp.tsx @@ -19,7 +19,7 @@ export function HydrationApp({runtime}: Props) { void persistence.rehydrate().then(() => { runtime.probe.restored = true; }); - return persistence.unsubscribe; + return persistence.dispose; }, [runtime]); return ( diff --git a/packages/classy-store/tests/browser/hydration/hydration.store.ts b/packages/classy-store/tests/browser/hydration/hydration.store.ts index e02d1b9..f5809e2 100644 --- a/packages/classy-store/tests/browser/hydration/hydration.store.ts +++ b/packages/classy-store/tests/browser/hydration/hydration.store.ts @@ -1,8 +1,6 @@ import {createClassyStore} from '../../../src/core/core'; -import { - createStoreHook, - prepareStoreForHydration, -} from '../../../src/frameworks/react/react'; +import {createStoreHook} from '../../../src/frameworks/react/react'; +import {prepareStoreForHydration} from '../../../src/frameworks/react/server'; export type HydrationData = {user: {name: string}; count: number}; diff --git a/packages/classy-store/tests/browser/persistence/components/persistenceForm/PersistenceForm.tsx b/packages/classy-store/tests/browser/persistence/components/persistenceForm/PersistenceForm.tsx index 9a4636e..717b565 100644 --- a/packages/classy-store/tests/browser/persistence/components/persistenceForm/PersistenceForm.tsx +++ b/packages/classy-store/tests/browser/persistence/components/persistenceForm/PersistenceForm.tsx @@ -22,7 +22,7 @@ export function PersistenceForm() { -
@@ -95,6 +97,18 @@ history.dispose(); After disposing, mutations are no longer recorded and the snapshot stack is cleared. +### Observe status outside React + +```ts +const stopListening = history.subscribe(() => { + console.log(history.getSnapshot()); + // {canUndo, canRedo, isPaused, isDisposed} +}); +stopListening(); // Only removes this listener; history keeps recording. +``` + +Snapshots are readonly and cached until a status field changes. `pause()` and `resume()` notify status subscribers even when store data is unchanged. `dispose()` publishes the final disposed status, clears listeners, and makes subsequent history operations no-ops. `getServerSnapshot()` always returns initial status for SSR and hydration. The public `HistoryStatus` type is exported from `@codebelt/classy-store/utils`. + ## Signature ```ts @@ -121,16 +135,19 @@ function withHistory( | `pause()` | `() => void` | Stop recording history entries | | `resume()` | `() => void` | Resume recording history entries | | `dispose()` | `() => void` | Unsubscribe and clean up | +| `getSnapshot()` | `() => HistoryStatus` | Cached current status | +| `getServerSnapshot()` | `() => HistoryStatus` | Initial status for SSR/hydration | +| `subscribe(listener)` | `(listener: () => void) => () => void` | Observe status changes; returns listener cleanup | ## How It Works 1. **Init:** captures an initial snapshot as `history[0]` and sets `pointer = 0`. 2. **Record:** on each `subscribe()` callback (when not paused), captures a new snapshot, truncates any redo entries after the current pointer, pushes the snapshot, and enforces the limit by shifting from the front. -3. **Undo:** decrements the pointer, pauses recording, applies `history[pointer]` back to the store proxy (skipping getters and methods), and resumes recording. -4. **Redo:** increments the pointer, pauses recording, applies `history[pointer]`, and resumes. +3. **Undo:** decrements the pointer, suppresses recording during restoration, and applies `history[pointer]` back to the store proxy (skipping getters and methods). +4. **Redo:** increments the pointer and restores `history[pointer]` with the same recording suppression. 5. **State restoration:** iterates own enumerable keys of the snapshot, skips keys with getter descriptors on the prototype chain, and assigns values to the proxy. This triggers SET traps and reactivity — your components update automatically. -Undo and redo themselves do **not** create new history entries because recording is paused during the restore operation. +Undo and redo themselves do **not** create new history entries. Restoration does not change the explicit `isPaused` status set by `pause()` and `resume()`. ## Use Cases @@ -205,9 +222,9 @@ while (history.canUndo) { // All fields are back to their initial values ``` -### Batch operations with pause/resume +### Batched edits and pause/resume -When performing multiple related mutations that should count as a single history entry, use `pause()` and `resume()`: +Synchronous mutations in one turn already share a history entry because ordinary store subscriptions are batched: ```ts class SpreadsheetStore { @@ -223,20 +240,14 @@ const history = withHistory(spreadsheetStore); // Paste a block of cells — should be a single undo step function pasteBlock(data: Record) { - history.pause(); - for (const [id, value] of Object.entries(data)) { spreadsheetStore.setCell(id, value); } - - history.resume(); - - // Manually trigger a snapshot capture by making a final - // mutation after resume, or just let the next user action - // create the history entry. } ``` +`pause()` skips history recording for notifications delivered while paused. `resume()` enables recording again; it does not capture an entry itself or group asynchronous edits into a transaction. `useHistoryStatus(history).isPaused` exposes this recording state. Undo/redo restoration preserves an explicit pause. + ### Canvas with limited history For memory-sensitive applications, set a lower limit: diff --git a/website/docs/PERSIST_ARCHITECTURE.md b/website/docs/PERSIST_ARCHITECTURE.md index 9c185d6..f227d3f 100644 --- a/website/docs/PERSIST_ARCHITECTURE.md +++ b/website/docs/PERSIST_ARCHITECTURE.md @@ -4,7 +4,7 @@ Internal design documentation for the `persist()` utility in `@codebelt/classy-s ## Design Principles -1. **Standalone utility, not middleware.** `persist` consumes the existing `subscribe`, `snapshot`, and `getVersion` APIs. No changes to core reactivity or React hooks are needed. +1. **Standalone utility, not middleware.** `persist` uses `subscribe` and `snapshot`, plus an internal version helper for stale-read protection. No changes to core reactivity or React hooks are needed. 2. **Tree-shakeable.** `persist` lives in a separate entry point (`@codebelt/classy-store/utils`). Applications that don't use persistence pay zero cost -- the code is never bundled. @@ -52,8 +52,12 @@ src/ utils/ index.ts # Barrel: persist, devtools, subscribeKey, withHistory persist/ - persist.ts # persist(), types, and all logic - persist.test.ts # tests with mock storage adapters + persist.ts # Storage queue, hydration, and persistence logic + persist.types.ts # Public options, transforms, adapter, status, and handle + persist.test.ts # Tests with mock storage adapters + persist.api.test.ts # Public lifecycle and status behavior + persist.concurrent.test.ts # Overlapping reads, writes, and disposal + persist.errors.test.ts # Error reporting and rejection policy package.json # "./utils" export entry tsdown.config.ts # 'src/utils/index.ts' in entry array ``` @@ -64,15 +68,14 @@ Each `persist()` call creates a closure with the following internal state: | Variable | Type | Purpose | |---|---|---| -| `disposed` | `boolean` | Guards against writes after `unsubscribe()` | -| `debounceTimer` | `ReturnType \| null` | Active debounce timer (cancelled on save/clear/unsubscribe or accepted hydration) | +| `disposed` | `boolean` | Guards against writes after `dispose()` | +| `debounceTimer` | `ReturnType \| null` | Active debounce timer (cancelled on save/clear/dispose or accepted hydration) | | `storageQueue` | `Promise \| undefined` | Orders this handle's writes/removals and lets reads wait for prior operations | | `hydrationRequest` | `number` | Rejects read results superseded by newer requests or storage events | | `clearedVersion` | `number \| undefined` | Suppresses a pre-clear mutation's pending batched auto-save | | `hydratedFlag` | `boolean` | Synchronous hydration status | | `expiredFlag` | `boolean` | Set to `true` when hydration encounters expired data | | `resolveHydrated` | `() => void` | Resolves the `hydrated` promise | -| `rejectHydrated` | `(error) => void` | Rejects the `hydrated` promise on error | | `resolvedProps` | `Array<{key, transform?}>` | Normalized property list with optional transforms | | `transformMap` | `Map` | Fast lookup from key to transform | | `propKeys` | `string[]` | Flat list of property keys to persist | @@ -185,9 +188,9 @@ Both `'shallow'` and `'replace'` produce the same result for flat stores. The di ### Error Handling -- **Corrupted JSON** in storage (`JSON.parse` throws): caught silently, hydration completes with no state changes. +- **Corrupted JSON** in storage (`JSON.parse` throws): reported as `parse` through `onError` and status, with no state changes. - **Invalid envelope** (missing `.state` or not an object): detected by shape check, skipped silently. -- **Adapter read errors** during `storage.getItem()`: reported through `onError` when the request is current; hydration completes. Exceptions from custom migration/merge/transform callbacks can reject hydration. +- **Adapter read errors** during `storage.getItem()`: reported as `read` when the request is current. Exceptions from custom migration/merge/transform callbacks are reported as `parse`. Initial hydration settles after reporting; explicit `rehydrate()` rejects with the original error. ## Cross-Tab Sync @@ -219,7 +222,7 @@ Key properties of `window.storage`: ## Cleanup -`handle.unsubscribe()` performs three cleanup steps: +`handle.dispose()` performs three cleanup steps: ``` 1. Set disposed = true (guards all future writes) @@ -228,10 +231,10 @@ Key properties of `window.storage`: 4. Remove window 'storage' event listener (if syncTabs was active) ``` -After `unsubscribe()`: +After `dispose()`: - Store mutations no longer trigger writes to storage. - Cross-tab changes to the storage key are ignored. -- `save()`, `clear()`, and `rehydrate()` become no-ops. +- `save()`, `clear()`, and `rehydrate()` reject without starting new work. - Queued writes/removals that have not started are skipped. Already-issued adapter I/O can still finish. - Pending read results/errors are ignored, and their promises settle when the reads finish. @@ -239,28 +242,35 @@ After `unsubscribe()`: | Method/Property | Behavior | |---|---| -| `unsubscribe()` | Full cleanup: unsubscribe + cancel debounce + remove storage listener. Idempotent. | -| `hydrated` | Promise that resolves when initial hydration completes or is superseded. Custom hydration callback exceptions can reject it. | -| `isHydrated` | Getter returning `boolean`. Becomes `true` after hydration resolves or rejects. | -| `isExpired` | Getter returning `boolean`. `true` when the last hydration found expired data (requires `expireIn`). Re-evaluated on every `rehydrate()` call. | -| `save()` | Cancel pending debounce, capture current state, and enqueue its write. No-op if disposed. | -| `clear()` | Cancel pending auto-saves and enqueue `storage.removeItem(name)` after earlier writes. No-op if disposed. | -| `rehydrate()` | Re-read from storage, apply to store. Also resolves `hydrated` if not yet resolved (for `skipHydration` flows). | +| `dispose()` | Idempotent cleanup: cancel debounce and listeners, publish disposed status, ignore pending reads and skip unstarted writes. | +| `hydrated` | Resolves when a hydration attempt settles, including failure or a superseded read. Inspect `lastError` for reported failures. | +| `isHydrated` | Readonly getter; true after a hydration attempt settles. | +| `isExpired` | Readonly getter; true when hydration found expired data. Re-evaluated by `rehydrate()`. | +| `save()` | Cancel debounce, capture current state, and enqueue its write. | +| `clear()` | Cancel pending auto-saves and enqueue removal after earlier writes. | +| `rehydrate()` | Read after prior writes, apply if still current, and await expired-data removal when enabled. | +| `getSnapshot()` | Cached readonly `PersistStatus`: hydration, expiry, disposal, and latest error. | +| `getServerSnapshot()` | Initial status for server rendering and hydration. | +| `subscribe(listener)` | Synchronous status subscription; returns listener cleanup. | + +Explicit operations reject with the original failure after reporting it through `onError` and status. Automatic operations catch reported failures at their background entry points. Decoding, transform, migration, and merge errors use the `parse` operation. The internal queue absorbs rejections so later writes still run. Calls on disposed handles reject; explicit queued operations cancelled by disposal also reject without invoking the adapter. ## Dependency Graph ```mermaid graph LR - persist["utils/persist.ts"] --> subscribe["core.ts: subscribe()"] + persist["utils/persist/persist.ts"] --> subscribe["core.ts: subscribe()"] persist --> snapshot["snapshot.ts: snapshot()"] persist --> findGetter["utils.ts: findGetterDescriptor()"] utilsIndex["utils/index.ts"] --> persist ``` -`persist` has **no dependency** on `useClassyStore`, `types.ts`, or `collections.ts`. It only uses: +`persist` has no React runtime dependency. Its public contracts live in `persist.types.ts` and are exported by `utils/index.ts`. Transform types use `Snapshot` from the public core `types.ts`. Runtime helpers include: - `subscribe()` -- to listen for store mutations - `snapshot()` -- to create an immutable copy for serialization - `findGetterDescriptor()` -- to detect class getters during property resolution +- `createUtilityStatus()` -- to cache and publish utility status +- `cloneSnapshotValue()` -- to restore mutable values from snapshots ## Performance Characteristics @@ -272,7 +282,7 @@ graph LR | Hydration | O(propKeys) | JSON.parse + deserialize + merge + assign | | Cross-tab sync | O(propKeys) | Same as hydration, triggered by storage event | | `save()` | O(propKeys) | Same as auto-save, synchronous trigger | -| `unsubscribe()` | O(1) | Flag + timer cancel + listener removal | +| `dispose()` | O(1) | Flag + timer cancel + listener removal | ## Design Decisions @@ -305,10 +315,10 @@ Debounce waits for a quiet period after the last mutation. Throttle writes at re | Version migration | Migration call, version match skip | | Merge strategies | Shallow, replace, custom function | | skipHydration | No auto-hydrate, manual rehydrate, promise resolution | -| Unsubscribe | Stop writes, cancel debounce | +| Dispose | Stop writes, cancel debounce | | Flush/clear/rehydrate | Immediate write, remove data, in-memory preservation, re-read | | Hydration state | Promise instance, resolution timing | | Async storage | Async write, async hydrate | -| Cross-tab sync | Correct key, wrong key, post-unsubscribe, disabled | +| Cross-tab sync | Correct key, wrong key, post-disposal, disabled | | expireIn / TTL | Normal hydration, expired skip, clearOnExpire, default leave-in-storage, cross-tab reject, no-expiresAt envelope, TTL reset on write, rehydrate re-check | | Edge cases | Empty storage, corrupted JSON, invalid envelope, no-storage error, multiple persists | diff --git a/website/docs/PERSIST_TUTORIAL.md b/website/docs/PERSIST_TUTORIAL.md index 4f7802b..5e54023 100644 --- a/website/docs/PERSIST_TUTORIAL.md +++ b/website/docs/PERSIST_TUTORIAL.md @@ -105,7 +105,7 @@ await handle.hydrated; // Promise that resolves when initial hydration i await handle.save(); // Manually write to storage (bypasses debounce) await handle.clear(); // Remove this store's persisted data from storage await handle.rehydrate(); // Manually re-hydrate the store from storage -handle.unsubscribe(); // Stop persisting: unsubscribe + cancel debounce + remove storage listener +handle.dispose(); // Stop persisting: unsubscribe + cancel debounce + remove storage listener ``` --- @@ -116,6 +116,8 @@ handle.unsubscribe(); // Stop persisting: unsubscribe + cancel debounce Some values aren't JSON-safe. `Date` objects become strings, and custom classes vanish entirely after `JSON.stringify` + `JSON.parse`. Transforms handle the round-trip through the `properties` option. +The transform's `key` selects its TypeScript types: `serialize` receives that property's readonly snapshot value, and `deserialize` must return that property's mutable value. Stored input remains `unknown`, so validate it before restoring. Optional properties retain `undefined` in their value type. Standalone descriptors can use `PropertyTransform`, exported from `@codebelt/classy-store/utils`. + ```ts class UserStore { name = ''; @@ -132,6 +134,7 @@ const userStore = createClassyStore(new UserStore()); persist(userStore, { name: 'user-store', + storage: localStorage, properties: [ 'name', // plain key -- string, no transform needed { @@ -244,7 +247,7 @@ async hydration is pending keeps the newer local state. An ignored read still settles its promise; hydration completion does not guarantee that stored data won. `clear()` cancels pending auto-saves and removes stored data after earlier issued -writes finish. Later edits can persist again. `unsubscribe()` skips queued writes +writes finish. Later edits can persist again. `dispose()` skips queued writes that have not started; it cannot cancel adapter I/O already in progress. Ordering is per handle. Separate handles sharing an async storage key still @@ -325,7 +328,7 @@ import {handle} from './store'; function App() { useEffect(() => { - handle.rehydrate(); + void handle.rehydrate().catch((error) => console.error('Could not restore persisted state', error)); }, []); return ; @@ -357,9 +360,12 @@ export function StoreHydrator({children}: {children: ReactNode}) { const [hydrated, setHydrated] = useState(false); useEffect(() => { - Promise.all(persists.map((p) => p.rehydrate())).then(() => - setHydrated(true), - ); + void Promise.allSettled(persists.map((handle) => handle.rehydrate())).then((results) => { + for (const result of results) { + if (result.status === 'rejected') console.error('Could not restore persisted state', result.reason); + } + setHydrated(true); + }); }, []); if (!hydrated) { @@ -370,7 +376,7 @@ export function StoreHydrator({children}: {children: ReactNode}) { } ``` -Wrap your app layout with `StoreHydrator` so nothing renders until `localStorage` values have been applied: +Wrap your app layout with `StoreHydrator` so its children wait for all restoration attempts to settle: ```tsx // layout.tsx (Next.js App Router) @@ -392,10 +398,10 @@ export default function RootLayout({children}: {children: React.ReactNode}) { **How it works:** -1. The server renders the page using each store's class defaults — no `localStorage` access. +1. The server and initial client render show the loading placeholder without accessing `localStorage`. 2. On the client, `StoreHydrator` mounts and calls `rehydrate()` on every persist handle. -3. Until all handles resolve, `StoreHydrator` renders `null` (or a loading indicator), preventing any child component from reading stale default values. -4. Once hydration completes, children render with the correct `localStorage`-restored state — no mismatch. +3. Until all attempts settle, `StoreHydrator` renders `null` (or a loading indicator). Failures are logged, and do not leave the UI stuck loading. +4. Children then render with restored values, or current defaults for failed attempts. To server-render the children themselves, use the [prepared-snapshot SSR pattern](./REACT_SSR.md). ### `debounce` (Debouncing Writes) @@ -496,23 +502,65 @@ persist(todoStore, { ## The Handle +### Observable status + +```tsx +import {usePersistStatus} from '@codebelt/classy-store/react'; +import type {PersistHandle} from '@codebelt/classy-store/utils'; + +function PersistenceStatus({handle}: {handle: PersistHandle}) { + const {isHydrated, isExpired, lastError} = usePersistStatus(handle); + if (lastError) return Persistence failed: {lastError.operation}; + if (!isHydrated) return Restoring…; + return {isExpired ? 'Stored data expired' : 'Ready'}; +} +``` + +`PersistStatus` contains readonly `isHydrated`, `isExpired`, `isDisposed`, and `lastError` fields. `lastError` is `null` or `{error, operation}`; the next successful operation clears it. Hydration, expiry, and errors notify status consumers even when store data stays unchanged. + +Outside React, use `handle.subscribe(() => handle.getSnapshot())`. The callback takes no arguments; read the current snapshot inside it. `subscribe` returns listener cleanup and does not control persistence itself. `getSnapshot()` returns the same frozen object until status changes. `getServerSnapshot()` returns initial status for SSR/hydration; server and client handles must start with matching status. See [`usePersistStatus`](./index.md#usepersiststatushandle). + +Public types `PersistStatus` and `PersistOperation` are exported from `@codebelt/classy-store/utils`. + ### `isHydrated` & `hydrated` -If you need to know when the stored data has been applied to the store: +These indicate that a hydration attempt has settled, including failures and obsolete reads that were skipped. They do not guarantee that stored data was applied: ```ts // Async -- await the promise await handle.hydrated; -console.log('Restored:', todoStore.todos); +console.log('Hydration status:', handle.getSnapshot()); // Sync check if (handle.isHydrated) { - // safe to read persisted state + // The attempt has settled; inspect lastError for a reported failure. } ``` For `localStorage` (synchronous), hydration resolves almost immediately. For async storage adapters like `AsyncStorage`, the promise resolves after the async read completes. +### Handle operation failures + +Explicit `save()`, `clear()`, and `rehydrate()` calls reject on failure after reporting through `onError` and `lastError`. Automatic writes and initial hydration report failures without producing unhandled rejections. Handle errors around manual operations: + +```ts +try { + await handle.save(); + console.log('Saved'); +} catch (error) { + console.error('Could not save', error); +} + +await handle.clear(); +await handle.rehydrate(); +``` + +Each failing operation still calls `onError` once and updates status before rejecting with the original error. `PersistOperation` is `'read' | 'write' | 'remove' | 'parse'`; decoding, transforms, migrations, and merge failures use `'parse'`. A failed operation does not block later queued saves. `rehydrate` also waits for expired-data removal when `clearOnExpire` is enabled and rejects if that removal fails. + +An obsolete read superseded by a local edit or newer request is still ignored. Calls on a disposed handle reject; explicit queued writes cancelled by disposal also reject without invoking the adapter. Disposal errors do not invoke `onError` or publish more status. + +Handle rejected promises in DOM event handlers, for example `onClick={() => { void handle.save().catch(showError); }}`. Use `try`/`catch` inside an async handler when the UI also needs to show success. + ### `clear()` (Clear stored data) ```ts @@ -530,12 +578,13 @@ When you need to save immediately (e.g., before the user navigates away): ```ts const handle = persist(formStore, { name: 'form-draft', + storage: localStorage, debounce: 1000, }); // Force save before page unload window.addEventListener('beforeunload', () => { - handle.save(); + void handle.save().catch((error) => console.error('Could not save', error)); }); ``` @@ -544,13 +593,13 @@ state immediately. With async storage it waits behind earlier writes. Await the returned promise when completion matters; an async write is not guaranteed to finish during page unload. -### `unsubscribe()` (Stop persisting) +### `dispose()` (Stop persisting) ```ts -const handle = persist(todoStore, {name: 'todo-store'}); +const handle = persist(todoStore, {name: 'todo-store', storage: localStorage}); // Later: stop all persistence (e.g., on logout) -handle.unsubscribe(); +handle.dispose(); // Mutations after this point are NOT written to storage. // Cross-tab sync listener is also removed. @@ -558,7 +607,9 @@ handle.unsubscribe(); todoStore.addTodo('This is not persisted'); ``` -Pending storage reads that finish after unsubscribe no longer apply state, clear expired entries, or invoke read-error callbacks. Hydration promises still settle when the read finishes. This prevents a retired React effect from overwriting a newer scope during Strict Mode replay, resets, or storage configuration changes. Already-issued adapter I/O is not cancelled; cleanup cannot undo a write already sent to storage. See the [typed context lifecycle example](./REACT_SCOPING.md). +Pending storage reads that finish after disposal no longer apply state, clear expired entries, or invoke read-error callbacks. Hydration promises still settle when the read finishes. This prevents a retired React effect from overwriting a newer scope during Strict Mode replay, resets, or storage configuration changes. Already-issued adapter I/O is not cancelled; cleanup cannot undo a write already sent to storage. See the [typed context lifecycle example](./REACT_SCOPING.md). + +`dispose()` is safe to call repeatedly. Disposal publishes `isDisposed: true` and clears status listeners; no later callbacks run. A pending hydration may still settle afterward and update the readable `isHydrated` flag without notifying disposed listeners. The public `isHydrated` and `isExpired` properties are readonly, matching their runtime getters. --- @@ -654,5 +705,5 @@ console.log(`Welcome back! Theme: ${appStore.theme}`); | **Clear stored data** | `await handle.clear()` | | **Force immediate save** | `await handle.save()` | | **Re-hydrate manually** | `await handle.rehydrate()` | -| **Stop persisting** | `handle.unsubscribe()` | +| **Stop persisting** | `handle.dispose()` | | **Check if data expired** | `handle.isExpired` | diff --git a/website/docs/REACT_NEXT.md b/website/docs/REACT_NEXT.md index 5781f87..e6cc6dc 100644 --- a/website/docs/REACT_NEXT.md +++ b/website/docs/REACT_NEXT.md @@ -16,7 +16,7 @@ React defines the supported [serializable prop types](https://react.dev/referenc | `@codebelt/classy-store` | Core store creation and snapshots in server or client modules. | | `@codebelt/classy-store/collections` | Reactive collections in server or client modules. | | `@codebelt/classy-store/utils` | Server-safe imports; configure browser storage and effects inside Client Components. | -| `@codebelt/classy-store/react` | Client hooks and the compatible `prepareStoreForHydration` export. The published ESM and CommonJS entries carry `use client`. | +| `@codebelt/classy-store/react` | Client hooks. The published ESM and CommonJS entries carry `use client`. | | `@codebelt/classy-store/react/server` | `prepareStoreForHydration` without importing React hooks; safe in Server Components and ordinary SSR code. | Components that use hooks still need their own client boundary. Do not call @@ -54,11 +54,8 @@ Construct and prepare it inside the Client Component: // components/profileEditor/ProfileEditor.tsx 'use client'; -import { - prepareStoreForHydration, - useClassyStore, - useLocalStore, -} from '@codebelt/classy-store/react'; +import {useClassyStore, useLocalStore} from '@codebelt/classy-store/react'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; import {type ProfileData, ProfileStore} from '../../stores/profile/profile.store'; export function ProfileEditor({initialData}: {initialData: ProfileData}) { @@ -103,7 +100,7 @@ module-level server stores; construct request state inside the request instead. ## Streaming, persistence, and navigation Attach persistence in an effect after preparation, call `rehydrate()` there, -and return `unsubscribe()` from cleanup. The [SSR guide](./REACT_SSR.md) shows +and call `handle.dispose()` during cleanup. The [SSR guide](./REACT_SSR.md) shows this pattern. If persistence restores before a streamed boundary hydrates, the boundary first reads the prepared server data, then catches up to live state. Keep the initial server and browser payloads identical. diff --git a/website/docs/REACT_SCOPING.md b/website/docs/REACT_SCOPING.md index d690c35..5e099c8 100644 --- a/website/docs/REACT_SCOPING.md +++ b/website/docs/REACT_SCOPING.md @@ -56,7 +56,8 @@ The example provider initializes once per mount and captures the initial hydrati ```tsx // components/editorStoreProvider/EditorStoreProvider.tsx -import {prepareStoreForHydration, useLocalStore} from '@codebelt/classy-store/react'; +import {useLocalStore} from '@codebelt/classy-store/react'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; import {persist, type StorageAdapter} from '@codebelt/classy-store/utils'; import {type ReactNode, useEffect} from 'react'; import {type EditorData, EditorStore} from '../../editorStore'; @@ -83,8 +84,8 @@ export function EditorStoreProvider({initialData, children, persistence}: Props) properties: ['title', 'body'], skipHydration: true, }); - void handle.rehydrate(); - return () => handle.unsubscribe(); + void handle.rehydrate().catch((error) => console.error('Could not restore persisted state', error)); + return () => handle.dispose(); }, [store, name, storage]); return ( @@ -139,7 +140,7 @@ Change `documentId` when moving to a different document; increment `revision` fo A reset does **not** clear saved data. If the new provider uses the same persistence key, it restores that saved draft. To reset saved data as well, first unmount or disable the old persistence owner, await `storage.removeItem(name)`, then remount. Use distinct storage keys for independent documents or users. Different React providers using the same storage key still share that storage namespace. -Cleanup prevents pending storage reads from applying after unsubscribe, including the discarded first effect in Strict Mode. Such reads still finish and their promises settle; the adapter's I/O is not cancelled. Already-issued writes cannot be undone by cleanup. External code retaining a store can still mutate it after unmount, but its disposed persistence owner and unmounted consumers no longer react. `useLocalStore` does not automatically call application-specific `dispose()` methods. +Cleanup prevents pending storage reads from applying after disposal, including the discarded first effect in Strict Mode. Such reads still finish and their promises settle; the adapter's I/O is not cancelled. Already-issued writes cannot be undone by cleanup. External code retaining a store can still mutate it after unmount, but its disposed persistence owner and unmounted consumers no longer react. `useLocalStore` does not automatically call application-specific `dispose()` methods. ## Server rendering and hydration diff --git a/website/docs/REACT_SSR.md b/website/docs/REACT_SSR.md index 711a1a2..42c407c 100644 --- a/website/docs/REACT_SSR.md +++ b/website/docs/REACT_SSR.md @@ -4,7 +4,7 @@ Create one store for each server request and reconstruct a client store from the The [typed context example](./REACT_SCOPING.md) provides a complete provider pattern with independent instances, nested scopes, keyed resets, and persistence cleanup. -For React Server Components, use the server-safe `@codebelt/classy-store/react/server` preparation entry. The `@codebelt/classy-store/react` hooks entry is marked `use client`; its preparation export remains available inside client modules and ordinary SSR. The [Next.js App Router guide](./REACT_NEXT.md) explains where to construct stores and how to cross that boundary with initial data. +Import hydration preparation from `@codebelt/classy-store/react/server` in server code and client modules. This entry does not import React hooks. The separate `@codebelt/classy-store/react` hooks entry is marked `use client`. The [Next.js App Router guide](./REACT_NEXT.md) explains where to construct stores and how to cross that boundary with initial data. Call `prepareStoreForHydration(store)` after initializing the store with the data used to render the page, on both the server and client. It captures that initial snapshot before persistence or other live updates begin. All direct and bound hooks use the captured data during server rendering and client hydration, then read the current live store. React requires the initial server and client data to match; see [React's external-store SSR guidance](https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering). @@ -64,12 +64,13 @@ For a store owned by a component, `prepareStoreForHydration(useLocalStore(() => ## Restore persistence while other boundaries hydrate -Browser storage may contain values different from the server-rendered payload. Prepare the store before starting persistence, attach persistence in an effect, and unsubscribe when the component unmounts. The effect can restore storage even while a Suspense boundary is still waiting to hydrate: +Browser storage may contain values different from the server-rendered payload. Prepare the store before starting persistence, attach persistence in an effect, and dispose it when the component unmounts. The effect can restore storage even while a Suspense boundary is still waiting to hydrate: ```tsx // PersistedProfile.tsx import {useEffect} from 'react'; -import {prepareStoreForHydration, useLocalStore} from '@codebelt/classy-store/react'; +import {useLocalStore} from '@codebelt/classy-store/react'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; import {persist} from '@codebelt/classy-store/utils'; import {Profile} from './Profile'; import {type ProfileData, ProfileStore} from './profileStore'; @@ -85,8 +86,8 @@ export function PersistedProfile({initialData}: {initialData: ProfileData}) { storage: localStorage, skipHydration: true, }); - void persistence.rehydrate(); - return () => persistence.unsubscribe(); + void persistence.rehydrate().catch((error) => console.error('Could not restore persisted state', error)); + return () => persistence.dispose(); }, [store]); return ; diff --git a/website/docs/TUTORIAL.md b/website/docs/TUTORIAL.md index bfeb38e..fe8bd05 100644 --- a/website/docs/TUTORIAL.md +++ b/website/docs/TUTORIAL.md @@ -426,7 +426,7 @@ The factory initializes component state. Subsequent re-renders reuse the same st ### Persisting a local store -`persist()` subscribes to the store, which keeps a reference alive. You must call `handle.unsubscribe()` on unmount to allow garbage collection. +`persist()` subscribes to the store, which keeps a reference alive. You must call `handle.dispose()` on unmount to allow garbage collection. ```tsx import {useEffect} from 'react'; @@ -447,7 +447,7 @@ function EditProfile() { useEffect(() => { const handle = persist(store, { name: 'edit-profile-draft' }); - return () => handle.unsubscribe(); + return () => handle.dispose(); }, [store]); return ( diff --git a/website/docs/UPGRADING_0_8.md b/website/docs/UPGRADING_0_8.md new file mode 100644 index 0000000..29ac6f6 --- /dev/null +++ b/website/docs/UPGRADING_0_8.md @@ -0,0 +1,75 @@ +# Upgrading to 0.8.0 + +Version 0.8.0 adds observable history and persistence status and simplifies the public API. This pre-1.0 release includes breaking changes for consumers of 0.7.x. + +## Required updates + +| Previous usage | Use in 0.8.0 | +| --- | --- | +| `persistence.unsubscribe()` | `persistence.dispose()` | +| Import `prepareStoreForHydration` from `/react` | Import it from `@codebelt/classy-store/react/server`, on both server and client | +| Import `getVersion` from the root entry | Use `subscribe()` for notifications or compare `snapshot()` references | +| Assume explicit persistence calls always resolve | Handle rejected promises from `save()`, `clear()`, and `rehydrate()` | +| Transform a property's value as a union of all store properties | Match `serialize` and `deserialize` to the property selected by `key` | + +## Dispose persistence handles + +Update effect cleanups and other teardown calls: + +```ts +// Before +return () => persistence.unsubscribe(); + +// After +return () => persistence.dispose(); +``` + +Disposal is idempotent. It stops subscriptions and timers, ignores pending read results, and cancels writes that have not started. Already-issued storage I/O cannot be cancelled. Explicit queued operations cancelled by disposal reject, as do explicit operations called after disposal. + +## Handle explicit persistence failures + +`save()`, `clear()`, and `rehydrate()` take no options. They report storage failures through `onError` and `lastError`, and reject their promises. Handle the promise even when an `onError` callback is configured: + +```ts +try { + await persistence.save(); + showSavedMessage(); +} catch (error) { + showSaveError(error); +} +``` + +Automatic writes and initial hydration handle reported failures internally. `hydrated` resolves when the initial attempt settles; `isHydrated` also means an attempt settled, including failure or a superseded read. Check `persistence.getSnapshot().lastError` when the result matters. A failed operation does not prevent later queued writes from running. + +## Subscribe to utility status in React + +Use `useClassyStore(store)` for store data. Use the new hooks when rendering utility metadata that can change independently of that data: + +```tsx +import {useHistoryStatus, usePersistStatus} from '@codebelt/classy-store/react'; + +const {canUndo, canRedo, isPaused} = useHistoryStatus(history); +const {isHydrated, isExpired, lastError} = usePersistStatus(persistence); +``` + +Call these hooks inside a component or custom hook, with stable handles. Both status snapshots include `isDisposed`. History pause state now stays consistent when undo/redo finishes. + +Outside React, handles expose `getSnapshot()`, `getServerSnapshot()`, and `subscribe(listener)`. The function returned by `subscribe` removes that status listener; `dispose()` tears down the utility handle itself. The shared status source implementation is internal. Consumer types are `HistoryHandle`, `HistoryStatus`, `PersistHandle`, and `PersistStatus`, exported from `@codebelt/classy-store/utils`. + +During SSR and hydration, status hooks use the handle's initial status snapshot. Construct server and client handles with matching initial status. + +## Use the hydration entry on server and client + +```ts +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; +``` + +The `/react` entry exports React hooks and `createStoreHook`. Hydration preparation is exported only from `/react/server`; its captured-snapshot behavior is unchanged. + +## Match persistence transforms to their property + +Transform serializers receive the selected property's readonly snapshot value. Deserializers receive `unknown` and must return that property's mutable value. Validate stored values before returning them. Transforms that mutate their snapshot input or return another property's type now fail TypeScript checks. + +Persistence status flags are readonly, matching their runtime getters. `PersistOperation` is exported from the utilities entry for typed error handlers. + +If upgrading from 0.6.x or earlier, also follow the [0.7.0 upgrade guide](./UPGRADING_0_7.md) for React timing options and the native `WeakRef` requirement. diff --git a/website/docs/index.md b/website/docs/index.md index f3d13db..0067fb2 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -25,7 +25,7 @@ Class-based reactive state management for React, Vue, Svelte, Solid, and Angular ## Installation -Upgrading from 0.6.x? Read the [0.7.0 upgrade guide and release notes](./UPGRADING_0_7.md) for the React API changes and runtime requirements. +Upgrading from 0.7.x? Read the [0.8.0 upgrade guide](./UPGRADING_0_8.md) for public API changes and persistence error handling. From 0.6.x or earlier, also follow the [0.7.0 upgrade guide](./UPGRADING_0_7.md). ```bash bun add @codebelt/classy-store @@ -184,13 +184,52 @@ React subscriptions always notify synchronously, including selector, auto-tracke Migration: remove `{sync: true}` or `{sync: false}` from React hook calls and remove imports of `UseClassyStoreOptions`. Selector options still support `isEqual`. Synchronous notification does not make a method transactional: selectors can observe each write, while React batches rendering within event handlers. +### `useHistoryStatus(history)` + +Use `useClassyStore(store)` for application data. Use `useHistoryStatus(history)` when a component renders undo/redo availability, recording state, or history disposal: + +```tsx +import {useHistoryStatus} from '@codebelt/classy-store/react'; +import type {HistoryHandle} from '@codebelt/classy-store/utils'; + +function UndoButton({history}: {history: HistoryHandle}) { + const {canUndo} = useHistoryStatus(history); + return ; +} +``` + +The hook accepts the `HistoryHandle` returned by `withHistory(store)` and returns `HistoryStatus`: `canUndo`, `canRedo`, `isPaused`, and `isDisposed`. It updates even when store data stays unchanged, such as when recording is paused. Creating or using history does not require the hook. + +### `usePersistStatus(handle)` + +Use `usePersistStatus(handle)` when a component renders persistence hydration, expiry, disposal, or errors: + +```tsx +import {usePersistStatus} from '@codebelt/classy-store/react'; +import type {PersistHandle} from '@codebelt/classy-store/utils'; + +function PersistenceBadge({handle}: {handle: PersistHandle}) { + const {isHydrated, lastError} = usePersistStatus(handle); + if (lastError) return Persistence failed: {lastError.operation}; + return {isHydrated ? 'Ready' : 'Restoring…'}; +} +``` + +The hook accepts the `PersistHandle` returned by `persist(store, options)` and returns `PersistStatus`: `isHydrated`, `isExpired`, `isDisposed`, and `lastError` (`{error, operation}` or `null`). `isHydrated` means a hydration attempt settled; inspect `lastError` for a reported failure. Configuring persistence and calling its methods do not require the hook. + +Both hooks return readonly status snapshots whose identity stays stable until a status field changes. They share an internal subscription implementation. Status listeners run synchronously when status changes; ordinary store subscriptions, including history recording, remain batched. Unmounting a status consumer removes its listener without disposing the handle. + +Outside React, both handles provide framework-neutral status methods: `getSnapshot()`, `getServerSnapshot()`, and `subscribe(listener)`, which returns listener cleanup. + +For SSR, handles expose their initial status through `getServerSnapshot()` and React catches up with live status after hydration. Construct request-scoped handles and matching client handles with the same initial status; keep persistence side effects in the provider lifecycle described in [React store scoping](./REACT_SCOPING.md). + ### `prepareStoreForHydration(store)` Captures the initial snapshot for server rendering and client hydration, returning the same mutable store proxy: ```ts import {createClassyStore} from '@codebelt/classy-store'; -import {prepareStoreForHydration} from '@codebelt/classy-store/react'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; const store = prepareStoreForHydration( createClassyStore(new ProfileStore(initialData)), @@ -201,7 +240,7 @@ Call on each request's store and its client counterpart using matching initial d Preparation is idempotent, retains the first snapshot for the store's lifetime, and applies to the exact proxy passed in. Client-only apps need no preparation. See [React SSR and hydration](./REACT_SSR.md) for streaming, request isolation, nested proxies, and snapshot leaf semantics. -The same preparation helper is available from `@codebelt/classy-store/react/server` without importing hooks. Use that entry in React Server Components; the `/react` entry is marked `use client`. See [Next.js App Router](./REACT_NEXT.md) for production package boundaries and navigation patterns. +Import the preparation helper from `@codebelt/classy-store/react/server` on both the server and client. It does not import React hooks; the separate `/react` hooks entry is marked `use client`. See [Next.js App Router](./REACT_NEXT.md) for production package boundaries and navigation patterns. ### `useLocalStore(factory)` @@ -313,20 +352,6 @@ const unsubSync = subscribe( // Later: unsub(); ``` -### `getVersion(store)` - -Returns the current version number of a store proxy. Versions are monotonically increasing and bump on any mutation in the store's subtree (child mutations propagate up to the root). Useful for debugging, testing whether a store has changed, or custom cache invalidation. - -```typescript -import {getVersion} from '@codebelt/classy-store'; - -const v1 = getVersion(myStore); -myStore.count++; -// After microtask: -const v2 = getVersion(myStore); -// v2 > v1 -``` - ### `shallowEqual(a, b)` Shallow equality comparison for objects and arrays. Useful as a custom `isEqual` for selectors that return derived objects. diff --git a/website/sidebars.ts b/website/sidebars.ts index aa60210..bb5d635 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -3,6 +3,7 @@ import type {SidebarsConfig} from '@docusaurus/plugin-content-docs'; const sidebars: SidebarsConfig = { tutorialSidebar: [ 'index', + 'UPGRADING_0_8', 'UPGRADING_0_7', { type: 'category', diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt index f747e69..2e0531e 100644 --- a/website/static/llms-full.txt +++ b/website/static/llms-full.txt @@ -4,6 +4,86 @@ This file contains the full documentation for `@codebelt/classy-store` concatena single file for LLM context windows. Generated from the source docs at: https://codebelt.github.io/classy-store/docs/ +--- +SOURCE: https://codebelt.github.io/classy-store/docs/UPGRADING_0_8 +--- + +# Upgrading to 0.8.0 + +Version 0.8.0 adds observable history and persistence status and simplifies the public API. This pre-1.0 release includes breaking changes for consumers of 0.7.x. + +## Required updates + +| Previous usage | Use in 0.8.0 | +| --- | --- | +| `persistence.unsubscribe()` | `persistence.dispose()` | +| Import `prepareStoreForHydration` from `/react` | Import it from `@codebelt/classy-store/react/server`, on both server and client | +| Import `getVersion` from the root entry | Use `subscribe()` for notifications or compare `snapshot()` references | +| Assume explicit persistence calls always resolve | Handle rejected promises from `save()`, `clear()`, and `rehydrate()` | +| Transform a property's value as a union of all store properties | Match `serialize` and `deserialize` to the property selected by `key` | + +## Dispose persistence handles + +Update effect cleanups and other teardown calls: + +```ts +// Before +return () => persistence.unsubscribe(); + +// After +return () => persistence.dispose(); +``` + +Disposal is idempotent. It stops subscriptions and timers, ignores pending read results, and cancels writes that have not started. Already-issued storage I/O cannot be cancelled. Explicit queued operations cancelled by disposal reject, as do explicit operations called after disposal. + +## Handle explicit persistence failures + +`save()`, `clear()`, and `rehydrate()` take no options. They report storage failures through `onError` and `lastError`, and reject their promises. Handle the promise even when an `onError` callback is configured: + +```ts +try { + await persistence.save(); + showSavedMessage(); +} catch (error) { + showSaveError(error); +} +``` + +Automatic writes and initial hydration handle reported failures internally. `hydrated` resolves when the initial attempt settles; `isHydrated` also means an attempt settled, including failure or a superseded read. Check `persistence.getSnapshot().lastError` when the result matters. A failed operation does not prevent later queued writes from running. + +## Subscribe to utility status in React + +Use `useClassyStore(store)` for store data. Use the new hooks when rendering utility metadata that can change independently of that data: + +```tsx +import {useHistoryStatus, usePersistStatus} from '@codebelt/classy-store/react'; + +const {canUndo, canRedo, isPaused} = useHistoryStatus(history); +const {isHydrated, isExpired, lastError} = usePersistStatus(persistence); +``` + +Call these hooks inside a component or custom hook, with stable handles. Both status snapshots include `isDisposed`. History pause state now stays consistent when undo/redo finishes. + +Outside React, handles expose `getSnapshot()`, `getServerSnapshot()`, and `subscribe(listener)`. The function returned by `subscribe` removes that status listener; `dispose()` tears down the utility handle itself. The shared status source implementation is internal. Consumer types are `HistoryHandle`, `HistoryStatus`, `PersistHandle`, and `PersistStatus`, exported from `@codebelt/classy-store/utils`. + +During SSR and hydration, status hooks use the handle's initial status snapshot. Construct server and client handles with matching initial status. + +## Use the hydration entry on server and client + +```ts +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; +``` + +The `/react` entry exports React hooks and `createStoreHook`. Hydration preparation is exported only from `/react/server`; its captured-snapshot behavior is unchanged. + +## Match persistence transforms to their property + +Transform serializers receive the selected property's readonly snapshot value. Deserializers receive `unknown` and must return that property's mutable value. Validate stored values before returning them. Transforms that mutate their snapshot input or return another property's type now fail TypeScript checks. + +Persistence status flags are readonly, matching their runtime getters. `PersistOperation` is exported from the utilities entry for typed error handlers. + +If upgrading from 0.6.x or earlier, also follow the [0.7.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7) for React timing options and the native `WeakRef` requirement. + --- SOURCE: https://codebelt.github.io/classy-store/docs/UPGRADING_0_7 --- @@ -302,13 +382,31 @@ React subscriptions always notify synchronously, including selector, auto-tracke Migration: remove `{sync: true}` or `{sync: false}` from React hook calls and remove imports of `UseClassyStoreOptions`. Selector options still support `isEqual`. Synchronous notification does not make a method transactional: selectors can observe each write, while React batches rendering within event handlers. +### `useHistoryStatus(history)` + +Import from `@codebelt/classy-store/react` to observe a `HistoryHandle` returned by `withHistory(store)`. `const {canUndo, canRedo, isPaused} = useHistoryStatus(history)` updates undo controls and recording status even when store data is unchanged. The returned readonly `HistoryStatus` also contains `isDisposed`. + +### `usePersistStatus(handle)` + +Import from `@codebelt/classy-store/react` to observe a `PersistHandle` returned by `persist(store, options)`. `const {isHydrated, isExpired, lastError} = usePersistStatus(handle)` updates hydration, expiry, and error UI without requiring a store data mutation. The readonly `PersistStatus` also contains `isDisposed`. `lastError` is `{error, operation}` or `null`. + +Most components only need `useClassyStore(store)` for application data. Use the two status hooks only when rendering their matching feature's status; configuring history or persistence does not require them. The shared subscription implementation is internal. + +Both handles provide framework-neutral status methods: `getSnapshot()` returns cached readonly status; `getServerSnapshot()` returns initial status for SSR/hydration; `subscribe(listener)` returns listener cleanup. Status notifications are synchronous, while normal store subscriptions and history recording remain batched. Server and client handles must start with matching initial status. Disposal publishes its final status and clears listeners. Unsubscribing a status listener does not dispose the utility. + +Persistence `save`, `clear`, and `rehydrate` reject on failure after updating status and calling `onError`. Automatic writes and initial hydration catch reported errors without producing unhandled rejections. Failed operations do not block later queued writes. Decoding, transform, migration, and merge failures use the `parse` operation. `lastError` clears after the next successful operation. `rehydrate` waits for expired-data removal when enabled. Superseded reads are still ignored. Disposed calls and explicit queued writes cancelled by disposal reject; disposal errors do not publish status or call `onError`. + +`dispose()` stops persistence and releases its subscriptions and timers; it is safe to call repeatedly. `isHydrated` and `isExpired` are readonly. `hydrated` and `isHydrated` indicate that a hydration attempt settled, not that data was applied successfully. Pending hydration may settle after disposal and update readable flags without notifying disposed listeners. Exported types include `PersistOperation`, `PersistStatus`, and `HistoryStatus`. + +Persistence transforms now infer `serialize` and `deserialize` types from `key`: serializers receive `Snapshot`, and deserializers return `T[K]`. Stored input remains `unknown`. Optional properties preserve `undefined`. Standalone transforms can use `PropertyTransform`. Existing transforms that mutate snapshots or deserialize to another property's type must be corrected. + ### `prepareStoreForHydration(store)` Captures the initial snapshot for server rendering and client hydration, returning the same mutable store proxy: ```ts import {createClassyStore} from '@codebelt/classy-store'; -import {prepareStoreForHydration} from '@codebelt/classy-store/react'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; const store = prepareStoreForHydration( createClassyStore(new ProfileStore(initialData)), @@ -319,7 +417,7 @@ Call on each request's store and its client counterpart using matching initial d Preparation is idempotent, retains the first snapshot for the store's lifetime, and applies to the exact proxy passed in. Client-only apps need no preparation. See [React SSR and hydration](./REACT_SSR.md) for streaming, request isolation, nested proxies, and snapshot leaf semantics. -The same preparation helper is available from `@codebelt/classy-store/react/server` without importing hooks. Use that entry in React Server Components; the `/react` entry is marked `use client`. See [Next.js App Router](./REACT_NEXT.md) for production package boundaries and navigation patterns. +Import the preparation helper from `@codebelt/classy-store/react/server` on both the server and client. It does not import React hooks; the separate `/react` hooks entry is marked `use client`. See [Next.js App Router](./REACT_NEXT.md) for production package boundaries and navigation patterns. ### `useLocalStore(factory)` @@ -431,20 +529,6 @@ const unsubSync = subscribe( // Later: unsub(); ``` -### `getVersion(store)` - -Returns the current version number of a store proxy. Versions are monotonically increasing and bump on any mutation in the store's subtree (child mutations propagate up to the root). Useful for debugging, testing whether a store has changed, or custom cache invalidation. - -```typescript -import {getVersion} from '@codebelt/classy-store'; - -const v1 = getVersion(myStore); -myStore.count++; -// After microtask: -const v2 = getVersion(myStore); -// v2 > v1 -``` - ### `shallowEqual(a, b)` Shallow equality comparison for objects and arrays. Useful as a custom `isEqual` for selectors that return derived objects. @@ -1237,7 +1321,7 @@ The factory initializes component state. Subsequent re-renders reuse the same st ### Persisting a local store -`persist()` subscribes to the store, which keeps a reference alive. You must call `handle.unsubscribe()` on unmount to allow garbage collection. +`persist()` subscribes to the store, which keeps a reference alive. You must call `handle.dispose()` on unmount to allow garbage collection. ```tsx import {useEffect} from 'react'; @@ -1258,7 +1342,7 @@ function EditProfile() { useEffect(() => { const handle = persist(store, { name: 'edit-profile-draft' }); - return () => handle.unsubscribe(); + return () => handle.dispose(); }, [store]); return ( @@ -1430,7 +1514,7 @@ Create one store for each server request and reconstruct a client store from the The [typed context example](./REACT_SCOPING.md) provides a complete provider pattern with independent instances, nested scopes, keyed resets, and persistence cleanup. -For React Server Components, use the server-safe `@codebelt/classy-store/react/server` preparation entry. The `@codebelt/classy-store/react` hooks entry is marked `use client`; its preparation export remains available inside client modules and ordinary SSR. The [Next.js App Router guide](./REACT_NEXT.md) explains where to construct stores and how to cross that boundary with initial data. +Import hydration preparation from `@codebelt/classy-store/react/server` in server code and client modules. This entry does not import React hooks. The separate `@codebelt/classy-store/react` hooks entry is marked `use client`. The [Next.js App Router guide](./REACT_NEXT.md) explains where to construct stores and how to cross that boundary with initial data. Call `prepareStoreForHydration(store)` after initializing the store with the data used to render the page, on both the server and client. It captures that initial snapshot before persistence or other live updates begin. All direct and bound hooks use the captured data during server rendering and client hydration, then read the current live store. React requires the initial server and client data to match; see [React's external-store SSR guidance](https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering). @@ -1490,12 +1574,13 @@ For a store owned by a component, `prepareStoreForHydration(useLocalStore(() => ## Restore persistence while other boundaries hydrate -Browser storage may contain values different from the server-rendered payload. Prepare the store before starting persistence, attach persistence in an effect, and unsubscribe when the component unmounts. The effect can restore storage even while a Suspense boundary is still waiting to hydrate: +Browser storage may contain values different from the server-rendered payload. Prepare the store before starting persistence, attach persistence in an effect, and dispose it when the component unmounts. The effect can restore storage even while a Suspense boundary is still waiting to hydrate: ```tsx // PersistedProfile.tsx import {useEffect} from 'react'; -import {prepareStoreForHydration, useLocalStore} from '@codebelt/classy-store/react'; +import {useLocalStore} from '@codebelt/classy-store/react'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; import {persist} from '@codebelt/classy-store/utils'; import {Profile} from './Profile'; import {type ProfileData, ProfileStore} from './profileStore'; @@ -1511,8 +1596,8 @@ export function PersistedProfile({initialData}: {initialData: ProfileData}) { storage: localStorage, skipHydration: true, }); - void persistence.rehydrate(); - return () => persistence.unsubscribe(); + void persistence.rehydrate().catch((error) => console.error('Could not restore persisted state', error)); + return () => persistence.dispose(); }, [store]); return ; @@ -1595,7 +1680,8 @@ The example provider initializes once per mount and captures the initial hydrati ```tsx // components/editorStoreProvider/EditorStoreProvider.tsx -import {prepareStoreForHydration, useLocalStore} from '@codebelt/classy-store/react'; +import {useLocalStore} from '@codebelt/classy-store/react'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; import {persist, type StorageAdapter} from '@codebelt/classy-store/utils'; import {type ReactNode, useEffect} from 'react'; import {type EditorData, EditorStore} from '../../editorStore'; @@ -1622,8 +1708,8 @@ export function EditorStoreProvider({initialData, children, persistence}: Props) properties: ['title', 'body'], skipHydration: true, }); - void handle.rehydrate(); - return () => handle.unsubscribe(); + void handle.rehydrate().catch((error) => console.error('Could not restore persisted state', error)); + return () => handle.dispose(); }, [store, name, storage]); return ( @@ -1678,7 +1764,7 @@ Change `documentId` when moving to a different document; increment `revision` fo A reset does **not** clear saved data. If the new provider uses the same persistence key, it restores that saved draft. To reset saved data as well, first unmount or disable the old persistence owner, await `storage.removeItem(name)`, then remount. Use distinct storage keys for independent documents or users. Different React providers using the same storage key still share that storage namespace. -Cleanup prevents pending storage reads from applying after unsubscribe, including the discarded first effect in Strict Mode. Such reads still finish and their promises settle; the adapter's I/O is not cancelled. Already-issued writes cannot be undone by cleanup. External code retaining a store can still mutate it after unmount, but its disposed persistence owner and unmounted consumers no longer react. `useLocalStore` does not automatically call application-specific `dispose()` methods. +Cleanup prevents pending storage reads from applying after disposal, including the discarded first effect in Strict Mode. Such reads still finish and their promises settle; the adapter's I/O is not cancelled. Already-issued writes cannot be undone by cleanup. External code retaining a store can still mutate it after unmount, but its disposed persistence owner and unmounted consumers no longer react. `useLocalStore` does not automatically call application-specific `dispose()` methods. ## Server rendering and hydration @@ -1742,11 +1828,11 @@ That's it. On every mutation, `todos` and `filter` are saved to `localStorage`. ```ts // Async -- await the promise await handle.hydrated; -console.log('Restored:', todoStore.todos); +console.log('Hydration status:', handle.getSnapshot()); // Sync check if (handle.isHydrated) { - // safe to read persisted state + // The attempt settled; inspect lastError for a reported failure. } ``` @@ -1825,7 +1911,6 @@ persist(todoStore, { ## Merge Strategies - **`'shallow'` (default):** Persisted values overwrite store values one key at a time. Properties not in storage keep their class defaults. -- **`'replace'`:** Same as shallow for flat stores. - **Custom merge function:** `merge: (persisted, current) => { ... }` ## Cross-Tab Synchronization @@ -1872,7 +1957,7 @@ Then hydrate manually in a `useEffect`: ```tsx function App() { useEffect(() => { - handle.rehydrate(); + void handle.rehydrate().catch((error) => console.error('Could not restore persisted state', error)); }, []); return ; @@ -1899,14 +1984,14 @@ await handle.hydrated; // Clear stored data await handle.clear(); -// Manual save (bypass debounce) -handle.save(); +// Manual save (bypass debounce; rejects on failure) +await handle.save(); // Re-read from storage await handle.rehydrate(); -// Stop persisting; later handle operations become no-ops -handle.unsubscribe(); +// Stop persisting; later save/clear/rehydrate calls reject +handle.dispose(); ``` ## Overlapping async operations @@ -1944,7 +2029,7 @@ coordination. Async saves are not guaranteed to finish during page unload. | Check if data expired | `handle.isExpired` | | Force immediate save | `handle.save()` | | Clear stored data | `handle.clear()` | -| Stop persisting | `handle.unsubscribe()` | +| Stop persisting | `handle.dispose()` | | Wait for hydration | `await handle.hydrated` | | Check hydration | `handle.isHydrated` | @@ -2225,12 +2310,12 @@ history.canRedo; // true if there's a next state ```tsx function UndoRedoButtons() { - const snap = useClassyStore(drawingStore); + const {canUndo, canRedo} = useHistoryStatus(history); return (
- - + +
); } @@ -2277,17 +2362,15 @@ document.addEventListener('keydown', (e) => { }); ``` -### Batch operations with pause/resume +### Batched edits and pause/resume + +Synchronous edits in one turn already share a history entry. `pause()` skips notifications delivered while paused; `resume()` enables recording without creating an entry or grouping asynchronous edits into a transaction. ```ts function pasteBlock(data: Record) { - history.pause(); - for (const [id, value] of Object.entries(data)) { spreadsheetStore.setCell(id, value); } - - history.resume(); } ``` @@ -2359,7 +2442,7 @@ flowchart TB GetterEval["Getters: memoized per-snapshot + cross-snapshot caching"] end - subgraph layer3 ["Layer 3: React Hook (react.ts)"] + subgraph layer3 ["Layer 3: React Hook (useClassyStore.ts)"] UseStore["useClassyStore(store, selector?)"] UseLocalStore["useLocalStore(factory)"] USES["useSyncExternalStore"] @@ -2394,56 +2477,74 @@ flowchart TB ## File Map -``` -src/ -├── index.ts # Barrel export: createClassyStore, snapshot, subscribe, getVersion, shallowEqual, Snapshot -├── collections/ -│ ├── collections.ts # ReactiveMap and ReactiveSet implementations -│ ├── index.ts # Collections barrel: reactiveMap, reactiveSet, ReactiveMap, ReactiveSet -│ └── collections.test.ts # tests: ReactiveMap, ReactiveSet, class store integration -├── core/ -│ ├── core.ts # Layer 1: Write Proxy — createClassyStore(), subscribe(), getVersion() -│ ├── core.test.ts # tests: mutations, batching, methods, getters, arrays -│ └── computed.test.tsx # tests: write proxy + snapshot memoization, useClassyStore integration -├── react/ -│ ├── react.ts # Layer 3: React Hook — useClassyStore(), useLocalStore() -│ ├── react.test.tsx # tests: selector mode, auto-tracked mode, re-render control -│ └── react.behavior.test.tsx # tests: batching, set-then-revert, async, multi-component, unmount -├── snapshot/ -│ ├── snapshot.ts # Layer 2: Immutable Snapshots — snapshot() -│ └── snapshot.test.ts # tests: freezing, caching, structural sharing, getters -├── utils/ -│ ├── index.ts # Barrel export for @codebelt/classy-store/utils: persist, devtools, subscribeKey, withHistory -│ ├── devtools/ -│ │ ├── devtools.ts # devtools() utility: Redux DevTools integration, time-travel debugging -│ │ └── devtools.test.ts # tests: connect, disconnect, state sync, time-travel -│ ├── equality/ -│ │ ├── equality.ts # shallowEqual -│ │ └── equality.test.ts # tests for shallowEqual -│ ├── history/ -│ │ ├── history.ts # withHistory() utility: undo/redo via snapshot stack, pause/resume, configurable limits -│ │ └── history.test.ts # tests: undo, redo, limits, pause/resume -│ ├── internal/ -│ │ ├── internal.ts # Internal helpers: isPlainObject, canProxy, findGetterDescriptor, PROXYABLE -│ │ └── internal.test.ts # tests for internal helpers -│ ├── persist/ -│ │ ├── persist.ts # persist() utility: storage, transforms, versioning, cross-tab sync -│ │ └── persist.test.ts # tests: round-trip, transforms, debounce, migration, merge, SSR, cross-tab -│ ├── subscribe-key/ -│ │ ├── subscribe-key.ts # subscribeKey() utility: single-property subscription with prev/current values -│ │ └── subscribe-key.test.ts # tests: single key changes, prev/current values -├── types.ts # Shared types: Snapshot, StoreInternal, DepEntry, ComputedEntry -package.json -tsconfig.json -tsdown.config.ts -bunfig.toml # Preload happy-dom for React hook tests -happydom.ts # happy-dom global registrator -README.md # Usage guide -ARCHITECTURE.md # This file -TUTORIAL.md # Step-by-step tutorial -PERSIST_TUTORIAL.md # Persist utility tutorial -PERSIST_ARCHITECTURE.md # Persist utility internals -``` +The library lives in `packages/classy-store`. Public entry points expose the API; +implementation files and types live with the feature that owns them. + +```text +packages/classy-store/ +├── src/ +│ ├── index.ts # Core public entry; no React dependency +│ ├── types.ts # Public Snapshot and SubscribeOptions +│ ├── core/ +│ │ ├── core.ts # Write proxies, subscriptions, computed getters +│ │ ├── core.types.ts # Internal dependency and store bookkeeping types +│ │ ├── core.computed.test.ts # Write-proxy getter memoization +│ │ └── core.ownership.test.ts # Child ownership and subscription cleanup +│ ├── snapshot/ +│ │ ├── snapshot.ts # Immutable snapshots and structural sharing +│ │ └── snapshot.computed.test.ts # Snapshot getter memoization +│ ├── collections/ +│ │ ├── index.ts # Public collections entry +│ │ └── collections.ts # ReactiveMap and ReactiveSet +│ ├── frameworks/ +│ │ └── react/ +│ │ ├── react.ts # Public client entry; exports hooks and factory +│ │ ├── server.ts # Server-safe prepareStoreForHydration entry +│ │ ├── hooks/ +│ │ │ ├── useClassyStore/ # Selector and auto-tracked subscriptions; hook types +│ │ │ ├── useLocalStore/ # Component-scoped store creation +│ │ │ ├── useHistoryStatus/ # Public history status hook +│ │ │ ├── usePersistStatus/ # Public persistence status hook +│ │ │ └── useUtilityStatus/ # Internal shared status subscription hook +│ │ ├── utils/ +│ │ │ ├── createStoreHook/ # Bound store hook factory +│ │ │ ├── hydrationSnapshots/ # Shared captured snapshots, held by weak keys +│ │ │ ├── snapshotTargetCache/ # Shared tracking targets +│ │ │ └── trackingReadMap/ # Render-local and committed dependency reads +│ │ ├── testHelpers/ # React fixtures +│ │ └── react.*.test.tsx # Integration tests grouped by behavior +│ └── utils/ +│ ├── index.ts # Public utility functions and types +│ ├── persist/ +│ │ ├── persist.ts # Storage queue, hydration, transforms, tab sync +│ │ ├── persist.types.ts # Public persistence options, status, and handle +│ │ └── persist.*.test.ts # API, concurrency, and error regressions +│ ├── history/ # Undo/redo and status +│ ├── devtools/ # Redux DevTools integration +│ ├── subscribe-key/ # Single-property subscriptions +│ ├── equality/ # shallowEqual +│ ├── utilityStatus/ # Internal framework-neutral status source +│ ├── cloneSnapshot/ # Internal snapshot cloning +│ └── internal/ # Proxy and property helpers +├── tests/ +│ ├── browser/ # Real input, hydration, and persistence tests +│ ├── compatibility/ # Packed-library React version matrix +│ ├── compiler/ # Compiled consumer fixtures and regressions +│ ├── scoping/ # Provider isolation, lifecycle, and SSR +│ ├── next/ # Packed Next.js boundaries and navigation +│ ├── memory/ # Heap snapshots and retention checks +│ └── performance/ # Form and list benchmarks +├── tsdown.config.ts # Published entry points and bundle settings +└── bunfig.toml # happy-dom setup for source tests +``` + +Tests for core behavior stay beside their implementation. React integration tests +exercise the public client entry, including computed getter behavior. Consumer +fixtures under `tests/` verify the packed library and framework build boundaries. +The other framework adapters remain under `src/frameworks/`. + +Runnable examples live in `examples/react` and `examples/nextjs` at the repository +root. This guide and the utility architecture guides live in `website/docs`. ## Layer 1: Write Proxy (`core.ts`) @@ -2668,7 +2769,7 @@ flowchart TD - **Selector mode:** `useClassyStore(store, (state) => state.filtered)` gets a stable reference from the memoized snapshot getter. `Object.is` correctly detects "no change" without `shallowEqual`. - **Auto-tracked mode:** `proxy-compare`'s `isChanged` gets stable references from snapshot getters, reducing false positives. -## Layer 3: React Hook (`react.ts`) +## Layer 3: React Hook (`hooks/useClassyStore/useClassyStore.ts`) ### Overview @@ -2747,18 +2848,29 @@ Snapshot creation marks only library-created snapshot nodes with `markToTrack`, ```mermaid graph LR - react["react.ts"] --> core["core.ts"] - react --> snapshot["snapshot.ts"] - react --> proxyCompare["proxy-compare"] + reactEntry["/react client entry"] --> hook["useClassyStore.ts"] + reactEntry --> localHook["useLocalStore.ts"] + reactEntry --> factory["createStoreHook.ts"] + factory --> hook + factory --> core["core.ts"] + localHook --> core + hook --> core + hook --> snapshot["snapshot.ts"] + hook --> proxyCompare["proxy-compare"] + hook --> hydration["hydrationSnapshots.ts"] + serverEntry["/react/server entry"] --> hydration + serverEntry --> snapshot + serverEntry --> core snapshot --> core - snapshot --> types["types.ts"] + snapshot --> types["types.ts: public types"] + snapshot --> coreTypes["core.types.ts: internal types"] snapshot --> internal["utils/internal/internal.ts"] core --> types + core --> coreTypes core --> internal - index["index.ts"] --> core + index["Core entry: index.ts"] --> core index --> snapshot - index --> react - index --> internal + index --> equality["equality.ts"] index --> types subgraph utilities ["Utilities (utils/)"] @@ -2766,19 +2878,31 @@ graph LR devtools["devtools.ts"] subscribeKey["subscribe-key.ts"] withHistory["history.ts"] + status["utilityStatus.ts"] end persist --> core persist --> snapshot persist --> internal + persist --> status devtools --> core devtools --> snapshot subscribeKey --> core subscribeKey --> snapshot withHistory --> core withHistory --> snapshot + withHistory --> status + reactEntry --> statusHooks["useHistoryStatus / usePersistStatus"] + statusHooks --> sharedHook["useUtilityStatus.ts"] ``` +The client entry owns the React boundary. Core, snapshots, collections, and +utilities remain framework-neutral. React status hooks share an internal hook; +the status source itself lives with the utilities and has no React dependency. +`types.ts` contains public core types, while `core.types.ts` describes private +bookkeeping. Persistence contracts live in `persist.types.ts` and are exported +through the public utilities entry. + ## Key Design Decisions ### Why ES6 Proxy instead of `Object.defineProperty`? @@ -2853,7 +2977,7 @@ Internal design documentation for the `persist()` utility in `@codebelt/classy-s ## Design Principles -1. **Standalone utility, not middleware.** `persist` consumes the existing `subscribe`, `snapshot`, and `getVersion` APIs. +1. **Standalone utility, not middleware.** `persist` uses `subscribe` and `snapshot`, plus an internal version helper for stale-read protection. 2. **Tree-shakeable.** Lives in `@codebelt/classy-store/utils`. Applications that don't use persistence pay zero cost. 3. **Storage-agnostic.** The `StorageAdapter` interface accepts sync (`localStorage`, `sessionStorage`) and async (`AsyncStorage`, `localForage`) implementations. 4. **Class-aware.** Getters and methods are automatically excluded from persistence. Only source-of-truth data properties are saved. @@ -2898,13 +3022,15 @@ obsolete event values and suppresses echo writes while applying accepted updates | Method/Property | Behavior | |---|---| -| `unsubscribe()` | Cancel debounce/listeners, ignore pending read results/errors, and skip queued operations not yet started. Already-issued adapter I/O is not cancelled. Idempotent. | -| `hydrated` | Promise that resolves when initial hydration completes. | -| `isHydrated` | `boolean`. Becomes `true` after hydration. | -| `isExpired` | `boolean`. `true` when the last hydration found expired data. | +| `dispose()` | Cancel debounce/listeners, ignore pending reads, and skip queued operations not yet started. Already-issued adapter I/O is not cancelled. Idempotent. | +| `hydrated` | Resolves when a hydration attempt settles, including failure or a superseded read. | +| `isHydrated` | Readonly boolean indicating a settled hydration attempt. | +| `isExpired` | Readonly boolean; true when hydration found expired data. | | `save()` | Cancel debounce, capture current state, and enqueue its write. | | `clear()` | Cancel pending auto-saves and enqueue removal after prior writes. | -| `rehydrate()` | Wait for prior writes, read storage, and apply only if the request and store version remain current. | +| `rehydrate()` | Wait for prior writes, apply only if the read remains current, and await expired-data removal when enabled. | +| `getSnapshot()` / `getServerSnapshot()` | Cached current / initial readonly status. | +| `subscribe(listener)` | Observe status; returns listener cleanup. | ## Property Resolution @@ -3311,7 +3437,7 @@ React defines the supported [serializable prop types](https://react.dev/referenc | `@codebelt/classy-store` | Core store creation and snapshots in server or client modules. | | `@codebelt/classy-store/collections` | Reactive collections in server or client modules. | | `@codebelt/classy-store/utils` | Server-safe imports; configure browser storage and effects inside Client Components. | -| `@codebelt/classy-store/react` | Client hooks and the compatible `prepareStoreForHydration` export. The published ESM and CommonJS entries carry `use client`. | +| `@codebelt/classy-store/react` | Client hooks. The published ESM and CommonJS entries carry `use client`. | | `@codebelt/classy-store/react/server` | `prepareStoreForHydration` without importing React hooks; safe in Server Components and ordinary SSR code. | Components that use hooks still need their own client boundary. Do not call @@ -3349,11 +3475,8 @@ Construct and prepare it inside the Client Component: // components/profileEditor/ProfileEditor.tsx 'use client'; -import { - prepareStoreForHydration, - useClassyStore, - useLocalStore, -} from '@codebelt/classy-store/react'; +import {useClassyStore, useLocalStore} from '@codebelt/classy-store/react'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; import {type ProfileData, ProfileStore} from '../../stores/profile/profile.store'; export function ProfileEditor({initialData}: {initialData: ProfileData}) { @@ -3398,7 +3521,7 @@ module-level server stores; construct request state inside the request instead. ## Streaming, persistence, and navigation Attach persistence in an effect after preparation, call `rehydrate()` there, -and return `unsubscribe()` from cleanup. The [SSR guide](./REACT_SSR.md) shows +and call `handle.dispose()` during cleanup. The [SSR guide](./REACT_SSR.md) shows this pattern. If persistence restores before a streamed boundary hydrates, the boundary first reads the prepared server data, then catches up to live state. Keep the initial server and browser payloads identical. diff --git a/website/static/llms.txt b/website/static/llms.txt index e6ba596..481b1c1 100644 --- a/website/static/llms.txt +++ b/website/static/llms.txt @@ -6,9 +6,10 @@ ## Getting Started +- [Upgrading to 0.8.0](https://codebelt.github.io/classy-store/docs/UPGRADING_0_8): Public API removals, persistence disposal and explicit errors, and React utility status hooks - [Upgrading to 0.7.0](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7): Removed React timing options, native WeakRef requirement, SSR and Next.js setup, persistence changes, and release notes - [Introduction & API Reference](https://codebelt.github.io/classy-store/docs/): Full API - covering `createClassyStore`, `useClassyStore`, `createStoreHook`, `snapshot`, `subscribe`, + covering `createClassyStore`, `useClassyStore`, `useHistoryStatus`, `usePersistStatus`, `createStoreHook`, `snapshot`, `subscribe`, `shallowEqual`, synchronous React subscriptions, React Compiler compatibility, `reactiveMap`, `reactiveSet` (collections entry), and all patterns - [Tutorial](https://codebelt.github.io/classy-store/docs/TUTORIAL): Step-by-step guide — @@ -19,13 +20,13 @@ - [Persistence (`persist`)](https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL): Save state to localStorage/sessionStorage/AsyncStorage with transforms, versioning, - migration, debounce, cross-tab sync, TTL, and SSR support + migration, debounce, cross-tab sync, TTL, SSR support, typed transforms, observable status, and rejected promises for explicit failures - [DevTools (`devtools`)](https://codebelt.github.io/classy-store/docs/DEVTOOLS_TUTORIAL): Connect to Redux DevTools for state inspection and time-travel debugging - [Property Subscriptions (`subscribeKey`)](https://codebelt.github.io/classy-store/docs/SUBSCRIBE_KEY_TUTORIAL): Watch a single property with previous/current values - [Undo/Redo (`withHistory`)](https://codebelt.github.io/classy-store/docs/HISTORY_TUTORIAL): - Snapshot-based undo/redo with pause/resume and configurable limits + Snapshot-based undo/redo with pause/resume, configurable limits, and observable status ## Architecture From b118b8201494e9dd93236793adf57e1ce1d1233d Mon Sep 17 00:00:00 2001 From: codeBelt Date: Thu, 22 Oct 2026 22:21:58 -0500 Subject: [PATCH 2/2] docs: refresh public API guides and acknowledgements --- README.md | 37 +- website/README.md | 61 +- website/docs/HISTORY_TUTORIAL.md | 17 +- website/docs/PERSIST_ARCHITECTURE.md | 11 +- website/docs/PERSIST_TUTORIAL.md | 225 +- website/docs/REACTIVE_COLLECTIONS_TUTORIAL.md | 127 +- website/docs/SUBSCRIBE_KEY_TUTORIAL.md | 8 +- website/docs/TUTORIAL.md | 13 +- website/docs/UPGRADING_0_7.md | 2 + website/docs/index.md | 53 +- website/package.json | 3 +- website/scripts/generateLlms.mjs | 38 + website/sidebars.ts | 1 + website/static/llms-full.txt | 4459 ++++++++++------- website/static/llms.txt | 4 +- 15 files changed, 3086 insertions(+), 1973 deletions(-) create mode 100644 website/scripts/generateLlms.mjs diff --git a/README.md b/README.md index 96b66ad..fd316e0 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,12 @@ See the [0.8.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRA ## ⚡ Quick Example -```ts +This example creates a shared store for a client-rendered React app. For SSR, create one store per request or provider as shown in the [scoping guide](https://codebelt.github.io/classy-store/docs/REACT_SCOPING). + +```tsx +import {createClassyStore} from '@codebelt/classy-store'; +import {useClassyStore} from '@codebelt/classy-store/react'; + // 1. Plain class — fields are state, getters are computed values, the class is the type class CartStore { items: { name: string; price: number; qty: number }[] = []; @@ -61,6 +66,30 @@ function AddButton() { } ``` +## Choosing an API + +| What you need | API | +| --- | --- | +| Create shared state from a class or object | `createClassyStore(instance)` | +| Render store data in React | `useClassyStore(store)` or `useClassyStore(store, selector)` | +| Create a store owned by a component | `useLocalStore(factory)` | +| Bind a reusable hook to an existing store | `createStoreHook(store)` | +| Render undo/redo availability or pause state | `useHistoryStatus(history)` | +| Render persistence hydration, expiry, or errors | `usePersistStatus(handle)` | +| Prepare matching initial data for SSR/hydration | `prepareStoreForHydration(store)` | +| Read state outside React or observe mutations | `snapshot(store)`, `subscribe(store, listener)`, `subscribeKey(store, key, listener)` | +| Persist state, record history, or inspect it in DevTools | `persist(store, options)`, `withHistory(store)`, `devtools(store)` | +| Track changes inside Map-like or Set-like collections | `reactiveMap()`, `reactiveSet()` | +| Compare selector results one level deep | `shallowEqual(previous, next)` | + +`useClassyStore` is the starting point for React components. The two status hooks +are optional: use them only when displaying metadata from an existing history or +persistence handle. They do not create those handles or subscribe to store data. + +See the [API reference and import paths](https://codebelt.github.io/classy-store/docs/#package-entry-points) for signatures, public types, and lifecycle details. + +## React integration + React subscriptions notify synchronously, so controlled inputs preserve caret position without extra options: ```tsx @@ -96,6 +125,8 @@ npm install @codebelt/classy-store bun add @codebelt/classy-store ``` +The React adapter requires React 18 or later. Install `react` and `react-dom` in your application if it does not already provide them. + ## 🤖 AI / LLM Usage This library provides machine-readable documentation for LLM-powered tools: @@ -105,14 +136,12 @@ This library provides machine-readable documentation for LLM-powered tools: These files follow the [llms.txt standard](https://llmstxt.org/). - ## 💡 Why Another State Library - A class-based store where the class is the object and the type — no separate interface or type definition needed. - Getters as computed values, cached automatically and only recalculated when something changes. - No component wrapping — no observers, no HOCs, just a hook. - ## 🙏 Acknowledgements This library wouldn't exist without the ideas pioneered by these projects. Each one taught us something different, and we took the best of each: @@ -129,6 +158,8 @@ This library wouldn't exist without the ideas pioneered by these projects. Each **[Claude 4.6 Opus](https://anthropic.com)** — Let's be real: this library was designed, architected, implemented, tested, and documented almost entirely by Claude 4.6 Opus (Anthropic) via [Cursor](https://cursor.com). From the three-layer proxy architecture to the memoized computed getters with dependency tracking, the cross-snapshot caching strategy, and tests — it was all pair-programmed with an AI that never gets tired of writing Proxy traps. The human brought the vision, the taste, and the "no, make it better" energy. Claude brought the code. +**[OpenAI Codex](https://openai.com/codex/)** — Contributed public API refinements, core and React regression tests, and fixes for hydration, React Compiler compatibility, Next.js integration, performance, memory retention, and persistence. Also helped review and reorganize the codebase, update examples, and improve the documentation. + ## 🤝 Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) for details on how to contribute and the release workflow. diff --git a/website/README.md b/website/README.md index b28211a..76bab3d 100644 --- a/website/README.md +++ b/website/README.md @@ -1,41 +1,54 @@ -# Website +# Classy Store documentation -This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. +The published documentation uses Docusaurus in this `website/` directory. +Documentation pages live in `docs/`; `sidebars.ts` controls navigation. +The main project README is `../README.md`. -## Installation +## Install -```bash -yarn -``` - -## Local Development +Install the workspace dependencies from the repository root, then the website's +separate dependencies: -```bash -yarn start +```sh +bun install --frozen-lockfile +cd website +bun install --frozen-lockfile ``` -This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. +## Develop -## Build +From the repository root: -```bash -yarn build +```sh +bun run build +bun run docs:dev ``` -This command generates static content into the `build` directory and can be served using any static contents hosting service. +This builds the React demos and starts Docusaurus. Changes to documentation pages +reload during development. -## Deployment +## Validate and build -Using SSH: +From the repository root: -```bash -USE_SSH=true yarn deploy +```sh +bun run build +bun run docs:build ``` -Not using SSH: +The docs command rebuilds the demos, regenerates the full machine-readable docs, +and builds the static site in `website/build/`. Docusaurus checks links during +the build. To preview the result, run `bun run serve` from `website/`. -```bash -GIT_USER= yarn deploy -``` +## Machine-readable documentation + +- `static/llms.txt` is the navigation index; update it when adding a guide. +- `static/llms-full.txt` is generated from every Markdown page in `docs/`. + Do not edit the generated file directly. Run `bun run docs:llms` from + `website/` to refresh it without a full build. + +## Deployment -If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. +The `Deploy Docs` GitHub Actions workflow builds and deploys to GitHub Pages when +relevant changes reach `main`. It also supports manual workflow dispatch. A local +build does not deploy the site or publish the npm package. diff --git a/website/docs/HISTORY_TUTORIAL.md b/website/docs/HISTORY_TUTORIAL.md index 22338de..7eecde6 100644 --- a/website/docs/HISTORY_TUTORIAL.md +++ b/website/docs/HISTORY_TUTORIAL.md @@ -211,9 +211,12 @@ const history = withHistory(formStore); formStore.setField('name', 'Alice'); formStore.setField('email', 'alice@example.com'); -// Undo last change +// Let the batched subscription record both edits. +await Promise.resolve(); + +// Both writes in this turn form one undo step. history.undo(); -// email is back to '' +// name and email are both back to ''. // Undo all changes while (history.canUndo) { @@ -292,10 +295,14 @@ import {persist, withHistory} from '@codebelt/classy-store/utils'; const todoStore = createClassyStore(new TodoStore()); // Persist to localStorage (survives page reload) -persist(todoStore, { name: 'todos' }); +const persistence = persist(todoStore, {name: 'todos', storage: localStorage}); // Undo/redo in memory (resets on page reload) const history = withHistory(todoStore); + +// When the store's owner is torn down: +history.dispose(); +persistence.dispose(); ``` History is in-memory only — it doesn't persist across page loads. The persisted state is always the latest state (not the undo pointer position). @@ -326,6 +333,8 @@ const history = withHistory(store); | Check if undoable | `h.canUndo` | | Check if redoable | `h.canRedo` | | Limit history size | `withHistory(store, { limit: 50 })` | -| Batch as single step | `h.pause(); /* mutations */; h.resume()` | +| Record one undo step | Make synchronous edits in one turn; history records after the microtask flush | +| Temporarily stop recording | `h.pause()`; keep paused until those notifications flush, then call `h.resume()` | +| Render history status in React | `useHistoryStatus(h)` | | Stop tracking | `h.dispose()` | | Undo all | `while (h.canUndo) h.undo()` | diff --git a/website/docs/PERSIST_ARCHITECTURE.md b/website/docs/PERSIST_ARCHITECTURE.md index f227d3f..a82d43c 100644 --- a/website/docs/PERSIST_ARCHITECTURE.md +++ b/website/docs/PERSIST_ARCHITECTURE.md @@ -168,7 +168,7 @@ batched subscription notification has run, with cleanup in `finally`. if transform exists → state[key] = transform.deserialize(state[key]) 6. Build currentState from snapshot (only propKeys) 7. Apply merge strategy: - 'shallow' / 'replace': { ...currentState, ...persistedState } + 'shallow': { ...currentState, ...persistedState } custom function: merge(persistedState, currentState) 8. for each key in propKeys: if key in mergedState → proxy[key] = mergedState[key] @@ -181,10 +181,9 @@ batched subscription notification has run, with cleanup in `finally`. | Strategy | Behavior | |---|---| | `'shallow'` (default) | `{ ...current, ...persisted }` -- persisted keys overwrite, new keys keep defaults | -| `'replace'` | Same as shallow at top level. Conceptually signals full replacement for nested objects. | | Custom `fn` | `fn(persisted, current)` -- full control. Enables deep merge or selective overrides. | -Both `'shallow'` and `'replace'` produce the same result for flat stores. The distinction is semantic and affects how consumers think about nested object handling. +The public merge option accepts `shallow` or a function. Shallow merging replaces each stored top-level property, including its nested value; use a custom function to combine nested data. ### Error Handling @@ -300,7 +299,7 @@ Class getters are derived/computed values that recompute from persisted data pro ### Why a hydrated Promise instead of lifecycle hooks? -Zustand uses `onRehydrateStorage` (a callback), Pinia uses `beforeHydrate`/`afterHydrate` hooks. A Promise is more composable: it works with `async`/`await`, can be passed to React Suspense boundaries, and doesn't require a registration pattern. The `isHydrated` boolean covers the synchronous check case. +`hydrated` lets callers await settlement of the initial hydration attempt. It resolves even if the attempt fails, so inspect `lastError` for reported errors. Use `rehydrate()` when an explicit read should reject on failure. React components can observe changes with `usePersistStatus(handle)`. ### Why debounce instead of throttle? @@ -313,10 +312,10 @@ Debounce waits for a quiet period after the last mutation. Throttle writes at re | Per-property transforms | Date round-trip, ReactiveMap round-trip | | Debounce | Coalescing, save bypass | | Version migration | Migration call, version match skip | -| Merge strategies | Shallow, replace, custom function | +| Merge strategies | Shallow or custom function | | skipHydration | No auto-hydrate, manual rehydrate, promise resolution | | Dispose | Stop writes, cancel debounce | -| Flush/clear/rehydrate | Immediate write, remove data, in-memory preservation, re-read | +| Save/clear/rehydrate | Immediate write, remove data, in-memory preservation, re-read | | Hydration state | Promise instance, resolution timing | | Async storage | Async write, async hydrate | | Cross-tab sync | Correct key, wrong key, post-disposal, disabled | diff --git a/website/docs/PERSIST_TUTORIAL.md b/website/docs/PERSIST_TUTORIAL.md index 5e54023..b047884 100644 --- a/website/docs/PERSIST_TUTORIAL.md +++ b/website/docs/PERSIST_TUTORIAL.md @@ -10,7 +10,7 @@ // todoStore.ts import {createClassyStore} from '@codebelt/classy-store'; -class TodoStore { +export class TodoStore { filter: 'all' | 'active' | 'done' = 'all'; todos: {text: string; done: boolean}[] = []; @@ -32,6 +32,8 @@ export const todoStore = createClassyStore(new TodoStore()); ### 2. Persist it +For a client-only app, pass `localStorage` explicitly. Both `name` and `storage` are required. For server-rendered React apps, attach persistence in a client effect as shown in the [SSR section](#skiphydration-ssr--nextjs-support). + ```ts // todoStore.ts import {createClassyStore} from '@codebelt/classy-store'; @@ -41,25 +43,26 @@ import {persist} from '@codebelt/classy-store/utils'; // Saves all store properties to localStorage const handle = persist(todoStore, { + storage: localStorage, name: 'todo-store', }); ``` -**What gets stored in storage:** +**Stored under the `todo-store` key:** ```json -// In localStorage the key will be `todo-store` and the value will be: { "version": 0, "state": { "todos": [], "filter": "all" - }, - "expiresAt": 300000 // Only present if you set the expireIn option (5 minutes) + } } ``` -On every mutation, `todos` and `filter` are saved to `localStorage`. On next page load, they're restored automatically. The getter `remaining` and the methods `addTodo`/`toggle` are **not** persisted -- getters are derived values that recompute from the persisted data, and methods are functions that don't belong in storage. +The optional `expiresAt` field is an absolute timestamp in milliseconds, calculated as `Date.now() + expireIn` when writing. + +Store notifications trigger writes once per mutation batch, or after the configured debounce. On the next initialization, persistence restores saved data. The getter `remaining` and the methods `addTodo`/`toggle` are excluded. ### Choosing What to Persist @@ -67,6 +70,7 @@ By default, `persist()` saves all own data properties of the store -- everything ```ts persist(todoStore, { + storage: localStorage, name: 'todo-store', properties: ['todos'], // Only persist todos, not filter }); @@ -76,7 +80,7 @@ Now `filter` resets to its class default (`'all'`) on every page load, while `to ### Why getters are excluded -Class getters like `get remaining()` are derived values. They compute their results from the data property (`todos`) every time they're accessed. Persisting a getter result would be redundant and risks restoring a stale value that contradicts the underlying data. +Class getters like `get remaining()` are derived values. Their memoized results are invalidated when the data they depend on changes. Persisting a getter result would be redundant and risks restoring a stale value that contradicts the underlying data. ### Configuration and Handle Overview @@ -87,25 +91,36 @@ import {persist} from '@codebelt/classy-store/utils'; const handle = persist(todoStore, { name: 'TodoStore', // Unique storage key (required) - properties: [], // Keys or transform descriptors to persist (default: all own data properties) - storage: localStorage, // Storage adapter with getItem/setItem/removeItem (default: localStorage) + properties: ['todos'], // Omit for all own data properties; [] persists no properties + storage: localStorage, // Required adapter with getItem/setItem/removeItem; no default debounce: 500, // Coalesce rapid mutations into one write, in ms (default: 0) version: 0, // Schema version number; triggers migrate when mismatched (default: 0) - migrate: (state, v) => {}, // Called when stored version ≠ current; must return state in current shape - merge: 'shallow', // How to merge persisted state on hydration: 'shallow' | 'replace' | custom fn - skipHydration: true, // Don't hydrate on init; call handle.rehydrate() manually (default: false) - syncTabs: true, // Re-hydrate when another tab writes to the same key (localStorage only) + migrate: (state, oldVersion) => state, // Return the migrated state when versions differ + merge: 'shallow', // How to merge persisted state on hydration: 'shallow' | custom function + skipHydration: false, // Set true to wait for a manual handle.rehydrate() call + syncTabs: true, // Opt in to localStorage tab updates (default: false) expireIn: 1000, // TTL in ms; expired data is skipped on hydration. Resets on every write clearOnExpire: false, // Auto-remove storage key when expired data is found (default: false) + onError: (error, operation) => console.error(operation, error), }); -handle.isHydrated; // Whether hydration from storage has completed -handle.isExpired; // True if last hydration found expired data (requires expireIn) -await handle.hydrated; // Promise that resolves when initial hydration is complete -await handle.save(); // Manually write to storage (bypasses debounce) -await handle.clear(); // Remove this store's persisted data from storage -await handle.rehydrate(); // Manually re-hydrate the store from storage -handle.dispose(); // Stop persisting: unsubscribe + cancel debounce + remove storage listener +handle.isHydrated; // Whether a hydration attempt has settled, including failures +handle.isExpired; // Whether the last hydration found expired data +await handle.hydrated; // Initial attempt settled; inspect getSnapshot().lastError +handle.getSnapshot(); // Current readonly status +handle.getServerSnapshot(); // Initial status for SSR/hydration +const stopStatus = handle.subscribe(() => console.log(handle.getSnapshot())); + +try { + await handle.save(); // Save now, bypassing debounce + await handle.clear(); // Remove stored data; keep in-memory state + await handle.rehydrate(); // Read storage again +} catch (error) { + console.error('Explicit persistence operation failed', error); +} + +stopStatus(); // Remove only this status listener +handle.dispose(); // Stop persistence and release its resources ``` --- @@ -114,7 +129,7 @@ handle.dispose(); // Stop persisting: unsubscribe + cancel debounce + r ### `properties` (Per-Property Transforms) -Some values aren't JSON-safe. `Date` objects become strings, and custom classes vanish entirely after `JSON.stringify` + `JSON.parse`. Transforms handle the round-trip through the `properties` option. +Some values aren't JSON-safe. `Date` objects become strings, and JSON loses custom class prototypes and methods. Transforms handle the round-trip through the `properties` option. The transform's `key` selects its TypeScript types: `serialize` receives that property's readonly snapshot value, and `deserialize` must return that property's mutable value. Stored input remains `unknown`, so validate it before restoring. Optional properties retain `undefined` in their value type. Standalone descriptors can use `PropertyTransform`, exported from `@codebelt/classy-store/utils`. @@ -140,7 +155,12 @@ persist(userStore, { { key: 'birthdate', serialize: (date) => date.toISOString(), // Date → string - deserialize: (stored) => new Date(stored as string), // string → Date + deserialize: (stored) => { + if (typeof stored !== 'string') throw new Error('Expected a birthdate'); + const date = new Date(stored); + if (Number.isNaN(date.getTime())) throw new Error('Invalid birthdate'); + return date; + }, }, ], }); @@ -221,6 +241,7 @@ Disable it when you don't want cross-tab sync: ```ts persist(uiStore, { + storage: localStorage, name: 'ui', syncTabs: false, }); @@ -259,6 +280,7 @@ Use `expireIn` to set a time-to-live (in milliseconds) on persisted data. After ```ts persist(sessionStore, { + storage: localStorage, name: 'session', expireIn: 900_000, // 15 minutes }); @@ -272,6 +294,7 @@ The handle exposes an `isExpired` flag that becomes `true` when hydration encoun ```ts const handle = persist(sessionStore, { + storage: localStorage, name: 'session', expireIn: 900_000, }); @@ -287,6 +310,7 @@ By default, expired data is skipped but left in storage. Set `clearOnExpire: tru ```ts persist(sessionStore, { + storage: localStorage, name: 'session', expireIn: 900_000, clearOnExpire: true, // Remove key from storage when expired @@ -295,113 +319,56 @@ persist(sessionStore, { ### `skipHydration` (SSR / Next.js Support) -`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, -}); -``` - -Then hydrate manually in a `useEffect` (which only runs on the client): - -```tsx -import {useEffect} from 'react'; -import {handle} from './store'; - -function App() { - useEffect(() => { - void handle.rehydrate().catch((error) => console.error('Could not restore persisted state', error)); - }, []); - - return ; -} -``` - -#### The hydration mismatch problem - -In SSR frameworks like Next.js, the server renders HTML using the store's class defaults (e.g., `theme = 'light'`). When the client hydrates, if `persist()` has already restored a different value from `localStorage` (e.g., `theme = 'dark'`), React sees a mismatch between the server-rendered HTML and the client state. This causes a **hydration error**. - -Setting `skipHydration: true` prevents `persist()` from reading `localStorage` until you explicitly call `rehydrate()` — letting you control *when* the stored state is applied and avoid the mismatch. - -#### `StoreHydrator` component pattern +Create the store per request or provider from matching initial data. Call +`prepareStoreForHydration(store)` from `@codebelt/classy-store/react/server` +before rendering or starting persistence. This preserves the server snapshot +for boundaries that hydrate after the live store changes. -When you have multiple persisted stores, calling `rehydrate()` in individual components is repetitive. A better pattern is a single `StoreHydrator` component that gates rendering until all stores are hydrated: +Attach persistence in a client effect. The effect owns the handle and disposes +it during cleanup, including Strict Mode replay: ```tsx -// StoreHydrator.tsx 'use client'; -import {type ReactNode, useEffect, useState} from 'react'; -import {settingsPersist} from './stores/settings-store'; -import {todoPersist} from './stores/todo-store'; - -// Collect all persist handles that use skipHydration: true -const persists = [settingsPersist, todoPersist]; - -export function StoreHydrator({children}: {children: ReactNode}) { - const [hydrated, setHydrated] = useState(false); +import {useEffect} from 'react'; +import {persist} from '@codebelt/classy-store/utils'; +import type {TodoStore} from './todoStore'; +export function TodoPersistence({store}: {store: TodoStore}) { useEffect(() => { - void Promise.allSettled(persists.map((handle) => handle.rehydrate())).then((results) => { - for (const result of results) { - if (result.status === 'rejected') console.error('Could not restore persisted state', result.reason); - } - setHydrated(true); + const handle = persist(store, { + name: 'todo-store', + storage: localStorage, + skipHydration: true, + onError: (error, operation) => console.error(operation, error), }); - }, []); - if (!hydrated) { - return null; // or a loading spinner - } - - return <>{children}; -} -``` + // Explicit calls reject even when onError is configured. + void handle.rehydrate().catch(() => { + // onError already reported the failure; keep the current store values. + }); -Wrap your app layout with `StoreHydrator` so its children wait for all restoration attempts to settle: + return () => handle.dispose(); + }, [store]); -```tsx -// layout.tsx (Next.js App Router) -import {StoreHydrator} from '@/components/StoreHydrator'; - -export default function RootLayout({children}: {children: React.ReactNode}) { - return ( - - - - -
{children}
-
- - - ); + return null; } ``` -**How it works:** +Render this component inside the provider that owns the prepared store. It +accesses browser storage only inside the effect. Avoid module-level mutable +stores or shared in-memory storage adapters on the server, where they can mix +state across requests. + +`skipHydration: true` delays the initial read until `rehydrate()` is called. +It does not delay writes, and it does not replace snapshot preparation for +streamed or selective hydration. If no manual timing is needed, omit it and +let the effect's `persist()` call start automatic hydration. -1. The server and initial client render show the loading placeholder without accessing `localStorage`. -2. On the client, `StoreHydrator` mounts and calls `rehydrate()` on every persist handle. -3. Until all attempts settle, `StoreHydrator` renders `null` (or a loading indicator). Failures are logged, and do not leave the UI stuck loading. -4. Children then render with restored values, or current defaults for failed attempts. To server-render the children themselves, use the [prepared-snapshot SSR pattern](./REACT_SSR.md). +See [React SSR](./REACT_SSR.md), [store scoping](./REACT_SCOPING.md), and +[Next.js App Router](./REACT_NEXT.md) for complete provider examples. Rendering +children only after restoration settles is also possible, but then those +children do not contribute their own server-rendered content. ### `debounce` (Debouncing Writes) @@ -409,6 +376,7 @@ For stores that mutate frequently (form inputs, drag positions, real-time data), ```ts persist(formStore, { + storage: localStorage, name: 'form-draft', debounce: 500, // Write at most once every 500ms }); @@ -426,6 +394,7 @@ When the store's shape changes between releases, versioning and migration preven ```ts persist(todoStore, { + storage: localStorage, name: 'todo-store', version: 2, migrate: (state, oldVersion) => { @@ -482,6 +451,7 @@ persist(todoStore, { ```ts persist(todoStore, { + storage: localStorage, name: 'todo-store', merge: (persisted, current) => { return { @@ -551,8 +521,12 @@ try { console.error('Could not save', error); } -await handle.clear(); -await handle.rehydrate(); +try { + await handle.clear(); + await handle.rehydrate(); +} catch (error) { + console.error('Could not clear or restore stored data', error); +} ``` Each failing operation still calls `onError` once and updates status before rejecting with the original error. `PersistOperation` is `'read' | 'write' | 'remove' | 'parse'`; decoding, transforms, migrations, and merge failures use `'parse'`. A failed operation does not block later queued saves. `rehydrate` also waits for expired-data removal when `clearOnExpire` is enabled and rejects if that removal fails. @@ -622,6 +596,7 @@ const todoStore = createClassyStore(new TodoStore()); // Persist todos to localStorage (survives page close) persist(todoStore, { + storage: localStorage, name: 'todo-items', properties: ['todos'], }); @@ -655,6 +630,7 @@ class AppStore { const appStore = createClassyStore(new AppStore()); const handle = persist(appStore, { + storage: localStorage, name: 'app-store', version: 1, debounce: 300, @@ -665,7 +641,12 @@ const handle = persist(appStore, { { key: 'lastLogin', serialize: (date) => date.toISOString(), - deserialize: (stored) => new Date(stored as string), + deserialize: (stored) => { + if (typeof stored !== 'string') throw new Error('Expected a login date'); + const date = new Date(stored); + if (Number.isNaN(date.getTime())) throw new Error('Invalid login date'); + return date; + }, }, ], @@ -685,12 +666,14 @@ console.log(`Welcome back! Theme: ${appStore.theme}`); ## Quick Reference +Catch failures from explicit `save()`, `clear()`, and `rehydrate()` calls. Reading `isHydrated` or awaiting `hydrated` only confirms an attempt settled; inspect status for errors. + | Feature | Code Example | |---|---| -| **Persist everything** | `persist(myStore, {name: 'key'})` | +| **Persist everything** | `persist(myStore, {name: 'key', storage: localStorage})` | | **Persist specific properties** | `properties: ['count', 'name']` | -| **Handle Dates & Objects** | `{key: 'date', serialize: (d) => d.toISOString(), deserialize: (state) => new Date(s)}` | -| **Handle Reactive collections** | `users: reactiveMap()` (supported natively) | +| **Handle Dates & Objects** | Supply a property transform with a serializer and a validating deserializer | +| **Handle reactive collections** | Serialize entries/values and deserialize with `reactiveMap()` / `reactiveSet()`; see the [collections guide](./REACTIVE_COLLECTIONS_TUTORIAL.md) | | **Use sessionStorage** | `storage: sessionStorage` | | **Use AsyncStorage** | `storage: AsyncStorage, syncTabs: false` | | **Disable cross-tab sync** | `syncTabs: false` | @@ -699,7 +682,7 @@ console.log(`Welcome back! Theme: ${appStore.theme}`); | **SSR & manual hydration** | `skipHydration: true` + `handle.rehydrate()` | | **Debounce storage writes**| `debounce: 500` | | **Migrate schema versions**| `version: 2, migrate: (state, old) => { ... }` | -| **Merge strategy** | `merge: 'shallow'` \| `'replace'` \| `(persisted, current) => merged` | +| **Merge strategy** | `merge: 'shallow'` or a custom `(persisted, current) => merged` function | | **Check hydration** | `handle.isHydrated` | | **Wait for hydration** | `await handle.hydrated` | | **Clear stored data** | `await handle.clear()` | diff --git a/website/docs/REACTIVE_COLLECTIONS_TUTORIAL.md b/website/docs/REACTIVE_COLLECTIONS_TUTORIAL.md index 4d2c3a1..abdd2a5 100644 --- a/website/docs/REACTIVE_COLLECTIONS_TUTORIAL.md +++ b/website/docs/REACTIVE_COLLECTIONS_TUTORIAL.md @@ -1,57 +1,102 @@ # Reactive Collections -Classy Store provides `ReactiveMap` and `ReactiveSet` to help with persisting `Map` and `Set` collections. Both collections are backed by plain arrays under the hood, but they expose the exact same methods and APIs as their native counterparts. +Use `reactiveMap()` and `reactiveSet()` when mutations such as `.set()`, `.add()`, +`.delete()`, and `.clear()` need to notify store subscribers. Put the collection +inside a store created by `createClassyStore()` and mutate it through that store. -> [!NOTE] -> **Performance Tip:** You should use native `Map` and `Set` in the store for better performance in most cases. `ReactiveMap` and `ReactiveSet` should only be used if you explicitly need to persist a `Map` or `Set` to storage using the `persist()` utility. +Native `Map` and `Set` are opaque leaves: replacing their property can notify +subscribers, but mutating their contents does not. Reactive collections use +arrays internally and expose Map-like and Set-like methods. They are not native +`Map` or `Set` instances, and key/value lookups scan those arrays. -## Why Reactive Collections? +## Reactive updates -Native `Map` and `Set` objects are not JSON-serializable by default, making them incompatible with `localStorage` and `sessionStorage`. When you `JSON.stringify` a `Map` or `Set`, it usually becomes an empty object `{}`. +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {reactiveMap, reactiveSet} from '@codebelt/classy-store/collections'; + +class DirectoryStore { + labels = reactiveMap(); + selected = reactiveSet(); + + select(id: string, label: string) { + this.labels.set(id, label); + this.selected.add(id); + } +} + +const directoryStore = createClassyStore(new DirectoryStore()); +directoryStore.select('u1', 'Alice'); +``` + +React components read these collections with `useClassyStore`, just like other +store data. Select an existing snapshot property or a primitive such as `.size`; +if a selector creates a new array, consider `shallowEqual` where its one-level +comparison matches your needs. -`ReactiveMap` and `ReactiveSet` are backed by internal structures that allow them to be persisted correctly when combined with the `serialize` and `deserialize` options in the `persist` utility. +`ReactiveMap` and `ReactiveSet` are public types from the collections entry. +Construct values with the factory functions. ## Persisting a ReactiveMap -When persisting a `ReactiveMap`, you do not need to provide custom `serialize` or `deserialize` logic. The reactive collection is backed by an internal structure the `persist` utility can natively stringify and parse. +JSON does not retain a collection's prototype or methods. Use a property transform +to serialize entries and reconstruct the collection during hydration. For string +keys and values: ```ts import {createClassyStore} from '@codebelt/classy-store'; import {reactiveMap} from '@codebelt/classy-store/collections'; import {persist} from '@codebelt/classy-store/utils'; -class UserStore { - users = reactiveMap(); - - addUser(id: string, name: string, role: string) { - this.users.set(id, {name, role}); - } +class LabelStore { + labels = reactiveMap(); } -const userStore = createClassyStore(new UserStore()); - -persist(userStore, { - name: 'user-store', +const labelStore = createClassyStore(new LabelStore()); +const persistence = persist(labelStore, { + name: 'labels', + storage: localStorage, + properties: [ + { + key: 'labels', + serialize: (labels) => [...labels.entries()], + deserialize: (stored) => { + if (!Array.isArray(stored)) throw new Error('Expected label entries'); + const labels = reactiveMap(); + for (const entry of stored) { + if ( + !Array.isArray(entry) || entry.length !== 2 || + typeof entry[0] !== 'string' || typeof entry[1] !== 'string' + ) { + throw new Error('Invalid label entry'); + } + labels.set(entry[0], entry[1]); + } + return labels; + }, + }, + ], }); ``` -**What gets stored in storage:** +After saving `labelStore.labels.set('u1', 'Alice')`, the storage envelope contains: ```json { "version": 0, "state": { - "users": [ - ["u1", { "name": "Alice", "role": "admin" }], - ["u2", { "name": "Bob", "role": "viewer" }] - ] + "labels": [["u1", "Alice"]] } } ``` +The restored `labels` value retains `.get()`, `.set()`, and the other collection +methods. Validate object-valued entries according to your application's schema +before constructing the collection. + ## Persisting a ReactiveSet -The same pattern works for `ReactiveSet`. You can simply stringify it by referring to the property directly. +Serialize the values and rebuild the set after validating stored input: ```ts import {createClassyStore} from '@codebelt/classy-store'; @@ -60,26 +105,44 @@ import {persist} from '@codebelt/classy-store/utils'; class TagStore { tags = reactiveSet(); - - addTag(tag: string) { - this.tags.add(tag); - } } const tagStore = createClassyStore(new TagStore()); - -persist(tagStore, { - name: 'tag-store', +const persistence = persist(tagStore, { + name: 'tags', + storage: localStorage, + properties: [ + { + key: 'tags', + serialize: (tags) => [...tags], + deserialize: (stored) => { + if (!Array.isArray(stored)) throw new Error('Expected tags'); + const tags = reactiveSet(); + for (const value of stored) { + if (typeof value !== 'string') throw new Error('Invalid tag'); + tags.add(value); + } + return tags; + }, + }, + ], }); ``` -**What gets stored in storage:** +With tags added, the stored state is an array of strings: ```json { "version": 0, "state": { - "tags": ["typescript", "javascript", "react"] + "tags": ["typescript", "react"] } } ``` + +These are client-only setup examples. Dispose the persistence handle when its +owner is torn down. For React and SSR, create it in an effect and call +`persistence.dispose()` in cleanup; see [store scoping](./REACT_SCOPING.md). +Transform failures are reported as parse errors during hydration. Explicit +`rehydrate()` calls also reject, so handle their promises as described in the +[persistence guide](./PERSIST_TUTORIAL.md#handle-operation-failures). diff --git a/website/docs/SUBSCRIBE_KEY_TUTORIAL.md b/website/docs/SUBSCRIBE_KEY_TUTORIAL.md index fadb599..0626fa8 100644 --- a/website/docs/SUBSCRIBE_KEY_TUTORIAL.md +++ b/website/docs/SUBSCRIBE_KEY_TUTORIAL.md @@ -48,10 +48,11 @@ unsub(); ## Signature ```ts -function subscribeKey( +function subscribeKey>( proxyStore: T, key: K, callback: (value: Snapshot[K], previousValue: Snapshot[K]) => void, + options?: SubscribeOptions, ): () => void; ``` @@ -60,14 +61,17 @@ function subscribeKey( | `proxyStore` | `T` | A reactive proxy created by `createClassyStore()` | | `key` | `keyof T` | The property to watch | | `callback` | `(value, previousValue) => void` | Called when the watched property changes | +| `options` | `SubscribeOptions` | Optional `{sync: true}` for immediate notifications; otherwise batched | | **Returns** | `() => void` | Unsubscribe function | +The callback is not called when you subscribe. It receives the current and previous snapshot values when the watched key changes. By default, several synchronous writes coalesce into one callback with the final value; passing `{sync: true}` as the fourth argument observes each change. Import `Snapshot` from the root entry and `SubscribeOptions` from the root or utilities entry. + ## How It Works Under the hood, `subscribeKey` wraps the existing `subscribe()` + `snapshot()` APIs: 1. Captures an initial snapshot and stores `snapshot[key]` as the previous value. -2. On each store mutation, takes a new snapshot and compares `snapshot[key]` with the previous value via `Object.is()`. +2. On each batched notification (or each mutation with `sync: true`), takes a new snapshot and compares `snapshot[key]` with the previous value via `Object.is()`. 3. If different, fires the callback with `(currentValue, previousValue)` and updates the stored previous value. Because `snapshot()` uses structural sharing, unchanged sub-trees return the same reference. This makes the `Object.is()` comparison efficient — no deep equality checks are needed. diff --git a/website/docs/TUTORIAL.md b/website/docs/TUTORIAL.md index fe8bd05..8e407f3 100644 --- a/website/docs/TUTORIAL.md +++ b/website/docs/TUTORIAL.md @@ -399,11 +399,11 @@ A component selecting `loading` can show a spinner while the request is pending ## Local Stores -By default, stores are module-level singletons — shared across your entire app. For component-scoped state that is garbage collected on unmount, use `useLocalStore`. +Where you create a store determines its scope. A module-level store is shared by importers; `useLocalStore` creates one instance per mounted component. For SSR, use request-scoped or provider-owned stores instead of a shared server singleton. ### Basic usage -`useLocalStore` creates a reactive store scoped to the component's lifetime. Each component instance gets its own isolated store. When the component unmounts, the store is garbage collected. +`useLocalStore` creates a reactive store scoped to the component's lifetime. Each component instance gets its own isolated store. Unmounting releases React's reference. Garbage collection can reclaim the store once no other references remain. ```tsx import {useLocalStore, useClassyStore} from '@codebelt/classy-store/react'; @@ -426,7 +426,7 @@ The factory initializes component state. Subsequent re-renders reuse the same st ### Persisting a local store -`persist()` subscribes to the store, which keeps a reference alive. You must call `handle.dispose()` on unmount to allow garbage collection. +`persist()` owns subscriptions, timers, and optional browser listeners. Create the handle in an effect and dispose it in that effect's cleanup, including during Strict Mode replay. `useLocalStore` does not dispose application effects for you. ```tsx import {useEffect} from 'react'; @@ -446,7 +446,7 @@ function EditProfile() { const snap = useClassyStore(store); useEffect(() => { - const handle = persist(store, { name: 'edit-profile-draft' }); + const handle = persist(store, { storage: localStorage, name: 'edit-profile-draft' }); return () => handle.dispose(); }, [store]); @@ -459,7 +459,7 @@ function EditProfile() { } ``` -The `useEffect` cleanup ensures the persist subscription is removed and the store can be garbage collected when the component unmounts. +The effect cleanup stops persistence. The store can be collected once React and any other owners release it. ## Creating a Bound Hook with `createStoreHook` @@ -596,12 +596,13 @@ a === c; // false — new snapshot after mutation import {persist} from '@codebelt/classy-store/utils'; const handle = persist(todoStore, { + storage: localStorage, name: 'todo-store', }); // State survives page refreshes. // Getters and methods are automatically excluded. -// Cross-tab sync is enabled by default. +// Opt into cross-tab sync with syncTabs: true when using localStorage. ``` The persist utility supports per-property transforms (for `Date`, `ReactiveMap`, etc.), debounced writes, schema versioning with migration, merge strategies, TTL expiration (`expireIn`), SSR-safe `skipHydration`, and cross-tab synchronization. diff --git a/website/docs/UPGRADING_0_7.md b/website/docs/UPGRADING_0_7.md index 920f9ad..2a3d02d 100644 --- a/website/docs/UPGRADING_0_7.md +++ b/website/docs/UPGRADING_0_7.md @@ -1,5 +1,7 @@ # Upgrading to 0.7.0 +This is the historical 0.7.0 upgrade guide. For the current API, also read [Upgrading to 0.8.0](./UPGRADING_0_8.md), which replaces persistence cleanup and removes duplicate exports. + Version 0.7.0 focuses on core and React correctness, including controlled inputs, React Compiler, SSR, Next.js App Router, memory retention, and persistence. It includes React API and runtime changes that consumers upgrading from 0.6.x should review. ## Remove React timing options diff --git a/website/docs/index.md b/website/docs/index.md index 0067fb2..4eb5bb3 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -3,7 +3,7 @@ Class-based reactive state management for React, Vue, Svelte, Solid, and Angular. Write plain TypeScript classes — get fine-grained reactivity, immutable snapshots, and zero-boilerplate hooks. ``` -~2.3 KB gzipped · ES6 Proxy · useSyncExternalStore · proxy-compare +ES6 Proxy · useSyncExternalStore · proxy-compare ``` ## Features @@ -109,6 +109,42 @@ function AddButton() { } ``` +## Choosing an API + +| What you need | API | +| --- | --- | +| Create shared state from a class or object | `createClassyStore(instance)` | +| Render store data in React | `useClassyStore(store)` or `useClassyStore(store, selector)` | +| Create a store owned by a component | `useLocalStore(factory)` | +| Bind a reusable hook to an existing store | `createStoreHook(store)` | +| Render undo/redo availability or pause state | `useHistoryStatus(history)` | +| Render persistence hydration, expiry, or errors | `usePersistStatus(handle)` | +| Prepare matching initial data for SSR/hydration | `prepareStoreForHydration(store)` | +| Read state outside React or observe mutations | `snapshot(store)`, `subscribe(store, listener)`, `subscribeKey(store, key, listener)` | +| Persist state, record history, or inspect it in DevTools | `persist(store, options)`, `withHistory(store)`, `devtools(store)` | +| Track changes inside Map-like or Set-like collections | `reactiveMap()`, `reactiveSet()` | +| Compare selector results one level deep | `shallowEqual(previous, next)` | + +`useClassyStore` is the starting point for React components. The two status hooks +are optional: use them only when displaying metadata from an existing history or +persistence handle. They do not create those handles or subscribe to store data. + +## Package entry points + +Import from the entry that owns the API. Core, collections, and utilities do not depend on React. + +| Import path | Runtime exports | Public types | +| --- | --- | --- | +| `@codebelt/classy-store` | `createClassyStore`, `snapshot`, `subscribe`, `shallowEqual` | `Snapshot`, `SubscribeOptions` | +| `@codebelt/classy-store/react` | `useClassyStore`, `useLocalStore`, `createStoreHook`, `useHistoryStatus`, `usePersistStatus` | `UseClassyStoreSelectorOptions` | +| `@codebelt/classy-store/react/server` | `prepareStoreForHydration` | — | +| `@codebelt/classy-store/collections` | `reactiveMap`, `reactiveSet` | `ReactiveMap`, `ReactiveSet` | +| `@codebelt/classy-store/utils` | `persist`, `withHistory`, `subscribeKey`, `devtools` | `PersistOptions`, `PersistHandle`, `PersistStatus`, `PersistOperation`, `PropertyTransform`, `StorageAdapter`, `HistoryOptions`, `HistoryHandle`, `HistoryStatus`, `DevtoolsOptions`, `SubscribeOptions` | + +`ReactiveMap` and `ReactiveSet` are exported as types; construct values with the +factory functions. The React entry carries `use client`. Import hydration +preparation from `/react/server` in both server code and client modules. + ## API Reference ### `createClassyStore(instance)` @@ -244,7 +280,7 @@ Import the preparation helper from `@codebelt/classy-store/react/server` on both ### `useLocalStore(factory)` -Creates a component-scoped reactive store. Each component instance gets its own isolated store, garbage collected on unmount. +Creates a component-scoped reactive store. Each component instance gets its own isolated store. Unmounting releases React's reference; the store can be garbage collected when no other references remain. ```tsx import {useLocalStore, useClassyStore} from '@codebelt/classy-store/react'; @@ -320,13 +356,13 @@ Validates at creation time — throws if the argument is not a store proxy. ### `snapshot(store)` -Creates a deeply frozen, immutable snapshot of the current state. Used internally by `useClassyStore` but also available directly. +Reads a snapshot with frozen copies of store objects and arrays, cached by store version with structural sharing. Used internally by `useClassyStore` but also available directly. Opaque leaves such as native `Map`, `Set`, `Date`, and functions retain their references; do not mutate them through a snapshot. ```typescript import {snapshot} from '@codebelt/classy-store'; const snap = snapshot(myStore); -// snap is deeply frozen — mutations throw +// Copied objects and arrays are frozen; mutate the store, not its snapshot. // Structural sharing: unchanged sub-trees === previous snapshot ``` @@ -456,7 +492,10 @@ Persist store state to storage with transforms, versioning, migration, cross-tab ```typescript import {persist} from '@codebelt/classy-store/utils'; -const handle = persist(todoStore, {name: 'todo-store'}); +const handle = persist(todoStore, {name: 'todo-store', storage: localStorage}); + +// When this store's owner no longer needs persistence: +handle.dispose(); ``` > See the [Persist Tutorial](./PERSIST_TUTORIAL.md) and [Persist Architecture](./PERSIST_ARCHITECTURE.md). @@ -473,9 +512,9 @@ const disconnect = devtools(myStore, {name: 'MyStore'}); > See the [DevTools Tutorial](./DEVTOOLS_TUTORIAL.md). -### `subscribeKey(store, key, callback)` +### `subscribeKey(store, key, callback, options?)` -Subscribe to changes on a single property. Fires only when the watched key changes. +Subscribe to changes on a single property, comparing snapshot values with `Object.is`. Notifications are batched by default; pass `{sync: true}` as the fourth argument to observe each change immediately. Returns listener cleanup. ```typescript import {subscribeKey} from '@codebelt/classy-store/utils'; diff --git a/website/package.json b/website/package.json index 1ee512f..9d576ea 100644 --- a/website/package.json +++ b/website/package.json @@ -5,7 +5,8 @@ "scripts": { "docusaurus": "docusaurus", "start": "docusaurus start", - "build": "docusaurus build", + "build": "node scripts/generateLlms.mjs && docusaurus build", + "docs:llms": "node scripts/generateLlms.mjs", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", diff --git a/website/scripts/generateLlms.mjs b/website/scripts/generateLlms.mjs new file mode 100644 index 0000000..ee44bc3 --- /dev/null +++ b/website/scripts/generateLlms.mjs @@ -0,0 +1,38 @@ +import {readdirSync, readFileSync, writeFileSync} from 'node:fs'; + +const docsDirectory = new URL('../docs/', import.meta.url); +const outputFile = new URL('../static/llms-full.txt', import.meta.url); +const docsUrl = 'https://codebelt.github.io/classy-store/docs/'; +const introduction = [ + 'index.md', + 'UPGRADING_0_8.md', + 'UPGRADING_0_7.md', + 'TUTORIAL.md', +]; +const files = readdirSync(docsDirectory) + .filter((file) => file.endsWith('.md')) + .sort(); +const orderedFiles = [ + ...introduction.filter((file) => files.includes(file)), + ...files.filter((file) => !introduction.includes(file)), +]; +const sections = orderedFiles.map((file) => { + const route = file === 'index.md' ? '' : file.slice(0, -3); + const content = readFileSync(new URL(file, docsDirectory), 'utf8').replace( + /\]\((?:\.\/)?([\w-]+)\.md(#[^)]*)?\)/g, + (_, name, anchor = '') => + `](${docsUrl}${name === 'index' ? '' : name}${anchor})`, + ); + return `---\nSOURCE: ${docsUrl}${route}\n---\n\n${content.trim()}\n`; +}); + +writeFileSync( + outputFile, + `# @codebelt/classy-store — Complete Documentation + +Generated from the Markdown sources in website/docs by scripts/generateLlms.mjs. +Source: ${docsUrl} + +${sections.join('\n')}`, +); +console.log(`Generated llms-full.txt from ${orderedFiles.length} documents.`); diff --git a/website/sidebars.ts b/website/sidebars.ts index bb5d635..e83632c 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -15,6 +15,7 @@ const sidebars: SidebarsConfig = { 'REACT_NEXT', 'REACT_SCOPING', 'PERSIST_TUTORIAL', + 'REACTIVE_COLLECTIONS_TUTORIAL', 'DEVTOOLS_TUTORIAL', 'SUBSCRIBE_KEY_TUTORIAL', 'HISTORY_TUTORIAL', diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt index 2e0531e..7c46ff1 100644 --- a/website/static/llms-full.txt +++ b/website/static/llms-full.txt @@ -1,198 +1,7 @@ # @codebelt/classy-store — Complete Documentation -This file contains the full documentation for `@codebelt/classy-store` concatenated into a -single file for LLM context windows. Generated from the source docs at: -https://codebelt.github.io/classy-store/docs/ - ---- -SOURCE: https://codebelt.github.io/classy-store/docs/UPGRADING_0_8 ---- - -# Upgrading to 0.8.0 - -Version 0.8.0 adds observable history and persistence status and simplifies the public API. This pre-1.0 release includes breaking changes for consumers of 0.7.x. - -## Required updates - -| Previous usage | Use in 0.8.0 | -| --- | --- | -| `persistence.unsubscribe()` | `persistence.dispose()` | -| Import `prepareStoreForHydration` from `/react` | Import it from `@codebelt/classy-store/react/server`, on both server and client | -| Import `getVersion` from the root entry | Use `subscribe()` for notifications or compare `snapshot()` references | -| Assume explicit persistence calls always resolve | Handle rejected promises from `save()`, `clear()`, and `rehydrate()` | -| Transform a property's value as a union of all store properties | Match `serialize` and `deserialize` to the property selected by `key` | - -## Dispose persistence handles - -Update effect cleanups and other teardown calls: - -```ts -// Before -return () => persistence.unsubscribe(); - -// After -return () => persistence.dispose(); -``` - -Disposal is idempotent. It stops subscriptions and timers, ignores pending read results, and cancels writes that have not started. Already-issued storage I/O cannot be cancelled. Explicit queued operations cancelled by disposal reject, as do explicit operations called after disposal. - -## Handle explicit persistence failures - -`save()`, `clear()`, and `rehydrate()` take no options. They report storage failures through `onError` and `lastError`, and reject their promises. Handle the promise even when an `onError` callback is configured: - -```ts -try { - await persistence.save(); - showSavedMessage(); -} catch (error) { - showSaveError(error); -} -``` - -Automatic writes and initial hydration handle reported failures internally. `hydrated` resolves when the initial attempt settles; `isHydrated` also means an attempt settled, including failure or a superseded read. Check `persistence.getSnapshot().lastError` when the result matters. A failed operation does not prevent later queued writes from running. - -## Subscribe to utility status in React - -Use `useClassyStore(store)` for store data. Use the new hooks when rendering utility metadata that can change independently of that data: - -```tsx -import {useHistoryStatus, usePersistStatus} from '@codebelt/classy-store/react'; - -const {canUndo, canRedo, isPaused} = useHistoryStatus(history); -const {isHydrated, isExpired, lastError} = usePersistStatus(persistence); -``` - -Call these hooks inside a component or custom hook, with stable handles. Both status snapshots include `isDisposed`. History pause state now stays consistent when undo/redo finishes. - -Outside React, handles expose `getSnapshot()`, `getServerSnapshot()`, and `subscribe(listener)`. The function returned by `subscribe` removes that status listener; `dispose()` tears down the utility handle itself. The shared status source implementation is internal. Consumer types are `HistoryHandle`, `HistoryStatus`, `PersistHandle`, and `PersistStatus`, exported from `@codebelt/classy-store/utils`. - -During SSR and hydration, status hooks use the handle's initial status snapshot. Construct server and client handles with matching initial status. - -## Use the hydration entry on server and client - -```ts -import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; -``` - -The `/react` entry exports React hooks and `createStoreHook`. Hydration preparation is exported only from `/react/server`; its captured-snapshot behavior is unchanged. - -## Match persistence transforms to their property - -Transform serializers receive the selected property's readonly snapshot value. Deserializers receive `unknown` and must return that property's mutable value. Validate stored values before returning them. Transforms that mutate their snapshot input or return another property's type now fail TypeScript checks. - -Persistence status flags are readonly, matching their runtime getters. `PersistOperation` is exported from the utilities entry for typed error handlers. - -If upgrading from 0.6.x or earlier, also follow the [0.7.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7) for React timing options and the native `WeakRef` requirement. - ---- -SOURCE: https://codebelt.github.io/classy-store/docs/UPGRADING_0_7 ---- - -# Upgrading to 0.7.0 - -Version 0.7.0 focuses on core and React correctness, including controlled inputs, React Compiler, SSR, Next.js App Router, memory retention, and persistence. It includes React API and runtime changes that consumers upgrading from 0.6.x should review. - -## Remove React timing options - -React hooks now always subscribe synchronously. This keeps controlled inputs and their caret positions aligned with store updates. Remove `sync: true` and `sync: false` from both direct and bound hooks. - -Before, in 0.6.x: - -```tsx -const state = useClassyStore(store, {sync: true}); -const count = useClassyStore(store, (state) => state.count, {sync: true}); - -// useCounterStore was created with createStoreHook(store). -const boundState = useCounterStore({sync: true}); -const boundCount = useCounterStore((state) => state.count, {sync: true}); -``` - -After, in 0.7.0: - -```tsx -const state = useClassyStore(store); -const count = useClassyStore(store, (state) => state.count); - -const boundState = useCounterStore(); -const boundCount = useCounterStore((state) => state.count); -``` - -Auto-tracked hooks no longer accept an options argument, even an empty `{}`. Remove imports and annotations using the removed `UseClassyStoreOptions` type. Selector hooks still accept `UseClassyStoreSelectorOptions` with `isEqual`: - -```tsx -// Before -const selection = useClassyStore(store, selectSummary, { - sync: true, - isEqual: shallowEqual, -}); - -// After -const selection = useClassyStore(store, selectSummary, { - isEqual: shallowEqual, -}); -``` - -The same change applies to selector options passed to a bound hook. Existing calls without React timing options continue to work. - -Ordinary `subscribe(store, listener)` callbacks still batch in a microtask, and core subscriptions retain their `{sync: true}` option. React selectors can run after each individual mutation, even when React batches the resulting renders into one commit. Keep selectors pure and able to read intermediate state during a multi-property update; this release does not introduce atomic transactions. - -## Check the auto-tracking runtime requirement - -Auto-tracked React hooks (`useClassyStore(store)` and bound hooks without a selector) now require native `WeakRef` support from ES2021. Weak references let mounted consumers release obsolete snapshots and collection values. - -Check that every browser or server runtime running auto-tracked hooks supports `WeakRef`. Transpiling JavaScript does not supply native weak-reference behavior. If your supported runtime lacks it, use explicit selectors or retain 0.6.x until you can raise that runtime requirement. Selector hooks do not require `WeakRef`. - -React 18 and 19 remain covered by the package compatibility tests. React Compiler works with both selector and auto-tracked hooks; no compiler opt-out is required. - -## Prepare server-rendered stores before live updates - -Client-only apps do not need hydration preparation. For server-rendered apps where persistence or another update can run before every Suspense boundary hydrates, call `prepareStoreForHydration(store)` on server and client stores initialized from matching data, before rendering or starting persistence. - -```ts -import {createClassyStore} from '@codebelt/classy-store'; -import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; - -const store = prepareStoreForHydration( - createClassyStore(new ProfileStore(initialData)), -); -``` - -Create a separate store for each server request. The prepared snapshot is retained for that store's lifetime; use a new store for a new request or an intentional reset. See the [SSR guide](https://codebelt.github.io/classy-store/docs/REACT_SSR) and [typed context example](https://codebelt.github.io/classy-store/docs/REACT_SCOPING). - -The published `@codebelt/classy-store/react` entry now carries `use client`. In Next.js Server Components or other server-only modules, import preparation from `@codebelt/classy-store/react/server`. Hooks and the existing preparation export remain available from `/react` inside client modules and ordinary SSR. Pass serializable initial data across the Server Component boundary and construct the store inside a Client Provider; do not pass the store proxy across that boundary. See the [Next.js App Router guide](https://codebelt.github.io/classy-store/docs/REACT_NEXT). - -## Review persistence ordering and cleanup - -The stored JSON envelope has not changed, so upgrading the library alone does not require a persisted-data migration or a change to your application's persistence `version`. - -- Saves and removals requested through one persistence handle execute in request order. Each save captures the state when it is requested. -- Rehydration waits for that handle's prior writes and ignores read results superseded by a newer read, save, clear, local edit, or accepted storage event. -- `clear()` cancels pending auto-saves and removes storage after writes already requested. It does not reset the live store; a later edit can save again. -- `unsubscribe()` stops listeners and timers, ignores pending reads, and skips queued operations that have not started. It cannot cancel I/O already issued to an adapter. If you need a final save, await `handle.save()` before unsubscribing while the application is still running. -- Real browser tab synchronization ignores outdated storage events and avoids echo writes. Ordering is per handle; separate handles writing the same async storage key still need application or adapter coordination. - -See the [persistence guide](https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL) for adapter, debounce, and cross-tab behavior. - -## Release notes - -### React integration - -- Synchronous React subscriptions fix controlled-input caret jumps without configuration; core subscriptions remain batched by default. -- Selector or equality-function changes refresh selections without requiring a store mutation. Concurrent and abandoned renders keep their tracking isolated. -- Auto-tracking preserves reads reused by React Compiler and memoized children. It can conservatively retain earlier reads until the relevant snapshot object's identity changes. -- `prepareStoreForHydration` and the `/react/server` entry support delayed and streamed hydration, including persistence updates before a boundary hydrates. -- Next.js App Router package boundaries, request isolation, navigation, refresh, and cleanup have production packed-package coverage with Compiler off and on. -- A typed context example covers independent and nested stores, keyed resets, and effect-owned persistence cleanup. - -### Core, utilities, and performance - -- Undo/redo and persistence restore mutable nested state while preserving reactive collection methods and recorded snapshots. -- Frozen snapshot backing copies are reused across auto-tracked consumers, reducing repeated work in large forms and lists. -- Detached child proxies, obsolete snapshot clones, and retired React selections can be released, including after collection replacement and array truncation. -- `Snapshot` preserves callable signatures for typed methods, overloads, and generic functions under strict TypeScript. -- Persistence orders asynchronous operations and rejects stale reads and storage events, with regression coverage for overlapping operations and real two-tab React inputs. - -Validation includes core/React tests, three-browser input tests, packed React 18/19 consumers, Compiler off/on, streaming hydration, Next.js, render-count budgets, and heap-snapshot checks. Persistence, history, DevTools, and reactive collections also have combined happy-path coverage and public TypeScript checks. +Generated from the Markdown sources in website/docs by scripts/generateLlms.mjs. +Source: https://codebelt.github.io/classy-store/docs/ --- SOURCE: https://codebelt.github.io/classy-store/docs/ @@ -203,7 +12,7 @@ SOURCE: https://codebelt.github.io/classy-store/docs/ Class-based reactive state management for React, Vue, Svelte, Solid, and Angular. Write plain TypeScript classes — get fine-grained reactivity, immutable snapshots, and zero-boilerplate hooks. ``` -~2.3 KB gzipped · ES6 Proxy · useSyncExternalStore · proxy-compare +ES6 Proxy · useSyncExternalStore · proxy-compare ``` ## Features @@ -225,6 +34,8 @@ Class-based reactive state management for React, Vue, Svelte, Solid, and Angular ## Installation +Upgrading from 0.7.x? Read the [0.8.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_8) for public API changes and persistence error handling. From 0.6.x or earlier, also follow the [0.7.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7). + ```bash bun add @codebelt/classy-store ``` @@ -307,6 +118,42 @@ function AddButton() { } ``` +## Choosing an API + +| What you need | API | +| --- | --- | +| Create shared state from a class or object | `createClassyStore(instance)` | +| Render store data in React | `useClassyStore(store)` or `useClassyStore(store, selector)` | +| Create a store owned by a component | `useLocalStore(factory)` | +| Bind a reusable hook to an existing store | `createStoreHook(store)` | +| Render undo/redo availability or pause state | `useHistoryStatus(history)` | +| Render persistence hydration, expiry, or errors | `usePersistStatus(handle)` | +| Prepare matching initial data for SSR/hydration | `prepareStoreForHydration(store)` | +| Read state outside React or observe mutations | `snapshot(store)`, `subscribe(store, listener)`, `subscribeKey(store, key, listener)` | +| Persist state, record history, or inspect it in DevTools | `persist(store, options)`, `withHistory(store)`, `devtools(store)` | +| Track changes inside Map-like or Set-like collections | `reactiveMap()`, `reactiveSet()` | +| Compare selector results one level deep | `shallowEqual(previous, next)` | + +`useClassyStore` is the starting point for React components. The two status hooks +are optional: use them only when displaying metadata from an existing history or +persistence handle. They do not create those handles or subscribe to store data. + +## Package entry points + +Import from the entry that owns the API. Core, collections, and utilities do not depend on React. + +| Import path | Runtime exports | Public types | +| --- | --- | --- | +| `@codebelt/classy-store` | `createClassyStore`, `snapshot`, `subscribe`, `shallowEqual` | `Snapshot`, `SubscribeOptions` | +| `@codebelt/classy-store/react` | `useClassyStore`, `useLocalStore`, `createStoreHook`, `useHistoryStatus`, `usePersistStatus` | `UseClassyStoreSelectorOptions` | +| `@codebelt/classy-store/react/server` | `prepareStoreForHydration` | — | +| `@codebelt/classy-store/collections` | `reactiveMap`, `reactiveSet` | `ReactiveMap`, `ReactiveSet` | +| `@codebelt/classy-store/utils` | `persist`, `withHistory`, `subscribeKey`, `devtools` | `PersistOptions`, `PersistHandle`, `PersistStatus`, `PersistOperation`, `PropertyTransform`, `StorageAdapter`, `HistoryOptions`, `HistoryHandle`, `HistoryStatus`, `DevtoolsOptions`, `SubscribeOptions` | + +`ReactiveMap` and `ReactiveSet` are exported as types; construct values with the +factory functions. The React entry carries `use client`. Import hydration +preparation from `/react/server` in both server code and client modules. + ## API Reference ### `createClassyStore(instance)` @@ -384,21 +231,42 @@ Migration: remove `{sync: true}` or `{sync: false}` from React hook calls and re ### `useHistoryStatus(history)` -Import from `@codebelt/classy-store/react` to observe a `HistoryHandle` returned by `withHistory(store)`. `const {canUndo, canRedo, isPaused} = useHistoryStatus(history)` updates undo controls and recording status even when store data is unchanged. The returned readonly `HistoryStatus` also contains `isDisposed`. +Use `useClassyStore(store)` for application data. Use `useHistoryStatus(history)` when a component renders undo/redo availability, recording state, or history disposal: + +```tsx +import {useHistoryStatus} from '@codebelt/classy-store/react'; +import type {HistoryHandle} from '@codebelt/classy-store/utils'; + +function UndoButton({history}: {history: HistoryHandle}) { + const {canUndo} = useHistoryStatus(history); + return ; +} +``` + +The hook accepts the `HistoryHandle` returned by `withHistory(store)` and returns `HistoryStatus`: `canUndo`, `canRedo`, `isPaused`, and `isDisposed`. It updates even when store data stays unchanged, such as when recording is paused. Creating or using history does not require the hook. ### `usePersistStatus(handle)` -Import from `@codebelt/classy-store/react` to observe a `PersistHandle` returned by `persist(store, options)`. `const {isHydrated, isExpired, lastError} = usePersistStatus(handle)` updates hydration, expiry, and error UI without requiring a store data mutation. The readonly `PersistStatus` also contains `isDisposed`. `lastError` is `{error, operation}` or `null`. +Use `usePersistStatus(handle)` when a component renders persistence hydration, expiry, disposal, or errors: -Most components only need `useClassyStore(store)` for application data. Use the two status hooks only when rendering their matching feature's status; configuring history or persistence does not require them. The shared subscription implementation is internal. +```tsx +import {usePersistStatus} from '@codebelt/classy-store/react'; +import type {PersistHandle} from '@codebelt/classy-store/utils'; + +function PersistenceBadge({handle}: {handle: PersistHandle}) { + const {isHydrated, lastError} = usePersistStatus(handle); + if (lastError) return Persistence failed: {lastError.operation}; + return {isHydrated ? 'Ready' : 'Restoring…'}; +} +``` -Both handles provide framework-neutral status methods: `getSnapshot()` returns cached readonly status; `getServerSnapshot()` returns initial status for SSR/hydration; `subscribe(listener)` returns listener cleanup. Status notifications are synchronous, while normal store subscriptions and history recording remain batched. Server and client handles must start with matching initial status. Disposal publishes its final status and clears listeners. Unsubscribing a status listener does not dispose the utility. +The hook accepts the `PersistHandle` returned by `persist(store, options)` and returns `PersistStatus`: `isHydrated`, `isExpired`, `isDisposed`, and `lastError` (`{error, operation}` or `null`). `isHydrated` means a hydration attempt settled; inspect `lastError` for a reported failure. Configuring persistence and calling its methods do not require the hook. -Persistence `save`, `clear`, and `rehydrate` reject on failure after updating status and calling `onError`. Automatic writes and initial hydration catch reported errors without producing unhandled rejections. Failed operations do not block later queued writes. Decoding, transform, migration, and merge failures use the `parse` operation. `lastError` clears after the next successful operation. `rehydrate` waits for expired-data removal when enabled. Superseded reads are still ignored. Disposed calls and explicit queued writes cancelled by disposal reject; disposal errors do not publish status or call `onError`. +Both hooks return readonly status snapshots whose identity stays stable until a status field changes. They share an internal subscription implementation. Status listeners run synchronously when status changes; ordinary store subscriptions, including history recording, remain batched. Unmounting a status consumer removes its listener without disposing the handle. -`dispose()` stops persistence and releases its subscriptions and timers; it is safe to call repeatedly. `isHydrated` and `isExpired` are readonly. `hydrated` and `isHydrated` indicate that a hydration attempt settled, not that data was applied successfully. Pending hydration may settle after disposal and update readable flags without notifying disposed listeners. Exported types include `PersistOperation`, `PersistStatus`, and `HistoryStatus`. +Outside React, both handles provide framework-neutral status methods: `getSnapshot()`, `getServerSnapshot()`, and `subscribe(listener)`, which returns listener cleanup. -Persistence transforms now infer `serialize` and `deserialize` types from `key`: serializers receive `Snapshot`, and deserializers return `T[K]`. Stored input remains `unknown`. Optional properties preserve `undefined`. Standalone transforms can use `PropertyTransform`. Existing transforms that mutate snapshots or deserialize to another property's type must be corrected. +For SSR, handles expose their initial status through `getServerSnapshot()` and React catches up with live status after hydration. Construct request-scoped handles and matching client handles with the same initial status; keep persistence side effects in the provider lifecycle described in [React store scoping](https://codebelt.github.io/classy-store/docs/REACT_SCOPING). ### `prepareStoreForHydration(store)` @@ -415,13 +283,13 @@ const store = prepareStoreForHydration( Call on each request's store and its client counterpart using matching initial data, before rendering or starting persistence. Direct and bound hooks hydrate from the captured snapshot even if persistence updates the live store before a Suspense boundary hydrates. After hydration they read current state normally. -Preparation is idempotent, retains the first snapshot for the store's lifetime, and applies to the exact proxy passed in. Client-only apps need no preparation. See [React SSR and hydration](./REACT_SSR.md) for streaming, request isolation, nested proxies, and snapshot leaf semantics. +Preparation is idempotent, retains the first snapshot for the store's lifetime, and applies to the exact proxy passed in. Client-only apps need no preparation. See [React SSR and hydration](https://codebelt.github.io/classy-store/docs/REACT_SSR) for streaming, request isolation, nested proxies, and snapshot leaf semantics. -Import the preparation helper from `@codebelt/classy-store/react/server` on both the server and client. It does not import React hooks; the separate `/react` hooks entry is marked `use client`. See [Next.js App Router](./REACT_NEXT.md) for production package boundaries and navigation patterns. +Import the preparation helper from `@codebelt/classy-store/react/server` on both the server and client. It does not import React hooks; the separate `/react` hooks entry is marked `use client`. See [Next.js App Router](https://codebelt.github.io/classy-store/docs/REACT_NEXT) for production package boundaries and navigation patterns. ### `useLocalStore(factory)` -Creates a component-scoped reactive store. Each component instance gets its own isolated store, garbage collected on unmount. +Creates a component-scoped reactive store. Each component instance gets its own isolated store. Unmounting releases React's reference; the store can be garbage collected when no other references remain. ```tsx import {useLocalStore, useClassyStore} from '@codebelt/classy-store/react'; @@ -441,9 +309,9 @@ function Counter() { Subsequent re-renders reuse the same store instance. Keep the factory pure: React StrictMode can invoke the initializer more than once in development. -For a shared subtree, use the [typed context and lifecycle example](./REACT_SCOPING.md). It covers independent and nested instances, explicit resets with React keys, effect cleanup, persistence, and SSR. `useLocalStore` owns the instance reference; application side effects still need cleanup in their effects. +For a shared subtree, use the [typed context and lifecycle example](https://codebelt.github.io/classy-store/docs/REACT_SCOPING). It covers independent and nested instances, explicit resets with React keys, effect cleanup, persistence, and SSR. `useLocalStore` owns the instance reference; application side effects still need cleanup in their effects. -> See the [Local Stores](./TUTORIAL.md#local-stores) section in the Tutorial for persistence patterns and more examples. +> See the [Local Stores](https://codebelt.github.io/classy-store/docs/TUTORIAL#local-stores) section in the Tutorial for persistence patterns and more examples. ### `createStoreHook(store)` @@ -497,13 +365,13 @@ Validates at creation time — throws if the argument is not a store proxy. ### `snapshot(store)` -Creates a deeply frozen, immutable snapshot of the current state. Used internally by `useClassyStore` but also available directly. +Reads a snapshot with frozen copies of store objects and arrays, cached by store version with structural sharing. Used internally by `useClassyStore` but also available directly. Opaque leaves such as native `Map`, `Set`, `Date`, and functions retain their references; do not mutate them through a snapshot. ```typescript import {snapshot} from '@codebelt/classy-store'; const snap = snapshot(myStore); -// snap is deeply frozen — mutations throw +// Copied objects and arrays are frozen; mutate the store, not its snapshot. // Structural sharing: unchanged sub-trees === previous snapshot ``` @@ -633,10 +501,13 @@ Persist store state to storage with transforms, versioning, migration, cross-tab ```typescript import {persist} from '@codebelt/classy-store/utils'; -const handle = persist(todoStore, {name: 'todo-store'}); +const handle = persist(todoStore, {name: 'todo-store', storage: localStorage}); + +// When this store's owner no longer needs persistence: +handle.dispose(); ``` -> See the [Persist Tutorial](./PERSIST_TUTORIAL.md) and [Persist Architecture](./PERSIST_ARCHITECTURE.md). +> See the [Persist Tutorial](https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL) and [Persist Architecture](https://codebelt.github.io/classy-store/docs/PERSIST_ARCHITECTURE). ### `devtools(store, options?)` @@ -648,11 +519,11 @@ import {devtools} from '@codebelt/classy-store/utils'; const disconnect = devtools(myStore, {name: 'MyStore'}); ``` -> See the [DevTools Tutorial](./DEVTOOLS_TUTORIAL.md). +> See the [DevTools Tutorial](https://codebelt.github.io/classy-store/docs/DEVTOOLS_TUTORIAL). -### `subscribeKey(store, key, callback)` +### `subscribeKey(store, key, callback, options?)` -Subscribe to changes on a single property. Fires only when the watched key changes. +Subscribe to changes on a single property, comparing snapshot values with `Object.is`. Notifications are batched by default; pass `{sync: true}` as the fourth argument to observe each change immediately. Returns listener cleanup. ```typescript import {subscribeKey} from '@codebelt/classy-store/utils'; @@ -662,7 +533,7 @@ const unsub = subscribeKey(store, 'theme', (value, prev) => { }); ``` -> See the [subscribeKey Tutorial](./SUBSCRIBE_KEY_TUTORIAL.md). +> See the [subscribeKey Tutorial](https://codebelt.github.io/classy-store/docs/SUBSCRIBE_KEY_TUTORIAL). ### `withHistory(store, options?)` @@ -676,7 +547,7 @@ history.undo(); history.redo(); ``` -> See the [withHistory Tutorial](./HISTORY_TUTORIAL.md). +> See the [withHistory Tutorial](https://codebelt.github.io/classy-store/docs/HISTORY_TUTORIAL). ## Patterns @@ -890,48 +761,240 @@ This library wouldn't exist without the ideas pioneered by these projects. Each **[Claude 4.6 Opus](https://anthropic.com)** — Let's be real: this library was designed, architected, implemented, tested, and documented almost entirely by Claude 4.6 Opus (Anthropic) via [Cursor](https://cursor.com). From the three-layer proxy architecture to the memoized computed getters with dependency tracking, the cross-snapshot caching strategy, and tests — it was all pair-programmed with an AI that never gets tired of writing Proxy traps. The human brought the vision, the taste, and the "no, make it better" energy. Claude brought the code. --- -SOURCE: https://codebelt.github.io/classy-store/docs/TUTORIAL +SOURCE: https://codebelt.github.io/classy-store/docs/UPGRADING_0_8 --- -# Classy Store Tutorial +# Upgrading to 0.8.0 -`Classy Store` is a reactive state library for React, Vue, Svelte, Solid, and Angular built on ES proxies. You define state as plain classes, wrap them with `createClassyStore()`, and read them with the binding for your framework. There are no Providers, no observers, no reducers, and no extra TypeScript interfaces to define your state shape — just classes and a hook. The library is ~3.5 KB gzipped, batches ordinary subscriptions while notifying React synchronously, and uses structural sharing for efficient change detection. +Version 0.8.0 adds observable history and persistence status and simplifies the public API. This pre-1.0 release includes breaking changes for consumers of 0.7.x. -## Quick Start +## Required updates -**1. Define a class:** +| Previous usage | Use in 0.8.0 | +| --- | --- | +| `persistence.unsubscribe()` | `persistence.dispose()` | +| Import `prepareStoreForHydration` from `/react` | Import it from `@codebelt/classy-store/react/server`, on both server and client | +| Import `getVersion` from the root entry | Use `subscribe()` for notifications or compare `snapshot()` references | +| Assume explicit persistence calls always resolve | Handle rejected promises from `save()`, `clear()`, and `rehydrate()` | +| Transform a property's value as a union of all store properties | Match `serialize` and `deserialize` to the property selected by `key` | -```ts -// stores.ts -import {createClassyStore} from '@codebelt/classy-store'; +## Dispose persistence handles -class Counter { - count = 0; +Update effect cleanups and other teardown calls: - increment() { - this.count++; - } -} +```ts +// Before +return () => persistence.unsubscribe(); -export const counterStore = createClassyStore(new Counter()); +// After +return () => persistence.dispose(); ``` -**2. Use it in a component:** - -```tsx -// Counter.tsx -import {useClassyStore} from '@codebelt/classy-store/react'; -import {counterStore} from './stores'; - -export function Counter() { - const count = useClassyStore(counterStore, (state) => state.count); - return ; -} -``` +Disposal is idempotent. It stops subscriptions and timers, ignores pending read results, and cancels writes that have not started. Already-issued storage I/O cannot be cancelled. Explicit queued operations cancelled by disposal reject, as do explicit operations called after disposal. -That's it. No Provider wrapping your app. The store is a module-level singleton — import it wherever you need it. +## Handle explicit persistence failures -## Defining Stores +`save()`, `clear()`, and `rehydrate()` take no options. They report storage failures through `onError` and `lastError`, and reject their promises. Handle the promise even when an `onError` callback is configured: + +```ts +try { + await persistence.save(); + showSavedMessage(); +} catch (error) { + showSaveError(error); +} +``` + +Automatic writes and initial hydration handle reported failures internally. `hydrated` resolves when the initial attempt settles; `isHydrated` also means an attempt settled, including failure or a superseded read. Check `persistence.getSnapshot().lastError` when the result matters. A failed operation does not prevent later queued writes from running. + +## Subscribe to utility status in React + +Use `useClassyStore(store)` for store data. Use the new hooks when rendering utility metadata that can change independently of that data: + +```tsx +import {useHistoryStatus, usePersistStatus} from '@codebelt/classy-store/react'; + +const {canUndo, canRedo, isPaused} = useHistoryStatus(history); +const {isHydrated, isExpired, lastError} = usePersistStatus(persistence); +``` + +Call these hooks inside a component or custom hook, with stable handles. Both status snapshots include `isDisposed`. History pause state now stays consistent when undo/redo finishes. + +Outside React, handles expose `getSnapshot()`, `getServerSnapshot()`, and `subscribe(listener)`. The function returned by `subscribe` removes that status listener; `dispose()` tears down the utility handle itself. The shared status source implementation is internal. Consumer types are `HistoryHandle`, `HistoryStatus`, `PersistHandle`, and `PersistStatus`, exported from `@codebelt/classy-store/utils`. + +During SSR and hydration, status hooks use the handle's initial status snapshot. Construct server and client handles with matching initial status. + +## Use the hydration entry on server and client + +```ts +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; +``` + +The `/react` entry exports React hooks and `createStoreHook`. Hydration preparation is exported only from `/react/server`; its captured-snapshot behavior is unchanged. + +## Match persistence transforms to their property + +Transform serializers receive the selected property's readonly snapshot value. Deserializers receive `unknown` and must return that property's mutable value. Validate stored values before returning them. Transforms that mutate their snapshot input or return another property's type now fail TypeScript checks. + +Persistence status flags are readonly, matching their runtime getters. `PersistOperation` is exported from the utilities entry for typed error handlers. + +If upgrading from 0.6.x or earlier, also follow the [0.7.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7) for React timing options and the native `WeakRef` requirement. + +--- +SOURCE: https://codebelt.github.io/classy-store/docs/UPGRADING_0_7 +--- + +# Upgrading to 0.7.0 + +This is the historical 0.7.0 upgrade guide. For the current API, also read [Upgrading to 0.8.0](https://codebelt.github.io/classy-store/docs/UPGRADING_0_8), which replaces persistence cleanup and removes duplicate exports. + +Version 0.7.0 focuses on core and React correctness, including controlled inputs, React Compiler, SSR, Next.js App Router, memory retention, and persistence. It includes React API and runtime changes that consumers upgrading from 0.6.x should review. + +## Remove React timing options + +React hooks now always subscribe synchronously. This keeps controlled inputs and their caret positions aligned with store updates. Remove `sync: true` and `sync: false` from both direct and bound hooks. + +Before, in 0.6.x: + +```tsx +const state = useClassyStore(store, {sync: true}); +const count = useClassyStore(store, (state) => state.count, {sync: true}); + +// useCounterStore was created with createStoreHook(store). +const boundState = useCounterStore({sync: true}); +const boundCount = useCounterStore((state) => state.count, {sync: true}); +``` + +After, in 0.7.0: + +```tsx +const state = useClassyStore(store); +const count = useClassyStore(store, (state) => state.count); + +const boundState = useCounterStore(); +const boundCount = useCounterStore((state) => state.count); +``` + +Auto-tracked hooks no longer accept an options argument, even an empty `{}`. Remove imports and annotations using the removed `UseClassyStoreOptions` type. Selector hooks still accept `UseClassyStoreSelectorOptions` with `isEqual`: + +```tsx +// Before +const selection = useClassyStore(store, selectSummary, { + sync: true, + isEqual: shallowEqual, +}); + +// After +const selection = useClassyStore(store, selectSummary, { + isEqual: shallowEqual, +}); +``` + +The same change applies to selector options passed to a bound hook. Existing calls without React timing options continue to work. + +Ordinary `subscribe(store, listener)` callbacks still batch in a microtask, and core subscriptions retain their `{sync: true}` option. React selectors can run after each individual mutation, even when React batches the resulting renders into one commit. Keep selectors pure and able to read intermediate state during a multi-property update; this release does not introduce atomic transactions. + +## Check the auto-tracking runtime requirement + +Auto-tracked React hooks (`useClassyStore(store)` and bound hooks without a selector) now require native `WeakRef` support from ES2021. Weak references let mounted consumers release obsolete snapshots and collection values. + +Check that every browser or server runtime running auto-tracked hooks supports `WeakRef`. Transpiling JavaScript does not supply native weak-reference behavior. If your supported runtime lacks it, use explicit selectors or retain 0.6.x until you can raise that runtime requirement. Selector hooks do not require `WeakRef`. + +React 18 and 19 remain covered by the package compatibility tests. React Compiler works with both selector and auto-tracked hooks; no compiler opt-out is required. + +## Prepare server-rendered stores before live updates + +Client-only apps do not need hydration preparation. For server-rendered apps where persistence or another update can run before every Suspense boundary hydrates, call `prepareStoreForHydration(store)` on server and client stores initialized from matching data, before rendering or starting persistence. + +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; + +const store = prepareStoreForHydration( + createClassyStore(new ProfileStore(initialData)), +); +``` + +Create a separate store for each server request. The prepared snapshot is retained for that store's lifetime; use a new store for a new request or an intentional reset. See the [SSR guide](https://codebelt.github.io/classy-store/docs/REACT_SSR) and [typed context example](https://codebelt.github.io/classy-store/docs/REACT_SCOPING). + +The published `@codebelt/classy-store/react` entry now carries `use client`. In Next.js Server Components or other server-only modules, import preparation from `@codebelt/classy-store/react/server`. Hooks and the existing preparation export remain available from `/react` inside client modules and ordinary SSR. Pass serializable initial data across the Server Component boundary and construct the store inside a Client Provider; do not pass the store proxy across that boundary. See the [Next.js App Router guide](https://codebelt.github.io/classy-store/docs/REACT_NEXT). + +## Review persistence ordering and cleanup + +The stored JSON envelope has not changed, so upgrading the library alone does not require a persisted-data migration or a change to your application's persistence `version`. + +- Saves and removals requested through one persistence handle execute in request order. Each save captures the state when it is requested. +- Rehydration waits for that handle's prior writes and ignores read results superseded by a newer read, save, clear, local edit, or accepted storage event. +- `clear()` cancels pending auto-saves and removes storage after writes already requested. It does not reset the live store; a later edit can save again. +- `unsubscribe()` stops listeners and timers, ignores pending reads, and skips queued operations that have not started. It cannot cancel I/O already issued to an adapter. If you need a final save, await `handle.save()` before unsubscribing while the application is still running. +- Real browser tab synchronization ignores outdated storage events and avoids echo writes. Ordering is per handle; separate handles writing the same async storage key still need application or adapter coordination. + +See the [persistence guide](https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL) for adapter, debounce, and cross-tab behavior. + +## Release notes + +### React integration + +- Synchronous React subscriptions fix controlled-input caret jumps without configuration; core subscriptions remain batched by default. +- Selector or equality-function changes refresh selections without requiring a store mutation. Concurrent and abandoned renders keep their tracking isolated. +- Auto-tracking preserves reads reused by React Compiler and memoized children. It can conservatively retain earlier reads until the relevant snapshot object's identity changes. +- `prepareStoreForHydration` and the `/react/server` entry support delayed and streamed hydration, including persistence updates before a boundary hydrates. +- Next.js App Router package boundaries, request isolation, navigation, refresh, and cleanup have production packed-package coverage with Compiler off and on. +- A typed context example covers independent and nested stores, keyed resets, and effect-owned persistence cleanup. + +### Core, utilities, and performance + +- Undo/redo and persistence restore mutable nested state while preserving reactive collection methods and recorded snapshots. +- Frozen snapshot backing copies are reused across auto-tracked consumers, reducing repeated work in large forms and lists. +- Detached child proxies, obsolete snapshot clones, and retired React selections can be released, including after collection replacement and array truncation. +- `Snapshot` preserves callable signatures for typed methods, overloads, and generic functions under strict TypeScript. +- Persistence orders asynchronous operations and rejects stale reads and storage events, with regression coverage for overlapping operations and real two-tab React inputs. + +Validation includes core/React tests, three-browser input tests, packed React 18/19 consumers, Compiler off/on, streaming hydration, Next.js, render-count budgets, and heap-snapshot checks. Persistence, history, DevTools, and reactive collections also have combined happy-path coverage and public TypeScript checks. + +--- +SOURCE: https://codebelt.github.io/classy-store/docs/TUTORIAL +--- + +# Classy Store Tutorial + +`Classy Store` is a reactive state library for React, Vue, Svelte, Solid, and Angular built on ES proxies. You define state as plain classes, wrap them with `createClassyStore()`, and read them with the binding for your framework. There are no Providers, no observers, no reducers, and no extra TypeScript interfaces to define your state shape — just classes and a hook. The library is ~3.5 KB gzipped, batches ordinary subscriptions while notifying React synchronously, and uses structural sharing for efficient change detection. + +## Quick Start + +**1. Define a class:** + +```ts +// stores.ts +import {createClassyStore} from '@codebelt/classy-store'; + +class Counter { + count = 0; + + increment() { + this.count++; + } +} + +export const counterStore = createClassyStore(new Counter()); +``` + +**2. Use it in a component:** + +```tsx +// Counter.tsx +import {useClassyStore} from '@codebelt/classy-store/react'; +import {counterStore} from './stores'; + +export function Counter() { + const count = useClassyStore(counterStore, (state) => state.count); + return ; +} +``` + +That's it. No Provider wrapping your app. The store is a module-level singleton — import it wherever you need it. + +## Defining Stores A store is any class instance wrapped with `createClassyStore()`. State lives as properties, mutations are plain assignments, and computed values are `get` accessors. @@ -1294,11 +1357,11 @@ A component selecting `loading` can show a spinner while the request is pending ## Local Stores -By default, stores are module-level singletons — shared across your entire app. For component-scoped state that is garbage collected on unmount, use `useLocalStore`. +Where you create a store determines its scope. A module-level store is shared by importers; `useLocalStore` creates one instance per mounted component. For SSR, use request-scoped or provider-owned stores instead of a shared server singleton. ### Basic usage -`useLocalStore` creates a reactive store scoped to the component's lifetime. Each component instance gets its own isolated store. When the component unmounts, the store is garbage collected. +`useLocalStore` creates a reactive store scoped to the component's lifetime. Each component instance gets its own isolated store. Unmounting releases React's reference. Garbage collection can reclaim the store once no other references remain. ```tsx import {useLocalStore, useClassyStore} from '@codebelt/classy-store/react'; @@ -1321,7 +1384,7 @@ The factory initializes component state. Subsequent re-renders reuse the same st ### Persisting a local store -`persist()` subscribes to the store, which keeps a reference alive. You must call `handle.dispose()` on unmount to allow garbage collection. +`persist()` owns subscriptions, timers, and optional browser listeners. Create the handle in an effect and dispose it in that effect's cleanup, including during Strict Mode replay. `useLocalStore` does not dispose application effects for you. ```tsx import {useEffect} from 'react'; @@ -1341,7 +1404,7 @@ function EditProfile() { const snap = useClassyStore(store); useEffect(() => { - const handle = persist(store, { name: 'edit-profile-draft' }); + const handle = persist(store, { storage: localStorage, name: 'edit-profile-draft' }); return () => handle.dispose(); }, [store]); @@ -1354,7 +1417,7 @@ function EditProfile() { } ``` -The `useEffect` cleanup ensures the persist subscription is removed and the store can be garbage collected when the component unmounts. +The effect cleanup stops persistence. The store can be collected once React and any other owners release it. ## Creating a Bound Hook with `createStoreHook` @@ -1491,2069 +1554,2933 @@ a === c; // false — new snapshot after mutation import {persist} from '@codebelt/classy-store/utils'; const handle = persist(todoStore, { + storage: localStorage, name: 'todo-store', }); // State survives page refreshes. // Getters and methods are automatically excluded. -// Cross-tab sync is enabled by default. +// Opt into cross-tab sync with syncTabs: true when using localStorage. ``` The persist utility supports per-property transforms (for `Date`, `ReactiveMap`, etc.), debounced writes, schema versioning with migration, merge strategies, TTL expiration (`expireIn`), SSR-safe `skipHydration`, and cross-tab synchronization. -For the full walkthrough, see the **[Persist Tutorial](./PERSIST_TUTORIAL.md)**. - +For the full walkthrough, see the **[Persist Tutorial](https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL)**. --- -SOURCE: https://codebelt.github.io/classy-store/docs/REACT_SSR +SOURCE: https://codebelt.github.io/classy-store/docs/ARCHITECTURE --- -# React server rendering and hydration - -Create one store for each server request and reconstruct a client store from the same initial data. Pass the store through props or React context. A module-level store on the server shares mutable state across requests and can expose one user's data to another. +# Classy Store — Architecture -The [typed context example](./REACT_SCOPING.md) provides a complete provider pattern with independent instances, nested scopes, keyed resets, and persistence cleanup. +Internal design documentation for contributors and maintainers. -Import hydration preparation from `@codebelt/classy-store/react/server` in server code and client modules. This entry does not import React hooks. The separate `@codebelt/classy-store/react` hooks entry is marked `use client`. The [Next.js App Router guide](./REACT_NEXT.md) explains where to construct stores and how to cross that boundary with initial data. +## Three-Layer Architecture -Call `prepareStoreForHydration(store)` after initializing the store with the data used to render the page, on both the server and client. It captures that initial snapshot before persistence or other live updates begin. All direct and bound hooks use the captured data during server rendering and client hydration, then read the current live store. React requires the initial server and client data to match; see [React's external-store SSR guidance](https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering). +The library is organized into three distinct layers, each with a single responsibility: -## Initialize from request data +```mermaid +flowchart TB + subgraph userCode ["User Code"] + ClassDef["class MyStore { fields, methods, getters }"] + end -Keep the serialized payload limited to the data the client needs. Reconstruct the class so its methods and getters remain available: + subgraph layer1 ["Layer 1: Write Proxy (core.ts)"] + storeFn["createClassyStore(instance)"] + SetTrap["SET trap: forward write → bump version → notify subscribers"] + GetTrap["GET trap: return value, bind methods, lazy-wrap nested objects, memoize getters"] + DeleteTrap["DELETE trap: same as SET"] + Batch["queueMicrotask: coalesce synchronous mutations → 1 notification"] + SyncNotify["sync listeners: notify immediately per mutation"] + DeepProxy["Lazy deep proxy: nested objects/arrays wrapped on first access"] + end -```ts -// profileStore.ts -import {createClassyStore} from '@codebelt/classy-store'; -import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; + subgraph layer2 ["Layer 2: Snapshot (snapshot.ts)"] + SnapshotFn["snapshot(proxy): immutable deep copy"] + StructuralShare["Structural sharing: unchanged refs reused across snapshots"] + Freeze["Object.freeze: enforces immutability"] + VersionCache["Version-stamped cache: skip rebuild if version unchanged"] + GetterEval["Getters: memoized per-snapshot + cross-snapshot caching"] + end -export type ProfileData = { - name: string; - visits: number; -}; + subgraph layer3 ["Layer 3: React Hook (useClassyStore.ts)"] + UseStore["useClassyStore(store, selector?)"] + UseLocalStore["useLocalStore(factory)"] + USES["useSyncExternalStore"] + ProxyCompare["proxy-compare: track which snapshot props selector reads"] + IsChanged["isChanged(prev, next, affected): fine-grained diff"] + SelectorMode["Selector mode: Object.is or custom isEqual"] + end -export class ProfileStore { - name: string; - visits: number; + subgraph component ["React Component"] + Render["Re-render ONLY when accessed snapshot props change"] + end - constructor(initialData: ProfileData) { - this.name = initialData.name; - this.visits = initialData.visits; + ClassDef --> storeFn + storeFn --> SetTrap + storeFn --> GetTrap + storeFn --> DeleteTrap + SetTrap --> SyncNotify + SetTrap --> Batch + Batch --> SnapshotFn + GetTrap --> DeepProxy + SnapshotFn --> StructuralShare + StructuralShare --> Freeze + Freeze --> VersionCache + VersionCache --> GetterEval + UseStore --> USES + USES --> VersionCache + VersionCache --> ProxyCompare + ProxyCompare --> IsChanged + IsChanged --> Render + SelectorMode --> Render +``` + +## File Map + +The library lives in `packages/classy-store`. Public entry points expose the API; +implementation files and types live with the feature that owns them. + +```text +packages/classy-store/ +├── src/ +│ ├── index.ts # Core public entry; no React dependency +│ ├── types.ts # Public Snapshot and SubscribeOptions +│ ├── core/ +│ │ ├── core.ts # Write proxies, subscriptions, computed getters +│ │ ├── core.types.ts # Internal dependency and store bookkeeping types +│ │ ├── core.computed.test.ts # Write-proxy getter memoization +│ │ └── core.ownership.test.ts # Child ownership and subscription cleanup +│ ├── snapshot/ +│ │ ├── snapshot.ts # Immutable snapshots and structural sharing +│ │ └── snapshot.computed.test.ts # Snapshot getter memoization +│ ├── collections/ +│ │ ├── index.ts # Public collections entry +│ │ └── collections.ts # ReactiveMap and ReactiveSet +│ ├── frameworks/ +│ │ └── react/ +│ │ ├── react.ts # Public client entry; exports hooks and factory +│ │ ├── server.ts # Server-safe prepareStoreForHydration entry +│ │ ├── hooks/ +│ │ │ ├── useClassyStore/ # Selector and auto-tracked subscriptions; hook types +│ │ │ ├── useLocalStore/ # Component-scoped store creation +│ │ │ ├── useHistoryStatus/ # Public history status hook +│ │ │ ├── usePersistStatus/ # Public persistence status hook +│ │ │ └── useUtilityStatus/ # Internal shared status subscription hook +│ │ ├── utils/ +│ │ │ ├── createStoreHook/ # Bound store hook factory +│ │ │ ├── hydrationSnapshots/ # Shared captured snapshots, held by weak keys +│ │ │ ├── snapshotTargetCache/ # Shared tracking targets +│ │ │ └── trackingReadMap/ # Render-local and committed dependency reads +│ │ ├── testHelpers/ # React fixtures +│ │ └── react.*.test.tsx # Integration tests grouped by behavior +│ └── utils/ +│ ├── index.ts # Public utility functions and types +│ ├── persist/ +│ │ ├── persist.ts # Storage queue, hydration, transforms, tab sync +│ │ ├── persist.types.ts # Public persistence options, status, and handle +│ │ └── persist.*.test.ts # API, concurrency, and error regressions +│ ├── history/ # Undo/redo and status +│ ├── devtools/ # Redux DevTools integration +│ ├── subscribe-key/ # Single-property subscriptions +│ ├── equality/ # shallowEqual +│ ├── utilityStatus/ # Internal framework-neutral status source +│ ├── cloneSnapshot/ # Internal snapshot cloning +│ └── internal/ # Proxy and property helpers +├── tests/ +│ ├── browser/ # Real input, hydration, and persistence tests +│ ├── compatibility/ # Packed-library React version matrix +│ ├── compiler/ # Compiled consumer fixtures and regressions +│ ├── scoping/ # Provider isolation, lifecycle, and SSR +│ ├── next/ # Packed Next.js boundaries and navigation +│ ├── memory/ # Heap snapshots and retention checks +│ └── performance/ # Form and list benchmarks +├── tsdown.config.ts # Published entry points and bundle settings +└── bunfig.toml # happy-dom setup for source tests +``` + +Tests for core behavior stay beside their implementation. React integration tests +exercise the public client entry, including computed getter behavior. Consumer +fixtures under `tests/` verify the packed library and framework build boundaries. +The other framework adapters remain under `src/frameworks/`. + +Runnable examples live in `examples/react` and `examples/nextjs` at the repository +root. This guide and the utility architecture guides live in `website/docs`. + +## Layer 1: Write Proxy (`core.ts`) + +### Overview + +The `createClassyStore()` function wraps a class instance in an ES6 Proxy. All mutations — property writes, array operations, nested object changes — are intercepted. Subscribers are batched by default into a single notification per microtask, while React always uses the synchronous path. Other code can opt into that path when it must observe each mutation immediately. + +### Data Flow: Mutation → Notification + +```mermaid +sequenceDiagram + participant User as User Code + participant Proxy as Write Proxy + participant Target as Raw Target + participant Internal as StoreInternal + participant Sync as Sync Subscribers + participant Micro as queueMicrotask + participant Batched as Batched Subscribers + + User->>Proxy: store.count = 5 + Proxy->>Target: Reflect.set(target, 'count', 5) + Proxy->>Internal: bumpVersion(internal) up to root + Proxy->>Internal: snapshotCache = null (invalidate) + Proxy->>Sync: notify sync listeners immediately + Proxy->>Micro: schedule batched notify (if not already scheduled) + Note over Micro: Coalesces all sync mutations + User->>Proxy: store.name = 'new' + Proxy->>Target: Reflect.set(target, 'name', 'new') + Proxy->>Internal: bumpVersion (version increments again) + Proxy->>Sync: notify sync listeners again + Note over Micro: Still same microtask batch + Micro->>Batched: notify batched listeners once +``` + +### Internal State Storage + +All internal bookkeeping is stored in a `WeakMap`: + +```typescript +type StoreInternal = { + target: object; // Raw class instance + version: number; // Monotonically increasing + batchedListeners: Set<() => void>; // Subscribers notified once per microtask + syncListeners: Set<() => void>; // Subscribers notified immediately per mutation + childProxies: Map; // Cached child proxies + childInternals: Map; + parent: StoreInternal | null; // For version propagation + notifyScheduled: boolean; // Batch dedup flag + computedCache: Map; // Memoized getter cache +}; +``` + +### Proxy Traps + +**SET trap:** +1. Compare old and new values with `Object.is` — noop if equal +2. Clean up child proxy if the property is being replaced +3. Forward write to raw target via `Reflect.set` +4. Bump version for this node and all ancestors +5. Notify sync listeners immediately, then schedule batched listeners via `queueMicrotask` (deduped) + +**GET trap (priority order):** +1. **Memoized getter detection** — walk prototype chain with `Object.getOwnPropertyDescriptor`. If a getter is found, call `evaluateComputed()` which checks dependency validity and returns the cached result or re-evaluates with dependency tracking. +2. **Method binding** — if value is a function, bind to proxy so `this.count++` in methods goes through the SET trap. Bound methods are cached. +3. **Nested objects/arrays** — if value passes `canProxy()` (plain object or array), lazily wrap in a child proxy. Child proxies are cached in `childProxies` Map. Also records a dependency if a getter is currently being tracked. +4. **Primitives** — return as-is. Also records a dependency if a getter is currently being tracked. + +**DELETE trap:** Same pattern as SET — clean up child proxy, delete from target, bump version, notify sync listeners, and schedule batched listeners. + +### Batching via `queueMicrotask` + +Array operations like `push()` trigger multiple SET traps (element + length). For ordinary listeners, a dirty flag + `queueMicrotask` coalesces these mutations into one notification: + +``` +store.items.push('a') + → SET trap for index 0 → scheduleNotify (flags, queues microtask) + → SET trap for length → scheduleNotify (already flagged, skips) + → microtask fires → notifies listeners ONCE +``` + +Subscribers opt into immediate timing with `{sync: true}`. Sync subscribers run after version propagation and before the batched microtask. They are intentionally not deduped: two writes mean two sync notifications. Every React hook uses this timing so `useSyncExternalStore` observes the new value during the input event, preserving controlled-input caret position. Selectors can run after each write; React batches rendering within event handlers. + +### Version Propagation + +When a nested property mutates, versions bump from the mutated node up to the root: + +```mermaid +graph TD + Root["Root (v=10 → v=13)"] --> User["user (v=11 → v=12)"] + Root --> Settings["settings (v=5, unchanged)"] + User --> Name["name = 'Bob' (mutation here, v=12)"] + User --> Age["age = 30 (unchanged)"] +``` + +This enables structural sharing: when the snapshot layer sees that `settings` has the same version, it returns the cached snapshot reference. + +## Layer 2: Immutable Snapshots (`snapshot.ts`) + +### Overview + +`snapshot(proxy)` creates a deeply frozen, plain-JS copy of the store state. Unchanged sub-trees reuse previous snapshot references (structural sharing). + +### Snapshot Creation Flow + +```mermaid +flowchart TD + Start["snapshot(proxy)"] --> CheckCache{"Version match\nin snapshotCache?"} + CheckCache -->|Yes| ReturnCached["Return cached snapshot (O(1))"] + CheckCache -->|No| CreateSnap["Create new snapshot object"] + CreateSnap --> IterateKeys["For each own key"] + IterateKeys --> CheckChild{"Has child\ninternal?"} + CheckChild -->|Yes| Recurse["Recurse: createSnapshotRecursive(child)"] + CheckChild -->|No| CheckProxy{"Value is\nplain obj/array?"} + CheckProxy -->|Yes| DeepClone["deepFreezeClone (cached by identity)"] + CheckProxy -->|No| CopyValue["Copy as-is (primitive/function)"] + Recurse --> AssignProp["Assign to snapshot"] + DeepClone --> AssignProp + CopyValue --> AssignProp + AssignProp --> IterateKeys + IterateKeys -->|Done| Freeze["Object.freeze(snapshot)"] + Freeze --> Cache["Cache: snapshotCache.set(target, [version, snap])"] + Cache --> Return["Return frozen snapshot"] +``` + +### Two Cache Strategies + +1. **Tracked sub-trees** (`snapshotCache` — `WeakMap`): + Objects that have been accessed through the proxy have a `StoreInternal` with version tracking. The snapshot recurses into them and uses version-stamped caching for structural sharing. + +2. **Untracked sub-trees** (`untrackedCache` — `WeakMap`): + Objects never accessed through the proxy are deep-cloned and frozen. The clone is cached by the raw object's identity. Since untracked objects can't be mutated through the proxy, the cache is always fresh. + +### Structural Sharing Example + +``` +Snapshot v1: Snapshot v2 (after user.name = 'Bob'): +┌──────────┐ ┌──────────┐ +│ root │ │ root │ (new object) +│ user ────┼──→ {name:'Alice'} │ user ────┼──→ {name:'Bob'} (new) +│ settings─┼──→ {theme:'dark'} │ settings─┼──→ {theme:'dark'} (SAME ref ===) +└──────────┘ └──────────┘ +``` + +`snap2.settings === snap1.settings` because the `settings` sub-tree's version didn't change → cache hit → same reference. + +### Getter Evaluation (Memoized) + +Snapshots preserve the prototype chain and install lazy-memoizing getters on each snapshot object. Getter results are cached at two levels: + +1. **Per-snapshot cache** — repeated access to the same getter on the same snapshot returns the identical reference. +2. **Cross-snapshot cache** — if the properties the getter reads are structurally the same across snapshots (via reference equality from structural sharing), the previous result is returned. + +```typescript +class Store { + count = 5; + get doubled() { return this.count * 2; } +} + +const snap = snapshot(createClassyStore(new Store())); +snap.doubled; // 10 — computed once, cached +snap.doubled; // 10 — same reference (per-snapshot cache hit) +``` + +## Computed Getter Memoization + +Class getters are automatically memoized at two layers — the write proxy and the snapshot — without any user-facing API changes. This is analogous to MobX's `@computed`, but implemented transparently via Proxy. + +### Write Proxy Memoization (`core.ts`) + +When a getter is accessed on the write proxy, `evaluateComputed()` runs the getter with dependency tracking. A module-level tracker stack records which properties the getter reads on `this`: + +```mermaid +flowchart TD + GetterAccess["store.filtered accessed"] + CheckDeps{"Dependencies\nchanged?"} + ReturnCached["Return cached value"] + Recompute["Re-run getter with\ndependency tracking"] + CacheResult["Cache result + deps"] + + GetterAccess --> CheckDeps + CheckDeps -->|No| ReturnCached + CheckDeps -->|Yes| Recompute + Recompute --> CacheResult + CacheResult --> ReturnCached +``` + +**Dependency types:** +- **Sub-tree dep** (`kind: 'version'`): the getter read a nested object/array. Validated by comparing `internal.version` — O(1) per dep. +- **Primitive dep** (`kind: 'value'`): the getter read a primitive. Validated by `Object.is(target[prop], recordedValue)` — O(1) per dep. + +**Nested getter support:** when getter A reads getter B, B pushes its own frame onto the tracker stack. If B returns cached, A's deps include B's deps (propagated). If B recomputes, the underlying reads naturally propagate to A's tracker. + +### Snapshot Memoization (`snapshot.ts`) + +Since snapshots are frozen, a getter's result can never change for a given snapshot. But more importantly, getter results are stable **across** snapshots when dependencies haven't changed: + +```mermaid +flowchart TD + SnapGetter["snap.filtered accessed"] + PerSnapCheck{"Per-snapshot\ncache hit?"} + PerSnapReturn["Return cached ref"] + CrossSnapCheck{"Cross-snapshot\ndeps unchanged?"} + CrossSnapReturn["Return prev result"] + Compute["Run getter with dep tracking,\nrecord which this.X were read"] + CacheBoth["Cache in per-snapshot + cross-snapshot"] + + SnapGetter --> PerSnapCheck + PerSnapCheck -->|Yes| PerSnapReturn + PerSnapCheck -->|No| CrossSnapCheck + CrossSnapCheck -->|Yes| CrossSnapReturn + CrossSnapCheck -->|No| Compute + Compute --> CacheBoth +``` + +**Cross-snapshot mechanism:** +1. First evaluation: a lightweight tracking Proxy records which `this` properties the getter reads and their values. +2. On subsequent snapshots: for each recorded property, check `Object.is(currentSnap[prop], previousValue)`. +3. Structural sharing guarantees that unchanged sub-trees have the same reference — so if `this.todos` hasn't changed, the reference comparison passes and the cached result is returned. +4. For getters reading other getters (e.g., `get filteredCount() { return this.filtered.length; }`): the inner getter is itself memoized, so it returns a stable reference, which the outer getter's dep check recognizes as unchanged. + +**Impact on `useClassyStore`:** The existing hook benefits automatically — no changes needed: +- **Selector mode:** `useClassyStore(store, (state) => state.filtered)` gets a stable reference from the memoized snapshot getter. `Object.is` correctly detects "no change" without `shallowEqual`. +- **Auto-tracked mode:** `proxy-compare`'s `isChanged` gets stable references from snapshot getters, reducing false positives. + +## Layer 3: React Hook (`hooks/useClassyStore/useClassyStore.ts`) + +### Overview + +`useClassyStore` uses `useSyncExternalStore` for tear-free React integration. React subscriptions always notify synchronously, allowing controlled inputs to observe updates during their event. The React API has no timing option; ordinary core subscriptions remain batched. It supports two modes: + +### Server rendering and hydration + +`prepareStoreForHydration(store)` captures an immutable snapshot in a WeakMap keyed by the exact store proxy. It returns the same mutable proxy and preserves the first capture on repeated calls. Capturing is explicit at bootstrap, before rendering or persistence, because a delayed Suspense boundary may not call its hook until after the live store changes. + +Prepared stores supply that snapshot through `getServerSnapshot`; ordinary `getSnapshot` still reads live state. Each selector has separate server and live caches. When React reads the hydration result, it can seed the live cache with that result so unchanged inputs and equal selections retain their references without overwriting committed selections during rendering. Client-only mounts never evaluate the server selector. + +The capture stays available for the store's lifetime, including later boundaries or roots. It is never cleared when the first consumer mounts. Unprepared stores keep the existing current-snapshot behavior. Core mutation timing, ordinary subscriptions, and `snapshot(store)` are unchanged. + +### Mode 1: Selector + +```typescript +const count = useClassyStore(myStore, (state) => state.count); +``` + +```mermaid +sequenceDiagram + participant React as React (useSyncExternalStore) + participant Hook as useClassyStore + participant Snap as snapshot() + participant Selector as selector(snap) + + React->>Hook: getSnapshot() + Hook->>Snap: snapshot(proxy) + Snap-->>Hook: frozen snapshot + Hook->>Hook: Same snapshot ref? → return cached result + Hook->>Selector: selector(snapshot) + Selector-->>Hook: selected value + Hook->>Hook: Object.is(prev, next)? + alt Same value + Hook-->>React: return cached (no re-render) + else Different value + Hook-->>React: return new value (re-render) + end +``` + +**Equality chain:** +1. Same store, selector, equality function, and snapshot reference? → reuse the cached result (O(1)) +2. Run selector → compare result with `Object.is` (or `options.isEqual`) +3. Same result → return previous reference (no re-render) + +**Controlled input timing:** + +```typescript +const name = useClassyStore(formStore, (state) => state.name); +``` + +The hook always calls the core `subscribe()` with `{sync: true}`. Ordinary listeners still batch with `queueMicrotask`. This timing is independent of React's own render scheduling and does not provide atomic transactions across several writes. + +### Mode 2: Auto-tracked (selectorless) + +```typescript +const snap = useClassyStore(myStore); +// snap.count — tracked +// snap.user.name — tracked +``` + +Each render creates its own `affected` map and raw-snapshot getter; the hook retains a proxy cache across renders. `useSyncExternalStore` receives a cached raw snapshot: unchanged tracked values return the previous snapshot for that render. After reading it, the hook wraps the snapshot with `createProxy` to record the component's property reads. + +A parent re-render starts from current store data and a render-local `TrackingReadMap`. It lazily copies committed operation records for reused immutable snapshot objects, including structurally shared children. This retains reads skipped by React Compiler or manual memoization. Sets are copied before recording new reads, so suspended and abandoned renders cannot change committed dependencies. An effect publishes only committed reads into a WeakMap; old snapshot objects can be collected without retaining a chain of prior render maps. + +The adapter follows the operation-record/Set layout of the pinned `proxy-compare` 3 dependency without hardcoding its private field names. Compiled regressions cover value reads, `in`, `Object.hasOwn`, and key enumeration. Reused snapshot objects conservatively retain prior committed dependencies, so changing a conditional branch may cause an extra render before that object's identity changes. Explicit selectors provide selected-value equality when stricter filtering is needed. + +Snapshot creation marks only library-created snapshot nodes with `markToTrack`, including class and null-prototype objects. This enables field tracking without altering opaque leaves such as native maps and dates. Nodes containing object-valued symbol fields use whole-node comparison to avoid a frozen-symbol limitation in `proxy-compare` 3; updates remain reactive with coarser render filtering. + +**How `proxy-compare` works:** +- `createProxy(snapshot, affected)` records properties read into the render's map; committed records are retained separately with weak snapshot keys. +- `isChanged(previousSnapshot, nextSnapshot, affected)` checks whether a tracked property differs. +- Unchanged tracked properties suppress subscription-driven renders; `snapshot()` retains its existing identity cache and structural sharing. + +## Dependency Graph + +```mermaid +graph LR + reactEntry["/react client entry"] --> hook["useClassyStore.ts"] + reactEntry --> localHook["useLocalStore.ts"] + reactEntry --> factory["createStoreHook.ts"] + factory --> hook + factory --> core["core.ts"] + localHook --> core + hook --> core + hook --> snapshot["snapshot.ts"] + hook --> proxyCompare["proxy-compare"] + hook --> hydration["hydrationSnapshots.ts"] + serverEntry["/react/server entry"] --> hydration + serverEntry --> snapshot + serverEntry --> core + snapshot --> core + snapshot --> types["types.ts: public types"] + snapshot --> coreTypes["core.types.ts: internal types"] + snapshot --> internal["utils/internal/internal.ts"] + core --> types + core --> coreTypes + core --> internal + index["Core entry: index.ts"] --> core + index --> snapshot + index --> equality["equality.ts"] + index --> types + + subgraph utilities ["Utilities (utils/)"] + persist["persist.ts"] + devtools["devtools.ts"] + subscribeKey["subscribe-key.ts"] + withHistory["history.ts"] + status["utilityStatus.ts"] + end + + persist --> core + persist --> snapshot + persist --> internal + persist --> status + devtools --> core + devtools --> snapshot + subscribeKey --> core + subscribeKey --> snapshot + withHistory --> core + withHistory --> snapshot + withHistory --> status + reactEntry --> statusHooks["useHistoryStatus / usePersistStatus"] + statusHooks --> sharedHook["useUtilityStatus.ts"] +``` + +The client entry owns the React boundary. Core, snapshots, collections, and +utilities remain framework-neutral. React status hooks share an internal hook; +the status source itself lives with the utilities and has no React dependency. +`types.ts` contains public core types, while `core.types.ts` describes private +bookkeeping. Persistence contracts live in `persist.types.ts` and are exported +through the public utilities entry. + +## Key Design Decisions + +### Why ES6 Proxy instead of `Object.defineProperty`? + +ES6 Proxy supports intercepting property creation, deletion, and arbitrary key access — not just known properties. This is essential for arrays (index access) and dynamic objects. All modern browsers and Node.js/Bun support Proxy natively. + +### Why `queueMicrotask` for batching? + +`queueMicrotask` runs after the current synchronous code but before the next paint/timer. For ordinary subscribers, this means: +- Multiple mutations in the same event handler → 1 notification +- Array `push` (which triggers multiple SET traps) → 1 notification +- No risk of missing updates (unlike `requestAnimationFrame`) + +### Why structural sharing in snapshots? + +Without structural sharing, `Object.is(prevSelector, nextSelector)` would always return `false` for object/array selectors because each snapshot creates new objects. Structural sharing ensures unchanged sub-trees maintain reference equality, making selectors efficient without custom equality functions. + +### Why `proxy-compare` for auto-tracking? + +`proxy-compare` (~1KB, maintained by the creator of Valtio/Zustand/Jotai) provides battle-tested property access tracking. It wraps a frozen object in a Proxy that records reads, then efficiently diffs only the accessed properties between snapshots. This eliminates the need for manual selectors in many cases. + +### Why `useSyncExternalStore` instead of `useState` + `useEffect`? + +`useSyncExternalStore` is React's official API for external stores. It: +- Prevents tearing (concurrent mode safe) +- Integrates with React's scheduler +- Works with server-side rendering (via `getServerSnapshot`) +- Is the same API used by Zustand, Redux, and Valtio + +### Why `WeakMap` for internal state? + +Storing internals in a `WeakMap` instead of on the proxy itself: +- Doesn't pollute the user's object with library metadata +- Allows garbage collection when the store is no longer referenced +- Prevents conflicts with user-defined properties + +### Why subclass inheritance works + +No special handling is needed — three mechanisms in the existing design make it work automatically: + +1. **Methods** — The GET trap uses `Reflect.get(target, prop)`, which naturally walks the JavaScript prototype chain. Methods from any ancestor class are found and bound to the proxy via `.bind(receiver)`, so `this` mutations inside inherited methods go through the SET trap. **Important:** Arrow function properties (`increment = () => {}`) are own properties whose `this` is lexically bound to the raw instance at construction time. `.bind()` is a no-op on arrow functions, so mutations inside them bypass the proxy entirely. Always use prototype methods (`increment() {}`) for reactive mutations. + +2. **Getters** — `findGetterDescriptor()` (in `utils.ts`) walks the prototype chain with `Object.getOwnPropertyDescriptor` and returns the first (most-derived) getter found. For snapshots, `collectGetters()` (in `snapshot.ts`) walks the full chain using a `seen` Set to ensure overridden getters are collected only once — the most-derived version wins. + +3. **Snapshot prototype** — `createSnapshotRecursive()` creates snapshot objects with `Object.create(Object.getPrototypeOf(target))`, preserving the full prototype chain. This means `snapshot(store) instanceof DerivedClass` is `true`, and `installMemoizedGetters()` installs getters from all levels with cross-snapshot memoization. + +`super.method()` calls also work because they use the language-level prototype dispatch mechanism, which resolves to the parent method with `this` still bound to the proxy. + +## Performance Characteristics + +| Operation | Complexity | Notes | +|-----------|-----------|-------| +| Property read | O(1) | Direct Proxy GET trap | +| Property write | O(depth) | Versions bump from node to root | +| Notification | O(listeners) | Ordinary listeners once per microtask; React listeners per mutation | +| Getter (cache hit) | O(deps) | Check deps validity, return cached | +| Getter (cache miss) | O(getter body) | Re-evaluates with dep tracking | +| Snapshot (cache hit) | O(1) | Version check only | +| Snapshot (cache miss) | O(changed branches) | Structural sharing skips unchanged | +| Snapshot getter (same snap) | O(1) | Per-snapshot cache hit | +| Snapshot getter (cross-snap) | O(deps) | Reference comparison via structural sharing | +| Selector equality | O(1) for primitives | `Object.is` check | +| Auto-track diff | O(accessed props) | `proxy-compare` checks only read props | + +--- +SOURCE: https://codebelt.github.io/classy-store/docs/DEVTOOLS_TUTORIAL +--- + +# DevTools Tutorial + +`devtools()` connects a store proxy to the [Redux DevTools](https://github.com/reduxjs/redux-devtools) browser extension for state inspection and time-travel debugging. Every mutation sends a snapshot to the DevTools panel. You can inspect state, jump to any point in history, and the store updates in real-time. + +## Prerequisites + +Install the Redux DevTools extension for your browser: + +- [Chrome](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) +- [Firefox](https://addons.mozilla.org/en-US/firefox/addon/reduxdevtools/) +- [Edge](https://microsoftedge.microsoft.com/addons/detail/redux-devtools/nnkgneoiohoecpdiaponcejilbhhikei) + +## Getting Started + +### 1. Create a store + +```ts +import {createClassyStore} from '@codebelt/classy-store'; + +class CounterStore { + count = 0; + step = 1; + + get doubled() { + return this.count * 2; + } + + increment() { + this.count += this.step; + } + + decrement() { + this.count -= this.step; + } + + setStep(step: number) { + this.step = step; } +} + +export const counterStore = createClassyStore(new CounterStore()); +``` + +### 2. Connect to DevTools + +```ts +import {devtools} from '@codebelt/classy-store/utils'; + +const disconnect = devtools(counterStore, {name: 'CounterStore'}); +``` + +Open the Redux DevTools panel in your browser. You'll see `CounterStore` in the instance selector with the initial state: `{ count: 0, step: 1 }`. + +### 3. Mutate and observe + +```ts +counterStore.increment(); +// DevTools shows: action "STORE_UPDATE", state { count: 1, step: 1 } + +counterStore.increment(); +// DevTools shows: action "STORE_UPDATE", state { count: 2, step: 1 } + +counterStore.setStep(5); +counterStore.increment(); +// DevTools shows: action "STORE_UPDATE", state { count: 7, step: 5 } +``` + +Each batched mutation appears as a new entry in the DevTools timeline. + +### 4. Time-travel + +Click any previous entry in the DevTools timeline. The store proxy updates to match that state — your React components re-render, side effects fire, everything stays in sync. Click "Jump to State" or "Jump to Action" to navigate through history. + +### 5. Disconnect when done + +```ts +disconnect(); +``` + +After disconnecting, mutations are no longer sent to DevTools and time-travel messages are ignored. + +## Signature + +```ts +function devtools( + proxyStore: T, + options?: DevtoolsOptions, +): () => void; +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `name` | `string` | `'ClassyStore'` | Display name in the DevTools panel | +| `enabled` | `boolean` | `true` | Set to `false` to disable (returns a noop) | + +### Return value + +A dispose function `() => void` that disconnects from DevTools and unsubscribes from the store. + +## How It Works + +1. **Connect:** calls `window.__REDUX_DEVTOOLS_EXTENSION__.connect({ name })` to create a DevTools connection. +2. **Init:** sends the initial snapshot via `connection.init(snapshot(store))`. +3. **Subscribe:** uses `subscribe(store, ...)` to listen for mutations. On each batched mutation, sends `snapshot(store)` to DevTools via `connection.send()`. +4. **Time-travel:** listens for `DISPATCH` messages from DevTools. When a `JUMP_TO_STATE` or `JUMP_TO_ACTION` message arrives, parses the state and applies it back to the store proxy — skipping getters and methods (same pattern as `persist()`). +5. **Dispose:** unsubscribes from the store and disconnects from DevTools. + +## Use Cases + +### Multiple stores + +Connect each store with a unique name to distinguish them in the DevTools panel: + +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {devtools} from '@codebelt/classy-store/utils'; + +const authStore = createClassyStore(new AuthStore()); +const todoStore = createClassyStore(new TodoStore()); +const uiStore = createClassyStore(new UiStore()); + +devtools(authStore, {name: 'AuthStore'}); +devtools(todoStore, {name: 'TodoStore'}); +devtools(uiStore, {name: 'UiStore'}); +``` + +Each store appears as a separate instance in the DevTools dropdown. + +### Development-only + +Disable DevTools in production by tying the `enabled` option to your build environment: + +```ts +devtools(counterStore, { + name: 'CounterStore', + enabled: import.meta.env.DEV, +}); +``` + +When `enabled` is `false`, `devtools()` returns a noop immediately — no DevTools connection is created, no subscriptions are added, and the function has zero runtime cost. + +### Conditional connection + +Connect DevTools only when the extension is available: + +```ts +const disconnect = devtools(counterStore, {name: 'CounterStore'}); - recordVisit() { - this.visits++; - } +// If the extension isn't installed, `disconnect` is a noop. +// No errors, no warnings — the store works normally. +``` + +`devtools()` checks for `window.__REDUX_DEVTOOLS_EXTENSION__` internally. If it's not available (missing extension, SSR, Node.js), it returns a noop without throwing. + +### Late disposal on unmount (React) + +```tsx +import {useEffect} from 'react'; +import {devtools} from '@codebelt/classy-store/utils'; + +function App() { + useEffect(() => { + const disconnect = devtools(counterStore, {name: 'CounterStore'}); + return () => disconnect(); + }, []); + + return ; } +``` -export function createProfileStore(initialData: ProfileData) { - return prepareStoreForHydration( - createClassyStore(new ProfileStore(initialData)), - ); +## Time-Travel Details + +When you click a previous state in the DevTools timeline, the extension sends a message like: + +```json +{ + "type": "DISPATCH", + "payload": {"type": "JUMP_TO_STATE"}, + "state": "{\"count\": 0, \"step\": 1}" } ``` -```tsx -// Profile.tsx -import {useClassyStore} from '@codebelt/classy-store/react'; -import type {ProfileStore} from './profileStore'; +The `devtools()` utility: -export function Profile({store}: {store: ProfileStore}) { - const state = useClassyStore(store); - return ( - - ); +1. Parses `message.state` via `JSON.parse()`. +2. Iterates over the keys of the parsed state. +3. **Skips getters** — detected via `findGetterDescriptor()` on the prototype chain. Getters like `doubled` are computed values that recompute from the restored data automatically. +4. **Skips methods** — detected via `typeof value === 'function'`. +5. Assigns data properties directly to the store proxy: `proxy[key] = value`. +6. The SET trap fires, triggers reactivity, and your components update. + +During time-travel, the utility temporarily pauses sending updates to DevTools to avoid echoing the applied state back as a new action. + +## Quick Reference + +| What you want | How to do it | +|---|---| +| Connect a store | `devtools(store, {name: 'MyStore'})` | +| Disconnect | `const dispose = devtools(...); dispose()` | +| Disable in production | `enabled: import.meta.env.DEV` | +| Multiple stores | Call `devtools()` once per store with unique names | +| Use without extension | Works safely — returns a noop if extension is missing | + +--- +SOURCE: https://codebelt.github.io/classy-store/docs/HISTORY_TUTORIAL +--- + +# withHistory Tutorial + +`withHistory()` adds undo/redo capability to any store proxy. It maintains a stack of snapshots captured on each mutation. Call `undo()` to go back, `redo()` to go forward, and `pause()`/`resume()` to batch operations that shouldn't create individual history entries. + +## Getting Started + +### 1. Create a store + +```ts +import {createClassyStore} from '@codebelt/classy-store'; + +class DrawingStore { + color = '#000000'; + strokeWidth = 2; + points: { x: number; y: number }[] = []; + + addPoint(x: number, y: number) { + this.points = [...this.points, { x, y }]; + } + + setColor(color: string) { + this.color = color; + } + + setStrokeWidth(width: number) { + this.strokeWidth = width; + } + + clear() { + this.points = []; + } } + +export const drawingStore = createClassyStore(new DrawingStore()); ``` -For each request, render ``. Transfer `initialData` using the framework's serialization facility and initialize the browser store from that same payload before hydration. If implementing the HTML transport yourself, use safe JSON serialization for the embedding context; do not interpolate raw JSON into an executable script. Class instances, functions, and native collection types need an explicit data representation. +### 2. Attach history -For a store owned by a component, `prepareStoreForHydration(useLocalStore(() => new ProfileStore(initialData)))` captures its initial state while keeping its identity stable across renders. Preparation is idempotent. Keep the factory pure: React Strict Mode can evaluate it more than once in development. Changes to `initialData` after mounting do not automatically reset the store or its captured snapshot. +```ts +import {withHistory} from '@codebelt/classy-store/utils'; -## Restore persistence while other boundaries hydrate +const history = withHistory(drawingStore); +``` -Browser storage may contain values different from the server-rendered payload. Prepare the store before starting persistence, attach persistence in an effect, and dispose it when the component unmounts. The effect can restore storage even while a Suspense boundary is still waiting to hydrate: +That's it. Every mutation is now recorded. The initial state is captured as `history[0]`. + +### 3. Undo and redo + +```ts +drawingStore.setColor('#ff0000'); +drawingStore.setStrokeWidth(5); +// history: [initial, {color: '#ff0000'}, {strokeWidth: 5}] + +history.undo(); +// strokeWidth is back to 2 + +history.undo(); +// color is back to '#000000' + +history.redo(); +// color is '#ff0000' again +``` + +### 4. Check availability + +```ts +history.canUndo; // true if there's a previous state +history.canRedo; // true if there's a next state (after an undo) +``` + +Use these to enable/disable undo/redo buttons: ```tsx -// PersistedProfile.tsx -import {useEffect} from 'react'; -import {useLocalStore} from '@codebelt/classy-store/react'; -import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; -import {persist} from '@codebelt/classy-store/utils'; -import {Profile} from './Profile'; -import {type ProfileData, ProfileStore} from './profileStore'; +import {useHistoryStatus} from '@codebelt/classy-store/react'; -export function PersistedProfile({initialData}: {initialData: ProfileData}) { - const store = prepareStoreForHydration( - useLocalStore(() => new ProfileStore(initialData)), +function UndoRedoButtons() { + const {canUndo, canRedo} = useHistoryStatus(history); + + return ( +
+ + +
); +} +``` - useEffect(() => { - const persistence = persist(store, { - name: 'profile', - storage: localStorage, - skipHydration: true, - }); - void persistence.rehydrate().catch((error) => console.error('Could not restore persisted state', error)); - return () => persistence.dispose(); - }, [store]); +### 5. Clean up - return ; -} +```ts +history.dispose(); ``` -For example, the server renders `Server:1`, persistence restores `Saved:8`, and a delayed boundary then hydrates. The boundary first reads `Server:1` from the captured snapshot, reuses the server DOM, and then updates to `Saved:8` from the live store. It does not need an earlier hook read to establish the initial data. This applies to completed server HTML with selective hydration and to streamed Suspense content. +After disposing, mutations are no longer recorded and the snapshot stack is cleared. -## Snapshot lifetime and scope +### Observe status outside React -- Capture after loading the initial request data, before rendering or starting persistence. Preparation cannot recover earlier data if the store has already changed. -- Repeated calls return the same mutable proxy and retain the first snapshot. The capture is kept for the store's lifetime because another boundary or root may hydrate later. Use a fresh store for a different request or initial payload. -- Prepare the exact proxy passed to `useClassyStore` or `createStoreHook`. If a hook subscribes directly to a nested proxy, prepare that proxy separately. -- Capture follows ordinary `snapshot()` semantics. Plain objects, arrays, and supported class state are immutable snapshots; opaque leaves such as native `Date`, `Map`, and `Set` retain their identity. Replace those fields instead of mutating their native objects in place. -- Selectors read captured data during hydration and live data afterward. Their caches are separate, while equal selected results preserve reference identity at the handoff. +```ts +const stopListening = history.subscribe(() => { + console.log(history.getSnapshot()); + // {canUndo, canRedo, isPaused, isDisposed} +}); +stopListening(); // Only removes this listener; history keeps recording. +``` -Client-only apps need no preparation. `createRoot` reads current state even for a prepared store. Unprepared stores retain the existing behavior of reading current state for server rendering and hydration, so keep them unchanged until every consumer hydrates if you do not use preparation. Neither preparation nor hydration resets the live store; `snapshot(store)` continues to return current data. +Snapshots are readonly and cached until a status field changes. `pause()` and `resume()` notify status subscribers even when store data is unchanged. `dispose()` publishes the final disposed status, clears listeners, and makes subsequent history operations no-ops. `getServerSnapshot()` always returns initial status for SSR and hydration. The public `HistoryStatus` type is exported from `@codebelt/classy-store/utils`. -React subscriptions notify synchronously by default, including after hydration. Ordinary `subscribe(store, listener)` callbacks remain batched. The React hooks do not have a `sync` option. A normal sequence of mutations in an event can be batched into one React commit, but selectors may run after each mutation; this is not a transaction API. +## Signature ---- -SOURCE: https://codebelt.github.io/classy-store/docs/REACT_SCOPING ---- +```ts +function withHistory( + proxyStore: T, + options?: { limit?: number }, +): HistoryHandle; +``` -# React store scoping and lifecycle +### Options -Use React context to choose a store instance for a subtree, then `useClassyStore` to subscribe to that instance. This is useful for independent forms, editors, nested workspaces, and request-scoped server rendering. +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `limit` | `number` | `100` | Maximum number of history entries | -The runnable [scoped editors demo](https://codebelt.github.io/classy-store/demos/#/scoped-stores) shows two independent editors and a nested editor. Its [source](https://github.com/codeBelt/classy-store/tree/main/examples/react/src/demos/scoped-store) is an application example built from existing APIs; there is no new context factory to install. +### HistoryHandle -## Define a typed context +| Property/Method | Type | Description | +|-----------------|------|-------------| +| `undo()` | `() => void` | Restore the previous state | +| `redo()` | `() => void` | Restore the next state (after an undo) | +| `canUndo` | `readonly boolean` | Whether there is a previous state | +| `canRedo` | `readonly boolean` | Whether there is a next state | +| `pause()` | `() => void` | Stop recording history entries | +| `resume()` | `() => void` | Resume recording history entries | +| `dispose()` | `() => void` | Unsubscribe and clean up | +| `getSnapshot()` | `() => HistoryStatus` | Cached current status | +| `getServerSnapshot()` | `() => HistoryStatus` | Initial status for SSR/hydration | +| `subscribe(listener)` | `(listener: () => void) => () => void` | Observe status changes; returns listener cleanup | -Share the context definition across the application. Create store instances inside each provider, so server requests and sibling providers cannot accidentally share mutable state. +## How It Works -```ts -// editorStore.ts -export type EditorData = {title: string; body: string}; +1. **Init:** captures an initial snapshot as `history[0]` and sets `pointer = 0`. +2. **Record:** on each `subscribe()` callback (when not paused), captures a new snapshot, truncates any redo entries after the current pointer, pushes the snapshot, and enforces the limit by shifting from the front. +3. **Undo:** decrements the pointer, suppresses recording during restoration, and applies `history[pointer]` back to the store proxy (skipping getters and methods). +4. **Redo:** increments the pointer and restores `history[pointer]` with the same recording suppression. +5. **State restoration:** iterates own enumerable keys of the snapshot, skips keys with getter descriptors on the prototype chain, and assigns values to the proxy. This triggers SET traps and reactivity — your components update automatically. -export class EditorStore { - title: string; - body: string; +Undo and redo themselves do **not** create new history entries. Restoration does not change the explicit `isPaused` status set by `pause()` and `resume()`. - constructor(initialData: EditorData) { - this.title = initialData.title; - this.body = initialData.body; +## Use Cases + +### Text editor with undo + +```ts +class EditorStore { + content = ''; + cursorPosition = 0; + + type(text: string) { + this.content = + this.content.slice(0, this.cursorPosition) + + text + + this.content.slice(this.cursorPosition); + this.cursorPosition += text.length; } - setTitle(title: string) { this.title = title; } - setBody(body: string) { this.body = body; } + backspace() { + if (this.cursorPosition === 0) return; + this.content = + this.content.slice(0, this.cursorPosition - 1) + + this.content.slice(this.cursorPosition); + this.cursorPosition--; + } } + +const editorStore = createClassyStore(new EditorStore()); +const history = withHistory(editorStore, { limit: 200 }); + +// Cmd+Z / Ctrl+Z +document.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'z') { + e.preventDefault(); + if (e.shiftKey) { + history.redo(); + } else { + history.undo(); + } + } +}); ``` +### Form with reset capability + ```ts -// editorStoreContext.ts -import {createContext} from 'react'; -import type {EditorStore} from './editorStore'; +class FormStore { + name = ''; + email = ''; + message = ''; -export const EditorStoreContext = createContext(null); + setField(field: 'name' | 'email' | 'message', value: string) { + this[field] = value; + } +} + +const formStore = createClassyStore(new FormStore()); +const history = withHistory(formStore); + +// User fills in the form... +formStore.setField('name', 'Alice'); +formStore.setField('email', 'alice@example.com'); + +// Let the batched subscription record both edits. +await Promise.resolve(); + +// Both writes in this turn form one undo step. +history.undo(); +// name and email are both back to ''. + +// Undo all changes +while (history.canUndo) { + history.undo(); +} +// All fields are back to their initial values ``` +### Batched edits and pause/resume + +Synchronous mutations in one turn already share a history entry because ordinary store subscriptions are batched: + ```ts -// hooks/useEditorStore.ts -import {useContext} from 'react'; -import {EditorStoreContext} from '../editorStoreContext'; +class SpreadsheetStore { + cells: Record = {}; -export function useEditorStore() { - const store = useContext(EditorStoreContext); - if (!store) { - throw new Error('useEditorStore must be used within EditorStoreProvider'); + setCell(id: string, value: string) { + this.cells = { ...this.cells, [id]: value }; + } +} + +const spreadsheetStore = createClassyStore(new SpreadsheetStore()); +const history = withHistory(spreadsheetStore); + +// Paste a block of cells — should be a single undo step +function pasteBlock(data: Record) { + for (const [id, value] of Object.entries(data)) { + spreadsheetStore.setCell(id, value); } - return store; } ``` -The hook returns a typed mutable `EditorStore`, with its methods intact. It fails clearly when a provider is missing. React resolves the [closest matching provider](https://react.dev/reference/react/useContext), so a nested provider overrides the instance only for its own subtree. +`pause()` skips history recording for notifications delivered while paused. `resume()` enables recording again; it does not capture an entry itself or group asynchronous edits into a transaction. `useHistoryStatus(history).isPaused` exposes this recording state. Undo/redo restoration preserves an explicit pause. + +### Canvas with limited history -## Let the provider own creation and effects +For memory-sensitive applications, set a lower limit: -The example provider initializes once per mount and captures the initial hydration data before starting persistence: +```ts +const history = withHistory(canvasStore, { limit: 50 }); -```tsx -// components/editorStoreProvider/EditorStoreProvider.tsx -import {useLocalStore} from '@codebelt/classy-store/react'; -import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; -import {persist, type StorageAdapter} from '@codebelt/classy-store/utils'; -import {type ReactNode, useEffect} from 'react'; -import {type EditorData, EditorStore} from '../../editorStore'; -import {EditorStoreContext} from '../../editorStoreContext'; +// Only the last 50 states are kept. +// Older entries are shifted off the front of the stack. +// Memory usage stays bounded. +``` -type Props = { - initialData: EditorData; - children: ReactNode; - persistence?: {name: string; storage: StorageAdapter}; -}; +## Undo/Redo and Branching -export function EditorStoreProvider({initialData, children, persistence}: Props) { - const store = prepareStoreForHydration( - useLocalStore(() => new EditorStore(initialData)), - ); - const name = persistence?.name; - const storage = persistence?.storage; +When you undo and then make a new mutation, the redo history is discarded: - useEffect(() => { - if (name === undefined || storage === undefined) return; - const handle = persist(store, { - name, - storage, - properties: ['title', 'body'], - skipHydration: true, - }); - void handle.rehydrate().catch((error) => console.error('Could not restore persisted state', error)); - return () => handle.dispose(); - }, [store, name, storage]); +``` +Initial state: { count: 0 } - return ( - - {children} - - ); -} +Mutation 1: count = 1 → history: [0, 1] +Mutation 2: count = 2 → history: [0, 1, 2] +Mutation 3: count = 3 → history: [0, 1, 2, 3] + +Undo → history: [0, 1, 2, 3], pointer at 2 (count = 2) +Undo → history: [0, 1, 2, 3], pointer at 1 (count = 1) + +New mutation: count = 99 → history: [0, 1, 99], pointer at 2 + (entries 2 and 3 are gone — no redo available) ``` -Keep constructors and initializers pure: Strict Mode can evaluate them more than once. Start subscriptions, timers, and storage work in effects with cleanup. The persistence effect depends on the storage adapter and name, so passing a new options object with those same values does not restart it. Keep a custom storage adapter's identity stable too. +This is the standard behavior for undo systems (like text editors). Making a new change from a past state creates a new branch and discards the old future. -The context holds the stable store proxy. Consumers read snapshots through the existing React hook and send edits through store methods: +## Combining with Other Utilities -```tsx -// components/editorTitle/EditorTitle.tsx -import {useClassyStore} from '@codebelt/classy-store/react'; -import {useEditorStore} from '../../hooks/useEditorStore'; +### withHistory + persist -export function EditorTitle() { - const store = useEditorStore(); - const title = useClassyStore(store, (state) => state.title); - return store.setTitle(event.target.value)} />; -} -``` +Persist the store's current state while maintaining undo history in memory: -Auto-tracking also works: `const state = useClassyStore(useEditorStore())`. Reading fields directly from the mutable store during render does not subscribe to mutations. Avoid creating a module-level bound hook for one store if consumers must select different instances through context. +```ts +import {persist, withHistory} from '@codebelt/classy-store/utils'; -## Preserve drafts and reset intentionally +const todoStore = createClassyStore(new TodoStore()); -`initialData` is an initializer. New props or an ordinary parent render leave the existing store and draft intact. Use a [changed React key](https://react.dev/learn/preserving-and-resetting-state#resetting-state-with-a-key) to create a fresh session: +// Persist to localStorage (survives page reload) +const persistence = persist(todoStore, {name: 'todos', storage: localStorage}); -```tsx - - - +// Undo/redo in memory (resets on page reload) +const history = withHistory(todoStore); + +// When the store's owner is torn down: +history.dispose(); +persistence.dispose(); ``` -Change `documentId` when moving to a different document; increment `revision` for an explicit reset. A key change remounts the provider's whole subtree, including nested providers. Resetting a nested provider leaves its parent and siblings intact. +History is in-memory only — it doesn't persist across page loads. The persisted state is always the latest state (not the undo pointer position). -| Event | Store and effect behavior | -| --- | --- | -| Parent render or changed `initialData`, same key | Preserve the existing store and edits | -| Provider key changes | Create a new store and clean up the old subtree | -| Nested provider | Consumers inside it use its independent store | -| Storage name or adapter changes | Keep the store; detach old persistence and load from the new configuration | -| Persistence removed | Keep current state and stop persistence | -| Provider unmounts | React releases its store reference and effect cleanup detaches persistence | +### withHistory + devtools -A reset does **not** clear saved data. If the new provider uses the same persistence key, it restores that saved draft. To reset saved data as well, first unmount or disable the old persistence owner, await `storage.removeItem(name)`, then remount. Use distinct storage keys for independent documents or users. Different React providers using the same storage key still share that storage namespace. +Use both for a rich debugging experience: -Cleanup prevents pending storage reads from applying after disposal, including the discarded first effect in Strict Mode. Such reads still finish and their promises settle; the adapter's I/O is not cancelled. Already-issued writes cannot be undone by cleanup. External code retaining a store can still mutate it after unmount, but its disposed persistence owner and unmounted consumers no longer react. `useLocalStore` does not automatically call application-specific `dispose()` methods. +```ts +import {devtools, withHistory} from '@codebelt/classy-store/utils'; -## Server rendering and hydration +const store = createClassyStore(new MyStore()); -Render a fresh provider for each server request. Transfer only its initial data through the framework's serialization facility, then initialize the client provider from that same data. The provider prepares the initial snapshot before any persistence effect, so a delayed Suspense boundary can hydrate correctly even after another part of the tree restores browser storage. See the [SSR guide](./REACT_SSR.md) for transport and streaming details. +devtools(store, { name: 'MyStore' }); +const history = withHistory(store); -Browser storage belongs in the client setup; do not access `localStorage` at module initialization on the server. Effects do not run during server rendering. The server and client's initial markup must match regardless of whether browser persistence is enabled. +// DevTools shows every mutation including undo/redo restores. +// Time-travel in DevTools is independent of withHistory's stack. +``` -## Regression coverage +## Quick Reference -`bun run test:react:compatibility` copies the actual example provider, context, hook, and class into isolated installed-package consumers on React 18.0, 18.3, and 19.3. Tests cover sibling and nested isolation, missing providers, stable initialization, keyed resets, Strict Mode, persistence replacement/removal, pending-read cleanup, request isolation, and delayed Suspense hydration with DOM reuse. Strict type checks verify mutable commands, readonly snapshots, selector inference, and provider props. +| What you want | How to do it | +|---|---| +| Add undo/redo | `const h = withHistory(store)` | +| Undo | `h.undo()` | +| Redo | `h.redo()` | +| Check if undoable | `h.canUndo` | +| Check if redoable | `h.canRedo` | +| Limit history size | `withHistory(store, { limit: 50 })` | +| Record one undo step | Make synchronous edits in one turn; history records after the microtask flush | +| Temporarily stop recording | `h.pause()`; keep paused until those notifications flush, then call `h.resume()` | +| Render history status in React | `useHistoryStatus(h)` | +| Stop tracking | `h.dispose()` | +| Undo all | `while (h.canUndo) h.undo()` | --- -SOURCE: https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL +SOURCE: https://codebelt.github.io/classy-store/docs/PERSIST_ARCHITECTURE --- -# Persist Tutorial +# Persist Utility -- Architecture -`persist()` saves your store's state to storage and restores it on page load. It's a standalone utility function that works with any `createClassyStore()` instance -- no plugins, no middleware, no configuration files. Import it from `@codebelt/classy-store/utils`, call it once, and your store survives page refreshes. +Internal design documentation for the `persist()` utility in `@codebelt/classy-store/utils`. -## Getting Started +## Design Principles -### 1. Create a store +1. **Standalone utility, not middleware.** `persist` uses `subscribe` and `snapshot`, plus an internal version helper for stale-read protection. No changes to core reactivity or React hooks are needed. -```ts -import {createClassyStore} from '@codebelt/classy-store'; +2. **Tree-shakeable.** `persist` lives in a separate entry point (`@codebelt/classy-store/utils`). Applications that don't use persistence pay zero cost -- the code is never bundled. -class TodoStore { - todos: { text: string; done: boolean }[] = []; - filter: 'all' | 'active' | 'done' = 'all'; +3. **Storage-agnostic.** The `StorageAdapter` interface accepts sync (`localStorage`, `sessionStorage`) and async (`AsyncStorage`, `localForage`) implementations through the same type. `persist` handles both transparently via `await`. - get remaining() { - return this.todos.filter((todo) => !todo.done).length; - } +4. **Class-aware.** The utility understands class stores: getters and methods are automatically excluded from persistence. Only source-of-truth data properties are saved. This is a key differentiator from libraries like Zustand where `partialize` requires manual exclusion. - addTodo(text: string) { - this.todos.push({text, done: false}); - } +## How It Hooks Into the Existing Architecture - toggle(index: number) { - this.todos[index]!.done = !this.todos[index]!.done; - } -} +`persist` sits beside the existing three-layer architecture as an independent consumer: -export const todoStore = createClassyStore(new TodoStore()); +```mermaid +flowchart LR + subgraph existingCore ["Existing Core (unchanged)"] + Mutation["store.prop = x"] --> Batch["queueMicrotask notify"] + Batch --> Listeners["subscribe callbacks"] + end + + subgraph persistLayer ["persist() utility"] + Listeners --> Debounce["debounce timer"] + Debounce --> Snap["snapshot(proxy)"] + Snap --> Pick["pick properties"] + Pick --> Serialize["per-property serialize"] + Serialize --> Envelope["JSON.stringify envelope"] + Envelope --> Storage["storage.setItem()"] + end + + subgraph hydration ["Hydration (init / cross-tab / manual)"] + Read["storage.getItem()"] --> Parse["JSON.parse"] + Parse --> Migrate["version check + migrate"] + Migrate --> Deserialize["per-property deserialize"] + Deserialize --> Merge["merge strategy"] + Merge --> Apply["proxy[key] = value"] + Apply --> Mutation + end ``` -### 2. Persist it +The key insight: writing `proxy[key] = value` during hydration flows through the existing SET trap in `core.ts`, which handles child proxy cleanup, version bumping, and notification scheduling automatically. The persist utility doesn't need to know anything about the proxy internals. -```ts -import {persist} from '@codebelt/classy-store/utils'; +## File Structure -const handle = persist(todoStore, { - name: 'todo-store', -}); +``` +src/ + index.ts # Existing barrel (unchanged) + utils/ + index.ts # Barrel: persist, devtools, subscribeKey, withHistory + persist/ + persist.ts # Storage queue, hydration, and persistence logic + persist.types.ts # Public options, transforms, adapter, status, and handle + persist.test.ts # Tests with mock storage adapters + persist.api.test.ts # Public lifecycle and status behavior + persist.concurrent.test.ts # Overlapping reads, writes, and disposal + persist.errors.test.ts # Error reporting and rejection policy +package.json # "./utils" export entry +tsdown.config.ts # 'src/utils/index.ts' in entry array ``` -That's it. On every mutation, `todos` and `filter` are saved to `localStorage`. On next page load, they're restored automatically. The getter `remaining` and the methods are **not** persisted. +## Internal State -### 3. Wait for hydration +Each `persist()` call creates a closure with the following internal state: -```ts -// Async -- await the promise -await handle.hydrated; -console.log('Hydration status:', handle.getSnapshot()); +| Variable | Type | Purpose | +|---|---|---| +| `disposed` | `boolean` | Guards against writes after `dispose()` | +| `debounceTimer` | `ReturnType \| null` | Active debounce timer (cancelled on save/clear/dispose or accepted hydration) | +| `storageQueue` | `Promise \| undefined` | Orders this handle's writes/removals and lets reads wait for prior operations | +| `hydrationRequest` | `number` | Rejects read results superseded by newer requests or storage events | +| `clearedVersion` | `number \| undefined` | Suppresses a pre-clear mutation's pending batched auto-save | +| `hydratedFlag` | `boolean` | Synchronous hydration status | +| `expiredFlag` | `boolean` | Set to `true` when hydration encounters expired data | +| `resolveHydrated` | `() => void` | Resolves the `hydrated` promise | +| `resolvedProps` | `Array<{key, transform?}>` | Normalized property list with optional transforms | +| `transformMap` | `Map` | Fast lookup from key to transform | +| `propKeys` | `string[]` | Flat list of property keys to persist | + +No global mutable state. Multiple `persist()` calls on different stores (or even the same store with different keys) are fully independent. + +## Save Flow + +Triggered by `subscribe(proxy, callback)` firing after each batched mutation: + +``` +1. subscribe callback fires +2. if disposed, applying hydration, or this version was cleared → return +3. if debounce > 0: + cancel existing timer + schedule new timer → goto step 4 after delay + else: + goto step 4 immediately +4. snapshot(proxy) → frozen immutable copy +5. for each key in propKeys: + read value from snapshot + if transform exists → call transform.serialize(value) + assign to state object +6. wrap in envelope: { version, state } +7. if expireIn is set → stamp expiresAt = Date.now() + expireIn +8. JSON.stringify(envelope) +9. enqueue storage.setItem(name, json) behind prior writes/removals +``` + +The serialized value is captured when requested. The first queued operation +starts immediately, so synchronous adapters keep their normal timing. Each +operation catches adapter errors and reports them through `onError`; a rejected +write does not prevent the next operation from starting. Cleanup skips operations +that have not started. This queue belongs to one handle, not the storage key globally. + +### Property Resolution -// Sync check -if (handle.isHydrated) { - // The attempt settled; inspect lastError for a reported failure. -} -``` +On initialization, `resolveProperties()` determines which keys to persist: -## Choosing What to Persist +- **If `properties` is provided:** each entry is either a string key (used as-is) or a `PropertyTransform` descriptor (key extracted, transform stored in `transformMap`). +- **If `properties` is omitted:** takes a snapshot of the store, iterates `Object.keys()`, and excludes: + - **Getters:** detected via `findGetterDescriptor()` walking the prototype chain with `Object.getOwnPropertyDescriptor`. + - **Methods:** detected via `typeof value === 'function'` on the proxy. -```ts -persist(todoStore, { - name: 'todo-store', - properties: ['todos'], // Only persist todos, not filter -}); -``` +This resolution happens once at `persist()` call time, not on every save. -## Per-Property Transforms +### Storage Envelope -### Dates +Data is stored as a JSON string with a version wrapper: -```ts -persist(sessionStore, { - name: 'session', - properties: [ - 'token', - { - key: 'expiresAt', - serialize: (date) => date.toISOString(), - deserialize: (stored) => new Date(stored as string), - }, - ], -}); +```json +{ + "version": 1, + "state": { + "todos": [{"text": "Buy milk", "done": false}], + "filter": "all" + }, + "expiresAt": 1700000000000 +} ``` -### ReactiveMap +The `version` field is always present (default `0`). The `state` field contains only the selected properties, post-serialization-transform. The `expiresAt` field is a Unix epoch timestamp (milliseconds) and is only present when `expireIn` is set. It is refreshed on every write. Envelopes without `expiresAt` are treated as "never expires". -```ts -persist(userStore, { - name: 'user-store', - properties: [ - { - key: 'users', - serialize: (users) => [...users.entries()], - deserialize: (stored) => reactiveMap(stored as [string, { name: string; role: string }][]), - }, - ], -}); -``` +## Restore Flow (Hydration) -## Debouncing Writes +Triggered in three scenarios: +1. **Auto-hydration on init** (unless `skipHydration: true`) +2. **Manual rehydrate** via `handle.rehydrate()` +3. **Cross-tab sync** via `window.storage` event -```ts -persist(formStore, { - name: 'form-draft', - debounce: 500, // Write at most once every 500ms -}); -``` +All three call the same `applyPersistedState(raw)` function: -## Version Migration +Async reads first wait for prior writes/removals. They capture a request number +and the store version, and only apply their result if both are still current and +the handle remains active. A newer read/save/clear/storage event changes the +request number; a local edit changes the store version. Obsolete results and +read errors are ignored. Applying hydration suppresses echo writes until the +batched subscription notification has run, with cleanup in `finally`. -```ts -persist(todoStore, { - name: 'todo-store', - version: 2, - migrate: (state, oldVersion) => { - if (oldVersion === 0) { - return {...state, todos: state.items, items: undefined}; - } - if (oldVersion === 1) { - return { - ...state, - todos: (state.todos as string[]).map((text) => ({text, done: false})), - }; - } - return state; - }, -}); +``` +1. JSON.parse(raw) → envelope +2. Validate envelope shape (object with .state) + if invalid → return silently (corrupted data) +3. if envelope.expiresAt exists AND Date.now() >= expiresAt: + set expiredFlag = true + if clearOnExpire → storage.removeItem(name) + return (skip hydration) +4. if envelope.version !== options.version AND migrate exists: + state = migrate(state, envelope.version) + if transform exists → state[key] = transform.deserialize(state[key]) +6. Build currentState from snapshot (only propKeys) +7. Apply merge strategy: + 'shallow': { ...currentState, ...persistedState } + custom function: merge(persistedState, currentState) +8. for each key in propKeys: + if key in mergedState → proxy[key] = mergedState[key] + (goes through SET trap → reactivity → batched notification) +9. Set hydratedFlag = true, resolve hydrated promise ``` -## Merge Strategies +### Merge Strategies -- **`'shallow'` (default):** Persisted values overwrite store values one key at a time. Properties not in storage keep their class defaults. -- **Custom merge function:** `merge: (persisted, current) => { ... }` +| Strategy | Behavior | +|---|---| +| `'shallow'` (default) | `{ ...current, ...persisted }` -- persisted keys overwrite, new keys keep defaults | +| Custom `fn` | `fn(persisted, current)` -- full control. Enables deep merge or selective overrides. | -## Cross-Tab Synchronization +The public merge option accepts `shallow` or a function. Shallow merging replaces each stored top-level property, including its nested value; use a custom function to combine nested data. -```ts -persist(authStore, { - name: 'auth', - storage: localStorage, - syncTabs: true, -}); -``` +### Error Handling -Opt in with `syncTabs: true` for localStorage-backed state. It defaults to false. -Accepted remote updates cancel pending debounced writes without echoing the -restored state back. An event whose value has already been replaced in its -storage area is ignored, so delayed events cannot roll back a newer local save. +- **Corrupted JSON** in storage (`JSON.parse` throws): reported as `parse` through `onError` and status, with no state changes. +- **Invalid envelope** (missing `.state` or not an object): detected by shape check, skipped silently. +- **Adapter read errors** during `storage.getItem()`: reported as `read` when the request is current. Exceptions from custom migration/merge/transform callbacks are reported as `parse`. Initial hydration settles after reporting; explicit `rehydrate()` rejects with the original error. -## Expiration / TTL +## Cross-Tab Sync -```ts -persist(sessionStore, { - name: 'session', - expireIn: 900_000, // 15 minutes - clearOnExpire: true, -}); +When `syncTabs: true` is set, the utility listens for the `window.storage` event: -await handle.hydrated; -if (handle.isExpired) { - router.push('/login'); -} +```mermaid +sequenceDiagram + participant TabA as Tab A + participant LS as localStorage + participant Event as window 'storage' event + participant TabB as Tab B (this instance) + + TabA->>LS: setItem('my-store', newJSON) + LS->>Event: StorageEvent { key: 'my-store', newValue: newJSON } + Event->>TabB: onStorageEvent fires + TabB->>TabB: if key !== name → ignore + TabB->>TabB: if newValue is null → ignore (cleared) + TabB->>TabB: if storageArea now contains another value → ignore (obsolete) + TabB->>TabB: applyPersistedState(newValue) + Note over TabB: proxy[key] = value → SET trap → reactivity ``` -## SSR / Next.js Support +Key properties of `window.storage`: +- For `localStorage`, fires in **other** same-origin tabs (not the tab that wrote). The hydration guard prevents echo writes and loops. +- Only works with `localStorage` -- not `sessionStorage`, not async adapters. +- The event payload includes `key`, `oldValue`, `newValue`, and `storageArea`. + +`syncTabs` defaults to `false`; enable it explicitly for localStorage-backed state. + +## Cleanup + +`handle.dispose()` performs three cleanup steps: -```ts -const handle = persist(todoStore, { - name: 'todo-store', - skipHydration: true, -}); +``` +1. Set disposed = true (guards all future writes) +2. Cancel pending debounce timer (if any) +3. Call unsubscribe from store mutations (remove subscribe callback) +4. Remove window 'storage' event listener (if syncTabs was active) ``` -Then hydrate manually in a `useEffect`: +After `dispose()`: +- Store mutations no longer trigger writes to storage. +- Cross-tab changes to the storage key are ignored. +- `save()`, `clear()`, and `rehydrate()` reject without starting new work. +- Queued writes/removals that have not started are skipped. Already-issued adapter I/O can still finish. +- Pending read results/errors are ignored, and their promises settle when the reads finish. -```tsx -function App() { - useEffect(() => { - void handle.rehydrate().catch((error) => console.error('Could not restore persisted state', error)); - }, []); +## PersistHandle API - return ; -} +| Method/Property | Behavior | +|---|---| +| `dispose()` | Idempotent cleanup: cancel debounce and listeners, publish disposed status, ignore pending reads and skip unstarted writes. | +| `hydrated` | Resolves when a hydration attempt settles, including failure or a superseded read. Inspect `lastError` for reported failures. | +| `isHydrated` | Readonly getter; true after a hydration attempt settles. | +| `isExpired` | Readonly getter; true when hydration found expired data. Re-evaluated by `rehydrate()`. | +| `save()` | Cancel debounce, capture current state, and enqueue its write. | +| `clear()` | Cancel pending auto-saves and enqueue removal after earlier writes. | +| `rehydrate()` | Read after prior writes, apply if still current, and await expired-data removal when enabled. | +| `getSnapshot()` | Cached readonly `PersistStatus`: hydration, expiry, disposal, and latest error. | +| `getServerSnapshot()` | Initial status for server rendering and hydration. | +| `subscribe(listener)` | Synchronous status subscription; returns listener cleanup. | + +Explicit operations reject with the original failure after reporting it through `onError` and status. Automatic operations catch reported failures at their background entry points. Decoding, transform, migration, and merge errors use the `parse` operation. The internal queue absorbs rejections so later writes still run. Calls on disposed handles reject; explicit queued operations cancelled by disposal also reject without invoking the adapter. + +## Dependency Graph + +```mermaid +graph LR + persist["utils/persist/persist.ts"] --> subscribe["core.ts: subscribe()"] + persist --> snapshot["snapshot.ts: snapshot()"] + persist --> findGetter["utils.ts: findGetterDescriptor()"] + utilsIndex["utils/index.ts"] --> persist ``` -## Async Storage (React Native) +`persist` has no React runtime dependency. Its public contracts live in `persist.types.ts` and are exported by `utils/index.ts`. Transform types use `Snapshot` from the public core `types.ts`. Runtime helpers include: +- `subscribe()` -- to listen for store mutations +- `snapshot()` -- to create an immutable copy for serialization +- `findGetterDescriptor()` -- to detect class getters during property resolution +- `createUtilityStatus()` -- to cache and publish utility status +- `cloneSnapshotValue()` -- to restore mutable values from snapshots -```ts -import AsyncStorage from '@react-native-async-storage/async-storage'; +## Performance Characteristics -persist(todoStore, { - name: 'todo-store', - storage: AsyncStorage, - syncTabs: false, -}); +| Operation | Complexity | Notes | +|---|---|---| +| Property resolution | O(keys) | Once at init, not per-save | +| Save (per mutation batch) | O(propKeys) | Snapshot + pick + serialize + JSON.stringify | +| Debounced save | O(1) per mutation | Timer reset; actual save is O(propKeys) when timer fires | +| Hydration | O(propKeys) | JSON.parse + deserialize + merge + assign | +| Cross-tab sync | O(propKeys) | Same as hydration, triggered by storage event | +| `save()` | O(propKeys) | Same as auto-save, synchronous trigger | +| `dispose()` | O(1) | Flag + timer cancel + listener removal | -await handle.hydrated; -``` +## Design Decisions -## Cleanup +### Why a function, not a decorator or middleware? -```ts -// Clear stored data -await handle.clear(); +Decorators require class modifications and don't compose well with the existing `createClassyStore()` wrapping. Middleware requires an interception layer that doesn't exist in the architecture (the proxy IS the middleware). A standalone function that takes a proxy and returns a handle is the simplest, most composable API. -// Manual save (bypass debounce; rejects on failure) -await handle.save(); +### Why per-property transforms instead of global serialize/deserialize? -// Re-read from storage -await handle.rehydrate(); +Global `serialize`/`deserialize` (as in Zustand's `createJSONStorage`) operates on the entire state blob. This makes it awkward when only one property needs special handling (e.g., a single `Date` field among ten string fields). Per-property transforms are more ergonomic for class-based stores where each property has a known type. -// Stop persisting; later save/clear/rehydrate calls reject -handle.dispose(); -``` +### Why exclude getters automatically? -## Overlapping async operations +Class getters are derived/computed values that recompute from persisted data properties. Persisting a getter result would be redundant and risk restoring a stale value that contradicts the actual data. For example, if `get remaining()` returns 3 but the persisted `todos` array has 5 incomplete items, the restored getter value would be wrong until something triggers a recompute. By excluding getters, we persist only the inputs and let the outputs recompute naturally. -Each handle orders its writes/removals. `save()` captures current state when -requested and waits behind earlier writes; a failed write reports through -`onError` without blocking the queue. `rehydrate()` waits for prior requested -writes/removals, then ignores results superseded by a newer read, save, clear, -accepted storage event, or local edit. Typing during initial async hydration -keeps newer local state. Ignored reads still settle their promises. +### Why a hydrated Promise instead of lifecycle hooks? -`clear()` cancels pending auto-saves and removes stored data after earlier issued -writes finish; later edits can persist again. Cleanup skips queued operations -that have not started, but cannot cancel already-issued adapter I/O. Ordering -is per handle; separate handles sharing an async key need application/adapter -coordination. Async saves are not guaranteed to finish during page unload. +`hydrated` lets callers await settlement of the initial hydration attempt. It resolves even if the attempt fails, so inspect `lastError` for reported errors. Use `rehydrate()` when an explicit read should reject on failure. React components can observe changes with `usePersistStatus(handle)`. -## Quick Reference +### Why debounce instead of throttle? -| What you want | How to do it | +Debounce waits for a quiet period after the last mutation. Throttle writes at regular intervals. For persistence, debounce is more appropriate: it coalesces bursts of mutations (like typing) into a single write, while throttle would write mid-burst with potentially incomplete state. `save()` provides the escape hatch for "write right now" scenarios. + +| Group | What's covered | |---|---| -| Persist everything | `persist(myStore, {name: 'key'})` | -| Persist specific properties | `properties: ['count', 'name']` | -| Handle Dates | `{key: 'date', serialize: (date) => date.toISOString(), deserialize: (stored) => new Date(stored)}` | -| Handle ReactiveMap | `{key: 'map', serialize: (map) => [...map.entries()], deserialize: (stored) => reactiveMap(stored)}` | -| Debounce writes | `debounce: 500` | -| Migrate schema changes | `version: 2, migrate: (state, old) => { ... }` | -| SSR support | `skipHydration: true` + `handle.rehydrate()` in `useEffect` | -| Cross-tab sync | `storage: localStorage, syncTabs: true` | -| Disable cross-tab sync | `syncTabs: false` | -| Use sessionStorage | `storage: sessionStorage` | -| Use AsyncStorage | `storage: AsyncStorage, syncTabs: false` | -| Expire after 15 minutes | `expireIn: 900_000` | -| Auto-clear expired data | `clearOnExpire: true` | -| Check if data expired | `handle.isExpired` | -| Force immediate save | `handle.save()` | -| Clear stored data | `handle.clear()` | -| Stop persisting | `handle.dispose()` | -| Wait for hydration | `await handle.hydrated` | -| Check hydration | `handle.isHydrated` | +| Basic round-trip | Save/restore, versioned envelope, getter exclusion, method exclusion | +| Properties option | Persist specific properties, restore only specified properties | +| Per-property transforms | Date round-trip, ReactiveMap round-trip | +| Debounce | Coalescing, save bypass | +| Version migration | Migration call, version match skip | +| Merge strategies | Shallow or custom function | +| skipHydration | No auto-hydrate, manual rehydrate, promise resolution | +| Dispose | Stop writes, cancel debounce | +| Save/clear/rehydrate | Immediate write, remove data, in-memory preservation, re-read | +| Hydration state | Promise instance, resolution timing | +| Async storage | Async write, async hydrate | +| Cross-tab sync | Correct key, wrong key, post-disposal, disabled | +| expireIn / TTL | Normal hydration, expired skip, clearOnExpire, default leave-in-storage, cross-tab reject, no-expiresAt envelope, TTL reset on write, rehydrate re-check | +| Edge cases | Empty storage, corrupted JSON, invalid envelope, no-storage error, multiple persists | --- -SOURCE: https://codebelt.github.io/classy-store/docs/DEVTOOLS_TUTORIAL +SOURCE: https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL --- -# DevTools Tutorial +# Classy Store Persist Utility -`devtools()` connects a store proxy to the [Redux DevTools](https://github.com/reduxjs/redux-devtools) browser extension for state inspection and time-travel debugging. Every mutation sends a snapshot to the DevTools panel. You can inspect state, jump to any point in history, and the store updates in real-time. +`persist()` saves your store's state to storage and restores it on page load. It's a standalone utility function that works with any `createClassyStore()` instance. ## Getting Started ### 1. Create a store ```ts +// todoStore.ts import {createClassyStore} from '@codebelt/classy-store'; -class CounterStore { - count = 0; - step = 1; - - get doubled() { - return this.count * 2; - } +export class TodoStore { + filter: 'all' | 'active' | 'done' = 'all'; + todos: {text: string; done: boolean}[] = []; - increment() { - this.count += this.step; + get remaining() { + return this.todos.filter((todo) => !todo.done).length; } - decrement() { - this.count -= this.step; + addTodo(text: string) { + this.todos.push({text, done: false}); } - setStep(step: number) { - this.step = step; + toggle(index: number) { + this.todos[index].done = !this.todos[index]!.done; } } -export const counterStore = createClassyStore(new CounterStore()); +export const todoStore = createClassyStore(new TodoStore()); ``` -### 2. Connect to DevTools +### 2. Persist it -```ts -import {devtools} from '@codebelt/classy-store/utils'; +For a client-only app, pass `localStorage` explicitly. Both `name` and `storage` are required. For server-rendered React apps, attach persistence in a client effect as shown in the [SSR section](#skiphydration-ssr--nextjs-support). -const disconnect = devtools(counterStore, {name: 'CounterStore'}); -``` +```ts +// todoStore.ts +import {createClassyStore} from '@codebelt/classy-store'; +import {persist} from '@codebelt/classy-store/utils'; -### 3. Disconnect when done +// ... (Include TodoStore setup from Step 1) -```ts -disconnect(); +// Saves all store properties to localStorage +const handle = persist(todoStore, { + storage: localStorage, + name: 'todo-store', +}); ``` -## Signature +**Stored under the `todo-store` key:** -```ts -function devtools( - proxyStore: T, - options?: DevtoolsOptions, -): () => void; +```json +{ + "version": 0, + "state": { + "todos": [], + "filter": "all" + } +} ``` -### Options +The optional `expiresAt` field is an absolute timestamp in milliseconds, calculated as `Date.now() + expireIn` when writing. -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `name` | `string` | `'ClassyStore'` | Display name in the DevTools panel | -| `enabled` | `boolean` | `true` | Set to `false` to disable (returns a noop) | +Store notifications trigger writes once per mutation batch, or after the configured debounce. On the next initialization, persistence restores saved data. The getter `remaining` and the methods `addTodo`/`toggle` are excluded. -## Use Cases +### Choosing What to Persist -### Development-only +By default, `persist()` saves all own data properties of the store -- everything that isn't a getter or a method. You can narrow this with the `properties` option: ```ts -devtools(counterStore, { - name: 'CounterStore', - enabled: import.meta.env.DEV, +persist(todoStore, { + storage: localStorage, + name: 'todo-store', + properties: ['todos'], // Only persist todos, not filter }); ``` -### Multiple stores +Now `filter` resets to its class default (`'all'`) on every page load, while `todos` are restored from storage. + +### Why getters are excluded + +Class getters like `get remaining()` are derived values. Their memoized results are invalidated when the data they depend on changes. Persisting a getter result would be redundant and risks restoring a stale value that contradicts the underlying data. + +### Configuration and Handle Overview + +Here is a quick overview of every configuration option and handle method. You won't need most of them day-to-day, but they're available when you do. ```ts -devtools(authStore, {name: 'AuthStore'}); -devtools(todoStore, {name: 'TodoStore'}); -devtools(uiStore, {name: 'UiStore'}); -``` +import {persist} from '@codebelt/classy-store/utils'; -### React cleanup +const handle = persist(todoStore, { + name: 'TodoStore', // Unique storage key (required) + properties: ['todos'], // Omit for all own data properties; [] persists no properties + storage: localStorage, // Required adapter with getItem/setItem/removeItem; no default + debounce: 500, // Coalesce rapid mutations into one write, in ms (default: 0) + version: 0, // Schema version number; triggers migrate when mismatched (default: 0) + migrate: (state, oldVersion) => state, // Return the migrated state when versions differ + merge: 'shallow', // How to merge persisted state on hydration: 'shallow' | custom function + skipHydration: false, // Set true to wait for a manual handle.rehydrate() call + syncTabs: true, // Opt in to localStorage tab updates (default: false) + expireIn: 1000, // TTL in ms; expired data is skipped on hydration. Resets on every write + clearOnExpire: false, // Auto-remove storage key when expired data is found (default: false) + onError: (error, operation) => console.error(operation, error), +}); -```tsx -function App() { - useEffect(() => { - const disconnect = devtools(counterStore, {name: 'CounterStore'}); - return () => disconnect(); - }, []); +handle.isHydrated; // Whether a hydration attempt has settled, including failures +handle.isExpired; // Whether the last hydration found expired data +await handle.hydrated; // Initial attempt settled; inspect getSnapshot().lastError +handle.getSnapshot(); // Current readonly status +handle.getServerSnapshot(); // Initial status for SSR/hydration +const stopStatus = handle.subscribe(() => console.log(handle.getSnapshot())); - return ; +try { + await handle.save(); // Save now, bypassing debounce + await handle.clear(); // Remove stored data; keep in-memory state + await handle.rehydrate(); // Read storage again +} catch (error) { + console.error('Explicit persistence operation failed', error); } -``` - -## Quick Reference -| What you want | How to do it | -|---|---| -| Connect a store | `devtools(store, {name: 'MyStore'})` | -| Disconnect | `const dispose = devtools(...); dispose()` | -| Disable in production | `enabled: import.meta.env.DEV` | -| Multiple stores | Call `devtools()` once per store with unique names | -| Use without extension | Works safely — returns a noop if extension is missing | +stopStatus(); // Remove only this status listener +handle.dispose(); // Stop persistence and release its resources +``` ---- -SOURCE: https://codebelt.github.io/classy-store/docs/SUBSCRIBE_KEY_TUTORIAL --- -# subscribeKey Tutorial +## Configuration Properties -`subscribeKey()` subscribes to changes on a single property of a store proxy. It's the property-level equivalent of `subscribe()` — instead of firing on every store mutation, it only fires when the specific key you're watching actually changes. +### `properties` (Per-Property Transforms) -## Getting Started +Some values aren't JSON-safe. `Date` objects become strings, and JSON loses custom class prototypes and methods. Transforms handle the round-trip through the `properties` option. -### 1. Create a store +The transform's `key` selects its TypeScript types: `serialize` receives that property's readonly snapshot value, and `deserialize` must return that property's mutable value. Stored input remains `unknown`, so validate it before restoring. Optional properties retain `undefined` in their value type. Standalone descriptors can use `PropertyTransform`, exported from `@codebelt/classy-store/utils`. ```ts -import {createClassyStore} from '@codebelt/classy-store'; - -class SettingsStore { - theme: 'light' | 'dark' = 'light'; - fontSize = 14; - language = 'en'; +class UserStore { + name = ''; + birthdate = new Date(); - setTheme(theme: 'light' | 'dark') { - this.theme = theme; + get isAdult() { + const cutoff = new Date(); + cutoff.setFullYear(cutoff.getFullYear() - 18); + return this.birthdate <= cutoff; } } -export const settingsStore = createClassyStore(new SettingsStore()); -``` - -### 2. Watch a single key - -```ts -import {subscribeKey} from '@codebelt/classy-store/utils'; +const userStore = createClassyStore(new UserStore()); -const unsub = subscribeKey(settingsStore, 'theme', (value, previousValue) => { - console.log(`Theme changed from "${previousValue}" to "${value}"`); - document.documentElement.setAttribute('data-theme', value); +persist(userStore, { + name: 'user-store', + storage: localStorage, + properties: [ + 'name', // plain key -- string, no transform needed + { + key: 'birthdate', + serialize: (date) => date.toISOString(), // Date → string + deserialize: (stored) => { + if (typeof stored !== 'string') throw new Error('Expected a birthdate'); + const date = new Date(stored); + if (Number.isNaN(date.getTime())) throw new Error('Invalid birthdate'); + return date; + }, + }, + ], }); ``` -### 3. Unsubscribe when done +**In storage:** -```ts -unsub(); +```json +{ + "version": 0, + "state": { + "name": "Robert", + "birthdate": "2026-02-14T00:00:00.000Z" + } +} ``` -## Signature +On restore, `deserialize` converts the ISO string back into a `Date` object before assigning it to `userStore.birthdate`. -```ts -function subscribeKey( - proxyStore: T, - key: K, - callback: (value: Snapshot[K], previousValue: Snapshot[K]) => void, -): () => void; -``` +*(If you need to persist `Map` or `Set` objects, see the [Reactive Collections Tutorial](https://codebelt.github.io/classy-store/docs/REACTIVE_COLLECTIONS_TUTORIAL).)* -## Use Cases +### `storage` -### Reacting to auth state changes +`persist()` requires a storage adapter. Any object with `getItem`, `setItem`, and `removeItem` (sync or async) works, including `localStorage`. + +**sessionStorage (Tab-Scoped)** + +Use `sessionStorage` for state that should reset when the tab closes: ```ts -subscribeKey(authStore, 'token', (token, previousToken) => { - if (token && !previousToken) { - router.push('/dashboard'); - } else if (!token && previousToken) { - router.push('/login'); - } +persist(uiStore, { + name: 'ui-state', + storage: sessionStorage, + properties: ['sidebarOpen', 'activeTab'], + // syncTabs defaults to false; opt in only for localStorage }); ``` -### Syncing with external systems +**Async Storage (React Native)** + +`AsyncStorage` from React Native works out of the box: ```ts -subscribeKey(playerStore, 'volume', (volume) => { - audioContext.gainNode.gain.value = volume; -}); +import AsyncStorage from '@react-native-async-storage/async-storage'; -subscribeKey(playerStore, 'track', (track) => { - document.title = track ? `Playing: ${track}` : 'Music Player'; +const handle = persist(todoStore, { + name: 'todo-store', + storage: AsyncStorage, + syncTabs: false, // no window.storage in React Native }); + +await handle.hydrated; ``` -## subscribeKey vs subscribe +### `syncTabs` (Cross-Tab Synchronization) -| | `subscribe()` | `subscribeKey()` | -|---|---|---| -| Fires on | Any mutation in the store | Only when the watched key changes | -| Callback args | `()` (no arguments) | `(value, previousValue)` | -| Use case | General side effects | Property-specific reactions | +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: -## Quick Reference +```ts +persist(authStore, { + name: 'auth', + storage: localStorage, + syncTabs: true, +}); -| What you want | How to do it | -|---|---| -| Watch a single property | `subscribeKey(store, 'key', (val, prev) => { ... })` | -| Stop watching | `const unsub = subscribeKey(...); unsub()` | -| Watch multiple properties | Call `subscribeKey()` once per key | -| Get both old and new value | Callback receives `(value, previousValue)` | +// Tab A: user logs out +authStore.token = null; +authStore.user = null; +// --> writes to localStorage ---- -SOURCE: https://codebelt.github.io/classy-store/docs/HISTORY_TUTORIAL ---- +// Tab B: automatically receives the update +// --> authStore.token and authStore.user become null +``` -# withHistory Tutorial +This works via the browser's `window.storage` event, which fires in all **other** same-origin tabs. The persist utility listens for changes to its storage key and re-hydrates when it detects a write from another tab. -`withHistory()` adds undo/redo capability to any store proxy. It maintains a stack of snapshots captured on each mutation. Call `undo()` to go back, `redo()` to go forward, and `pause()`/`resume()` to batch operations that shouldn't create individual history entries. +Disable it when you don't want cross-tab sync: -## Getting Started +```ts +persist(uiStore, { + storage: localStorage, + name: 'ui', + syncTabs: false, +}); +``` -### 1. Create a store +Cross-tab sync only works with `localStorage`. It does not fire for `sessionStorage`, `AsyncStorage`, or custom adapters. -```ts -import {createClassyStore} from '@codebelt/classy-store'; +An event whose value has already been replaced in its storage area is ignored. +This prevents a delayed event from rolling a newer local save back in the UI. +Accepted remote updates cancel pending debounced auto-saves and do not write +the restored state back to storage. -class DrawingStore { - color = '#000000'; - strokeWidth = 2; - points: { x: number; y: number }[] = []; +### Overlapping async operations - addPoint(x: number, y: number) { - this.points = [...this.points, { x, y }]; - } +Each persistence handle orders its writes and removals. A slow earlier save +cannot finish after a later save and overwrite it. `save()` captures the current +state when requested and resolves after that write completes; a failed write +reports through `onError` and does not block later writes. - setColor(color: string) { - this.color = color; - } +`rehydrate()` waits for writes/removals already requested through that handle. +If another read, save, clear, accepted storage event, or local mutation supersedes +an in-flight read, its result is ignored. In particular, typing while initial +async hydration is pending keeps the newer local state. An ignored read still +settles its promise; hydration completion does not guarantee that stored data won. - setStrokeWidth(width: number) { - this.strokeWidth = width; - } +`clear()` cancels pending auto-saves and removes stored data after earlier issued +writes finish. Later edits can persist again. `dispose()` skips queued writes +that have not started; it cannot cancel adapter I/O already in progress. - clear() { - this.points = []; - } -} +Ordering is per handle. Separate handles sharing an async storage key still +need coordination at the application or adapter level. -export const drawingStore = createClassyStore(new DrawingStore()); -``` +### `expireIn` & `clearOnExpire` (Expiration / TTL) -### 2. Attach history +Use `expireIn` to set a time-to-live (in milliseconds) on persisted data. After the TTL elapses, stored data is skipped during hydration and the store keeps its class defaults. ```ts -import {withHistory} from '@codebelt/classy-store/utils'; - -const history = withHistory(drawingStore); +persist(sessionStore, { + storage: localStorage, + name: 'session', + expireIn: 900_000, // 15 minutes +}); ``` -### 3. Undo and redo - -```ts -drawingStore.setColor('#ff0000'); -drawingStore.setStrokeWidth(5); +**The TTL resets on every write.** As long as mutations keep happening (an active user session), the data stays fresh. The countdown only matters when the store is hydrated from storage after a period of inactivity (e.g., a page reload). -history.undo(); // strokeWidth back to 2 -history.undo(); // color back to '#000000' -history.redo(); // color is '#ff0000' again -``` +**Checking expiration** -### 4. Check availability +The handle exposes an `isExpired` flag that becomes `true` when hydration encounters expired data: ```ts -history.canUndo; // true if there's a previous state -history.canRedo; // true if there's a next state -``` +const handle = persist(sessionStore, { + storage: localStorage, + name: 'session', + expireIn: 900_000, +}); -```tsx -function UndoRedoButtons() { - const {canUndo, canRedo} = useHistoryStatus(history); +await handle.hydrated; - return ( -
- - -
- ); +if (handle.isExpired) { + // Stored session expired — redirect to login } ``` -## Signature +By default, expired data is skipped but left in storage. Set `clearOnExpire: true` to automatically remove the key: ```ts -function withHistory( - proxyStore: T, - options?: { limit?: number }, -): HistoryHandle; +persist(sessionStore, { + storage: localStorage, + name: 'session', + expireIn: 900_000, + clearOnExpire: true, // Remove key from storage when expired +}); ``` -### HistoryHandle +### `skipHydration` (SSR / Next.js Support) -| Property/Method | Type | Description | -|-----------------|------|-------------| -| `undo()` | `() => void` | Restore the previous state | -| `redo()` | `() => void` | Restore the next state (after an undo) | -| `canUndo` | `readonly boolean` | Whether there is a previous state | -| `canRedo` | `readonly boolean` | Whether there is a next state | -| `pause()` | `() => void` | Stop recording history entries | -| `resume()` | `() => void` | Resume recording history entries | -| `dispose()` | `() => void` | Unsubscribe and clean up | +Create the store per request or provider from matching initial data. Call +`prepareStoreForHydration(store)` from `@codebelt/classy-store/react/server` +before rendering or starting persistence. This preserves the server snapshot +for boundaries that hydrate after the live store changes. -## Use Cases +Attach persistence in a client effect. The effect owns the handle and disposes +it during cleanup, including Strict Mode replay: -### Text editor with undo +```tsx +'use client'; -```ts -const editorStore = createClassyStore(new EditorStore()); -const history = withHistory(editorStore, { limit: 200 }); +import {useEffect} from 'react'; +import {persist} from '@codebelt/classy-store/utils'; +import type {TodoStore} from './todoStore'; -document.addEventListener('keydown', (e) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'z') { - e.preventDefault(); - if (e.shiftKey) { - history.redo(); - } else { - history.undo(); - } - } -}); -``` +export function TodoPersistence({store}: {store: TodoStore}) { + useEffect(() => { + const handle = persist(store, { + name: 'todo-store', + storage: localStorage, + skipHydration: true, + onError: (error, operation) => console.error(operation, error), + }); -### Batched edits and pause/resume + // Explicit calls reject even when onError is configured. + void handle.rehydrate().catch(() => { + // onError already reported the failure; keep the current store values. + }); -Synchronous edits in one turn already share a history entry. `pause()` skips notifications delivered while paused; `resume()` enables recording without creating an entry or grouping asynchronous edits into a transaction. + return () => handle.dispose(); + }, [store]); -```ts -function pasteBlock(data: Record) { - for (const [id, value] of Object.entries(data)) { - spreadsheetStore.setCell(id, value); - } + return null; } ``` -## Combining with Other Utilities +Render this component inside the provider that owns the prepared store. It +accesses browser storage only inside the effect. Avoid module-level mutable +stores or shared in-memory storage adapters on the server, where they can mix +state across requests. -### withHistory + persist +`skipHydration: true` delays the initial read until `rehydrate()` is called. +It does not delay writes, and it does not replace snapshot preparation for +streamed or selective hydration. If no manual timing is needed, omit it and +let the effect's `persist()` call start automatic hydration. + +See [React SSR](https://codebelt.github.io/classy-store/docs/REACT_SSR), [store scoping](https://codebelt.github.io/classy-store/docs/REACT_SCOPING), and +[Next.js App Router](https://codebelt.github.io/classy-store/docs/REACT_NEXT) for complete provider examples. Rendering +children only after restoration settles is also possible, but then those +children do not contribute their own server-rendered content. + +### `debounce` (Debouncing Writes) + +For stores that mutate frequently (form inputs, drag positions, real-time data), debouncing prevents excessive writes: ```ts -persist(todoStore, { name: 'todos' }); -const history = withHistory(todoStore); -// History is in-memory only — doesn't persist across page loads. +persist(formStore, { + storage: localStorage, + name: 'form-draft', + debounce: 500, // Write at most once every 500ms +}); + +// Rapid mutations: +formStore.name = 'J'; +formStore.name = 'Jo'; +formStore.name = 'John'; +// One write to storage, 500ms after the last mutation. ``` -### withHistory + devtools +### `version`, `migrate`, & `merge` (Version Migration and Strategies) + +When the store's shape changes between releases, versioning and migration prevent data loss: ```ts -devtools(store, { name: 'MyStore' }); -const history = withHistory(store); -// DevTools shows every mutation including undo/redo restores. +persist(todoStore, { + storage: localStorage, + name: 'todo-store', + version: 2, + migrate: (state, oldVersion) => { + if (oldVersion === 0) { + // v0 stored todos as `items` + return {...state, todos: state.items, items: undefined}; + } + if (oldVersion === 1) { + // v1 todos were plain strings, v2 todos are objects + return { + ...state, + todos: (state.todos as string[]).map((text) => ({text, done: false})), + }; + } + return state; + }, +}); ``` -## Quick Reference +**Merge Strategies** -| What you want | How to do it | -|---|---| -| Add undo/redo | `const h = withHistory(store)` | -| Undo | `h.undo()` | -| Redo | `h.redo()` | -| Check if undoable | `h.canUndo` | -| Check if redoable | `h.canRedo` | -| Limit history size | `withHistory(store, { limit: 50 })` | -| Batch as single step | `h.pause(); /* mutations */; h.resume()` | -| Stop tracking | `h.dispose()` | -| Undo all | `while (h.canUndo) h.undo()` | +When you add new properties to a store, old persisted data won't include them. The `merge` strategy controls how persisted state combines with the store's current state. ---- -SOURCE: https://codebelt.github.io/classy-store/docs/ARCHITECTURE ---- +Imagine your store adds a new `tags` property, but old persisted data only has `todos` and `filter`: -# Classy Store — Architecture +```ts +// Current store class (v2 — added `tags`) +class TodoStore { + filter: 'all' | 'active' | 'done' = 'all'; + todos: {text: string; done: boolean}[] = []; + tags: string[] = ['work', 'personal']; // new property with defaults +} -Internal design documentation for contributors and maintainers. +// Old persisted data in storage (no `tags` key): +// { "version": 1, "state": { "filter": "done", "todos": [{"text": "Buy milk", "done": false}] } } +``` -## Three-Layer Architecture +**`'shallow'`** (default) — persisted values overwrite current values one key at a time. Properties not in storage keep their default value: -The library is organized into three distinct layers, each with a single responsibility: +```ts +persist(todoStore, { + name: 'todo-store', + storage: localStorage, + merge: 'shallow', +}); -```mermaid -flowchart TB - subgraph userCode ["User Code"] - ClassDef["class MyStore { fields, methods, getters }"] - end +// Result after hydration: +// filter → "done" (from storage) +// todos → [{text: "Buy milk", done: false}] (from storage) +// tags → ["work", "personal"] (kept class default) +``` - subgraph layer1 ["Layer 1: Write Proxy (core.ts)"] - storeFn["createClassyStore(instance)"] - SetTrap["SET trap: forward write → bump version → notify subscribers"] - GetTrap["GET trap: return value, bind methods, lazy-wrap nested objects, memoize getters"] - DeleteTrap["DELETE trap: same as SET"] - Batch["queueMicrotask: coalesce synchronous mutations → 1 notification"] - SyncNotify["sync listeners: notify immediately per mutation"] - DeepProxy["Lazy deep proxy: nested objects/arrays wrapped on first access"] - end +**Custom function** — for full control, pass a function that receives `(persisted, current)` and returns the merged state: - subgraph layer2 ["Layer 2: Snapshot (snapshot.ts)"] - SnapshotFn["snapshot(proxy): immutable deep copy"] - StructuralShare["Structural sharing: unchanged refs reused across snapshots"] - Freeze["Object.freeze: enforces immutability"] - VersionCache["Version-stamped cache: skip rebuild if version unchanged"] - GetterEval["Getters: memoized per-snapshot + cross-snapshot caching"] - end +```ts +persist(todoStore, { + storage: localStorage, + name: 'todo-store', + merge: (persisted, current) => { + return { + ...current, // start with class defaults + ...persisted, // overwrite with stored values + tags: [ // custom: combine stored + default tags + ...new Set([ + ...(persisted.tags as string[] ?? []), + ...(current.tags as string[] ?? []), + ]), + ], + }; + }, +}); +``` - subgraph layer3 ["Layer 3: React Hook (useClassyStore.ts)"] - UseStore["useClassyStore(store, selector?)"] - UseLocalStore["useLocalStore(factory)"] - USES["useSyncExternalStore"] - ProxyCompare["proxy-compare: track which snapshot props selector reads"] - IsChanged["isChanged(prev, next, affected): fine-grained diff"] - SelectorMode["Selector mode: Object.is or custom isEqual"] - end +--- - subgraph component ["React Component"] - Render["Re-render ONLY when accessed snapshot props change"] - end +## The Handle - ClassDef --> storeFn - storeFn --> SetTrap - storeFn --> GetTrap - storeFn --> DeleteTrap - SetTrap --> SyncNotify - SetTrap --> Batch - Batch --> SnapshotFn - GetTrap --> DeepProxy - SnapshotFn --> StructuralShare - StructuralShare --> Freeze - Freeze --> VersionCache - VersionCache --> GetterEval - UseStore --> USES - USES --> VersionCache - VersionCache --> ProxyCompare - ProxyCompare --> IsChanged - IsChanged --> Render - SelectorMode --> Render +### Observable status + +```tsx +import {usePersistStatus} from '@codebelt/classy-store/react'; +import type {PersistHandle} from '@codebelt/classy-store/utils'; + +function PersistenceStatus({handle}: {handle: PersistHandle}) { + const {isHydrated, isExpired, lastError} = usePersistStatus(handle); + if (lastError) return Persistence failed: {lastError.operation}; + if (!isHydrated) return Restoring…; + return {isExpired ? 'Stored data expired' : 'Ready'}; +} ``` -## File Map +`PersistStatus` contains readonly `isHydrated`, `isExpired`, `isDisposed`, and `lastError` fields. `lastError` is `null` or `{error, operation}`; the next successful operation clears it. Hydration, expiry, and errors notify status consumers even when store data stays unchanged. -The library lives in `packages/classy-store`. Public entry points expose the API; -implementation files and types live with the feature that owns them. +Outside React, use `handle.subscribe(() => handle.getSnapshot())`. The callback takes no arguments; read the current snapshot inside it. `subscribe` returns listener cleanup and does not control persistence itself. `getSnapshot()` returns the same frozen object until status changes. `getServerSnapshot()` returns initial status for SSR/hydration; server and client handles must start with matching status. See [`usePersistStatus`](https://codebelt.github.io/classy-store/docs/#usepersiststatushandle). -```text -packages/classy-store/ -├── src/ -│ ├── index.ts # Core public entry; no React dependency -│ ├── types.ts # Public Snapshot and SubscribeOptions -│ ├── core/ -│ │ ├── core.ts # Write proxies, subscriptions, computed getters -│ │ ├── core.types.ts # Internal dependency and store bookkeeping types -│ │ ├── core.computed.test.ts # Write-proxy getter memoization -│ │ └── core.ownership.test.ts # Child ownership and subscription cleanup -│ ├── snapshot/ -│ │ ├── snapshot.ts # Immutable snapshots and structural sharing -│ │ └── snapshot.computed.test.ts # Snapshot getter memoization -│ ├── collections/ -│ │ ├── index.ts # Public collections entry -│ │ └── collections.ts # ReactiveMap and ReactiveSet -│ ├── frameworks/ -│ │ └── react/ -│ │ ├── react.ts # Public client entry; exports hooks and factory -│ │ ├── server.ts # Server-safe prepareStoreForHydration entry -│ │ ├── hooks/ -│ │ │ ├── useClassyStore/ # Selector and auto-tracked subscriptions; hook types -│ │ │ ├── useLocalStore/ # Component-scoped store creation -│ │ │ ├── useHistoryStatus/ # Public history status hook -│ │ │ ├── usePersistStatus/ # Public persistence status hook -│ │ │ └── useUtilityStatus/ # Internal shared status subscription hook -│ │ ├── utils/ -│ │ │ ├── createStoreHook/ # Bound store hook factory -│ │ │ ├── hydrationSnapshots/ # Shared captured snapshots, held by weak keys -│ │ │ ├── snapshotTargetCache/ # Shared tracking targets -│ │ │ └── trackingReadMap/ # Render-local and committed dependency reads -│ │ ├── testHelpers/ # React fixtures -│ │ └── react.*.test.tsx # Integration tests grouped by behavior -│ └── utils/ -│ ├── index.ts # Public utility functions and types -│ ├── persist/ -│ │ ├── persist.ts # Storage queue, hydration, transforms, tab sync -│ │ ├── persist.types.ts # Public persistence options, status, and handle -│ │ └── persist.*.test.ts # API, concurrency, and error regressions -│ ├── history/ # Undo/redo and status -│ ├── devtools/ # Redux DevTools integration -│ ├── subscribe-key/ # Single-property subscriptions -│ ├── equality/ # shallowEqual -│ ├── utilityStatus/ # Internal framework-neutral status source -│ ├── cloneSnapshot/ # Internal snapshot cloning -│ └── internal/ # Proxy and property helpers -├── tests/ -│ ├── browser/ # Real input, hydration, and persistence tests -│ ├── compatibility/ # Packed-library React version matrix -│ ├── compiler/ # Compiled consumer fixtures and regressions -│ ├── scoping/ # Provider isolation, lifecycle, and SSR -│ ├── next/ # Packed Next.js boundaries and navigation -│ ├── memory/ # Heap snapshots and retention checks -│ └── performance/ # Form and list benchmarks -├── tsdown.config.ts # Published entry points and bundle settings -└── bunfig.toml # happy-dom setup for source tests +Public types `PersistStatus` and `PersistOperation` are exported from `@codebelt/classy-store/utils`. + +### `isHydrated` & `hydrated` + +These indicate that a hydration attempt has settled, including failures and obsolete reads that were skipped. They do not guarantee that stored data was applied: + +```ts +// Async -- await the promise +await handle.hydrated; +console.log('Hydration status:', handle.getSnapshot()); + +// Sync check +if (handle.isHydrated) { + // The attempt has settled; inspect lastError for a reported failure. +} ``` -Tests for core behavior stay beside their implementation. React integration tests -exercise the public client entry, including computed getter behavior. Consumer -fixtures under `tests/` verify the packed library and framework build boundaries. -The other framework adapters remain under `src/frameworks/`. +For `localStorage` (synchronous), hydration resolves almost immediately. For async storage adapters like `AsyncStorage`, the promise resolves after the async read completes. -Runnable examples live in `examples/react` and `examples/nextjs` at the repository -root. This guide and the utility architecture guides live in `website/docs`. +### Handle operation failures -## Layer 1: Write Proxy (`core.ts`) +Explicit `save()`, `clear()`, and `rehydrate()` calls reject on failure after reporting through `onError` and `lastError`. Automatic writes and initial hydration report failures without producing unhandled rejections. Handle errors around manual operations: -### Overview +```ts +try { + await handle.save(); + console.log('Saved'); +} catch (error) { + console.error('Could not save', error); +} -The `createClassyStore()` function wraps a class instance in an ES6 Proxy. All mutations — property writes, array operations, nested object changes — are intercepted. Subscribers are batched by default into a single notification per microtask, while React always uses the synchronous path. Other code can opt into that path when it must observe each mutation immediately. +try { + await handle.clear(); + await handle.rehydrate(); +} catch (error) { + console.error('Could not clear or restore stored data', error); +} +``` -### Data Flow: Mutation → Notification +Each failing operation still calls `onError` once and updates status before rejecting with the original error. `PersistOperation` is `'read' | 'write' | 'remove' | 'parse'`; decoding, transforms, migrations, and merge failures use `'parse'`. A failed operation does not block later queued saves. `rehydrate` also waits for expired-data removal when `clearOnExpire` is enabled and rejects if that removal fails. -```mermaid -sequenceDiagram - participant User as User Code - participant Proxy as Write Proxy - participant Target as Raw Target - participant Internal as StoreInternal - participant Sync as Sync Subscribers - participant Micro as queueMicrotask - participant Batched as Batched Subscribers +An obsolete read superseded by a local edit or newer request is still ignored. Calls on a disposed handle reject; explicit queued writes cancelled by disposal also reject without invoking the adapter. Disposal errors do not invoke `onError` or publish more status. - User->>Proxy: store.count = 5 - Proxy->>Target: Reflect.set(target, 'count', 5) - Proxy->>Internal: bumpVersion(internal) up to root - Proxy->>Internal: snapshotCache = null (invalidate) - Proxy->>Sync: notify sync listeners immediately - Proxy->>Micro: schedule batched notify (if not already scheduled) - Note over Micro: Coalesces all sync mutations - User->>Proxy: store.name = 'new' - Proxy->>Target: Reflect.set(target, 'name', 'new') - Proxy->>Internal: bumpVersion (version increments again) - Proxy->>Sync: notify sync listeners again - Note over Micro: Still same microtask batch - Micro->>Batched: notify batched listeners once -``` +Handle rejected promises in DOM event handlers, for example `onClick={() => { void handle.save().catch(showError); }}`. Use `try`/`catch` inside an async handler when the UI also needs to show success. -### Internal State Storage +### `clear()` (Clear stored data) -All internal bookkeeping is stored in a `WeakMap`: +```ts +// Remove the stored data without affecting in-memory state +await handle.clear(); -```typescript -type StoreInternal = { - target: object; // Raw class instance - version: number; // Monotonically increasing - batchedListeners: Set<() => void>; // Subscribers notified once per microtask - syncListeners: Set<() => void>; // Subscribers notified immediately per mutation - childProxies: Map; // Cached child proxies - childInternals: Map; - parent: StoreInternal | null; // For version propagation - notifyScheduled: boolean; // Batch dedup flag - computedCache: Map; // Memoized getter cache -}; +// The store still has its current state in memory. +// Next page load starts with class defaults (nothing in storage). ``` -### Proxy Traps +### `save()` (Manual save) -**SET trap:** -1. Compare old and new values with `Object.is` — noop if equal -2. Clean up child proxy if the property is being replaced -3. Forward write to raw target via `Reflect.set` -4. Bump version for this node and all ancestors -5. Notify sync listeners immediately, then schedule batched listeners via `queueMicrotask` (deduped) +When you need to save immediately (e.g., before the user navigates away): -**GET trap (priority order):** -1. **Memoized getter detection** — walk prototype chain with `Object.getOwnPropertyDescriptor`. If a getter is found, call `evaluateComputed()` which checks dependency validity and returns the cached result or re-evaluates with dependency tracking. -2. **Method binding** — if value is a function, bind to proxy so `this.count++` in methods goes through the SET trap. Bound methods are cached. -3. **Nested objects/arrays** — if value passes `canProxy()` (plain object or array), lazily wrap in a child proxy. Child proxies are cached in `childProxies` Map. Also records a dependency if a getter is currently being tracked. -4. **Primitives** — return as-is. Also records a dependency if a getter is currently being tracked. +```ts +const handle = persist(formStore, { + name: 'form-draft', + storage: localStorage, + debounce: 1000, +}); -**DELETE trap:** Same pattern as SET — clean up child proxy, delete from target, bump version, notify sync listeners, and schedule batched listeners. +// Force save before page unload +window.addEventListener('beforeunload', () => { + void handle.save().catch((error) => console.error('Could not save', error)); +}); +``` -### Batching via `queueMicrotask` +`save()` cancels the pending debounce timer and requests a write of the current +state immediately. With async storage it waits behind earlier writes. Await the +returned promise when completion matters; an async write is not guaranteed to +finish during page unload. -Array operations like `push()` trigger multiple SET traps (element + length). For ordinary listeners, a dirty flag + `queueMicrotask` coalesces these mutations into one notification: +### `dispose()` (Stop persisting) -``` -store.items.push('a') - → SET trap for index 0 → scheduleNotify (flags, queues microtask) - → SET trap for length → scheduleNotify (already flagged, skips) - → microtask fires → notifies listeners ONCE +```ts +const handle = persist(todoStore, {name: 'todo-store', storage: localStorage}); + +// Later: stop all persistence (e.g., on logout) +handle.dispose(); + +// Mutations after this point are NOT written to storage. +// Cross-tab sync listener is also removed. +// Pending debounce timer is cancelled. +todoStore.addTodo('This is not persisted'); ``` -Subscribers opt into immediate timing with `{sync: true}`. Sync subscribers run after version propagation and before the batched microtask. They are intentionally not deduped: two writes mean two sync notifications. Every React hook uses this timing so `useSyncExternalStore` observes the new value during the input event, preserving controlled-input caret position. Selectors can run after each write; React batches rendering within event handlers. +Pending storage reads that finish after disposal no longer apply state, clear expired entries, or invoke read-error callbacks. Hydration promises still settle when the read finishes. This prevents a retired React effect from overwriting a newer scope during Strict Mode replay, resets, or storage configuration changes. Already-issued adapter I/O is not cancelled; cleanup cannot undo a write already sent to storage. See the [typed context lifecycle example](https://codebelt.github.io/classy-store/docs/REACT_SCOPING). -### Version Propagation +`dispose()` is safe to call repeatedly. Disposal publishes `isDisposed: true` and clears status listeners; no later callbacks run. A pending hydration may still settle afterward and update the readable `isHydrated` flag without notifying disposed listeners. The public `isHydrated` and `isExpired` properties are readonly, matching their runtime getters. -When a nested property mutates, versions bump from the mutated node up to the root: +--- -```mermaid -graph TD - Root["Root (v=10 → v=13)"] --> User["user (v=11 → v=12)"] - Root --> Settings["settings (v=5, unchanged)"] - User --> Name["name = 'Bob' (mutation here, v=12)"] - User --> Age["age = 30 (unchanged)"] +## Multiple Persistence Targets + +Call `persist()` multiple times with different options to persist different parts of a store to different locations: + +```ts +const todoStore = createClassyStore(new TodoStore()); + +// Persist todos to localStorage (survives page close) +persist(todoStore, { + storage: localStorage, + name: 'todo-items', + properties: ['todos'], +}); + +// Persist UI filter to sessionStorage (resets on tab close) +persist(todoStore, { + name: 'todo-filter', + storage: sessionStorage, + properties: ['filter'], +}); ``` -This enables structural sharing: when the snapshot layer sees that `settings` has the same version, it returns the cached snapshot reference. +## Putting It All Together -## Layer 2: Immutable Snapshots (`snapshot.ts`) +Here's a complete example combining several features: -### Overview +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {persist} from '@codebelt/classy-store/utils'; -`snapshot(proxy)` creates a deeply frozen, plain-JS copy of the store state. Unchanged sub-trees reuse previous snapshot references (structural sharing). +class AppStore { + theme: 'light' | 'dark' = 'light'; + language = 'en'; + lastLogin = new Date(0); -### Snapshot Creation Flow + setTheme(theme: 'light' | 'dark') { + this.theme = theme; + } +} -```mermaid -flowchart TD - Start["snapshot(proxy)"] --> CheckCache{"Version match\nin snapshotCache?"} - CheckCache -->|Yes| ReturnCached["Return cached snapshot (O(1))"] - CheckCache -->|No| CreateSnap["Create new snapshot object"] - CreateSnap --> IterateKeys["For each own key"] - IterateKeys --> CheckChild{"Has child\ninternal?"} - CheckChild -->|Yes| Recurse["Recurse: createSnapshotRecursive(child)"] - CheckChild -->|No| CheckProxy{"Value is\nplain obj/array?"} - CheckProxy -->|Yes| DeepClone["deepFreezeClone (cached by identity)"] - CheckProxy -->|No| CopyValue["Copy as-is (primitive/function)"] - Recurse --> AssignProp["Assign to snapshot"] - DeepClone --> AssignProp - CopyValue --> AssignProp - AssignProp --> IterateKeys - IterateKeys -->|Done| Freeze["Object.freeze(snapshot)"] - Freeze --> Cache["Cache: snapshotCache.set(target, [version, snap])"] - Cache --> Return["Return frozen snapshot"] +const appStore = createClassyStore(new AppStore()); + +const handle = persist(appStore, { + storage: localStorage, + name: 'app-store', + version: 1, + debounce: 300, + + properties: [ + 'theme', + 'language', + { + key: 'lastLogin', + serialize: (date) => date.toISOString(), + deserialize: (stored) => { + if (typeof stored !== 'string') throw new Error('Expected a login date'); + const date = new Date(stored); + if (Number.isNaN(date.getTime())) throw new Error('Invalid login date'); + return date; + }, + }, + ], + + migrate: (state, oldVersion) => { + if (oldVersion === 0) { + // v0 didn't have language + return {...state, language: 'en'}; + } + return state; + }, +}); + +// Wait for hydration, then start the app +await handle.hydrated; +console.log(`Welcome back! Theme: ${appStore.theme}`); ``` -### Two Cache Strategies +## Quick Reference -1. **Tracked sub-trees** (`snapshotCache` — `WeakMap`): - Objects that have been accessed through the proxy have a `StoreInternal` with version tracking. The snapshot recurses into them and uses version-stamped caching for structural sharing. +Catch failures from explicit `save()`, `clear()`, and `rehydrate()` calls. Reading `isHydrated` or awaiting `hydrated` only confirms an attempt settled; inspect status for errors. -2. **Untracked sub-trees** (`untrackedCache` — `WeakMap`): - Objects never accessed through the proxy are deep-cloned and frozen. The clone is cached by the raw object's identity. Since untracked objects can't be mutated through the proxy, the cache is always fresh. +| Feature | Code Example | +|---|---| +| **Persist everything** | `persist(myStore, {name: 'key', storage: localStorage})` | +| **Persist specific properties** | `properties: ['count', 'name']` | +| **Handle Dates & Objects** | Supply a property transform with a serializer and a validating deserializer | +| **Handle reactive collections** | Serialize entries/values and deserialize with `reactiveMap()` / `reactiveSet()`; see the [collections guide](https://codebelt.github.io/classy-store/docs/REACTIVE_COLLECTIONS_TUTORIAL) | +| **Use sessionStorage** | `storage: sessionStorage` | +| **Use AsyncStorage** | `storage: AsyncStorage, syncTabs: false` | +| **Disable cross-tab sync** | `syncTabs: false` | +| **Expire payload after time**| `expireIn: 900_000` | +| **Auto-clear expired data** | `clearOnExpire: true` | +| **SSR & manual hydration** | `skipHydration: true` + `handle.rehydrate()` | +| **Debounce storage writes**| `debounce: 500` | +| **Migrate schema versions**| `version: 2, migrate: (state, old) => { ... }` | +| **Merge strategy** | `merge: 'shallow'` or a custom `(persisted, current) => merged` function | +| **Check hydration** | `handle.isHydrated` | +| **Wait for hydration** | `await handle.hydrated` | +| **Clear stored data** | `await handle.clear()` | +| **Force immediate save** | `await handle.save()` | +| **Re-hydrate manually** | `await handle.rehydrate()` | +| **Stop persisting** | `handle.dispose()` | +| **Check if data expired** | `handle.isExpired` | -### Structural Sharing Example +--- +SOURCE: https://codebelt.github.io/classy-store/docs/REACTIVE_COLLECTIONS_TUTORIAL +--- -``` -Snapshot v1: Snapshot v2 (after user.name = 'Bob'): -┌──────────┐ ┌──────────┐ -│ root │ │ root │ (new object) -│ user ────┼──→ {name:'Alice'} │ user ────┼──→ {name:'Bob'} (new) -│ settings─┼──→ {theme:'dark'} │ settings─┼──→ {theme:'dark'} (SAME ref ===) -└──────────┘ └──────────┘ -``` +# Reactive Collections -`snap2.settings === snap1.settings` because the `settings` sub-tree's version didn't change → cache hit → same reference. +Use `reactiveMap()` and `reactiveSet()` when mutations such as `.set()`, `.add()`, +`.delete()`, and `.clear()` need to notify store subscribers. Put the collection +inside a store created by `createClassyStore()` and mutate it through that store. -### Getter Evaluation (Memoized) +Native `Map` and `Set` are opaque leaves: replacing their property can notify +subscribers, but mutating their contents does not. Reactive collections use +arrays internally and expose Map-like and Set-like methods. They are not native +`Map` or `Set` instances, and key/value lookups scan those arrays. -Snapshots preserve the prototype chain and install lazy-memoizing getters on each snapshot object. Getter results are cached at two levels: +## Reactive updates -1. **Per-snapshot cache** — repeated access to the same getter on the same snapshot returns the identical reference. -2. **Cross-snapshot cache** — if the properties the getter reads are structurally the same across snapshots (via reference equality from structural sharing), the previous result is returned. +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {reactiveMap, reactiveSet} from '@codebelt/classy-store/collections'; -```typescript -class Store { - count = 5; - get doubled() { return this.count * 2; } +class DirectoryStore { + labels = reactiveMap(); + selected = reactiveSet(); + + select(id: string, label: string) { + this.labels.set(id, label); + this.selected.add(id); + } } -const snap = snapshot(createClassyStore(new Store())); -snap.doubled; // 10 — computed once, cached -snap.doubled; // 10 — same reference (per-snapshot cache hit) +const directoryStore = createClassyStore(new DirectoryStore()); +directoryStore.select('u1', 'Alice'); ``` -## Computed Getter Memoization +React components read these collections with `useClassyStore`, just like other +store data. Select an existing snapshot property or a primitive such as `.size`; +if a selector creates a new array, consider `shallowEqual` where its one-level +comparison matches your needs. -Class getters are automatically memoized at two layers — the write proxy and the snapshot — without any user-facing API changes. This is analogous to MobX's `@computed`, but implemented transparently via Proxy. +`ReactiveMap` and `ReactiveSet` are public types from the collections entry. +Construct values with the factory functions. -### Write Proxy Memoization (`core.ts`) +## Persisting a ReactiveMap -When a getter is accessed on the write proxy, `evaluateComputed()` runs the getter with dependency tracking. A module-level tracker stack records which properties the getter reads on `this`: +JSON does not retain a collection's prototype or methods. Use a property transform +to serialize entries and reconstruct the collection during hydration. For string +keys and values: -```mermaid -flowchart TD - GetterAccess["store.filtered accessed"] - CheckDeps{"Dependencies\nchanged?"} - ReturnCached["Return cached value"] - Recompute["Re-run getter with\ndependency tracking"] - CacheResult["Cache result + deps"] +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {reactiveMap} from '@codebelt/classy-store/collections'; +import {persist} from '@codebelt/classy-store/utils'; - GetterAccess --> CheckDeps - CheckDeps -->|No| ReturnCached - CheckDeps -->|Yes| Recompute - Recompute --> CacheResult - CacheResult --> ReturnCached +class LabelStore { + labels = reactiveMap(); +} + +const labelStore = createClassyStore(new LabelStore()); +const persistence = persist(labelStore, { + name: 'labels', + storage: localStorage, + properties: [ + { + key: 'labels', + serialize: (labels) => [...labels.entries()], + deserialize: (stored) => { + if (!Array.isArray(stored)) throw new Error('Expected label entries'); + const labels = reactiveMap(); + for (const entry of stored) { + if ( + !Array.isArray(entry) || entry.length !== 2 || + typeof entry[0] !== 'string' || typeof entry[1] !== 'string' + ) { + throw new Error('Invalid label entry'); + } + labels.set(entry[0], entry[1]); + } + return labels; + }, + }, + ], +}); ``` -**Dependency types:** -- **Sub-tree dep** (`kind: 'version'`): the getter read a nested object/array. Validated by comparing `internal.version` — O(1) per dep. -- **Primitive dep** (`kind: 'value'`): the getter read a primitive. Validated by `Object.is(target[prop], recordedValue)` — O(1) per dep. +After saving `labelStore.labels.set('u1', 'Alice')`, the storage envelope contains: + +```json +{ + "version": 0, + "state": { + "labels": [["u1", "Alice"]] + } +} +``` + +The restored `labels` value retains `.get()`, `.set()`, and the other collection +methods. Validate object-valued entries according to your application's schema +before constructing the collection. + +## Persisting a ReactiveSet + +Serialize the values and rebuild the set after validating stored input: + +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {reactiveSet} from '@codebelt/classy-store/collections'; +import {persist} from '@codebelt/classy-store/utils'; + +class TagStore { + tags = reactiveSet(); +} + +const tagStore = createClassyStore(new TagStore()); +const persistence = persist(tagStore, { + name: 'tags', + storage: localStorage, + properties: [ + { + key: 'tags', + serialize: (tags) => [...tags], + deserialize: (stored) => { + if (!Array.isArray(stored)) throw new Error('Expected tags'); + const tags = reactiveSet(); + for (const value of stored) { + if (typeof value !== 'string') throw new Error('Invalid tag'); + tags.add(value); + } + return tags; + }, + }, + ], +}); +``` -**Nested getter support:** when getter A reads getter B, B pushes its own frame onto the tracker stack. If B returns cached, A's deps include B's deps (propagated). If B recomputes, the underlying reads naturally propagate to A's tracker. +With tags added, the stored state is an array of strings: -### Snapshot Memoization (`snapshot.ts`) +```json +{ + "version": 0, + "state": { + "tags": ["typescript", "react"] + } +} +``` -Since snapshots are frozen, a getter's result can never change for a given snapshot. But more importantly, getter results are stable **across** snapshots when dependencies haven't changed: +These are client-only setup examples. Dispose the persistence handle when its +owner is torn down. For React and SSR, create it in an effect and call +`persistence.dispose()` in cleanup; see [store scoping](https://codebelt.github.io/classy-store/docs/REACT_SCOPING). +Transform failures are reported as parse errors during hydration. Explicit +`rehydrate()` calls also reject, so handle their promises as described in the +[persistence guide](https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL#handle-operation-failures). -```mermaid -flowchart TD - SnapGetter["snap.filtered accessed"] - PerSnapCheck{"Per-snapshot\ncache hit?"} - PerSnapReturn["Return cached ref"] - CrossSnapCheck{"Cross-snapshot\ndeps unchanged?"} - CrossSnapReturn["Return prev result"] - Compute["Run getter with dep tracking,\nrecord which this.X were read"] - CacheBoth["Cache in per-snapshot + cross-snapshot"] +--- +SOURCE: https://codebelt.github.io/classy-store/docs/REACT_NEXT +--- - SnapGetter --> PerSnapCheck - PerSnapCheck -->|Yes| PerSnapReturn - PerSnapCheck -->|No| CrossSnapCheck - CrossSnapCheck -->|Yes| CrossSnapReturn - CrossSnapCheck -->|No| Compute - Compute --> CacheBoth -``` +# Next.js App Router -**Cross-snapshot mechanism:** -1. First evaluation: a lightweight tracking Proxy records which `this` properties the getter reads and their values. -2. On subsequent snapshots: for each recorded property, check `Object.is(currentSnap[prop], previousValue)`. -3. Structural sharing guarantees that unchanged sub-trees have the same reference — so if `this.todos` hasn't changed, the reference comparison passes and the cached result is returned. -4. For getters reading other getters (e.g., `get filteredCount() { return this.filtered.length; }`): the inner getter is itself memoized, so it returns a stable reference, which the outer getter's dep check recognizes as unchanged. +Pass initial data from a Server Component to a Client Component, then construct +the class store inside the client boundary. That component also renders on the +server for the initial request, so prepare its snapshot before persistence or +other effects start. The browser constructs its own store from the same data. -**Impact on `useClassyStore`:** The existing hook benefits automatically — no changes needed: -- **Selector mode:** `useClassyStore(store, (state) => state.filtered)` gets a stable reference from the memoized snapshot getter. `Object.is` correctly detects "no change" without `shallowEqual`. -- **Auto-tracked mode:** `proxy-compare`'s `isChanged` gets stable references from snapshot getters, reducing false positives. +Do not pass a store proxy or class instance across the React Server Component +boundary. Use a plain data object containing the fields the client needs. +React defines the supported [serializable prop types](https://react.dev/reference/rsc/use-client#serializable-types-returned-by-server-components). -## Layer 3: React Hook (`hooks/useClassyStore/useClassyStore.ts`) +## Package boundaries -### Overview +| Entry | App Router use | +| --- | --- | +| `@codebelt/classy-store` | Core store creation and snapshots in server or client modules. | +| `@codebelt/classy-store/collections` | Reactive collections in server or client modules. | +| `@codebelt/classy-store/utils` | Server-safe imports; configure browser storage and effects inside Client Components. | +| `@codebelt/classy-store/react` | Client hooks. The published ESM and CommonJS entries carry `use client`. | +| `@codebelt/classy-store/react/server` | `prepareStoreForHydration` without importing React hooks; safe in Server Components and ordinary SSR code. | -`useClassyStore` uses `useSyncExternalStore` for tear-free React integration. React subscriptions always notify synchronously, allowing controlled inputs to observe updates during their event. The React API has no timing option; ordinary core subscriptions remain batched. It supports two modes: +Components that use hooks still need their own client boundary. Do not call +`useClassyStore`, `useLocalStore`, or `createStoreHook` in a Server Component. +Preparing a store in an RSC module does not transfer that store or its capture +to the browser; initialize and prepare the browser counterpart separately. +Both preparation exports use the same capture cache within a module graph. -### Server rendering and hydration +## A route-owned store -`prepareStoreForHydration(store)` captures an immutable snapshot in a WeakMap keyed by the exact store proxy. It returns the same mutable proxy and preserves the first capture on repeated calls. Capturing is explicit at bootstrap, before rendering or persistence, because a delayed Suspense boundary may not call its hook until after the live store changes. +Define the class in a regular module that has no browser effects: -Prepared stores supply that snapshot through `getServerSnapshot`; ordinary `getSnapshot` still reads live state. Each selector has separate server and live caches. When React reads the hydration result, it can seed the live cache with that result so unchanged inputs and equal selections retain their references without overwriting committed selections during rendering. Client-only mounts never evaluate the server selector. +```ts +// stores/profile/profile.store.ts +export type ProfileData = {name: string; visits: number}; -The capture stays available for the store's lifetime, including later boundaries or roots. It is never cleared when the first consumer mounts. Unprepared stores keep the existing current-snapshot behavior. Core mutation timing, ordinary subscriptions, and `snapshot(store)` are unchanged. +export class ProfileStore { + name: string; + visits: number; -### Mode 1: Selector + constructor(initialData: ProfileData) { + this.name = initialData.name; + this.visits = initialData.visits; + } -```typescript -const count = useClassyStore(myStore, (state) => state.count); + recordVisit() { + this.visits++; + } +} ``` -```mermaid -sequenceDiagram - participant React as React (useSyncExternalStore) - participant Hook as useClassyStore - participant Snap as snapshot() - participant Selector as selector(snap) +Construct and prepare it inside the Client Component: - React->>Hook: getSnapshot() - Hook->>Snap: snapshot(proxy) - Snap-->>Hook: frozen snapshot - Hook->>Hook: Same snapshot ref? → return cached result - Hook->>Selector: selector(snapshot) - Selector-->>Hook: selected value - Hook->>Hook: Object.is(prev, next)? - alt Same value - Hook-->>React: return cached (no re-render) - else Different value - Hook-->>React: return new value (re-render) - end -``` +```tsx +// components/profileEditor/ProfileEditor.tsx +'use client'; -**Equality chain:** -1. Same store, selector, equality function, and snapshot reference? → reuse the cached result (O(1)) -2. Run selector → compare result with `Object.is` (or `options.isEqual`) -3. Same result → return previous reference (no re-render) +import {useClassyStore, useLocalStore} from '@codebelt/classy-store/react'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; +import {type ProfileData, ProfileStore} from '../../stores/profile/profile.store'; -**Controlled input timing:** +export function ProfileEditor({initialData}: {initialData: ProfileData}) { + const store = prepareStoreForHydration( + useLocalStore(() => new ProfileStore(initialData)), + ); + const state = useClassyStore(store); -```typescript -const name = useClassyStore(formStore, (state) => state.name); + return ( +
+ { store.name = event.target.value; }} + /> + +
+ ); +} ``` -The hook always calls the core `subscribe()` with `{sync: true}`. Ordinary listeners still batch with `queueMicrotask`. This timing is independent of React's own render scheduling and does not provide atomic transactions across several writes. - -### Mode 2: Auto-tracked (selectorless) +The Server Component supplies data and an intentional reset key: -```typescript -const snap = useClassyStore(myStore); -// snap.count — tracked -// snap.user.name — tracked -``` +```tsx +// app/profile/page.tsx +import {ProfileEditor} from '../../components/profileEditor/ProfileEditor'; -Each render creates its own `affected` map and raw-snapshot getter; the hook retains a proxy cache across renders. `useSyncExternalStore` receives a cached raw snapshot: unchanged tracked values return the previous snapshot for that render. After reading it, the hook wraps the snapshot with `createProxy` to record the component's property reads. +type Props = {searchParams: Promise<{name?: string}>}; -A parent re-render starts from current store data and a render-local `TrackingReadMap`. It lazily copies committed operation records for reused immutable snapshot objects, including structurally shared children. This retains reads skipped by React Compiler or manual memoization. Sets are copied before recording new reads, so suspended and abandoned renders cannot change committed dependencies. An effect publishes only committed reads into a WeakMap; old snapshot objects can be collected without retaining a chain of prior render maps. +export default async function ProfilePage({searchParams}: Props) { + const {name = 'Ada'} = await searchParams; + return ; +} +``` -The adapter follows the operation-record/Set layout of the pinned `proxy-compare` 3 dependency without hardcoding its private field names. Compiled regressions cover value reads, `in`, `Object.hasOwn`, and key enumeration. Reused snapshot objects conservatively retain prior committed dependencies, so changing a conditional branch may cause an extra render before that object's identity changes. Explicit selectors provide selected-value equality when stricter filtering is needed. +The key determines when a mounted editor gets a new store. A new `initialData` +object alone does not reset `useLocalStore` or its prepared snapshot. Avoid +module-level server stores; construct request state inside the request instead. -Snapshot creation marks only library-created snapshot nodes with `markToTrack`, including class and null-prototype objects. This enables field tracking without altering opaque leaves such as native maps and dates. Nodes containing object-valued symbol fields use whole-node comparison to avoid a frozen-symbol limitation in `proxy-compare` 3; updates remain reactive with coarser render filtering. +## Streaming, persistence, and navigation -**How `proxy-compare` works:** -- `createProxy(snapshot, affected)` records properties read into the render's map; committed records are retained separately with weak snapshot keys. -- `isChanged(previousSnapshot, nextSnapshot, affected)` checks whether a tracked property differs. -- Unchanged tracked properties suppress subscription-driven renders; `snapshot()` retains its existing identity cache and structural sharing. +Attach persistence in an effect after preparation, call `rehydrate()` there, +and call `handle.dispose()` during cleanup. The [SSR guide](https://codebelt.github.io/classy-store/docs/REACT_SSR) shows +this pattern. If persistence restores before a streamed boundary hydrates, +the boundary first reads the prepared server data, then catches up to live +state. Keep the initial server and browser payloads identical. -## Dependency Graph +For a store shared across routes, place its Client Provider in the appropriate +layout. Put route-specific providers below that layout and use keys when a +route identity change should reset them. See the [typed context example](https://codebelt.github.io/classy-store/docs/REACT_SCOPING). +`router.refresh()` merges new server output; it does not inherently reset a +mounted client store. Choose explicitly whether refreshed data should update +the existing store or replace its provider via a key. -```mermaid -graph LR - reactEntry["/react client entry"] --> hook["useClassyStore.ts"] - reactEntry --> localHook["useLocalStore.ts"] - reactEntry --> factory["createStoreHook.ts"] - factory --> hook - factory --> core["core.ts"] - localHook --> core - hook --> core - hook --> snapshot["snapshot.ts"] - hook --> proxyCompare["proxy-compare"] - hook --> hydration["hydrationSnapshots.ts"] - serverEntry["/react/server entry"] --> hydration - serverEntry --> snapshot - serverEntry --> core - snapshot --> core - snapshot --> types["types.ts: public types"] - snapshot --> coreTypes["core.types.ts: internal types"] - snapshot --> internal["utils/internal/internal.ts"] - core --> types - core --> coreTypes - core --> internal - index["Core entry: index.ts"] --> core - index --> snapshot - index --> equality["equality.ts"] - index --> types +The integration tests use the default App Router configuration with +`cacheComponents: false`. With Cache Components enabled, Next.js can use React +Activity to preserve hidden route state while cleaning up its effects. Returning +to a hidden route can resume the same store; effect cleanup does not necessarily +mean the instance was destroyed. See [Next.js state preservation](https://nextjs.org/docs/app/guides/preserving-ui-state). - subgraph utilities ["Utilities (utils/)"] - persist["persist.ts"] - devtools["devtools.ts"] - subscribeKey["subscribe-key.ts"] - withHistory["history.ts"] - status["utilityStatus.ts"] - end +## Tested package consumer - persist --> core - persist --> snapshot - persist --> internal - persist --> status - devtools --> core - devtools --> snapshot - subscribeKey --> core - subscribeKey --> snapshot - withHistory --> core - withHistory --> snapshot - withHistory --> status - reactEntry --> statusHooks["useHistoryStatus / usePersistStatus"] - statusHooks --> sharedHook["useUtilityStatus.ts"] -``` +Run `bun run test:react:next` from the repository root. It installs the package +tarball into an isolated Next.js 16.4.2 / React 19.3.1 app and runs clean +production Turbopack builds with React Compiler off and on. Tests verify the +actual compiler-generated selector and tracking functions, request isolation, +real inputs, streamed response ordering, delayed hydration without replacing +server DOM, navigation, refresh, and persistence cleanup. They also exercise +the server-safe APIs under React's `react-server` export condition and verify +shared hydration captures across public entry points. -The client entry owns the React boundary. Core, snapshots, collections, and -utilities remain framework-neutral. React status hooks share an internal hook; -the status source itself lives with the utilities and has no React dependency. -`types.ts` contains public core types, while `core.types.ts` describes private -bookkeeping. Persistence contracts live in `persist.types.ts` and are exported -through the public utilities entry. +This matrix covers the stated versions and default navigation behavior. It +does not establish compatibility with every Next.js version, bundler, or +Cache Components configuration. -## Key Design Decisions +Next App Router can supply its own React runtime. The test evidence records the +actual browser `React.version`; this Next.js version uses +`19.3.0-canary-278794d7-20261002`, while the installed React package is 19.3.1. -### Why ES6 Proxy instead of `Object.defineProperty`? +--- +SOURCE: https://codebelt.github.io/classy-store/docs/REACT_SCOPING +--- -ES6 Proxy supports intercepting property creation, deletion, and arbitrary key access — not just known properties. This is essential for arrays (index access) and dynamic objects. All modern browsers and Node.js/Bun support Proxy natively. +# React store scoping and lifecycle -### Why `queueMicrotask` for batching? +Use React context to choose a store instance for a subtree, then `useClassyStore` to subscribe to that instance. This is useful for independent forms, editors, nested workspaces, and request-scoped server rendering. -`queueMicrotask` runs after the current synchronous code but before the next paint/timer. For ordinary subscribers, this means: -- Multiple mutations in the same event handler → 1 notification -- Array `push` (which triggers multiple SET traps) → 1 notification -- No risk of missing updates (unlike `requestAnimationFrame`) +The runnable [scoped editors demo](https://codebelt.github.io/classy-store/demos/#/scoped-stores) shows two independent editors and a nested editor. Its [source](https://github.com/codeBelt/classy-store/tree/main/examples/react/src/demos/scoped-store) is an application example built from existing APIs; there is no new context factory to install. -### Why structural sharing in snapshots? +## Define a typed context -Without structural sharing, `Object.is(prevSelector, nextSelector)` would always return `false` for object/array selectors because each snapshot creates new objects. Structural sharing ensures unchanged sub-trees maintain reference equality, making selectors efficient without custom equality functions. +Share the context definition across the application. Create store instances inside each provider, so server requests and sibling providers cannot accidentally share mutable state. -### Why `proxy-compare` for auto-tracking? +```ts +// editorStore.ts +export type EditorData = {title: string; body: string}; -`proxy-compare` (~1KB, maintained by the creator of Valtio/Zustand/Jotai) provides battle-tested property access tracking. It wraps a frozen object in a Proxy that records reads, then efficiently diffs only the accessed properties between snapshots. This eliminates the need for manual selectors in many cases. +export class EditorStore { + title: string; + body: string; -### Why `useSyncExternalStore` instead of `useState` + `useEffect`? + constructor(initialData: EditorData) { + this.title = initialData.title; + this.body = initialData.body; + } -`useSyncExternalStore` is React's official API for external stores. It: -- Prevents tearing (concurrent mode safe) -- Integrates with React's scheduler -- Works with server-side rendering (via `getServerSnapshot`) -- Is the same API used by Zustand, Redux, and Valtio + setTitle(title: string) { this.title = title; } + setBody(body: string) { this.body = body; } +} +``` -### Why `WeakMap` for internal state? +```ts +// editorStoreContext.ts +import {createContext} from 'react'; +import type {EditorStore} from './editorStore'; -Storing internals in a `WeakMap` instead of on the proxy itself: -- Doesn't pollute the user's object with library metadata -- Allows garbage collection when the store is no longer referenced -- Prevents conflicts with user-defined properties +export const EditorStoreContext = createContext(null); +``` -### Why subclass inheritance works +```ts +// hooks/useEditorStore.ts +import {useContext} from 'react'; +import {EditorStoreContext} from '../editorStoreContext'; -No special handling is needed — three mechanisms in the existing design make it work automatically: +export function useEditorStore() { + const store = useContext(EditorStoreContext); + if (!store) { + throw new Error('useEditorStore must be used within EditorStoreProvider'); + } + return store; +} +``` -1. **Methods** — The GET trap uses `Reflect.get(target, prop)`, which naturally walks the JavaScript prototype chain. Methods from any ancestor class are found and bound to the proxy via `.bind(receiver)`, so `this` mutations inside inherited methods go through the SET trap. **Important:** Arrow function properties (`increment = () => {}`) are own properties whose `this` is lexically bound to the raw instance at construction time. `.bind()` is a no-op on arrow functions, so mutations inside them bypass the proxy entirely. Always use prototype methods (`increment() {}`) for reactive mutations. +The hook returns a typed mutable `EditorStore`, with its methods intact. It fails clearly when a provider is missing. React resolves the [closest matching provider](https://react.dev/reference/react/useContext), so a nested provider overrides the instance only for its own subtree. -2. **Getters** — `findGetterDescriptor()` (in `utils.ts`) walks the prototype chain with `Object.getOwnPropertyDescriptor` and returns the first (most-derived) getter found. For snapshots, `collectGetters()` (in `snapshot.ts`) walks the full chain using a `seen` Set to ensure overridden getters are collected only once — the most-derived version wins. +## Let the provider own creation and effects -3. **Snapshot prototype** — `createSnapshotRecursive()` creates snapshot objects with `Object.create(Object.getPrototypeOf(target))`, preserving the full prototype chain. This means `snapshot(store) instanceof DerivedClass` is `true`, and `installMemoizedGetters()` installs getters from all levels with cross-snapshot memoization. +The example provider initializes once per mount and captures the initial hydration data before starting persistence: -`super.method()` calls also work because they use the language-level prototype dispatch mechanism, which resolves to the parent method with `this` still bound to the proxy. +```tsx +// components/editorStoreProvider/EditorStoreProvider.tsx +import {useLocalStore} from '@codebelt/classy-store/react'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; +import {persist, type StorageAdapter} from '@codebelt/classy-store/utils'; +import {type ReactNode, useEffect} from 'react'; +import {type EditorData, EditorStore} from '../../editorStore'; +import {EditorStoreContext} from '../../editorStoreContext'; -## Performance Characteristics +type Props = { + initialData: EditorData; + children: ReactNode; + persistence?: {name: string; storage: StorageAdapter}; +}; -| Operation | Complexity | Notes | -|-----------|-----------|-------| -| Property read | O(1) | Direct Proxy GET trap | -| Property write | O(depth) | Versions bump from node to root | -| Notification | O(listeners) | Ordinary listeners once per microtask; React listeners per mutation | -| Getter (cache hit) | O(deps) | Check deps validity, return cached | -| Getter (cache miss) | O(getter body) | Re-evaluates with dep tracking | -| Snapshot (cache hit) | O(1) | Version check only | -| Snapshot (cache miss) | O(changed branches) | Structural sharing skips unchanged | -| Snapshot getter (same snap) | O(1) | Per-snapshot cache hit | -| Snapshot getter (cross-snap) | O(deps) | Reference comparison via structural sharing | -| Selector equality | O(1) for primitives | `Object.is` check | -| Auto-track diff | O(accessed props) | `proxy-compare` checks only read props | +export function EditorStoreProvider({initialData, children, persistence}: Props) { + const store = prepareStoreForHydration( + useLocalStore(() => new EditorStore(initialData)), + ); + const name = persistence?.name; + const storage = persistence?.storage; ---- -SOURCE: https://codebelt.github.io/classy-store/docs/PERSIST_ARCHITECTURE ---- + useEffect(() => { + if (name === undefined || storage === undefined) return; + const handle = persist(store, { + name, + storage, + properties: ['title', 'body'], + skipHydration: true, + }); + void handle.rehydrate().catch((error) => console.error('Could not restore persisted state', error)); + return () => handle.dispose(); + }, [store, name, storage]); -# Persist Utility — Architecture + return ( + + {children} + + ); +} +``` -Internal design documentation for the `persist()` utility in `@codebelt/classy-store/utils`. +Keep constructors and initializers pure: Strict Mode can evaluate them more than once. Start subscriptions, timers, and storage work in effects with cleanup. The persistence effect depends on the storage adapter and name, so passing a new options object with those same values does not restart it. Keep a custom storage adapter's identity stable too. -## Design Principles +The context holds the stable store proxy. Consumers read snapshots through the existing React hook and send edits through store methods: -1. **Standalone utility, not middleware.** `persist` uses `subscribe` and `snapshot`, plus an internal version helper for stale-read protection. -2. **Tree-shakeable.** Lives in `@codebelt/classy-store/utils`. Applications that don't use persistence pay zero cost. -3. **Storage-agnostic.** The `StorageAdapter` interface accepts sync (`localStorage`, `sessionStorage`) and async (`AsyncStorage`, `localForage`) implementations. -4. **Class-aware.** Getters and methods are automatically excluded from persistence. Only source-of-truth data properties are saved. +```tsx +// components/editorTitle/EditorTitle.tsx +import {useClassyStore} from '@codebelt/classy-store/react'; +import {useEditorStore} from '../../hooks/useEditorStore'; -## How It Hooks Into the Existing Architecture +export function EditorTitle() { + const store = useEditorStore(); + const title = useClassyStore(store, (state) => state.title); + return store.setTitle(event.target.value)} />; +} +``` -`persist` sits beside the existing three-layer architecture as an independent consumer: +Auto-tracking also works: `const state = useClassyStore(useEditorStore())`. Reading fields directly from the mutable store during render does not subscribe to mutations. Avoid creating a module-level bound hook for one store if consumers must select different instances through context. -- **Save flow:** `subscribe callback` → `debounce timer` → `snapshot(proxy)` → `pick properties` → `per-property serialize` → `JSON.stringify envelope` → `storage.setItem()` -- **Restore flow:** `storage.getItem()` → `JSON.parse` → `version check + migrate` → `per-property deserialize` → `merge strategy` → `proxy[key] = value` → triggers SET trap → reactivity +## Preserve drafts and reset intentionally -## Storage Envelope +`initialData` is an initializer. New props or an ordinary parent render leave the existing store and draft intact. Use a [changed React key](https://react.dev/learn/preserving-and-resetting-state#resetting-state-with-a-key) to create a fresh session: -```json -{ - "version": 1, - "state": { - "todos": [{"text": "Buy milk", "done": false}], - "filter": "all" - }, - "expiresAt": 1700000000000 -} +```tsx + + + ``` -The `version` field is always present (default `0`). The `expiresAt` field is a Unix epoch timestamp (ms) and is only present when `expireIn` is set. It resets on every write. +Change `documentId` when moving to a different document; increment `revision` for an explicit reset. A key change remounts the provider's whole subtree, including nested providers. Resetting a nested provider leaves its parent and siblings intact. -## Merge Strategies +| Event | Store and effect behavior | +| --- | --- | +| Parent render or changed `initialData`, same key | Preserve the existing store and edits | +| Provider key changes | Create a new store and clean up the old subtree | +| Nested provider | Consumers inside it use its independent store | +| Storage name or adapter changes | Keep the store; detach old persistence and load from the new configuration | +| Persistence removed | Keep current state and stop persistence | +| Provider unmounts | React releases its store reference and effect cleanup detaches persistence | -| Strategy | Behavior | -|---|---| -| `'shallow'` (default) | `{ ...current, ...persisted }` — persisted keys overwrite, new keys keep defaults | -| `'replace'` | Same as shallow at top level | -| Custom `fn` | `fn(persisted, current)` — full control | +A reset does **not** clear saved data. If the new provider uses the same persistence key, it restores that saved draft. To reset saved data as well, first unmount or disable the old persistence owner, await `storage.removeItem(name)`, then remount. Use distinct storage keys for independent documents or users. Different React providers using the same storage key still share that storage namespace. -## Cross-Tab Sync +Cleanup prevents pending storage reads from applying after disposal, including the discarded first effect in Strict Mode. Such reads still finish and their promises settle; the adapter's I/O is not cancelled. Already-issued writes cannot be undone by cleanup. External code retaining a store can still mutate it after unmount, but its disposed persistence owner and unmounted consumers no longer react. `useLocalStore` does not automatically call application-specific `dispose()` methods. -`syncTabs` defaults to false. When enabled for localStorage-backed state, the -utility listens for storage events from other same-origin tabs. It ignores -obsolete event values and suppresses echo writes while applying accepted updates. +## Server rendering and hydration -## PersistHandle API +Render a fresh provider for each server request. Transfer only its initial data through the framework's serialization facility, then initialize the client provider from that same data. The provider prepares the initial snapshot before any persistence effect, so a delayed Suspense boundary can hydrate correctly even after another part of the tree restores browser storage. See the [SSR guide](https://codebelt.github.io/classy-store/docs/REACT_SSR) for transport and streaming details. -| Method/Property | Behavior | -|---|---| -| `dispose()` | Cancel debounce/listeners, ignore pending reads, and skip queued operations not yet started. Already-issued adapter I/O is not cancelled. Idempotent. | -| `hydrated` | Resolves when a hydration attempt settles, including failure or a superseded read. | -| `isHydrated` | Readonly boolean indicating a settled hydration attempt. | -| `isExpired` | Readonly boolean; true when hydration found expired data. | -| `save()` | Cancel debounce, capture current state, and enqueue its write. | -| `clear()` | Cancel pending auto-saves and enqueue removal after prior writes. | -| `rehydrate()` | Wait for prior writes, apply only if the read remains current, and await expired-data removal when enabled. | -| `getSnapshot()` / `getServerSnapshot()` | Cached current / initial readonly status. | -| `subscribe(listener)` | Observe status; returns listener cleanup. | +Browser storage belongs in the client setup; do not access `localStorage` at module initialization on the server. Effects do not run during server rendering. The server and client's initial markup must match regardless of whether browser persistence is enabled. -## Property Resolution +## Regression coverage -On initialization, `resolveProperties()` determines which keys to persist: -- If `properties` is provided: each entry is either a string key or a `PropertyTransform` descriptor. -- If `properties` is omitted: takes a snapshot of the store, iterates `Object.keys()`, and excludes getters (detected via prototype chain) and methods (`typeof value === 'function'`). +`bun run test:react:compatibility` copies the actual example provider, context, hook, and class into isolated installed-package consumers on React 18.0, 18.3, and 19.3. Tests cover sibling and nested isolation, missing providers, stable initialization, keyed resets, Strict Mode, persistence replacement/removal, pending-read cleanup, request isolation, and delayed Suspense hydration with DOM reuse. Strict type checks verify mutable commands, readonly snapshots, selector inference, and provider props. --- -SOURCE: https://codebelt.github.io/classy-store/docs/FRAMEWORK_ADAPTERS +SOURCE: https://codebelt.github.io/classy-store/docs/REACT_SSR --- -# Framework Adapters - -`@codebelt/classy-store` provides first-class integrations for Vue, Svelte, Solid, and Angular. Each adapter follows the idiomatic reactive pattern for its framework. - -## Installation +# React server rendering and hydration -Install only the peer dependencies you need: +Create one store for each server request and reconstruct a client store from the same initial data. Pass the store through props or React context. A module-level store on the server shares mutable state across requests and can expose one user's data to another. -```bash -# Vue -npm install @codebelt/classy-store vue +The [typed context example](https://codebelt.github.io/classy-store/docs/REACT_SCOPING) provides a complete provider pattern with independent instances, nested scopes, keyed resets, and persistence cleanup. -# Svelte -npm install @codebelt/classy-store svelte +Import hydration preparation from `@codebelt/classy-store/react/server` in server code and client modules. This entry does not import React hooks. The separate `@codebelt/classy-store/react` hooks entry is marked `use client`. The [Next.js App Router guide](https://codebelt.github.io/classy-store/docs/REACT_NEXT) explains where to construct stores and how to cross that boundary with initial data. -# Solid -npm install @codebelt/classy-store solid-js +Call `prepareStoreForHydration(store)` after initializing the store with the data used to render the page, on both the server and client. It captures that initial snapshot before persistence or other live updates begin. All direct and bound hooks use the captured data during server rendering and client hydration, then read the current live store. React requires the initial server and client data to match; see [React's external-store SSR guidance](https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering). -# Angular -npm install @codebelt/classy-store @angular/core -``` +## Initialize from request data -## Vue 3 +Keep the serialized payload limited to the data the client needs. Reconstruct the class so its methods and getters remain available: -**Import:** `@codebelt/classy-store/vue` +```ts +// profileStore.ts +import {createClassyStore} from '@codebelt/classy-store'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; -**Function:** `useStore(proxyStore: T): ShallowRef>` +export type ProfileData = { + name: string; + visits: number; +}; -Returns a Vue `ShallowRef` that holds the current immutable snapshot. The ref updates whenever the store mutates. Automatically unsubscribes via `onUnmounted`. +export class ProfileStore { + name: string; + visits: number; -```vue - + recordVisit() { + this.visits++; + } +} - +export function createProfileStore(initialData: ProfileData) { + return prepareStoreForHydration( + createClassyStore(new ProfileStore(initialData)), + ); +} ``` -**How it works internally:** +```tsx +// Profile.tsx +import {useClassyStore} from '@codebelt/classy-store/react'; +import type {ProfileStore} from './profileStore'; -```ts -import { onUnmounted, shallowRef } from 'vue'; -import { subscribe } from '@codebelt/classy-store'; -import { snapshot } from '@codebelt/classy-store'; +export function Profile({store}: {store: ProfileStore}) { + const state = useClassyStore(store); + return ( + + ); +} +``` -export function useStore(proxyStore: T): ShallowRef> { - const state = shallowRef(snapshot(proxyStore)); +For each request, render ``. Transfer `initialData` using the framework's serialization facility and initialize the browser store from that same payload before hydration. If implementing the HTML transport yourself, use safe JSON serialization for the embedding context; do not interpolate raw JSON into an executable script. Class instances, functions, and native collection types need an explicit data representation. - const unsubscribe = subscribe(proxyStore, () => { - state.value = snapshot(proxyStore); - }); +For a store owned by a component, `prepareStoreForHydration(useLocalStore(() => new ProfileStore(initialData)))` captures its initial state while keeping its identity stable across renders. Preparation is idempotent. Keep the factory pure: React Strict Mode can evaluate it more than once in development. Changes to `initialData` after mounting do not automatically reset the store or its captured snapshot. - onUnmounted(unsubscribe); +## Restore persistence while other boundaries hydrate - return state; -} -``` +Browser storage may contain values different from the server-rendered payload. Prepare the store before starting persistence, attach persistence in an effect, and dispose it when the component unmounts. The effect can restore storage even while a Suspense boundary is still waiting to hydrate: -**Key notes:** -- Uses `shallowRef` (not `ref`) because snapshots are already deeply frozen — no deep Vue reactivity is needed. -- The unsubscribe callback is registered with `onUnmounted` — must be called inside a component's `setup()` or ` +For example, the server renders `Server:1`, persistence restores `Saved:8`, and a delayed boundary then hydrates. The boundary first reads `Server:1` from the captured snapshot, reuses the server DOM, and then updates to `Saved:8` from the live store. It does not need an earlier hook read to establish the initial data. This applies to completed server HTML with selective hydration and to streamed Suspense content. - -``` +## Snapshot lifetime and scope ---- +- Capture after loading the initial request data, before rendering or starting persistence. Preparation cannot recover earlier data if the store has already changed. +- Repeated calls return the same mutable proxy and retain the first snapshot. The capture is kept for the store's lifetime because another boundary or root may hydrate later. Use a fresh store for a different request or initial payload. +- Prepare the exact proxy passed to `useClassyStore` or `createStoreHook`. If a hook subscribes directly to a nested proxy, prepare that proxy separately. +- Capture follows ordinary `snapshot()` semantics. Plain objects, arrays, and supported class state are immutable snapshots; opaque leaves such as native `Date`, `Map`, and `Set` retain their identity. Replace those fields instead of mutating their native objects in place. +- Selectors read captured data during hydration and live data afterward. Their caches are separate, while equal selected results preserve reference identity at the handoff. -## Svelte 4 / 5 +Client-only apps need no preparation. `createRoot` reads current state even for a prepared store. Unprepared stores retain the existing behavior of reading current state for server rendering and hydration, so keep them unchanged until every consumer hydrates if you do not use preparation. Neither preparation nor hydration resets the live store; `snapshot(store)` continues to return current data. -**Import:** `@codebelt/classy-store/svelte` +React subscriptions notify synchronously by default, including after hydration. Ordinary `subscribe(store, listener)` callbacks remain batched. The React hooks do not have a `sync` option. A normal sequence of mutations in an event can be batched into one React commit, but selectors may run after each mutation; this is not a transaction API. -**Function:** `toSvelteStore(proxyStore: T): ClassyReadable>` +--- +SOURCE: https://codebelt.github.io/classy-store/docs/SUBSCRIBE_KEY_TUTORIAL +--- -Returns a Svelte-compatible readable store (implements the `{ subscribe }` contract). Use with Svelte's `$store` auto-subscription syntax or call `.subscribe()` manually. +# subscribeKey Tutorial -```svelte - +## Getting Started -

{$store.remaining} items remaining

+### 1. Create a store -
    - {#each $store.filtered as todo, index} -
  • - todoStore.toggle(index)} - /> - {todo.text} -
  • - {/each} -
+```ts +import {createClassyStore} from '@codebelt/classy-store'; - -``` +class SettingsStore { + theme: 'light' | 'dark' = 'light'; + fontSize = 14; + language = 'en'; -**How it works internally:** + setTheme(theme: 'light' | 'dark') { + this.theme = theme; + } -```ts -export interface ClassyReadable { - subscribe(run: (value: T) => void): () => void; + setFontSize(size: number) { + this.fontSize = size; + } } -export function toSvelteStore( - proxyStore: T, -): ClassyReadable> { - return { - subscribe(run: (value: Snapshot) => void): () => void { - // Svelte contract: call immediately with current value - run(snapshot(proxyStore)); - - return subscribe(proxyStore, () => { - run(snapshot(proxyStore)); - }); - }, - }; -} +export const settingsStore = createClassyStore(new SettingsStore()); ``` -**Key notes:** -- Follows the Svelte store contract: `subscribe` is called immediately with the current value, then on every change. Returns an unsubscribe function. -- The `$store` syntax auto-subscribes and auto-unsubscribes — no manual cleanup needed in Svelte components. -- `toSvelteStore` can be called at module level (outside a component), unlike the Vue/Solid/Angular adapters which must be called during component initialization. -- The `ClassyReadable` interface matches Svelte's built-in `Readable` contract for full compatibility. -- Actions are called directly on the original store proxy: `todoStore.addTodo('text')` — not on the converted store. - -**Typed usage:** +### 2. Watch a single key ```ts -// Convert once at module level -import { toSvelteStore } from '@codebelt/classy-store/svelte'; -import { todoStore } from './stores'; - -export const $todos = toSvelteStore(todoStore); -``` +import {subscribeKey} from '@codebelt/classy-store/utils'; -```svelte - +const unsub = subscribeKey(settingsStore, 'theme', (value, previousValue) => { + console.log(`Theme changed from "${previousValue}" to "${value}"`); + document.documentElement.setAttribute('data-theme', value); +}); ``` ---- - -## Solid.js - -**Import:** `@codebelt/classy-store/solid` - -**Function:** `useStore(proxyStore: T): () => Snapshot` - -Returns a Solid signal getter `() => Snapshot`. Call it in JSX to access the current snapshot. Automatically cleans up via `onCleanup`. +Now only `theme` changes trigger the callback. Mutations to `fontSize` or `language` are ignored entirely. -```tsx -import { useStore } from '@codebelt/classy-store/solid'; -import { todoStore } from './stores'; - -function TodoList() { - // state is a signal getter: () => Snapshot - const state = useStore(todoStore); +### 3. Unsubscribe when done - return ( -
-

{state().remaining} items remaining

-
    - {state().filtered.map((todo, index) => ( -
  • - todoStore.toggle(index)} - /> - {todo.text} -
  • - ))} -
- -
- ); -} +```ts +unsub(); ``` -**How it works internally:** +## Signature ```ts -import { createSignal, onCleanup } from 'solid-js'; -import { subscribe } from '@codebelt/classy-store'; -import { snapshot } from '@codebelt/classy-store'; - -export function useStore(proxyStore: T): () => Snapshot { - const [state, setState] = createSignal>(snapshot(proxyStore)); - - const unsubscribe = subscribe(proxyStore, () => { - setState(() => snapshot(proxyStore)); - }); - - onCleanup(unsubscribe); - - return state; -} +function subscribeKey>( + proxyStore: T, + key: K, + callback: (value: Snapshot[K], previousValue: Snapshot[K]) => void, + options?: SubscribeOptions, +): () => void; ``` -**Key notes:** -- Returns a signal getter (a function). You must call it — `state()` — to access the snapshot value. This is idiomatic Solid.js. -- The update uses the functional form `setState(() => snapshot(proxyStore))` to avoid Solid treating the snapshot as a setter function. -- Must be called inside a Solid component or reactive root (like `createRoot`), since it uses `onCleanup`. -- `onCleanup` is called when the reactive owner is disposed — typically when the component unmounts. -- Fine-grained reactivity: Solid tracks which signal properties are accessed. Accessing `state().count` in JSX creates a dependency on that signal. -- Actions are called directly on the store proxy: `todoStore.addTodo('text')`. - ---- - -## Angular +| Parameter | Type | Description | +|-----------|------|-------------| +| `proxyStore` | `T` | A reactive proxy created by `createClassyStore()` | +| `key` | `keyof T` | The property to watch | +| `callback` | `(value, previousValue) => void` | Called when the watched property changes | +| `options` | `SubscribeOptions` | Optional `{sync: true}` for immediate notifications; otherwise batched | +| **Returns** | `() => void` | Unsubscribe function | -**Import:** `@codebelt/classy-store/angular` +The callback is not called when you subscribe. It receives the current and previous snapshot values when the watched key changes. By default, several synchronous writes coalesce into one callback with the final value; passing `{sync: true}` as the fourth argument observes each change. Import `Snapshot` from the root entry and `SubscribeOptions` from the root or utilities entry. -**Function:** `injectStore(proxyStore: T): Signal>` +## How It Works -Returns a readonly Angular `Signal>`. Uses Angular's `inject(DestroyRef)` for automatic cleanup when the component or service is destroyed. +Under the hood, `subscribeKey` wraps the existing `subscribe()` + `snapshot()` APIs: -```typescript -// counter.component.ts -import { Component, computed } from '@angular/core'; -import { injectStore } from '@codebelt/classy-store/angular'; -import { counterStore } from './stores'; - -@Component({ - selector: 'app-counter', - standalone: true, - template: ` -
-

Count: {{ state().count }}

-

Doubled: {{ doubled() }}

- -
- `, -}) -export class CounterComponent { - // state is Signal> - readonly state = injectStore(counterStore); +1. Captures an initial snapshot and stores `snapshot[key]` as the previous value. +2. On each batched notification (or each mutation with `sync: true`), takes a new snapshot and compares `snapshot[key]` with the previous value via `Object.is()`. +3. If different, fires the callback with `(currentValue, previousValue)` and updates the stored previous value. - // Use computed() to derive values from the signal - readonly doubled = computed(() => this.state().count * 2); +Because `snapshot()` uses structural sharing, unchanged sub-trees return the same reference. This makes the `Object.is()` comparison efficient — no deep equality checks are needed. - increment() { - counterStore.increment(); - } -} -``` +## Use Cases -**How it works internally:** +### Reacting to auth state changes ```ts -import { DestroyRef, inject, signal, type Signal, type WritableSignal } from '@angular/core'; -import { subscribe } from '@codebelt/classy-store'; -import { snapshot } from '@codebelt/classy-store'; - -export function injectStore(proxyStore: T): Signal> { - const state: WritableSignal> = signal(snapshot(proxyStore)); - const destroyRef = inject(DestroyRef); +class AuthStore { + token: string | null = null; + user: { name: string; role: string } | null = null; - const unsubscribe = subscribe(proxyStore, () => { - state.set(snapshot(proxyStore)); - }); - - destroyRef.onDestroy(unsubscribe); + login(token: string, user: { name: string; role: string }) { + this.token = token; + this.user = user; + } - return state.asReadonly(); + logout() { + this.token = null; + this.user = null; + } } -``` - -**Key notes:** -- Must be called in an **injection context** — inside a component constructor, `inject()` call, or an `@Injectable` service constructor. Do not call it outside Angular's DI tree. -- Returns a readonly signal (`Signal`, not `WritableSignal`). Consumers cannot mutate the signal directly. -- Uses `DestroyRef` (available since Angular 16) for automatic cleanup — no need to manually unsubscribe in `ngOnDestroy`. -- Requires Angular 17+ (signals were stable in Angular 17). -- Use Angular's `computed()` to derive values: `readonly total = computed(() => this.state().items.length)`. -- Combine with `effect()` for side effects: `effect(() => console.log(this.state().count))`. -- Actions are called directly on the store proxy: `counterStore.increment()` — not through the signal. - -**Usage in a service:** -```typescript -import { Injectable } from '@angular/core'; -import { injectStore } from '@codebelt/classy-store/angular'; -import { todoStore } from './stores'; - -@Injectable({ providedIn: 'root' }) -export class TodoService { - // Works in services too — inject context is available in constructors - readonly todos = injectStore(todoStore); +const authStore = createClassyStore(new AuthStore()); - addTodo(text: string) { - todoStore.addTodo(text); +// Redirect on login/logout +subscribeKey(authStore, 'token', (token, previousToken) => { + if (token && !previousToken) { + router.push('/dashboard'); + } else if (!token && previousToken) { + router.push('/login'); } -} +}); ``` ---- - -## Framework Adapter Summary - -| Framework | Import | Function | Return type | Cleanup mechanism | -|-----------|--------|----------|-------------|-------------------| -| Vue 3 | `@codebelt/classy-store/vue` | `useStore(store)` | `ShallowRef>` | `onUnmounted` | -| Svelte 4/5 | `@codebelt/classy-store/svelte` | `toSvelteStore(store)` | `ClassyReadable>` | `$store` syntax / manual unsubscribe | -| Solid.js | `@codebelt/classy-store/solid` | `useStore(store)` | `() => Snapshot` | `onCleanup` | -| Angular | `@codebelt/classy-store/angular` | `injectStore(store)` | `Signal>` | `DestroyRef.onDestroy` | - -All adapters: -- Accept any `createClassyStore()` proxy -- Subscribe to batched mutations via `subscribe()` -- Update when `snapshot()` changes -- Clean up their subscription automatically when the component/owner is destroyed -- Return immutable snapshots (deeply frozen objects) -- Require calling actions directly on the store proxy — not on the returned reactive value - ---- -SOURCE: https://codebelt.github.io/classy-store/docs/REACT_NEXT ---- - -# Next.js App Router +### Syncing with external systems -Pass initial data from a Server Component to a Client Component, then construct -the class store inside the client boundary. That component also renders on the -server for the initial request, so prepare its snapshot before persistence or -other effects start. The browser constructs its own store from the same data. +```ts +class PlayerStore { + volume = 0.8; + track: string | null = null; + playing = false; -Do not pass a store proxy or class instance across the React Server Component -boundary. Use a plain data object containing the fields the client needs. -React defines the supported [serializable prop types](https://react.dev/reference/rsc/use-client#serializable-types-returned-by-server-components). + setVolume(v: number) { + this.volume = v; + } -## Package boundaries + play(track: string) { + this.track = track; + this.playing = true; + } +} -| Entry | App Router use | -| --- | --- | -| `@codebelt/classy-store` | Core store creation and snapshots in server or client modules. | -| `@codebelt/classy-store/collections` | Reactive collections in server or client modules. | -| `@codebelt/classy-store/utils` | Server-safe imports; configure browser storage and effects inside Client Components. | -| `@codebelt/classy-store/react` | Client hooks. The published ESM and CommonJS entries carry `use client`. | -| `@codebelt/classy-store/react/server` | `prepareStoreForHydration` without importing React hooks; safe in Server Components and ordinary SSR code. | +const playerStore = createClassyStore(new PlayerStore()); -Components that use hooks still need their own client boundary. Do not call -`useClassyStore`, `useLocalStore`, or `createStoreHook` in a Server Component. -Preparing a store in an RSC module does not transfer that store or its capture -to the browser; initialize and prepare the browser counterpart separately. -Both preparation exports use the same capture cache within a module graph. +// Sync volume slider with the Web Audio API +subscribeKey(playerStore, 'volume', (volume) => { + audioContext.gainNode.gain.value = volume; +}); -## A route-owned store +// Update document title when track changes +subscribeKey(playerStore, 'track', (track) => { + document.title = track ? `Playing: ${track}` : 'Music Player'; +}); +``` -Define the class in a regular module that has no browser effects: +### Logging specific changes ```ts -// stores/profile/profile.store.ts -export type ProfileData = {name: string; visits: number}; +class CartStore { + items: { id: string; qty: number }[] = []; + coupon: string | null = null; -export class ProfileStore { - name: string; - visits: number; + get total() { + return this.items.reduce((sum, item) => sum + item.qty, 0); + } - constructor(initialData: ProfileData) { - this.name = initialData.name; - this.visits = initialData.visits; + addItem(id: string) { + this.items.push({ id, qty: 1 }); } - recordVisit() { - this.visits++; + applyCoupon(code: string) { + this.coupon = code; } } -``` -Construct and prepare it inside the Client Component: +const cartStore = createClassyStore(new CartStore()); -```tsx -// components/profileEditor/ProfileEditor.tsx -'use client'; +// Only log when coupon changes — not on every item add +subscribeKey(cartStore, 'coupon', (coupon, prev) => { + analytics.track('coupon_changed', { from: prev, to: coupon }); +}); +``` -import {useClassyStore, useLocalStore} from '@codebelt/classy-store/react'; -import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; -import {type ProfileData, ProfileStore} from '../../stores/profile/profile.store'; +## subscribeKey vs subscribe -export function ProfileEditor({initialData}: {initialData: ProfileData}) { - const store = prepareStoreForHydration( - useLocalStore(() => new ProfileStore(initialData)), - ); - const state = useClassyStore(store); +| | `subscribe()` | `subscribeKey()` | +|---|---|---| +| Fires on | Any mutation in the store | Only when the watched key changes | +| Callback args | `()` (no arguments) | `(value, previousValue)` | +| Use case | General side effects | Property-specific reactions | +| Overhead | Minimal | One snapshot comparison per mutation | - return ( -
- { store.name = event.target.value; }} - /> - -
- ); -} -``` +Use `subscribe()` when you need to react to any change. Use `subscribeKey()` when you only care about one property and want to avoid running your callback unnecessarily. -The Server Component supplies data and an intentional reset key: +## Multiple Keys -```tsx -// app/profile/page.tsx -import {ProfileEditor} from '../../components/profileEditor/ProfileEditor'; +To watch multiple keys independently, call `subscribeKey` once per key: -type Props = {searchParams: Promise<{name?: string}>}; +```ts +const unsub1 = subscribeKey(store, 'theme', handleThemeChange); +const unsub2 = subscribeKey(store, 'language', handleLanguageChange); -export default async function ProfilePage({searchParams}: Props) { - const {name = 'Ada'} = await searchParams; - return ; +// Clean up both +function dispose() { + unsub1(); + unsub2(); } ``` -The key determines when a mounted editor gets a new store. A new `initialData` -object alone does not reset `useLocalStore` or its prepared snapshot. Avoid -module-level server stores; construct request state inside the request instead. - -## Streaming, persistence, and navigation - -Attach persistence in an effect after preparation, call `rehydrate()` there, -and call `handle.dispose()` during cleanup. The [SSR guide](./REACT_SSR.md) shows -this pattern. If persistence restores before a streamed boundary hydrates, -the boundary first reads the prepared server data, then catches up to live -state. Keep the initial server and browser payloads identical. - -For a store shared across routes, place its Client Provider in the appropriate -layout. Put route-specific providers below that layout and use keys when a -route identity change should reset them. See the [typed context example](./REACT_SCOPING.md). -`router.refresh()` merges new server output; it does not inherently reset a -mounted client store. Choose explicitly whether refreshed data should update -the existing store or replace its provider via a key. - -The integration tests use the default App Router configuration with -`cacheComponents: false`. With Cache Components enabled, Next.js can use React -Activity to preserve hidden route state while cleaning up its effects. Returning -to a hidden route can resume the same store; effect cleanup does not necessarily -mean the instance was destroyed. See [Next.js state preservation](https://nextjs.org/docs/app/guides/preserving-ui-state). - -## Tested package consumer - -Run `bun run test:react:next` from the repository root. It installs the package -tarball into an isolated Next.js 16.4.2 / React 19.3.1 app and runs clean -production Turbopack builds with React Compiler off and on. Tests verify the -actual compiler-generated selector and tracking functions, request isolation, -real inputs, streamed response ordering, delayed hydration without replacing -server DOM, navigation, refresh, and persistence cleanup. They also exercise -the server-safe APIs under React's `react-server` export condition and verify -shared hydration captures across public entry points. +Each subscription is independent — they don't interfere with each other. -This matrix covers the stated versions and default navigation behavior. It -does not establish compatibility with every Next.js version, bundler, or -Cache Components configuration. +## Quick Reference -Next App Router can supply its own React runtime. The test evidence records the -actual browser `React.version`; this Next.js version uses -`19.3.0-canary-278794d7-20261002`, while the installed React package is 19.3.1. +| What you want | How to do it | +|---|---| +| Watch a single property | `subscribeKey(store, 'key', (val, prev) => { ... })` | +| Stop watching | `const unsub = subscribeKey(...); unsub()` | +| Watch multiple properties | Call `subscribeKey()` once per key | +| Get both old and new value | Callback receives `(value, previousValue)` | diff --git a/website/static/llms.txt b/website/static/llms.txt index 481b1c1..47215fe 100644 --- a/website/static/llms.txt +++ b/website/static/llms.txt @@ -2,7 +2,7 @@ > Class-based reactive state management for React, Vue, Svelte, Solid, and Angular. Write > plain TypeScript classes — get fine-grained reactivity, immutable snapshots, and -> zero-boilerplate hooks. ~2.3 KB gzipped. No Providers, no decorators, no reducers. +> React hooks. Share stores directly or scope instances with typed context providers. ## Getting Started @@ -18,6 +18,8 @@ ## Utilities +- [Reactive collections](https://codebelt.github.io/classy-store/docs/REACTIVE_COLLECTIONS_TUTORIAL): Observable Map-like and Set-like operations, with explicit persistence transforms + - [Persistence (`persist`)](https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL): Save state to localStorage/sessionStorage/AsyncStorage with transforms, versioning, migration, debounce, cross-tab sync, TTL, SSR support, typed transforms, observable status, and rejected promises for explicit failures