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..fd316e0 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 @@ -32,7 +32,12 @@ See the [0.7.0 upgrade guide and release notes](https://codebelt.github.io/class ## ⚡ 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 @@ -76,7 +105,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 +115,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 @@ -94,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: @@ -103,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: @@ -127,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/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 @@ -194,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) { @@ -205,9 +225,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 +243,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: @@ -281,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). @@ -315,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 9c185d6..a82d43c 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 | @@ -165,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] @@ -178,16 +181,15 @@ 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 -- **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 +221,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 +230,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 +241,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 +281,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 @@ -290,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? @@ -303,12 +312,12 @@ 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 | -| Unsubscribe | Stop writes, cancel debounce | -| Flush/clear/rehydrate | Immediate write, remove data, in-memory preservation, re-read | +| 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-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..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.unsubscribe(); // 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,9 @@ handle.unsubscribe(); // Stop persisting: unsubscribe + cancel debounce ### `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`. ```ts class UserStore { @@ -132,12 +149,18 @@ const userStore = createClassyStore(new UserStore()); 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) => 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; + }, }, ], }); @@ -218,6 +241,7 @@ Disable it when you don't want cross-tab sync: ```ts persist(uiStore, { + storage: localStorage, name: 'ui', syncTabs: false, }); @@ -244,7 +268,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 @@ -256,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 }); @@ -269,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, }); @@ -284,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 @@ -292,110 +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`: +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. -```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(() => { - handle.rehydrate(); - }, []); - - 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 - -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(() => { - Promise.all(persists.map((p) => p.rehydrate())).then(() => - setHydrated(true), - ); - }, []); - - if (!hydrated) { - return null; // or a loading spinner - } - - return <>{children}; + const handle = persist(store, { + name: 'todo-store', + storage: localStorage, + skipHydration: true, + onError: (error, operation) => console.error(operation, error), + }); + + // Explicit calls reject even when onError is configured. + void handle.rehydrate().catch(() => { + // onError already reported the failure; keep the current store values. + }); + + return () => handle.dispose(); + }, [store]); + + return null; } ``` -Wrap your app layout with `StoreHydrator` so nothing renders until `localStorage` values have been applied: - -```tsx -// layout.tsx (Next.js App Router) -import {StoreHydrator} from '@/components/StoreHydrator'; - -export default function RootLayout({children}: {children: React.ReactNode}) { - return ( - - - - -
{children}
-
- - - ); -} -``` +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. -**How it works:** +`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 renders the page using each store's class defaults — no `localStorage` access. -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. +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) @@ -403,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 }); @@ -420,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) => { @@ -476,6 +451,7 @@ persist(todoStore, { ```ts persist(todoStore, { + storage: localStorage, name: 'todo-store', merge: (persisted, current) => { return { @@ -496,23 +472,69 @@ 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); +} + +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. + +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 +552,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 +567,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 +581,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. --- @@ -571,6 +596,7 @@ const todoStore = createClassyStore(new TodoStore()); // Persist todos to localStorage (survives page close) persist(todoStore, { + storage: localStorage, name: 'todo-items', properties: ['todos'], }); @@ -604,6 +630,7 @@ class AppStore { const appStore = createClassyStore(new AppStore()); const handle = persist(appStore, { + storage: localStorage, name: 'app-store', version: 1, debounce: 300, @@ -614,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; + }, }, ], @@ -634,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` | @@ -648,11 +682,11 @@ 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()` | | **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/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/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/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 bfeb38e..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.unsubscribe()` 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,8 +446,8 @@ function EditProfile() { const snap = useClassyStore(store); useEffect(() => { - const handle = persist(store, { name: 'edit-profile-draft' }); - return () => handle.unsubscribe(); + const handle = persist(store, { storage: localStorage, name: 'edit-profile-draft' }); + return () => handle.dispose(); }, [store]); return ( @@ -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/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..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 @@ -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 @@ -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)` @@ -184,13 +220,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,11 +276,11 @@ 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)` -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'; @@ -281,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 ``` @@ -313,20 +388,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. @@ -431,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). @@ -448,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 aa60210..e83632c 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', @@ -14,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 f747e69..7c46ff1 100644 --- a/website/static/llms-full.txt +++ b/website/static/llms-full.txt @@ -1,118 +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_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/ @@ -123,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 @@ -145,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 ``` @@ -227,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)` @@ -302,13 +229,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](https://codebelt.github.io/classy-store/docs/REACT_SCOPING). + ### `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)), @@ -317,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. -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](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'; @@ -343,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)` @@ -399,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 ``` @@ -431,20 +397,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. @@ -549,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?)` @@ -564,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'; @@ -578,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?)` @@ -592,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 @@ -806,134 +761,326 @@ 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:** +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. -```tsx -// Counter.tsx -import {useClassyStore} from '@codebelt/classy-store/react'; -import {counterStore} from './stores'; +## Handle explicit persistence failures -export function Counter() { - const count = useClassyStore(counterStore, (state) => state.count); - return ; +`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); } ``` -That's it. No Provider wrapping your app. The store is a module-level singleton — import it wherever you need it. - -## Defining Stores +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. -A store is any class instance wrapped with `createClassyStore()`. State lives as properties, mutations are plain assignments, and computed values are `get` accessors. +## Subscribe to utility status in React -```ts -import {createClassyStore} from '@codebelt/classy-store'; +Use `useClassyStore(store)` for store data. Use the new hooks when rendering utility metadata that can change independently of that data: -class TodoStore { - items: { id: number; text: string; done: boolean }[] = []; - filter: 'all' | 'active' | 'done' = 'all'; +```tsx +import {useHistoryStatus, usePersistStatus} from '@codebelt/classy-store/react'; - get remaining() { - return this.items.filter((item) => !item.done).length; - } +const {canUndo, canRedo, isPaused} = useHistoryStatus(history); +const {isHydrated, isExpired, lastError} = usePersistStatus(persistence); +``` - get filtered() { - if (this.filter === 'active') return this.items.filter((item) => !item.done); - if (this.filter === 'done') return this.items.filter((item) => item.done); - return this.items; - } +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. - add(text: string) { - this.items.push({id: Date.now(), text, done: false}); - } +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`. - toggle(id: number) { - const item = this.items.find((todo) => todo.id === id); - if (item) item.done = !item.done; - } +During SSR and hydration, status hooks use the handle's initial status snapshot. Construct server and client handles with matching initial status. - remove(id: number) { - this.items = this.items.filter((todo) => todo.id !== id); - } -} +## Use the hydration entry on server and client -export const todoStore = createClassyStore(new TodoStore()); +```ts +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; ``` -Key points: - -- Methods mutate state by assigning to `this`. The proxy intercepts every write. -- `get` accessors work as computed values — they run against the proxied state, so they're always up to date. -- Nested objects and arrays (like `items` and each item inside it) are automatically wrapped in child proxies. `item.done = true` triggers a notification just like a top-level write. +The `/react` entry exports React hooks and `createStoreHook`. Hydration preparation is exported only from `/react/server`; its captured-snapshot behavior is unchanged. -## Reading State in React +## Match persistence transforms to their property -`useClassyStore` has two modes. Pick the right one and your components only re-render when they need to. +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. -### Selector mode +Persistence status flags are readonly, matching their runtime getters. `PersistOperation` is exported from the utilities entry for typed error handlers. -```ts -const count = useClassyStore(counterStore, (state) => state.count); -``` +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. -The selector receives an immutable snapshot and returns the slice you need. The component re-renders only when the selected value changes (compared with `Object.is`). +--- +SOURCE: https://codebelt.github.io/classy-store/docs/UPGRADING_0_7 +--- -> **When to use:** Default choice. Best for render performance because it's explicit about what the component depends on. +# Upgrading to 0.7.0 -### Auto-tracked mode +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. -```ts -const snap = useClassyStore(counterStore); -// just use snap.count, snap.name, etc. in your JSX -``` +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. -No selector — you get back a tracking proxy over the snapshot. The library records which properties your component reads during render and only re-renders when one of those properties changes. +## Remove React timing options -> **When to use:** When you'd need 3+ selectors in one component, when prototyping, or when the shape of data you access is dynamic. +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. -### Custom equality with `shallowEqual` +Before, in 0.6.x: -By default, `useClassyStore` compares the selector's return value with `Object.is`, which is a reference check. This works perfectly for primitives and for selecting existing properties directly — structural sharing means that if `todos` didn't change, the snapshot returns the **same frozen array reference**, so `Object.is` returns `true` and the component skips the re-render. +```tsx +const state = useClassyStore(store, {sync: true}); +const count = useClassyStore(store, (state) => state.count, {sync: true}); -```ts -// ✅ No shallowEqual needed — structural sharing keeps the reference stable -const count = useClassyStore(todoStore, (state) => state.items.length); -const todos = useClassyStore(todoStore, (state) => state.items); +// useCounterStore was created with createStoreHook(store). +const boundState = useCounterStore({sync: true}); +const boundCount = useCounterStore((state) => state.count, {sync: true}); ``` -The problem shows up when your selector **derives a new value** — calling `.filter()`, `.map()`, or using object spread always allocates a new array or object, even when the underlying data hasn't changed. `Object.is` sees a different reference and triggers a re-render. +After, in 0.7.0: -`shallowEqual` fixes this by comparing one level deep: for arrays it checks length + each element with `Object.is`, and for objects it checks key count + each value with `Object.is`. It only works on plain objects and arrays — not on `Map`, `Set`, or class instances. +```tsx +const state = useClassyStore(store); +const count = useClassyStore(store, (state) => state.count); -Without `shallowEqual` — `.filter()` creates a new array every time the snapshot updates, even if the todo items haven't changed: +const boundState = useCounterStore(); +const boundCount = useCounterStore((state) => state.count); +``` -```ts -import {useClassyStore} from '@codebelt/classy-store/react'; +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. + +```ts +import {createClassyStore} from '@codebelt/classy-store'; + +class TodoStore { + items: { id: number; text: string; done: boolean }[] = []; + filter: 'all' | 'active' | 'done' = 'all'; + + get remaining() { + return this.items.filter((item) => !item.done).length; + } + + get filtered() { + if (this.filter === 'active') return this.items.filter((item) => !item.done); + if (this.filter === 'done') return this.items.filter((item) => item.done); + return this.items; + } + + add(text: string) { + this.items.push({id: Date.now(), text, done: false}); + } + + toggle(id: number) { + const item = this.items.find((todo) => todo.id === id); + if (item) item.done = !item.done; + } + + remove(id: number) { + this.items = this.items.filter((todo) => todo.id !== id); + } +} + +export const todoStore = createClassyStore(new TodoStore()); +``` + +Key points: + +- Methods mutate state by assigning to `this`. The proxy intercepts every write. +- `get` accessors work as computed values — they run against the proxied state, so they're always up to date. +- Nested objects and arrays (like `items` and each item inside it) are automatically wrapped in child proxies. `item.done = true` triggers a notification just like a top-level write. + +## Reading State in React + +`useClassyStore` has two modes. Pick the right one and your components only re-render when they need to. + +### Selector mode + +```ts +const count = useClassyStore(counterStore, (state) => state.count); +``` + +The selector receives an immutable snapshot and returns the slice you need. The component re-renders only when the selected value changes (compared with `Object.is`). + +> **When to use:** Default choice. Best for render performance because it's explicit about what the component depends on. + +### Auto-tracked mode + +```ts +const snap = useClassyStore(counterStore); +// just use snap.count, snap.name, etc. in your JSX +``` + +No selector — you get back a tracking proxy over the snapshot. The library records which properties your component reads during render and only re-renders when one of those properties changes. + +> **When to use:** When you'd need 3+ selectors in one component, when prototyping, or when the shape of data you access is dynamic. + +### Custom equality with `shallowEqual` + +By default, `useClassyStore` compares the selector's return value with `Object.is`, which is a reference check. This works perfectly for primitives and for selecting existing properties directly — structural sharing means that if `todos` didn't change, the snapshot returns the **same frozen array reference**, so `Object.is` returns `true` and the component skips the re-render. + +```ts +// ✅ No shallowEqual needed — structural sharing keeps the reference stable +const count = useClassyStore(todoStore, (state) => state.items.length); +const todos = useClassyStore(todoStore, (state) => state.items); +``` + +The problem shows up when your selector **derives a new value** — calling `.filter()`, `.map()`, or using object spread always allocates a new array or object, even when the underlying data hasn't changed. `Object.is` sees a different reference and triggers a re-render. + +`shallowEqual` fixes this by comparing one level deep: for arrays it checks length + each element with `Object.is`, and for objects it checks key count + each value with `Object.is`. It only works on plain objects and arrays — not on `Map`, `Set`, or class instances. + +Without `shallowEqual` — `.filter()` creates a new array every time the snapshot updates, even if the todo items haven't changed: + +```ts +import {useClassyStore} from '@codebelt/classy-store/react'; // ❌ New array reference every snapshot → unnecessary re-renders const active = useClassyStore(todoStore, (state) => state.items.filter((item) => !item.done)); @@ -1210,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'; @@ -1237,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.unsubscribe()` 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'; @@ -1257,8 +1404,8 @@ function EditProfile() { const snap = useClassyStore(store); useEffect(() => { - const handle = persist(store, { name: 'edit-profile-draft' }); - return () => handle.unsubscribe(); + const handle = persist(store, { storage: localStorage, name: 'edit-profile-draft' }); + return () => handle.dispose(); }, [store]); return ( @@ -1270,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` @@ -1407,296 +1554,796 @@ 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. -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. +## 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 - recordVisit() { - this.visits++; - } -} + subgraph utilities ["Utilities (utils/)"] + persist["persist.ts"] + devtools["devtools.ts"] + subscribeKey["subscribe-key.ts"] + withHistory["history.ts"] + status["utilityStatus.ts"] + end -export function createProfileStore(initialData: ProfileData) { - return prepareStoreForHydration( - createClassyStore(new ProfileStore(initialData)), - ); -} + 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"] ``` -```tsx -// Profile.tsx -import {useClassyStore} from '@codebelt/classy-store/react'; -import type {ProfileStore} from './profileStore'; +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. -export function Profile({store}: {store: ProfileStore}) { - const state = useClassyStore(store); - return ( - - ); -} -``` +## Key Design Decisions -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. +### Why ES6 Proxy instead of `Object.defineProperty`? -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. +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. -## Restore persistence while other boundaries hydrate +### Why `queueMicrotask` for batching? -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: +`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`) -```tsx -// PersistedProfile.tsx -import {useEffect} from 'react'; -import {prepareStoreForHydration, useLocalStore} from '@codebelt/classy-store/react'; -import {persist} from '@codebelt/classy-store/utils'; -import {Profile} from './Profile'; -import {type ProfileData, ProfileStore} from './profileStore'; +### Why structural sharing in snapshots? -export function PersistedProfile({initialData}: {initialData: ProfileData}) { - const store = prepareStoreForHydration( - useLocalStore(() => new ProfileStore(initialData)), - ); +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. - useEffect(() => { - const persistence = persist(store, { - name: 'profile', - storage: localStorage, - skipHydration: true, - }); - void persistence.rehydrate(); - return () => persistence.unsubscribe(); - }, [store]); +### Why `proxy-compare` for auto-tracking? - return ; -} -``` +`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. -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. +### Why `useSyncExternalStore` instead of `useState` + `useEffect`? -## Snapshot lifetime and scope +`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 -- 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. +### Why `WeakMap` for internal state? -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. +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 -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. +### 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/REACT_SCOPING +SOURCE: https://codebelt.github.io/classy-store/docs/DEVTOOLS_TUTORIAL --- -# React store scoping and lifecycle +# DevTools Tutorial -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. +`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. -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. +## Prerequisites -## Define a typed context +Install the Redux DevTools extension for your browser: -Share the context definition across the application. Create store instances inside each provider, so server requests and sibling providers cannot accidentally share mutable state. +- [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 -// editorStore.ts -export type EditorData = {title: string; body: string}; +import {createClassyStore} from '@codebelt/classy-store'; -export class EditorStore { - title: string; - body: string; +class CounterStore { + count = 0; + step = 1; - constructor(initialData: EditorData) { - this.title = initialData.title; - this.body = initialData.body; + get doubled() { + return this.count * 2; } - setTitle(title: string) { this.title = title; } - setBody(body: string) { this.body = body; } + 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 -// editorStoreContext.ts -import {createContext} from 'react'; -import type {EditorStore} from './editorStore'; +import {devtools} from '@codebelt/classy-store/utils'; -export const EditorStoreContext = createContext(null); +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 -// hooks/useEditorStore.ts -import {useContext} from 'react'; -import {EditorStoreContext} from '../editorStoreContext'; +counterStore.increment(); +// DevTools shows: action "STORE_UPDATE", state { count: 1, step: 1 } -export function useEditorStore() { - const store = useContext(EditorStoreContext); - if (!store) { - throw new Error('useEditorStore must be used within EditorStoreProvider'); - } - return store; -} +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 } ``` -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. +Each batched mutation appears as a new entry in the DevTools timeline. -## Let the provider own creation and effects +### 4. Time-travel -The example provider initializes once per mount and captures the initial hydration data before starting persistence: +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. -```tsx -// components/editorStoreProvider/EditorStoreProvider.tsx -import {prepareStoreForHydration, useLocalStore} from '@codebelt/classy-store/react'; -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'; +### 5. Disconnect when done -type Props = { - initialData: EditorData; - children: ReactNode; - persistence?: {name: string; storage: StorageAdapter}; -}; +```ts +disconnect(); +``` -export function EditorStoreProvider({initialData, children, persistence}: Props) { - const store = prepareStoreForHydration( - useLocalStore(() => new EditorStore(initialData)), - ); - const name = persistence?.name; - const storage = persistence?.storage; +After disconnecting, mutations are no longer sent to DevTools and time-travel messages are ignored. - useEffect(() => { - if (name === undefined || storage === undefined) return; - const handle = persist(store, { - name, - storage, - properties: ['title', 'body'], - skipHydration: true, - }); - void handle.rehydrate(); - return () => handle.unsubscribe(); - }, [store, name, storage]); +## 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()); - return ( - - {children} - - ); -} +devtools(authStore, {name: 'AuthStore'}); +devtools(todoStore, {name: 'TodoStore'}); +devtools(uiStore, {name: 'UiStore'}); ``` -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. +Each store appears as a separate instance in the DevTools dropdown. -The context holds the stable store proxy. Consumers read snapshots through the existing React hook and send edits through store methods: +### Development-only -```tsx -// components/editorTitle/EditorTitle.tsx -import {useClassyStore} from '@codebelt/classy-store/react'; -import {useEditorStore} from '../../hooks/useEditorStore'; +Disable DevTools in production by tying the `enabled` option to your build environment: -export function EditorTitle() { - const store = useEditorStore(); - const title = useClassyStore(store, (state) => state.title); - return store.setTitle(event.target.value)} />; -} +```ts +devtools(counterStore, { + name: 'CounterStore', + enabled: import.meta.env.DEV, +}); ``` -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. +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. -## Preserve drafts and reset intentionally +### Conditional connection -`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: +Connect DevTools only when the extension is available: -```tsx - - - +```ts +const disconnect = devtools(counterStore, {name: 'CounterStore'}); + +// If the extension isn't installed, `disconnect` is a noop. +// No errors, no warnings — the store works normally. ``` -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. +`devtools()` checks for `window.__REDUX_DEVTOOLS_EXTENSION__` internally. If it's not available (missing extension, SSR, Node.js), it returns a noop without throwing. -| 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 | +### Late disposal on unmount (React) -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. +```tsx +import {useEffect} from 'react'; +import {devtools} from '@codebelt/classy-store/utils'; -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. +function App() { + useEffect(() => { + const disconnect = devtools(counterStore, {name: 'CounterStore'}); + return () => disconnect(); + }, []); -## Server rendering and hydration + return ; +} +``` -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. +## Time-Travel Details -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. +When you click a previous state in the DevTools timeline, the extension sends a message like: -## Regression coverage +```json +{ + "type": "DISPATCH", + "payload": {"type": "JUMP_TO_STATE"}, + "state": "{\"count\": 0, \"step\": 1}" +} +``` -`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. +The `devtools()` utility: + +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/PERSIST_TUTORIAL +SOURCE: https://codebelt.github.io/classy-store/docs/HISTORY_TUTORIAL --- -# Persist Tutorial +# withHistory Tutorial -`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. +`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 @@ -1705,1732 +2352,2135 @@ SOURCE: https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL ```ts import {createClassyStore} from '@codebelt/classy-store'; -class TodoStore { - todos: { text: string; done: boolean }[] = []; - filter: 'all' | 'active' | 'done' = 'all'; +class DrawingStore { + color = '#000000'; + strokeWidth = 2; + points: { x: number; y: number }[] = []; - get remaining() { - return this.todos.filter((todo) => !todo.done).length; + addPoint(x: number, y: number) { + this.points = [...this.points, { x, y }]; } - addTodo(text: string) { - this.todos.push({text, done: false}); + setColor(color: string) { + this.color = color; } - toggle(index: number) { - this.todos[index]!.done = !this.todos[index]!.done; + setStrokeWidth(width: number) { + this.strokeWidth = width; + } + + clear() { + this.points = []; } } -export const todoStore = createClassyStore(new TodoStore()); +export const drawingStore = createClassyStore(new DrawingStore()); ``` -### 2. Persist it +### 2. Attach history ```ts -import {persist} from '@codebelt/classy-store/utils'; +import {withHistory} from '@codebelt/classy-store/utils'; -const handle = persist(todoStore, { - name: 'todo-store', -}); +const history = withHistory(drawingStore); ``` -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. +That's it. Every mutation is now recorded. The initial state is captured as `history[0]`. -### 3. Wait for hydration +### 3. Undo and redo ```ts -// Async -- await the promise -await handle.hydrated; -console.log('Restored:', todoStore.todos); +drawingStore.setColor('#ff0000'); +drawingStore.setStrokeWidth(5); +// history: [initial, {color: '#ff0000'}, {strokeWidth: 5}] -// Sync check -if (handle.isHydrated) { - // safe to read persisted state -} +history.undo(); +// strokeWidth is back to 2 + +history.undo(); +// color is back to '#000000' + +history.redo(); +// color is '#ff0000' again ``` -## Choosing What to Persist +### 4. Check availability ```ts -persist(todoStore, { - name: 'todo-store', - properties: ['todos'], // Only persist todos, not filter -}); +history.canUndo; // true if there's a previous state +history.canRedo; // true if there's a next state (after an undo) ``` -## Per-Property Transforms +Use these to enable/disable undo/redo buttons: + +```tsx +import {useHistoryStatus} from '@codebelt/classy-store/react'; + +function UndoRedoButtons() { + const {canUndo, canRedo} = useHistoryStatus(history); + + return ( +
+ + +
+ ); +} +``` -### Dates +### 5. Clean up ```ts -persist(sessionStore, { - name: 'session', - properties: [ - 'token', - { - key: 'expiresAt', - serialize: (date) => date.toISOString(), - deserialize: (stored) => new Date(stored as string), - }, - ], -}); +history.dispose(); ``` -### ReactiveMap +After disposing, mutations are no longer recorded and the snapshot stack is cleared. + +### Observe status outside React ```ts -persist(userStore, { - name: 'user-store', - properties: [ - { - key: 'users', - serialize: (users) => [...users.entries()], - deserialize: (stored) => reactiveMap(stored as [string, { name: string; role: string }][]), - }, - ], +const stopListening = history.subscribe(() => { + console.log(history.getSnapshot()); + // {canUndo, canRedo, isPaused, isDisposed} }); +stopListening(); // Only removes this listener; history keeps recording. ``` -## Debouncing Writes +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 -persist(formStore, { - name: 'form-draft', - debounce: 500, // Write at most once every 500ms -}); +function withHistory( + proxyStore: T, + options?: { limit?: number }, +): HistoryHandle; ``` -## Version Migration +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `limit` | `number` | `100` | Maximum number of history entries | + +### HistoryHandle + +| 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 | + +## 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, 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. Restoration does not change the explicit `isPaused` status set by `pause()` and `resume()`. + +## Use Cases + +### Text editor with undo ```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})), - }; +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; + } + + 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(); } - return state; - }, + } }); ``` -## Merge Strategies +### Form with reset capability + +```ts +class FormStore { + name = ''; + email = ''; + message = ''; + + 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 +class SpreadsheetStore { + cells: Record = {}; + + 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); + } +} +``` + +`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. -- **`'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) => { ... }` +### Canvas with limited history -## Cross-Tab Synchronization +For memory-sensitive applications, set a lower limit: ```ts -persist(authStore, { - name: 'auth', - storage: localStorage, - syncTabs: true, -}); -``` +const history = withHistory(canvasStore, { limit: 50 }); -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. +// Only the last 50 states are kept. +// Older entries are shifted off the front of the stack. +// Memory usage stays bounded. +``` -## Expiration / TTL +## Undo/Redo and Branching -```ts -persist(sessionStore, { - name: 'session', - expireIn: 900_000, // 15 minutes - clearOnExpire: true, -}); +When you undo and then make a new mutation, the redo history is discarded: -await handle.hydrated; -if (handle.isExpired) { - router.push('/login'); -} ``` +Initial state: { count: 0 } -## SSR / Next.js Support +Mutation 1: count = 1 → history: [0, 1] +Mutation 2: count = 2 → history: [0, 1, 2] +Mutation 3: count = 3 → history: [0, 1, 2, 3] -```ts -const handle = persist(todoStore, { - name: 'todo-store', - skipHydration: true, -}); +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) ``` -Then hydrate manually in a `useEffect`: +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. -```tsx -function App() { - useEffect(() => { - handle.rehydrate(); - }, []); +## Combining with Other Utilities - return ; -} -``` +### withHistory + persist -## Async Storage (React Native) +Persist the store's current state while maintaining undo history in memory: ```ts -import AsyncStorage from '@react-native-async-storage/async-storage'; +import {persist, withHistory} from '@codebelt/classy-store/utils'; -persist(todoStore, { - name: 'todo-store', - storage: AsyncStorage, - syncTabs: false, -}); +const todoStore = createClassyStore(new TodoStore()); -await handle.hydrated; -``` +// Persist to localStorage (survives page reload) +const persistence = persist(todoStore, {name: 'todos', storage: localStorage}); -## Cleanup +// Undo/redo in memory (resets on page reload) +const history = withHistory(todoStore); -```ts -// Clear stored data -await handle.clear(); +// When the store's owner is torn down: +history.dispose(); +persistence.dispose(); +``` -// Manual save (bypass debounce) -handle.save(); +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). -// Re-read from storage -await handle.rehydrate(); +### withHistory + devtools -// Stop persisting; later handle operations become no-ops -handle.unsubscribe(); -``` +Use both for a rich debugging experience: -## Overlapping async operations +```ts +import {devtools, withHistory} from '@codebelt/classy-store/utils'; -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. +const store = createClassyStore(new MyStore()); -`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. +devtools(store, { name: 'MyStore' }); +const history = withHistory(store); + +// DevTools shows every mutation including undo/redo restores. +// Time-travel in DevTools is independent of withHistory's stack. +``` ## Quick Reference | What you want | How to do it | |---|---| -| 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.unsubscribe()` | -| Wait for hydration | `await handle.hydrated` | -| Check hydration | `handle.isHydrated` | +| 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/DEVTOOLS_TUTORIAL +SOURCE: https://codebelt.github.io/classy-store/docs/PERSIST_ARCHITECTURE --- -# DevTools Tutorial +# Persist Utility -- Architecture -`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. +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 CounterStore { - count = 0; - step = 1; +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 doubled() { - return this.count * 2; - } +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. - increment() { - this.count += this.step; - } +## How It Hooks Into the Existing Architecture - decrement() { - this.count -= this.step; - } +`persist` sits beside the existing three-layer architecture as an independent consumer: - setStep(step: number) { - this.step = step; - } -} +```mermaid +flowchart LR + subgraph existingCore ["Existing Core (unchanged)"] + Mutation["store.prop = x"] --> Batch["queueMicrotask notify"] + Batch --> Listeners["subscribe callbacks"] + end -export const counterStore = createClassyStore(new CounterStore()); + 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. Connect to DevTools +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 {devtools} from '@codebelt/classy-store/utils'; +## File Structure -const disconnect = devtools(counterStore, {name: 'CounterStore'}); +``` +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 ``` -### 3. Disconnect when done +## Internal State -```ts -disconnect(); +Each `persist()` call creates a closure with the following internal state: + +| 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 + +On initialization, `resolveProperties()` determines which keys 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. + +This resolution happens once at `persist()` call time, not on every save. + +### Storage Envelope + +Data is stored as a JSON string with a version wrapper: + +```json +{ + "version": 1, + "state": { + "todos": [{"text": "Buy milk", "done": false}], + "filter": "all" + }, + "expiresAt": 1700000000000 +} ``` -## Signature +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 -function devtools( - proxyStore: T, - options?: DevtoolsOptions, -): () => void; +## Restore Flow (Hydration) + +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 + +All three call the same `applyPersistedState(raw)` function: + +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`. + +``` +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 ``` -### Options +### Merge Strategies -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `name` | `string` | `'ClassyStore'` | Display name in the DevTools panel | -| `enabled` | `boolean` | `true` | Set to `false` to disable (returns a noop) | +| 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. | -## Use Cases +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. -### Development-only +### Error Handling -```ts -devtools(counterStore, { - name: 'CounterStore', - enabled: import.meta.env.DEV, -}); +- **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. + +## Cross-Tab Sync + +When `syncTabs: true` is set, the utility listens for the `window.storage` event: + +```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 ``` -### Multiple stores +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 -devtools(authStore, {name: 'AuthStore'}); -devtools(todoStore, {name: 'TodoStore'}); -devtools(uiStore, {name: 'UiStore'}); +``` +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) ``` -### React cleanup +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(() => { - const disconnect = devtools(counterStore, {name: 'CounterStore'}); - return () => disconnect(); - }, []); +## 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 ``` -## Quick Reference +`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 -| What you want | How to do it | +## Performance Characteristics + +| 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 | + +## Design Decisions + +### Why a function, not a decorator or middleware? + +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. + +### Why per-property transforms instead of global serialize/deserialize? + +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. + +### Why exclude getters automatically? + +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. + +### Why a hydrated Promise instead of lifecycle hooks? + +`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? + +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 | |---|---| -| 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 | +| 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/SUBSCRIBE_KEY_TUTORIAL +SOURCE: https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL --- -# subscribeKey Tutorial +# Classy Store Persist Utility -`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. +`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 SettingsStore { - theme: 'light' | 'dark' = 'light'; - fontSize = 14; - language = 'en'; +export class TodoStore { + filter: 'all' | 'active' | 'done' = 'all'; + todos: {text: string; done: boolean}[] = []; - setTheme(theme: 'light' | 'dark') { - this.theme = theme; + get remaining() { + return this.todos.filter((todo) => !todo.done).length; + } + + addTodo(text: string) { + this.todos.push({text, done: false}); + } + + toggle(index: number) { + this.todos[index].done = !this.todos[index]!.done; } } -export const settingsStore = createClassyStore(new SettingsStore()); +export const todoStore = createClassyStore(new TodoStore()); ``` -### 2. Watch a single key +### 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 -import {subscribeKey} from '@codebelt/classy-store/utils'; +// todoStore.ts +import {createClassyStore} from '@codebelt/classy-store'; +import {persist} from '@codebelt/classy-store/utils'; -const unsub = subscribeKey(settingsStore, 'theme', (value, previousValue) => { - console.log(`Theme changed from "${previousValue}" to "${value}"`); - document.documentElement.setAttribute('data-theme', value); +// ... (Include TodoStore setup from Step 1) + +// Saves all store properties to localStorage +const handle = persist(todoStore, { + storage: localStorage, + name: 'todo-store', }); ``` -### 3. Unsubscribe when done +**Stored under the `todo-store` key:** -```ts -unsub(); +```json +{ + "version": 0, + "state": { + "todos": [], + "filter": "all" + } +} ``` -## Signature - -```ts -function subscribeKey( - proxyStore: T, - key: K, - callback: (value: Snapshot[K], previousValue: Snapshot[K]) => void, -): () => void; -``` +The optional `expiresAt` field is an absolute timestamp in milliseconds, calculated as `Date.now() + expireIn` when writing. -## Use Cases +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. -### Reacting to auth state changes +### Choosing What to Persist + +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 -subscribeKey(authStore, 'token', (token, previousToken) => { - if (token && !previousToken) { - router.push('/dashboard'); - } else if (!token && previousToken) { - router.push('/login'); - } +persist(todoStore, { + storage: localStorage, + name: 'todo-store', + properties: ['todos'], // Only persist todos, not filter }); ``` -### Syncing with external systems +Now `filter` resets to its class default (`'all'`) on every page load, while `todos` are restored from storage. -```ts -subscribeKey(playerStore, 'volume', (volume) => { - audioContext.gainNode.gain.value = volume; -}); +### Why getters are excluded -subscribeKey(playerStore, 'track', (track) => { - document.title = track ? `Playing: ${track}` : 'Music Player'; -}); -``` +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. -## subscribeKey vs subscribe +### Configuration and Handle Overview -| | `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 | +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. -## Quick Reference +```ts +import {persist} from '@codebelt/classy-store/utils'; -| 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)` | +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), +}); + +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 +``` ---- -SOURCE: https://codebelt.github.io/classy-store/docs/HISTORY_TUTORIAL --- -# withHistory Tutorial +## Configuration Properties -`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. +### `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 DrawingStore { - color = '#000000'; - strokeWidth = 2; - points: { x: number; y: number }[] = []; +class UserStore { + name = ''; + birthdate = new Date(); - addPoint(x: number, y: number) { - this.points = [...this.points, { x, y }]; + get isAdult() { + const cutoff = new Date(); + cutoff.setFullYear(cutoff.getFullYear() - 18); + return this.birthdate <= cutoff; } +} - setColor(color: string) { - this.color = color; - } +const userStore = createClassyStore(new UserStore()); - setStrokeWidth(width: number) { - this.strokeWidth = width; - } +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; + }, + }, + ], +}); +``` - clear() { - this.points = []; +**In storage:** + +```json +{ + "version": 0, + "state": { + "name": "Robert", + "birthdate": "2026-02-14T00:00:00.000Z" } } - -export const drawingStore = createClassyStore(new DrawingStore()); ``` -### 2. Attach history +On restore, `deserialize` converts the ISO string back into a `Date` object before assigning it to `userStore.birthdate`. -```ts -import {withHistory} from '@codebelt/classy-store/utils'; +*(If you need to persist `Map` or `Set` objects, see the [Reactive Collections Tutorial](https://codebelt.github.io/classy-store/docs/REACTIVE_COLLECTIONS_TUTORIAL).)* -const history = withHistory(drawingStore); +### `storage` + +`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 +persist(uiStore, { + name: 'ui-state', + storage: sessionStorage, + properties: ['sidebarOpen', 'activeTab'], + // syncTabs defaults to false; opt in only for localStorage +}); ``` -### 3. Undo and redo +**Async Storage (React Native)** + +`AsyncStorage` from React Native works out of the box: ```ts -drawingStore.setColor('#ff0000'); -drawingStore.setStrokeWidth(5); +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const handle = persist(todoStore, { + name: 'todo-store', + storage: AsyncStorage, + syncTabs: false, // no window.storage in React Native +}); -history.undo(); // strokeWidth back to 2 -history.undo(); // color back to '#000000' -history.redo(); // color is '#ff0000' again +await handle.hydrated; ``` -### 4. Check availability +### `syncTabs` (Cross-Tab Synchronization) + +Opt in to keep `localStorage`-backed state in sync across browser tabs. When another tab writes to the same key, this tab re-hydrates from the new value: ```ts -history.canUndo; // true if there's a previous state -history.canRedo; // true if there's a next state -``` +persist(authStore, { + name: 'auth', + storage: localStorage, + syncTabs: true, +}); -```tsx -function UndoRedoButtons() { - const snap = useClassyStore(drawingStore); +// Tab A: user logs out +authStore.token = null; +authStore.user = null; +// --> writes to localStorage - return ( -
- - -
- ); -} +// Tab B: automatically receives the update +// --> authStore.token and authStore.user become null ``` -## Signature +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. + +Disable it when you don't want cross-tab sync: ```ts -function withHistory( - proxyStore: T, - options?: { limit?: number }, -): HistoryHandle; +persist(uiStore, { + storage: localStorage, + name: 'ui', + syncTabs: false, +}); ``` -### HistoryHandle +Cross-tab sync only works with `localStorage`. It does not fire for `sessionStorage`, `AsyncStorage`, or custom adapters. -| 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 | +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. -## Use Cases +### Overlapping async operations -### Text editor with undo +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. -```ts -const editorStore = createClassyStore(new EditorStore()); -const history = withHistory(editorStore, { limit: 200 }); +`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. -document.addEventListener('keydown', (e) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'z') { - e.preventDefault(); - if (e.shiftKey) { - history.redo(); - } else { - history.undo(); - } - } +`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. + +Ordering is per handle. Separate handles sharing an async storage key still +need coordination at the application or adapter level. + +### `expireIn` & `clearOnExpire` (Expiration / TTL) + +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 +persist(sessionStore, { + storage: localStorage, + name: 'session', + expireIn: 900_000, // 15 minutes }); ``` -### Batch operations with pause/resume +**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). + +**Checking expiration** + +The handle exposes an `isExpired` flag that becomes `true` when hydration encounters expired data: ```ts -function pasteBlock(data: Record) { - history.pause(); +const handle = persist(sessionStore, { + storage: localStorage, + name: 'session', + expireIn: 900_000, +}); - for (const [id, value] of Object.entries(data)) { - spreadsheetStore.setCell(id, value); - } +await handle.hydrated; - history.resume(); +if (handle.isExpired) { + // Stored session expired — redirect to login } ``` -## Combining with Other Utilities - -### withHistory + persist +By default, expired data is skipped but left in storage. Set `clearOnExpire: true` to automatically remove the key: ```ts -persist(todoStore, { name: 'todos' }); -const history = withHistory(todoStore); -// History is in-memory only — doesn't persist across page loads. +persist(sessionStore, { + storage: localStorage, + name: 'session', + expireIn: 900_000, + clearOnExpire: true, // Remove key from storage when expired +}); ``` -### withHistory + devtools +### `skipHydration` (SSR / Next.js Support) -```ts -devtools(store, { name: 'MyStore' }); -const history = withHistory(store); -// DevTools shows every mutation including undo/redo restores. +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. + +Attach persistence in a client effect. The effect owns the handle and disposes +it during cleanup, including Strict Mode replay: + +```tsx +'use client'; + +import {useEffect} from 'react'; +import {persist} from '@codebelt/classy-store/utils'; +import type {TodoStore} from './todoStore'; + +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), + }); + + // Explicit calls reject even when onError is configured. + void handle.rehydrate().catch(() => { + // onError already reported the failure; keep the current store values. + }); + + return () => handle.dispose(); + }, [store]); + + return null; +} ``` -## Quick Reference +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. -| 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()` | +`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. ---- -SOURCE: https://codebelt.github.io/classy-store/docs/ARCHITECTURE ---- +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. -# Classy Store — Architecture +### `debounce` (Debouncing Writes) -Internal design documentation for contributors and maintainers. +For stores that mutate frequently (form inputs, drag positions, real-time data), debouncing prevents excessive writes: -## Three-Layer Architecture +```ts +persist(formStore, { + storage: localStorage, + name: 'form-draft', + debounce: 500, // Write at most once every 500ms +}); -The library is organized into three distinct layers, each with a single responsibility: +// Rapid mutations: +formStore.name = 'J'; +formStore.name = 'Jo'; +formStore.name = 'John'; +// One write to storage, 500ms after the last mutation. +``` -```mermaid -flowchart TB - subgraph userCode ["User Code"] - ClassDef["class MyStore { fields, methods, getters }"] - end +### `version`, `migrate`, & `merge` (Version Migration and Strategies) + +When the store's shape changes between releases, versioning and migration prevent data loss: + +```ts +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; + }, +}); +``` - 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 +**Merge Strategies** - 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 +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. - subgraph layer3 ["Layer 3: React Hook (react.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 +Imagine your store adds a new `tags` property, but old persisted data only has `todos` and `filter`: - subgraph component ["React Component"] - Render["Re-render ONLY when accessed snapshot props change"] - end +```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 +} - 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 +// Old persisted data in storage (no `tags` key): +// { "version": 1, "state": { "filter": "done", "todos": [{"text": "Buy milk", "done": false}] } } ``` -## File Map +**`'shallow'`** (default) — persisted values overwrite current values one key at a time. Properties not in storage keep their default value: -``` -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 +```ts +persist(todoStore, { + name: 'todo-store', + storage: localStorage, + merge: 'shallow', +}); + +// Result after hydration: +// filter → "done" (from storage) +// todos → [{text: "Buy milk", done: false}] (from storage) +// tags → ["work", "personal"] (kept class default) ``` -## Layer 1: Write Proxy (`core.ts`) +**Custom function** — for full control, pass a function that receives `(persisted, current)` and returns the merged state: -### Overview +```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[] ?? []), + ]), + ], + }; + }, +}); +``` -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 +## The Handle -```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 +### Observable 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 +```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'}; +} ``` -### Internal State Storage +`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. -All internal bookkeeping is stored in a `WeakMap`: +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). -```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 -}; -``` +Public types `PersistStatus` and `PersistOperation` are exported from `@codebelt/classy-store/utils`. -### Proxy Traps +### `isHydrated` & `hydrated` -**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) +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: -**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 +// Async -- await the promise +await handle.hydrated; +console.log('Hydration status:', handle.getSnapshot()); -**DELETE trap:** Same pattern as SET — clean up child proxy, delete from target, bump version, notify sync listeners, and schedule batched listeners. +// Sync check +if (handle.isHydrated) { + // The attempt has settled; inspect lastError for a reported failure. +} +``` -### Batching via `queueMicrotask` +For `localStorage` (synchronous), hydration resolves almost immediately. For async storage adapters like `AsyncStorage`, the promise resolves after the async read completes. -Array operations like `push()` trigger multiple SET traps (element + length). For ordinary listeners, a dirty flag + `queueMicrotask` coalesces these mutations into one notification: +### Handle operation failures -``` -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 +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); +} + +try { + await handle.clear(); + await handle.rehydrate(); +} catch (error) { + console.error('Could not clear or restore stored data', error); +} ``` -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. +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. -### Version Propagation +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. -When a nested property mutates, versions bump from the mutated node up to the root: +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. -```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)"] -``` +### `clear()` (Clear stored data) -This enables structural sharing: when the snapshot layer sees that `settings` has the same version, it returns the cached snapshot reference. +```ts +// Remove the stored data without affecting in-memory state +await handle.clear(); -## Layer 2: Immutable Snapshots (`snapshot.ts`) +// The store still has its current state in memory. +// Next page load starts with class defaults (nothing in storage). +``` -### Overview +### `save()` (Manual save) -`snapshot(proxy)` creates a deeply frozen, plain-JS copy of the store state. Unchanged sub-trees reuse previous snapshot references (structural sharing). +When you need to save immediately (e.g., before the user navigates away): -### Snapshot Creation Flow +```ts +const handle = persist(formStore, { + name: 'form-draft', + storage: localStorage, + debounce: 1000, +}); -```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"] +// Force save before page unload +window.addEventListener('beforeunload', () => { + void handle.save().catch((error) => console.error('Could not save', error)); +}); ``` -### Two Cache Strategies +`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. -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. +### `dispose()` (Stop persisting) -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. +```ts +const handle = persist(todoStore, {name: 'todo-store', storage: localStorage}); -### Structural Sharing Example +// Later: stop all persistence (e.g., on logout) +handle.dispose(); -``` -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 ===) -└──────────┘ └──────────┘ +// 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'); ``` -`snap2.settings === snap1.settings` because the `settings` sub-tree's version didn't change → cache hit → same reference. +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). -### Getter Evaluation (Memoized) +`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. -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. +## Multiple Persistence Targets -```typescript -class Store { - count = 5; - get doubled() { return this.count * 2; } -} +Call `persist()` multiple times with different options to persist different parts of a store to different locations: -const snap = snapshot(createClassyStore(new Store())); -snap.doubled; // 10 — computed once, cached -snap.doubled; // 10 — same reference (per-snapshot cache hit) +```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'], +}); ``` -## Computed Getter Memoization +## Putting It All Together -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. +Here's a complete example combining several features: -### Write Proxy Memoization (`core.ts`) +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {persist} from '@codebelt/classy-store/utils'; -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`: +class AppStore { + theme: 'light' | 'dark' = 'light'; + language = 'en'; + lastLogin = new Date(0); -```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"] + setTheme(theme: 'light' | 'dark') { + this.theme = theme; + } +} + +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; + }, +}); - GetterAccess --> CheckDeps - CheckDeps -->|No| ReturnCached - CheckDeps -->|Yes| Recompute - Recompute --> CacheResult - CacheResult --> ReturnCached +// Wait for hydration, then start the app +await handle.hydrated; +console.log(`Welcome back! Theme: ${appStore.theme}`); ``` -**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. +## Quick Reference -**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. +Catch failures from explicit `save()`, `clear()`, and `rehydrate()` calls. Reading `isHydrated` or awaiting `hydrated` only confirms an attempt settled; inspect status for errors. -### Snapshot Memoization (`snapshot.ts`) +| 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` | -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: +--- +SOURCE: https://codebelt.github.io/classy-store/docs/REACTIVE_COLLECTIONS_TUTORIAL +--- -```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"] +# Reactive Collections - SnapGetter --> PerSnapCheck - PerSnapCheck -->|Yes| PerSnapReturn - PerSnapCheck -->|No| CrossSnapCheck - CrossSnapCheck -->|Yes| CrossSnapReturn - CrossSnapCheck -->|No| Compute - Compute --> CacheBoth -``` +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. -**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. +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. -**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. +## Reactive updates -## Layer 3: React Hook (`react.ts`) +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {reactiveMap, reactiveSet} from '@codebelt/classy-store/collections'; -### Overview +class DirectoryStore { + labels = reactiveMap(); + selected = reactiveSet(); -`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: + select(id: string, label: string) { + this.labels.set(id, label); + this.selected.add(id); + } +} -### Server rendering and hydration +const directoryStore = createClassyStore(new DirectoryStore()); +directoryStore.select('u1', 'Alice'); +``` -`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. +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. -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. +`ReactiveMap` and `ReactiveSet` are public types from the collections entry. +Construct values with the factory functions. -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. +## Persisting a ReactiveMap -### Mode 1: Selector +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: -```typescript -const count = useClassyStore(myStore, (state) => state.count); +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {reactiveMap} from '@codebelt/classy-store/collections'; +import {persist} from '@codebelt/classy-store/utils'; + +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; + }, + }, + ], +}); ``` -```mermaid -sequenceDiagram - participant React as React (useSyncExternalStore) - participant Hook as useClassyStore - participant Snap as snapshot() - participant Selector as selector(snap) +After saving `labelStore.labels.set('u1', 'Alice')`, the storage envelope contains: - 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 +```json +{ + "version": 0, + "state": { + "labels": [["u1", "Alice"]] + } +} ``` -**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) +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. -**Controlled input timing:** +## Persisting a ReactiveSet -```typescript -const name = useClassyStore(formStore, (state) => state.name); -``` +Serialize the values and rebuild the set after validating stored input: -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. +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {reactiveSet} from '@codebelt/classy-store/collections'; +import {persist} from '@codebelt/classy-store/utils'; -### Mode 2: Auto-tracked (selectorless) +class TagStore { + tags = reactiveSet(); +} -```typescript -const snap = useClassyStore(myStore); -// snap.count — tracked -// snap.user.name — tracked +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; + }, + }, + ], +}); ``` -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. +With tags added, the stored state is an array of strings: -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. +```json +{ + "version": 0, + "state": { + "tags": ["typescript", "react"] + } +} +``` -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. +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). -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. +--- +SOURCE: https://codebelt.github.io/classy-store/docs/REACT_NEXT +--- -**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. +# Next.js App Router -## Dependency Graph +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. -```mermaid -graph LR - react["react.ts"] --> core["core.ts"] - react --> snapshot["snapshot.ts"] - react --> proxyCompare["proxy-compare"] - snapshot --> core - snapshot --> types["types.ts"] - snapshot --> internal["utils/internal/internal.ts"] - core --> types - core --> internal - index["index.ts"] --> core - index --> snapshot - index --> react - index --> internal - index --> types +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). - subgraph utilities ["Utilities (utils/)"] - persist["persist.ts"] - devtools["devtools.ts"] - subscribeKey["subscribe-key.ts"] - withHistory["history.ts"] - end +## Package boundaries - persist --> core - persist --> snapshot - persist --> internal - devtools --> core - devtools --> snapshot - subscribeKey --> core - subscribeKey --> snapshot - withHistory --> core - withHistory --> snapshot -``` +| 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. | -## Key Design Decisions +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. -### Why ES6 Proxy instead of `Object.defineProperty`? +## A route-owned store -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. +Define the class in a regular module that has no browser effects: -### Why `queueMicrotask` for batching? +```ts +// stores/profile/profile.store.ts +export type ProfileData = {name: string; visits: number}; -`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`) +export class ProfileStore { + name: string; + visits: number; -### Why structural sharing in snapshots? + constructor(initialData: ProfileData) { + this.name = initialData.name; + this.visits = initialData.visits; + } -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. + recordVisit() { + this.visits++; + } +} +``` -### Why `proxy-compare` for auto-tracking? +Construct and prepare it inside the Client Component: -`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. +```tsx +// components/profileEditor/ProfileEditor.tsx +'use client'; -### Why `useSyncExternalStore` instead of `useState` + `useEffect`? +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'; -`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 +export function ProfileEditor({initialData}: {initialData: ProfileData}) { + const store = prepareStoreForHydration( + useLocalStore(() => new ProfileStore(initialData)), + ); + const state = useClassyStore(store); -### Why `WeakMap` for internal state? + return ( +
+ { store.name = event.target.value; }} + /> + +
+ ); +} +``` + +The Server Component supplies data and an intentional reset key: + +```tsx +// app/profile/page.tsx +import {ProfileEditor} from '../../components/profileEditor/ProfileEditor'; + +type Props = {searchParams: Promise<{name?: string}>}; + +export default async function ProfilePage({searchParams}: Props) { + const {name = 'Ada'} = await searchParams; + return ; +} +``` -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 +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. -### Why subclass inheritance works +## Streaming, persistence, and navigation -No special handling is needed — three mechanisms in the existing design make it work automatically: +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. -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. +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. -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. +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). -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. +## Tested package consumer -`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. +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. -## Performance Characteristics +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. -| 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 | +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. --- -SOURCE: https://codebelt.github.io/classy-store/docs/PERSIST_ARCHITECTURE +SOURCE: https://codebelt.github.io/classy-store/docs/REACT_SCOPING --- -# Persist Utility — Architecture +# React store scoping and lifecycle -Internal design documentation for the `persist()` utility in `@codebelt/classy-store/utils`. +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. -## Design Principles +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. -1. **Standalone utility, not middleware.** `persist` consumes the existing `subscribe`, `snapshot`, and `getVersion` APIs. -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. +## Define a typed context -## How It Hooks Into the Existing Architecture +Share the context definition across the application. Create store instances inside each provider, so server requests and sibling providers cannot accidentally share mutable state. -`persist` sits beside the existing three-layer architecture as an independent consumer: +```ts +// editorStore.ts +export type EditorData = {title: string; body: string}; -- **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 +export class EditorStore { + title: string; + body: string; -## Storage Envelope + constructor(initialData: EditorData) { + this.title = initialData.title; + this.body = initialData.body; + } -```json -{ - "version": 1, - "state": { - "todos": [{"text": "Buy milk", "done": false}], - "filter": "all" - }, - "expiresAt": 1700000000000 + setTitle(title: string) { this.title = title; } + setBody(body: string) { this.body = body; } } ``` -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. +```ts +// editorStoreContext.ts +import {createContext} from 'react'; +import type {EditorStore} from './editorStore'; -## Merge Strategies +export const EditorStoreContext = createContext(null); +``` -| 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 | +```ts +// hooks/useEditorStore.ts +import {useContext} from 'react'; +import {EditorStoreContext} from '../editorStoreContext'; -## Cross-Tab Sync +export function useEditorStore() { + const store = useContext(EditorStoreContext); + if (!store) { + throw new Error('useEditorStore must be used within EditorStoreProvider'); + } + return store; +} +``` -`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. +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. -## PersistHandle API +## Let the provider own creation and effects -| 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. | -| `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. | +The example provider initializes once per mount and captures the initial hydration data before starting persistence: -## Property Resolution +```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'; -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'`). +type Props = { + initialData: EditorData; + children: ReactNode; + persistence?: {name: string; storage: StorageAdapter}; +}; ---- -SOURCE: https://codebelt.github.io/classy-store/docs/FRAMEWORK_ADAPTERS ---- +export function EditorStoreProvider({initialData, children, persistence}: Props) { + const store = prepareStoreForHydration( + useLocalStore(() => new EditorStore(initialData)), + ); + const name = persistence?.name; + const storage = persistence?.storage; -# Framework Adapters + 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]); -`@codebelt/classy-store` provides first-class integrations for Vue, Svelte, Solid, and Angular. Each adapter follows the idiomatic reactive pattern for its framework. + return ( + + {children} + + ); +} +``` -## Installation +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. -Install only the peer dependencies you need: +The context holds the stable store proxy. Consumers read snapshots through the existing React hook and send edits through store methods: -```bash -# Vue -npm install @codebelt/classy-store vue +```tsx +// components/editorTitle/EditorTitle.tsx +import {useClassyStore} from '@codebelt/classy-store/react'; +import {useEditorStore} from '../../hooks/useEditorStore'; -# Svelte -npm install @codebelt/classy-store svelte +export function EditorTitle() { + const store = useEditorStore(); + const title = useClassyStore(store, (state) => state.title); + return store.setTitle(event.target.value)} />; +} +``` -# Solid -npm install @codebelt/classy-store solid-js +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. -# Angular -npm install @codebelt/classy-store @angular/core -``` +## Preserve drafts and reset intentionally -## Vue 3 +`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: -**Import:** `@codebelt/classy-store/vue` +```tsx + + + +``` -**Function:** `useStore(proxyStore: T): ShallowRef>` +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. -Returns a Vue `ShallowRef` that holds the current immutable snapshot. The ref updates whenever the store mutates. Automatically unsubscribes via `onUnmounted`. +| 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 | -```vue - +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 -**How it works internally:** +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. -```ts -import { onUnmounted, shallowRef } from 'vue'; -import { subscribe } from '@codebelt/classy-store'; -import { snapshot } from '@codebelt/classy-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. -export function useStore(proxyStore: T): ShallowRef> { - const state = shallowRef(snapshot(proxyStore)); +## Regression coverage - const unsubscribe = subscribe(proxyStore, () => { - state.value = snapshot(proxyStore); - }); +`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. - onUnmounted(unsubscribe); +--- +SOURCE: https://codebelt.github.io/classy-store/docs/REACT_SSR +--- - return state; -} -``` +# React server rendering and hydration -**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 ` +```ts +// profileStore.ts +import {createClassyStore} from '@codebelt/classy-store'; +import {prepareStoreForHydration} from '@codebelt/classy-store/react/server'; - -``` +export type ProfileData = { + name: string; + visits: number; +}; ---- +export class ProfileStore { + name: string; + visits: number; + + constructor(initialData: ProfileData) { + this.name = initialData.name; + this.visits = initialData.visits; + } -## Svelte 4 / 5 + recordVisit() { + this.visits++; + } +} -**Import:** `@codebelt/classy-store/svelte` +export function createProfileStore(initialData: ProfileData) { + return prepareStoreForHydration( + createClassyStore(new ProfileStore(initialData)), + ); +} +``` -**Function:** `toSvelteStore(proxyStore: T): ClassyReadable>` +```tsx +// Profile.tsx +import {useClassyStore} from '@codebelt/classy-store/react'; +import type {ProfileStore} from './profileStore'; -Returns a Svelte-compatible readable store (implements the `{ subscribe }` contract). Use with Svelte's `$store` auto-subscription syntax or call `.subscribe()` manually. +export function Profile({store}: {store: ProfileStore}) { + const state = useClassyStore(store); + return ( + + ); +} +``` -```svelte - +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. -

{$store.remaining} items remaining

+## Restore persistence while other boundaries hydrate -
    - {#each $store.filtered as todo, index} -
  • - todoStore.toggle(index)} - /> - {todo.text} -
  • - {/each} -
+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 {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'; -**How it works internally:** +export function PersistedProfile({initialData}: {initialData: ProfileData}) { + const store = prepareStoreForHydration( + useLocalStore(() => new ProfileStore(initialData)), + ); -```ts -export interface ClassyReadable { - subscribe(run: (value: T) => void): () => void; -} + 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]); -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)); - }); - }, - }; + return ; } ``` -**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. +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. -**Typed usage:** +## Snapshot lifetime and scope -```ts -// Convert once at module level -import { toSvelteStore } from '@codebelt/classy-store/svelte'; -import { todoStore } from './stores'; +- 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. -export const $todos = toSvelteStore(todoStore); -``` +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. -```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. +--- +SOURCE: https://codebelt.github.io/classy-store/docs/SUBSCRIBE_KEY_TUTORIAL --- -## Solid.js +# subscribeKey Tutorial -**Import:** `@codebelt/classy-store/solid` +`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. -**Function:** `useStore(proxyStore: T): () => Snapshot` +## Getting Started -Returns a Solid signal getter `() => Snapshot`. Call it in JSX to access the current snapshot. Automatically cleans up via `onCleanup`. +### 1. Create a store -```tsx -import { useStore } from '@codebelt/classy-store/solid'; -import { todoStore } from './stores'; +```ts +import {createClassyStore} from '@codebelt/classy-store'; -function TodoList() { - // state is a signal getter: () => Snapshot - const state = useStore(todoStore); +class SettingsStore { + theme: 'light' | 'dark' = 'light'; + fontSize = 14; + language = 'en'; - return ( -
-

{state().remaining} items remaining

-
    - {state().filtered.map((todo, index) => ( -
  • - todoStore.toggle(index)} - /> - {todo.text} -
  • - ))} -
- -
- ); + setTheme(theme: 'light' | 'dark') { + this.theme = theme; + } + + setFontSize(size: number) { + this.fontSize = size; + } } + +export const settingsStore = createClassyStore(new SettingsStore()); ``` -**How it works internally:** +### 2. Watch a single key ```ts -import { createSignal, onCleanup } from 'solid-js'; -import { subscribe } from '@codebelt/classy-store'; -import { snapshot } from '@codebelt/classy-store'; +import {subscribeKey} from '@codebelt/classy-store/utils'; -export function useStore(proxyStore: T): () => Snapshot { - const [state, setState] = createSignal>(snapshot(proxyStore)); +const unsub = subscribeKey(settingsStore, 'theme', (value, previousValue) => { + console.log(`Theme changed from "${previousValue}" to "${value}"`); + document.documentElement.setAttribute('data-theme', value); +}); +``` - const unsubscribe = subscribe(proxyStore, () => { - setState(() => snapshot(proxyStore)); - }); +Now only `theme` changes trigger the callback. Mutations to `fontSize` or `language` are ignored entirely. - onCleanup(unsubscribe); +### 3. Unsubscribe when done - return state; -} +```ts +unsub(); ``` -**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')`. +## Signature ---- +```ts +function subscribeKey>( + proxyStore: T, + key: K, + callback: (value: Snapshot[K], previousValue: Snapshot[K]) => void, + options?: SubscribeOptions, +): () => void; +``` -## 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'; +class AuthStore { + token: string | null = null; + user: { name: string; role: string } | null = null; -export function injectStore(proxyStore: T): Signal> { - const state: WritableSignal> = signal(snapshot(proxyStore)); - const destroyRef = inject(DestroyRef); - - 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 and the compatible `prepareStoreForHydration` export. 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 { - prepareStoreForHydration, - useClassyStore, - useLocalStore, -} from '@codebelt/classy-store/react'; -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 return `unsubscribe()` from 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 e6ba596..47215fe 100644 --- a/website/static/llms.txt +++ b/website/static/llms.txt @@ -2,13 +2,14 @@ > 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 +- [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 — @@ -17,15 +18,17 @@ ## 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, 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