From ca3a78edcb9b7ec1a5b582fee30eb49be2cfe31c Mon Sep 17 00:00:00 2001 From: codeBelt Date: Mon, 16 Feb 2026 23:17:55 -0600 Subject: [PATCH 1/7] feat: add `useLocalStore` hook for component-scoped reactive stores with tests and documentation --- src/react/react.test.tsx | 154 ++++++++++++++++++++++++++++++++++++++- src/react/react.ts | 39 +++++++++- website/docs/TUTORIAL.md | 64 ++++++++++++++++ 3 files changed, 254 insertions(+), 3 deletions(-) diff --git a/src/react/react.test.tsx b/src/react/react.test.tsx index b57ba5a..30dcb9f 100644 --- a/src/react/react.test.tsx +++ b/src/react/react.test.tsx @@ -2,7 +2,7 @@ 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 {useStore} from './react'; +import {useLocalStore, useStore} from './react'; // ── Test harness ──────────────────────────────────────────────────────────── @@ -370,3 +370,155 @@ describe('useStore — auto-tracked mode', () => { expect(container.textContent).toBe('40'); }); }); + +// ── useLocalStore tests ───────────────────────────────────────────────────── + +describe('useLocalStore', () => { + afterEach(teardown); + + it('creates a component-scoped store and renders state', () => { + class Counter { + count = 42; + } + + function Display() { + const store = useLocalStore(() => new Counter()); + const count = useStore(store, (snap) => snap.count); + return
{count}
; + } + + setup(); + render(); + expect(container.textContent).toBe('42'); + }); + + it('responds to mutations on the local store', async () => { + class Counter { + count = 0; + increment() { + this.count++; + } + } + + let storeRef: Counter; + + function Display() { + const store = useLocalStore(() => new Counter()); + storeRef = store; + const count = useStore(store, (snap) => snap.count); + return
{count}
; + } + + setup(); + render(); + expect(container.textContent).toBe('0'); + + await act(async () => { + storeRef.increment(); + await flush(); + }); + + expect(container.textContent).toBe('1'); + }); + + it('each component instance gets its own isolated store', () => { + class Counter { + count: number; + constructor(initial: number) { + this.count = initial; + } + } + + function Display({initial}: {initial: number}) { + const store = useLocalStore(() => new Counter(initial)); + const count = useStore(store, (snap) => snap.count); + return
{count}
; + } + + setup(); + render( + <> + + + , + ); + + const divs = container.querySelectorAll('div'); + expect(divs[0].textContent).toBe('10'); + expect(divs[1].textContent).toBe('20'); + }); + + it('works with computed getters', async () => { + class Store { + count = 5; + get doubled() { + return this.count * 2; + } + setCount(value: number) { + this.count = value; + } + } + + let storeRef: Store; + + function Display() { + const store = useLocalStore(() => new Store()); + storeRef = store; + const doubled = useStore(store, (snap) => snap.doubled); + return
{doubled}
; + } + + setup(); + render(); + expect(container.textContent).toBe('10'); + + await act(async () => { + storeRef.setCount(20); + await flush(); + }); + + expect(container.textContent).toBe('40'); + }); + + it('works with auto-tracked mode', async () => { + class Store { + name = 'hello'; + count = 0; + } + + let storeRef: Store; + const renderCount = mock(() => {}); + + function Display() { + const store = useLocalStore(() => new Store()); + storeRef = store; + const snap = useStore(store); + renderCount(); + return
{snap.name}
; + } + + setup(); + render(); + expect(container.textContent).toBe('hello'); + expect(renderCount).toHaveBeenCalledTimes(1); + + // Change name — accessed by component → should re-render. + await act(async () => { + storeRef.name = 'world'; + await flush(); + }); + + expect(container.textContent).toBe('world'); + expect(renderCount).toHaveBeenCalledTimes(2); + + // Change count — NOT accessed by component, but auto-tracked mode + // re-renders because the snapshot reference changes on any mutation. + // (Documented behavior — see "Set-then-revert" in TUTORIAL.md.) + await act(async () => { + storeRef.count = 99; + await flush(); + }); + + expect(renderCount).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/react/react.ts b/src/react/react.ts index 8905c2b..832a47b 100644 --- a/src/react/react.ts +++ b/src/react/react.ts @@ -1,6 +1,10 @@ import {createProxy, isChanged} from 'proxy-compare'; -import {useCallback, useRef, useSyncExternalStore} from 'react'; -import {subscribe as coreSubscribe, getInternal} from '../core/core'; +import {useCallback, useRef, useState, useSyncExternalStore} from 'react'; +import { + subscribe as coreSubscribe, + createClassyStore, + getInternal, +} from '../core/core'; import {snapshot} from '../snapshot/snapshot'; import type {Snapshot} from '../types'; @@ -161,3 +165,34 @@ function getAutoTrackSnapshot( wrappedRef.current = wrapped; return wrapped; } + +// ── Component-scoped store ──────────────────────────────────────────────────── + +/** + * Create a component-scoped reactive store that lives for the lifetime of the + * component. When the component unmounts, the store becomes unreferenced and is + * garbage collected (all internal bookkeeping uses `WeakMap`). + * + * The factory function runs **once** per mount (via `useState` initializer). + * Each component instance gets its own isolated store. + * + * Use the returned proxy with `useStore()` 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). + * Called once per component mount. + * @returns A reactive store proxy scoped to the component's lifetime. + * + * @example + * ```tsx + * function Counter() { + * const store = useLocalStore(() => new CounterStore()); + * const count = useStore(store, s => s.count); + * return ; + * } + * ``` + */ +export function useLocalStore(factory: () => T): T { + const [store] = useState(() => createClassyStore(factory())); + return store; +} diff --git a/website/docs/TUTORIAL.md b/website/docs/TUTORIAL.md index f7bd64f..2b437b5 100644 --- a/website/docs/TUTORIAL.md +++ b/website/docs/TUTORIAL.md @@ -367,6 +367,70 @@ class PostStore { This means a component using `useStore(postStore, (store) => store.loading)` will re-render twice: once when loading starts, once when it ends. That's the correct behavior. +## 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`. + +### 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. + +```tsx +import {useLocalStore, useStore} from '@codebelt/classy-store/react'; + +class CounterStore { + count = 0; + get doubled() { return this.count * 2; } + increment() { this.count++; } +} + +function Counter() { + const store = useLocalStore(() => new CounterStore()); + const count = useStore(store, (s) => s.count); + + return ; +} +``` + +The factory function (`() => new CounterStore()`) runs once per mount. Subsequent re-renders reuse the same store instance. + +### 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. + +```tsx +import {useEffect} from 'react'; +import {useLocalStore, useStore} from '@codebelt/classy-store/react'; +import {persist} from '@codebelt/classy-store/utils'; + +class FormStore { + name = ''; + email = ''; + setName(v: string) { this.name = v; } + setEmail(v: string) { this.email = v; } +} + +function EditProfile() { + const store = useLocalStore(() => new FormStore()); + // Auto-tracked mode — this component reads both name and email (see Decision guide). + const snap = useStore(store); + + useEffect(() => { + const handle = persist(store, { name: 'edit-profile-draft' }); + return () => handle.unsubscribe(); + }, [store]); + + return ( +
+ store.setName(e.target.value)} /> + store.setEmail(e.target.value)} /> +
+ ); +} +``` + +The `useEffect` cleanup ensures the persist subscription is removed and the store can be garbage collected when the component unmounts. + ## Tips & Gotchas ### Mutate through methods, not from components From 07889bd5537c59417ca74cdae1245e237b7731d4 Mon Sep 17 00:00:00 2001 From: codeBelt Date: Mon, 16 Feb 2026 23:18:37 -0600 Subject: [PATCH 2/7] feat: add `useLocalStore` hook for component-scoped reactive stores with tests and documentation --- .changeset/short-mirrors-play.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/short-mirrors-play.md diff --git a/.changeset/short-mirrors-play.md b/.changeset/short-mirrors-play.md new file mode 100644 index 0000000..e35d567 --- /dev/null +++ b/.changeset/short-mirrors-play.md @@ -0,0 +1,5 @@ +--- +"@codebelt/classy-store": patch +--- + +add `useLocalStore` hook for component-scoped reactive stores with tests and documentation From 591fc996c2e0e87cb6bf2f94ebdf05769124a3ff Mon Sep 17 00:00:00 2001 From: codeBelt Date: Tue, 17 Feb 2026 00:20:14 -0600 Subject: [PATCH 3/7] feat: add `withHistory`, `devtools`, and `subscribeKey` utilities for enhanced state management - `withHistory`: Adds undo/redo functionality to stores with a snapshot stack. - `devtools`: Integrates stores with Redux DevTools for debugging and time travel. - `subscribeKey`: Enables subscribing to changes on specific properties in stores. --- .changeset/good-parts-double.md | 5 + src/utils/devtools/devtools.test.ts | 217 ++++++++++++ src/utils/devtools/devtools.ts | 126 +++++++ src/utils/history/history.test.ts | 267 +++++++++++++++ src/utils/history/history.ts | 135 ++++++++ src/utils/index.ts | 8 + src/utils/subscribe-key/subscribe-key.test.ts | 131 +++++++ src/utils/subscribe-key/subscribe-key.ts | 37 ++ website/docs/DEVTOOLS_TUTORIAL.md | 204 +++++++++++ website/docs/HISTORY_TUTORIAL.md | 320 ++++++++++++++++++ website/docs/SUBSCRIBE_KEY_TUTORIAL.md | 201 +++++++++++ website/docs/index.md | 107 ++---- website/sidebars.ts | 8 +- 13 files changed, 1693 insertions(+), 73 deletions(-) create mode 100644 .changeset/good-parts-double.md create mode 100644 src/utils/devtools/devtools.test.ts create mode 100644 src/utils/devtools/devtools.ts create mode 100644 src/utils/history/history.test.ts create mode 100644 src/utils/history/history.ts create mode 100644 src/utils/subscribe-key/subscribe-key.test.ts create mode 100644 src/utils/subscribe-key/subscribe-key.ts create mode 100644 website/docs/DEVTOOLS_TUTORIAL.md create mode 100644 website/docs/HISTORY_TUTORIAL.md create mode 100644 website/docs/SUBSCRIBE_KEY_TUTORIAL.md diff --git a/.changeset/good-parts-double.md b/.changeset/good-parts-double.md new file mode 100644 index 0000000..d99eab1 --- /dev/null +++ b/.changeset/good-parts-double.md @@ -0,0 +1,5 @@ +--- +"@codebelt/classy-store": patch +--- + +add `withHistory`, `devtools`, and `subscribeKey` utilities for enhanced state management diff --git a/src/utils/devtools/devtools.test.ts b/src/utils/devtools/devtools.test.ts new file mode 100644 index 0000000..00ebb32 --- /dev/null +++ b/src/utils/devtools/devtools.test.ts @@ -0,0 +1,217 @@ +import {afterEach, describe, expect, it, mock} from 'bun:test'; +import {createClassyStore} from '../../core/core'; +import {devtools} from './devtools'; + +/** Flush the queueMicrotask-based batching. */ +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// ── Mock DevTools Extension ────────────────────────────────────────────────── + +type MockConnection = { + init: ReturnType; + send: ReturnType; + subscribe: ReturnType; + _listener: ((message: unknown) => void) | null; +}; + +function createMockConnection(): MockConnection { + const conn: MockConnection = { + init: mock(() => {}), + send: mock(() => {}), + subscribe: mock((listener: (message: unknown) => void) => { + conn._listener = listener; + return () => { + conn._listener = null; + }; + }), + _listener: null, + }; + return conn; +} + +function createMockExtension(conn: MockConnection) { + return { + connect: mock(() => conn), + }; +} + +/** Helper to set the mock extension on window. */ +function setExtension(ext: ReturnType) { + (window as unknown as Record).__REDUX_DEVTOOLS_EXTENSION__ = + ext; +} + +/** Helper to delete the extension from window. */ +function deleteExtension() { + if (typeof window !== 'undefined') { + delete (window as unknown as Record) + .__REDUX_DEVTOOLS_EXTENSION__; + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('devtools', () => { + afterEach(() => { + deleteExtension(); + }); + + it('returns a noop if __REDUX_DEVTOOLS_EXTENSION__ is not available', () => { + deleteExtension(); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const dispose = devtools(store); + + // Should not throw and return a function + expect(typeof dispose).toBe('function'); + dispose(); // noop + }); + + it('returns a noop when enabled is false', () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const dispose = devtools(store, {enabled: false}); + + expect(ext.connect).not.toHaveBeenCalled(); + dispose(); + }); + + it('connects with a custom name and sends init state', () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + devtools(store, {name: 'MyStore'}); + + expect(ext.connect).toHaveBeenCalledWith({name: 'MyStore'}); + expect(conn.init).toHaveBeenCalledTimes(1); + + // Init should receive a snapshot of the store + const initState = conn.init.mock.calls[0][0] as Record; + expect(initState.count).toBe(0); + }); + + it('sends state updates to DevTools on store mutation', async () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + devtools(store); + + store.count = 5; + await tick(); + + expect(conn.send).toHaveBeenCalledTimes(1); + const [action, state] = conn.send.mock.calls[0] as [ + {type: string}, + Record, + ]; + expect(action.type).toBe('STORE_UPDATE'); + expect(state.count).toBe(5); + }); + + it('handles JUMP_TO_STATE time-travel', async () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + name = 'hello'; + } + + const store = createClassyStore(new Store()); + devtools(store); + + store.count = 10; + store.name = 'world'; + await tick(); + + // Simulate time-travel back to initial state + conn._listener?.({ + type: 'DISPATCH', + payload: {type: 'JUMP_TO_STATE'}, + state: JSON.stringify({count: 0, name: 'hello'}), + }); + await tick(); + + expect(store.count).toBe(0); + expect(store.name).toBe('hello'); + }); + + it('skips getters during time-travel restore', async () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 5; + + get doubled() { + return this.count * 2; + } + } + + const store = createClassyStore(new Store()); + devtools(store); + + // Simulate time-travel with a getter key included + conn._listener?.({ + type: 'DISPATCH', + payload: {type: 'JUMP_TO_STATE'}, + state: JSON.stringify({count: 3, doubled: 999}), + }); + await tick(); + + expect(store.count).toBe(3); + // Getter should recompute, not be overwritten + expect(store.doubled).toBe(6); + }); + + it('disposes correctly (unsubscribes from store and devtools)', async () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const dispose = devtools(store); + + store.count = 1; + await tick(); + expect(conn.send).toHaveBeenCalledTimes(1); + + dispose(); + + store.count = 2; + await tick(); + // No additional send after dispose + expect(conn.send).toHaveBeenCalledTimes(1); + // DevTools listener should be removed + expect(conn._listener).toBeNull(); + }); +}); diff --git a/src/utils/devtools/devtools.ts b/src/utils/devtools/devtools.ts new file mode 100644 index 0000000..72d3db2 --- /dev/null +++ b/src/utils/devtools/devtools.ts @@ -0,0 +1,126 @@ +import {subscribe} from '../../core/core'; +import {snapshot} from '../../snapshot/snapshot'; +import {findGetterDescriptor} from '../internal/internal'; + +// ── Redux DevTools types (minimal subset) ──────────────────────────────────── + +type DevToolsMessage = { + type: string; + payload?: {type?: string}; + state?: string; +}; + +type DevToolsConnection = { + init: (state: unknown) => void; + send: (action: string | {type: string}, state: unknown) => void; + subscribe: ( + listener: (message: DevToolsMessage) => void, + ) => (() => void) | {unsubscribe: () => void}; +}; + +type DevToolsExtension = { + connect: (options?: {name?: string}) => DevToolsConnection; +}; + +declare global { + interface Window { + __REDUX_DEVTOOLS_EXTENSION__?: DevToolsExtension; + } +} + +// ── Options ────────────────────────────────────────────────────────────────── + +export type DevtoolsOptions = { + /** Display name in the DevTools panel. Defaults to `'ClassyStore'`. */ + name?: string; + /** Set to `false` to disable the integration (returns a noop). Defaults to `true`. */ + enabled?: boolean; +}; + +// ── Implementation ─────────────────────────────────────────────────────────── + +/** + * Connect a store proxy to Redux DevTools for state inspection and time-travel debugging. + * + * Uses `subscribe()` + `snapshot()` to send state on each change. + * Listens for `DISPATCH` messages (`JUMP_TO_STATE`, `JUMP_TO_ACTION`) and applies + * the received state back to the store proxy, skipping getters and methods. + * + * @param proxyStore - A reactive proxy created by `createClassyStore()`. + * @param options - Optional configuration. + * @returns A dispose function that disconnects from DevTools and unsubscribes. + */ +export function devtools( + proxyStore: T, + options?: DevtoolsOptions, +): () => void { + const {name = 'ClassyStore', enabled = true} = options ?? {}; + + // Guard: no extension or disabled + if ( + !enabled || + typeof window === 'undefined' || + !window.__REDUX_DEVTOOLS_EXTENSION__ + ) { + return () => {}; + } + + const extension = window.__REDUX_DEVTOOLS_EXTENSION__; + const connection = extension.connect({name}); + + // Send initial state + connection.init(snapshot(proxyStore)); + + // Track whether we're currently applying time-travel state to avoid + // re-sending the state we just applied. + let isTimeTraveling = false; + + // Subscribe to store mutations → send to DevTools + const unsubscribeFromStore = subscribe(proxyStore, () => { + if (isTimeTraveling) return; + connection.send({type: 'STORE_UPDATE'}, snapshot(proxyStore)); + }); + + // Listen for DevTools dispatches (time-travel) + const devToolsUnsub = connection.subscribe((message: DevToolsMessage) => { + if (message.type === 'DISPATCH' && message.state) { + const payloadType = message.payload?.type; + if (payloadType === 'JUMP_TO_STATE' || payloadType === 'JUMP_TO_ACTION') { + try { + const newState = JSON.parse(message.state) as Record; + isTimeTraveling = true; + + // Apply state back to the proxy, skipping getters and methods + for (const key of Object.keys(newState)) { + // Skip getters + if (findGetterDescriptor(proxyStore, key)?.get) continue; + // Skip methods + if ( + typeof (proxyStore as Record)[key] === 'function' + ) { + continue; + } + (proxyStore as Record)[key] = newState[key]; + } + + isTimeTraveling = false; + } catch { + isTimeTraveling = false; + } + } + } + }); + + // Return dispose function + return () => { + unsubscribeFromStore(); + if (typeof devToolsUnsub === 'function') { + devToolsUnsub(); + } else if ( + devToolsUnsub && + typeof devToolsUnsub.unsubscribe === 'function' + ) { + devToolsUnsub.unsubscribe(); + } + }; +} diff --git a/src/utils/history/history.test.ts b/src/utils/history/history.test.ts new file mode 100644 index 0000000..c8d5c10 --- /dev/null +++ b/src/utils/history/history.test.ts @@ -0,0 +1,267 @@ +import {describe, expect, it} from 'bun:test'; +import {createClassyStore} from '../../core/core'; +import {withHistory} from './history'; + +/** Flush the queueMicrotask-based batching. */ +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('withHistory', () => { + it('captures initial state as history[0]', () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + expect(h.canUndo).toBe(false); + expect(h.canRedo).toBe(false); + h.dispose(); + }); + + it('undo restores previous state', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.count = 1; + await tick(); + + store.count = 2; + await tick(); + + expect(store.count).toBe(2); + expect(h.canUndo).toBe(true); + + h.undo(); + expect(store.count).toBe(1); + + h.undo(); + expect(store.count).toBe(0); + + expect(h.canUndo).toBe(false); + h.dispose(); + }); + + it('redo restores next state after undo', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.count = 1; + await tick(); + store.count = 2; + await tick(); + + h.undo(); + h.undo(); + expect(store.count).toBe(0); + expect(h.canRedo).toBe(true); + + h.redo(); + expect(store.count).toBe(1); + + h.redo(); + expect(store.count).toBe(2); + expect(h.canRedo).toBe(false); + + h.dispose(); + }); + + it('new mutation after undo truncates redo history', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.count = 1; + await tick(); + store.count = 2; + await tick(); + + // Undo to count=1 + h.undo(); + expect(store.count).toBe(1); + expect(h.canRedo).toBe(true); + + // New mutation from this point + store.count = 99; + await tick(); + + // Redo should no longer be available — redo entries truncated + expect(h.canRedo).toBe(false); + expect(store.count).toBe(99); + + // Undo should go back to 1, then 0 + h.undo(); + expect(store.count).toBe(1); + h.undo(); + expect(store.count).toBe(0); + + h.dispose(); + }); + + it('enforces the history limit', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store, {limit: 3}); + + // Initial state = history[0] (count=0) + store.count = 1; + await tick(); + store.count = 2; + await tick(); + // history now: [0, 1, 2] — at limit + + store.count = 3; + await tick(); + // history should shift: [1, 2, 3] + + // Can only undo twice (not three times back to 0) + h.undo(); + expect(store.count).toBe(2); + h.undo(); + expect(store.count).toBe(1); + expect(h.canUndo).toBe(false); + + h.dispose(); + }); + + it('pause() stops recording and resume() restarts it', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.count = 1; + await tick(); + + h.pause(); + + store.count = 2; + await tick(); + store.count = 3; + await tick(); + + h.resume(); + + store.count = 4; + await tick(); + + // History: [0, 1, 4] — 2 and 3 were skipped during pause + h.undo(); + expect(store.count).toBe(1); + + h.undo(); + expect(store.count).toBe(0); + expect(h.canUndo).toBe(false); + + h.dispose(); + }); + + it('skips getters during state restoration', async () => { + class Store { + count = 5; + + get doubled() { + return this.count * 2; + } + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.count = 10; + await tick(); + + expect(store.doubled).toBe(20); + + h.undo(); + expect(store.count).toBe(5); + expect(store.doubled).toBe(10); // Recomputed, not overwritten + + h.dispose(); + }); + + it('undo/redo do not record additional history entries', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.count = 1; + await tick(); + store.count = 2; + await tick(); + + // Undo twice, redo once — should not add history entries + h.undo(); + h.undo(); + h.redo(); + + // We should be at count=1 with redo available + expect(store.count).toBe(1); + expect(h.canRedo).toBe(true); + expect(h.canUndo).toBe(true); + + h.dispose(); + }); + + it('works with multiple properties', async () => { + class Store { + name = 'Alice'; + age = 30; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.name = 'Bob'; + store.age = 25; + await tick(); + + expect(store.name).toBe('Bob'); + expect(store.age).toBe(25); + + h.undo(); + expect(store.name).toBe('Alice'); + expect(store.age).toBe(30); + + h.dispose(); + }); + + it('dispose stops recording and cleans up', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.count = 1; + await tick(); + + h.dispose(); + + // After dispose, canUndo/canRedo should reflect empty state + expect(h.canUndo).toBe(false); + expect(h.canRedo).toBe(false); + }); +}); diff --git a/src/utils/history/history.ts b/src/utils/history/history.ts new file mode 100644 index 0000000..e65bf78 --- /dev/null +++ b/src/utils/history/history.ts @@ -0,0 +1,135 @@ +import {subscribe} from '../../core/core'; +import {snapshot} from '../../snapshot/snapshot'; +import type {Snapshot} from '../../types'; +import {findGetterDescriptor} from '../internal/internal'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type HistoryOptions = { + /** Maximum number of history entries. Default: 100. */ + limit?: number; +}; + +export type HistoryHandle = { + /** Restore the previous state. */ + undo: () => void; + /** Restore the next state (after an undo). */ + redo: () => void; + /** Whether there is a previous state to undo to. */ + readonly canUndo: boolean; + /** Whether there is a next state to redo to. */ + readonly canRedo: boolean; + /** Temporarily stop recording history entries. */ + pause: () => void; + /** Resume recording history entries after a pause. */ + resume: () => void; + /** Unsubscribe and clean up. */ + dispose: () => void; +}; + +// ── Implementation ─────────────────────────────────────────────────────────── + +/** + * Add undo/redo capability to a store proxy via a snapshot stack. + * + * Captures a snapshot on each mutation and maintains a history array with a + * pointer. `undo()` and `redo()` apply previous/next snapshots back to the + * store proxy, skipping getters and methods. + * + * @param proxyStore - A reactive proxy created by `createClassyStore()`. + * @param options - Optional configuration (e.g., history limit). + * @returns A `HistoryHandle` with undo/redo controls and a dispose function. + */ +export function withHistory( + proxyStore: T, + options?: HistoryOptions, +): HistoryHandle { + const limit = options?.limit ?? 100; + + // Snapshot stack and pointer + const history: Snapshot[] = [snapshot(proxyStore)]; + let pointer = 0; + let paused = false; + + /** + * Apply a snapshot's data properties back to the store proxy. + * Skips getters and methods — same pattern as `persist`. + */ + function applySnapshot(snap: Snapshot): void { + const snapRecord = snap as Record; + + for (const key of Object.keys(snapRecord)) { + // Skip getters + if (findGetterDescriptor(proxyStore, key)?.get) continue; + // Skip methods + if (typeof (proxyStore as Record)[key] === 'function') { + continue; + } + (proxyStore as Record)[key] = snapRecord[key]; + } + } + + // Subscribe to store mutations + const unsubscribeFromStore = subscribe(proxyStore, () => { + if (paused) return; + + const snap = snapshot(proxyStore); + + // Truncate any redo entries after the current pointer + if (pointer < history.length - 1) { + history.length = pointer + 1; + } + + // Push new snapshot + history.push(snap); + pointer = history.length - 1; + + // Enforce limit by shifting from front + if (history.length > limit) { + history.shift(); + pointer = history.length - 1; + } + }); + + const handle: HistoryHandle = { + undo() { + if (pointer <= 0) return; + pointer--; + paused = true; + applySnapshot(history[pointer]); + paused = false; + }, + + redo() { + if (pointer >= history.length - 1) return; + pointer++; + paused = true; + applySnapshot(history[pointer]); + paused = false; + }, + + get canUndo() { + return pointer > 0; + }, + + get canRedo() { + return pointer < history.length - 1; + }, + + pause() { + paused = true; + }, + + resume() { + paused = false; + }, + + dispose() { + unsubscribeFromStore(); + history.length = 0; + pointer = 0; + }, + }; + + return handle; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 84a493d..040f8a6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,10 +3,17 @@ * * Public API: * - `persist(store, options)` -- persist store state to storage with transforms, versioning, and cross-tab sync + * - `devtools(store, options?)` -- connect a store to Redux DevTools for inspection and time-travel + * - `subscribeKey(store, key, callback)` -- subscribe to changes on a single property + * - `withHistory(store, options?)` -- add undo/redo capability via a snapshot stack * * @module @codebelt/classy-store/utils */ +export type {DevtoolsOptions} from './devtools/devtools'; +export {devtools} from './devtools/devtools'; +export type {HistoryHandle, HistoryOptions} from './history/history'; +export {withHistory} from './history/history'; export type { PersistHandle, PersistOptions, @@ -14,3 +21,4 @@ export type { StorageAdapter, } from './persist/persist'; export {persist} from './persist/persist'; +export {subscribeKey} from './subscribe-key/subscribe-key'; diff --git a/src/utils/subscribe-key/subscribe-key.test.ts b/src/utils/subscribe-key/subscribe-key.test.ts new file mode 100644 index 0000000..746ac1d --- /dev/null +++ b/src/utils/subscribe-key/subscribe-key.test.ts @@ -0,0 +1,131 @@ +import {describe, expect, it, mock} from 'bun:test'; +import {createClassyStore} from '../../core/core'; +import {subscribeKey} from './subscribe-key'; + +/** Flush the queueMicrotask-based batching. */ +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('subscribeKey', () => { + it('fires callback when the watched key changes', async () => { + class Store { + count = 0; + name = 'hello'; + } + + const store = createClassyStore(new Store()); + const cb = mock((_value: number, _prev: number) => {}); + + subscribeKey(store, 'count', cb); + + store.count = 1; + await tick(); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(1, 0); + }); + + it('does NOT fire callback when a different key changes', async () => { + class Store { + count = 0; + name = 'hello'; + } + + const store = createClassyStore(new Store()); + const cb = mock((_value: number, _prev: number) => {}); + + subscribeKey(store, 'count', cb); + + store.name = 'world'; + await tick(); + + expect(cb).toHaveBeenCalledTimes(0); + }); + + it('tracks multiple changes with correct previous values', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const values: Array<[number, number]> = []; + + subscribeKey(store, 'count', (value, prev) => { + values.push([value, prev]); + }); + + store.count = 1; + await tick(); + store.count = 5; + await tick(); + store.count = 10; + await tick(); + + expect(values).toEqual([ + [1, 0], + [5, 1], + [10, 5], + ]); + }); + + it('returns an unsubscribe function that stops notifications', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const cb = mock((_value: number, _prev: number) => {}); + + const unsub = subscribeKey(store, 'count', cb); + + store.count = 1; + await tick(); + expect(cb).toHaveBeenCalledTimes(1); + + unsub(); + + store.count = 2; + await tick(); + expect(cb).toHaveBeenCalledTimes(1); // no additional call + }); + + it('works with object/array values using reference comparison', async () => { + class Store { + items = [1, 2, 3]; + label = 'test'; + } + + const store = createClassyStore(new Store()); + const cb = mock(() => {}); + + subscribeKey(store, 'items', cb); + + // Changing a different key should not fire + store.label = 'changed'; + await tick(); + expect(cb).toHaveBeenCalledTimes(0); + + // Replacing the array should fire + store.items = [4, 5, 6]; + await tick(); + expect(cb).toHaveBeenCalledTimes(1); + }); + + it('does not fire when the same value is assigned', async () => { + class Store { + count = 42; + } + + const store = createClassyStore(new Store()); + const cb = mock(() => {}); + + subscribeKey(store, 'count', cb); + + // Proxy SET trap prevents notification when value is same via Object.is + store.count = 42; + await tick(); + + expect(cb).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/utils/subscribe-key/subscribe-key.ts b/src/utils/subscribe-key/subscribe-key.ts new file mode 100644 index 0000000..20dd488 --- /dev/null +++ b/src/utils/subscribe-key/subscribe-key.ts @@ -0,0 +1,37 @@ +import {subscribe} from '../../core/core'; +import {snapshot} from '../../snapshot/snapshot'; +import type {Snapshot} from '../../types'; + +/** + * Subscribe to changes on a single property of a store proxy. + * + * Wraps `subscribe()` + `snapshot()` and compares `snapshot()[key]` with the + * previous value via `Object.is()`. The callback fires only when the watched + * property actually changes. + * + * @param proxyStore - A reactive proxy created by `createClassyStore()`. + * @param key - The property key to watch. + * @param callback - Called with `(value, previousValue)` when the property changes. + * @returns An unsubscribe function. + */ +export function subscribeKey< + T extends object, + K extends keyof T & keyof Snapshot, +>( + proxyStore: T, + key: K, + callback: (value: Snapshot[K], previousValue: Snapshot[K]) => void, +): () => void { + let previousValue = snapshot(proxyStore)[key]; + + return subscribe(proxyStore, () => { + const snap = snapshot(proxyStore); + const currentValue = snap[key]; + + if (!Object.is(currentValue, previousValue)) { + const prev = previousValue; + previousValue = currentValue; + callback(currentValue, prev); + } + }); +} diff --git a/website/docs/DEVTOOLS_TUTORIAL.md b/website/docs/DEVTOOLS_TUTORIAL.md new file mode 100644 index 0000000..29f2c40 --- /dev/null +++ b/website/docs/DEVTOOLS_TUTORIAL.md @@ -0,0 +1,204 @@ +# DevTools Tutorial + +`devtools()` connects a store proxy to the [Redux DevTools](https://github.com/reduxjs/redux-devtools) browser extension for state inspection and time-travel debugging. Every mutation sends a snapshot to the DevTools panel. You can inspect state, jump to any point in history, and the store updates in real-time. + +## Prerequisites + +Install the Redux DevTools extension for your browser: + +- [Chrome](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) +- [Firefox](https://addons.mozilla.org/en-US/firefox/addon/reduxdevtools/) +- [Edge](https://microsoftedge.microsoft.com/addons/detail/redux-devtools/nnkgneoiohoecpdiaponcejilbhhikei) + +## Getting Started + +### 1. Create a store + +```ts +import {createClassyStore} from '@codebelt/classy-store'; + +class CounterStore { + count = 0; + step = 1; + + get doubled() { + return this.count * 2; + } + + increment() { + this.count += this.step; + } + + decrement() { + this.count -= this.step; + } + + setStep(step: number) { + this.step = step; + } +} + +export const counterStore = createClassyStore(new CounterStore()); +``` + +### 2. Connect to DevTools + +```ts +import {devtools} from '@codebelt/classy-store/utils'; + +const disconnect = devtools(counterStore, {name: 'CounterStore'}); +``` + +Open the Redux DevTools panel in your browser. You'll see `CounterStore` in the instance selector with the initial state: `{ count: 0, step: 1 }`. + +### 3. Mutate and observe + +```ts +counterStore.increment(); +// DevTools shows: action "STORE_UPDATE", state { count: 1, step: 1 } + +counterStore.increment(); +// DevTools shows: action "STORE_UPDATE", state { count: 2, step: 1 } + +counterStore.setStep(5); +counterStore.increment(); +// DevTools shows: action "STORE_UPDATE", state { count: 7, step: 5 } +``` + +Each batched mutation appears as a new entry in the DevTools timeline. + +### 4. Time-travel + +Click any previous entry in the DevTools timeline. The store proxy updates to match that state — your React components re-render, side effects fire, everything stays in sync. Click "Jump to State" or "Jump to Action" to navigate through history. + +### 5. Disconnect when done + +```ts +disconnect(); +``` + +After disconnecting, mutations are no longer sent to DevTools and time-travel messages are ignored. + +## Signature + +```ts +function devtools( + proxyStore: T, + options?: DevtoolsOptions, +): () => void; +``` + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `name` | `string` | `'ClassyStore'` | Display name in the DevTools panel | +| `enabled` | `boolean` | `true` | Set to `false` to disable (returns a noop) | + +### Return value + +A dispose function `() => void` that disconnects from DevTools and unsubscribes from the store. + +## How It Works + +1. **Connect:** calls `window.__REDUX_DEVTOOLS_EXTENSION__.connect({ name })` to create a DevTools connection. +2. **Init:** sends the initial snapshot via `connection.init(snapshot(store))`. +3. **Subscribe:** uses `subscribe(store, ...)` to listen for mutations. On each batched mutation, sends `snapshot(store)` to DevTools via `connection.send()`. +4. **Time-travel:** listens for `DISPATCH` messages from DevTools. When a `JUMP_TO_STATE` or `JUMP_TO_ACTION` message arrives, parses the state and applies it back to the store proxy — skipping getters and methods (same pattern as `persist()`). +5. **Dispose:** unsubscribes from the store and disconnects from DevTools. + +## Use Cases + +### Multiple stores + +Connect each store with a unique name to distinguish them in the DevTools panel: + +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {devtools} from '@codebelt/classy-store/utils'; + +const authStore = createClassyStore(new AuthStore()); +const todoStore = createClassyStore(new TodoStore()); +const uiStore = createClassyStore(new UiStore()); + +devtools(authStore, {name: 'AuthStore'}); +devtools(todoStore, {name: 'TodoStore'}); +devtools(uiStore, {name: 'UiStore'}); +``` + +Each store appears as a separate instance in the DevTools dropdown. + +### Development-only + +Disable DevTools in production by tying the `enabled` option to your build environment: + +```ts +devtools(counterStore, { + name: 'CounterStore', + enabled: import.meta.env.DEV, +}); +``` + +When `enabled` is `false`, `devtools()` returns a noop immediately — no DevTools connection is created, no subscriptions are added, and the function has zero runtime cost. + +### Conditional connection + +Connect DevTools only when the extension is available: + +```ts +const disconnect = devtools(counterStore, {name: 'CounterStore'}); + +// If the extension isn't installed, `disconnect` is a noop. +// No errors, no warnings — the store works normally. +``` + +`devtools()` checks for `window.__REDUX_DEVTOOLS_EXTENSION__` internally. If it's not available (missing extension, SSR, Node.js), it returns a noop without throwing. + +### Late disposal on unmount (React) + +```tsx +import {useEffect} from 'react'; +import {devtools} from '@codebelt/classy-store/utils'; + +function App() { + useEffect(() => { + const disconnect = devtools(counterStore, {name: 'CounterStore'}); + return () => disconnect(); + }, []); + + return ; +} +``` + +## Time-Travel Details + +When you click a previous state in the DevTools timeline, the extension sends a message like: + +```json +{ + "type": "DISPATCH", + "payload": {"type": "JUMP_TO_STATE"}, + "state": "{\"count\": 0, \"step\": 1}" +} +``` + +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 | diff --git a/website/docs/HISTORY_TUTORIAL.md b/website/docs/HISTORY_TUTORIAL.md new file mode 100644 index 0000000..2e82485 --- /dev/null +++ b/website/docs/HISTORY_TUTORIAL.md @@ -0,0 +1,320 @@ +# withHistory Tutorial + +`withHistory()` adds undo/redo capability to any store proxy. It maintains a stack of snapshots captured on each mutation. Call `undo()` to go back, `redo()` to go forward, and `pause()`/`resume()` to batch operations that shouldn't create individual history entries. + +## Getting Started + +### 1. Create a store + +```ts +import {createClassyStore} from '@codebelt/classy-store'; + +class DrawingStore { + color = '#000000'; + strokeWidth = 2; + points: { x: number; y: number }[] = []; + + addPoint(x: number, y: number) { + this.points = [...this.points, { x, y }]; + } + + setColor(color: string) { + this.color = color; + } + + setStrokeWidth(width: number) { + this.strokeWidth = width; + } + + clear() { + this.points = []; + } +} + +export const drawingStore = createClassyStore(new DrawingStore()); +``` + +### 2. Attach history + +```ts +import {withHistory} from '@codebelt/classy-store/utils'; + +const history = withHistory(drawingStore); +``` + +That's it. Every mutation is now recorded. The initial state is captured as `history[0]`. + +### 3. Undo and redo + +```ts +drawingStore.setColor('#ff0000'); +drawingStore.setStrokeWidth(5); +// history: [initial, {color: '#ff0000'}, {strokeWidth: 5}] + +history.undo(); +// strokeWidth is back to 2 + +history.undo(); +// color is back to '#000000' + +history.redo(); +// color is '#ff0000' again +``` + +### 4. Check availability + +```ts +history.canUndo; // true if there's a previous state +history.canRedo; // true if there's a next state (after an undo) +``` + +Use these to enable/disable undo/redo buttons: + +```tsx +function UndoRedoButtons() { + const snap = useStore(drawingStore); + + return ( +
+ + +
+ ); +} +``` + +### 5. Clean up + +```ts +history.dispose(); +``` + +After disposing, mutations are no longer recorded and the snapshot stack is cleared. + +## Signature + +```ts +function withHistory( + proxyStore: T, + options?: { limit?: number }, +): HistoryHandle; +``` + +### 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 | + +## 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. +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. + +## Use Cases + +### Text editor with undo + +```ts +class EditorStore { + content = ''; + cursorPosition = 0; + + type(text: string) { + this.content = + this.content.slice(0, this.cursorPosition) + + text + + this.content.slice(this.cursorPosition); + this.cursorPosition += text.length; + } + + backspace() { + if (this.cursorPosition === 0) return; + this.content = + this.content.slice(0, this.cursorPosition - 1) + + this.content.slice(this.cursorPosition); + this.cursorPosition--; + } +} + +const editorStore = createClassyStore(new EditorStore()); +const history = withHistory(editorStore, { limit: 200 }); + +// Cmd+Z / Ctrl+Z +document.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'z') { + e.preventDefault(); + if (e.shiftKey) { + history.redo(); + } else { + history.undo(); + } + } +}); +``` + +### Form with reset capability + +```ts +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'); + +// Undo last change +history.undo(); +// email is back to '' + +// Undo all changes +while (history.canUndo) { + history.undo(); +} +// All fields are back to their initial values +``` + +### Batch operations with pause/resume + +When performing multiple related mutations that should count as a single history entry, use `pause()` and `resume()`: + +```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) { + 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. +} +``` + +### Canvas with limited history + +For memory-sensitive applications, set a lower limit: + +```ts +const history = withHistory(canvasStore, { limit: 50 }); + +// Only the last 50 states are kept. +// Older entries are shifted off the front of the stack. +// Memory usage stays bounded. +``` + +## Undo/Redo and Branching + +When you undo and then make a new mutation, the redo history is discarded: + +``` +Initial state: { count: 0 } + +Mutation 1: count = 1 → history: [0, 1] +Mutation 2: count = 2 → history: [0, 1, 2] +Mutation 3: count = 3 → history: [0, 1, 2, 3] + +Undo → history: [0, 1, 2, 3], pointer at 2 (count = 2) +Undo → history: [0, 1, 2, 3], pointer at 1 (count = 1) + +New mutation: count = 99 → history: [0, 1, 99], pointer at 2 + (entries 2 and 3 are gone — no redo available) +``` + +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. + +## Combining with Other Utilities + +### withHistory + persist + +Persist the store's current state while maintaining undo history in memory: + +```ts +import {persist, withHistory} from '@codebelt/classy-store/utils'; + +const todoStore = createClassyStore(new TodoStore()); + +// Persist to localStorage (survives page reload) +persist(todoStore, { name: 'todos' }); + +// Undo/redo in memory (resets on page reload) +const history = withHistory(todoStore); +``` + +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). + +### withHistory + devtools + +Use both for a rich debugging experience: + +```ts +import {devtools, withHistory} from '@codebelt/classy-store/utils'; + +const store = createClassyStore(new MyStore()); + +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 | +|---|---| +| 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()` | diff --git a/website/docs/SUBSCRIBE_KEY_TUTORIAL.md b/website/docs/SUBSCRIBE_KEY_TUTORIAL.md new file mode 100644 index 0000000..fadb599 --- /dev/null +++ b/website/docs/SUBSCRIBE_KEY_TUTORIAL.md @@ -0,0 +1,201 @@ +# subscribeKey Tutorial + +`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. + +## Getting Started + +### 1. Create a store + +```ts +import {createClassyStore} from '@codebelt/classy-store'; + +class SettingsStore { + theme: 'light' | 'dark' = 'light'; + fontSize = 14; + language = 'en'; + + setTheme(theme: 'light' | 'dark') { + this.theme = theme; + } + + setFontSize(size: number) { + this.fontSize = size; + } +} + +export const settingsStore = createClassyStore(new SettingsStore()); +``` + +### 2. Watch a single key + +```ts +import {subscribeKey} 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); +}); +``` + +Now only `theme` changes trigger the callback. Mutations to `fontSize` or `language` are ignored entirely. + +### 3. Unsubscribe when done + +```ts +unsub(); +``` + +## Signature + +```ts +function subscribeKey( + proxyStore: T, + key: K, + callback: (value: Snapshot[K], previousValue: Snapshot[K]) => void, +): () => void; +``` + +| 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 | +| **Returns** | `() => void` | Unsubscribe function | + +## 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()`. +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. + +## Use Cases + +### Reacting to auth state changes + +```ts +class AuthStore { + token: string | null = null; + user: { name: string; role: string } | null = null; + + login(token: string, user: { name: string; role: string }) { + this.token = token; + this.user = user; + } + + logout() { + this.token = null; + this.user = null; + } +} + +const authStore = createClassyStore(new AuthStore()); + +// Redirect on login/logout +subscribeKey(authStore, 'token', (token, previousToken) => { + if (token && !previousToken) { + router.push('/dashboard'); + } else if (!token && previousToken) { + router.push('/login'); + } +}); +``` + +### Syncing with external systems + +```ts +class PlayerStore { + volume = 0.8; + track: string | null = null; + playing = false; + + setVolume(v: number) { + this.volume = v; + } + + play(track: string) { + this.track = track; + this.playing = true; + } +} + +const playerStore = createClassyStore(new PlayerStore()); + +// Sync volume slider with the Web Audio API +subscribeKey(playerStore, 'volume', (volume) => { + audioContext.gainNode.gain.value = volume; +}); + +// Update document title when track changes +subscribeKey(playerStore, 'track', (track) => { + document.title = track ? `Playing: ${track}` : 'Music Player'; +}); +``` + +### Logging specific changes + +```ts +class CartStore { + items: { id: string; qty: number }[] = []; + coupon: string | null = null; + + get total() { + return this.items.reduce((sum, item) => sum + item.qty, 0); + } + + addItem(id: string) { + this.items.push({ id, qty: 1 }); + } + + applyCoupon(code: string) { + this.coupon = code; + } +} + +const cartStore = createClassyStore(new CartStore()); + +// Only log when coupon changes — not on every item add +subscribeKey(cartStore, 'coupon', (coupon, prev) => { + analytics.track('coupon_changed', { from: prev, to: coupon }); +}); +``` + +## subscribeKey vs subscribe + +| | `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 | + +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. + +## Multiple Keys + +To watch multiple keys independently, call `subscribeKey` once per key: + +```ts +const unsub1 = subscribeKey(store, 'theme', handleThemeChange); +const unsub2 = subscribeKey(store, 'language', handleLanguageChange); + +// Clean up both +function dispose() { + unsub1(); + unsub2(); +} +``` + +Each subscription is independent — they don't interfere with each other. + +## Quick Reference + +| 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/docs/index.md b/website/docs/index.md index b1c7ef5..1c62796 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -18,6 +18,9 @@ Class-based reactive state management for React. Write plain TypeScript classes - **Two hook modes** — explicit selector or automatic property tracking - **Reactive collections** — `reactiveMap()` and `reactiveSet()` for Map/Set-like state - **Persistence** — `persist()` utility with transforms, versioning, migration, debounce, cross-tab sync, TTL expiration, and SSR support +- **DevTools** — `devtools()` connects to Redux DevTools for state inspection and time-travel debugging +- **Property subscriptions** — `subscribeKey()` watches a single property for changes with previous/current values +- **Undo/Redo** — `withHistory()` adds undo/redo via a snapshot stack with pause/resume and configurable limits ## Installation @@ -268,103 +271,61 @@ type MyStoreSnap = Snapshot; Tree-shakeable utilities are available via a separate entry point: -```bash -import {persist} from '@codebelt/classy-store/utils'; +```typescript +import {persist, devtools, subscribeKey, withHistory} from '@codebelt/classy-store/utils'; ``` ### `persist(store, options)` -Persist store state to `localStorage`, `sessionStorage`, `AsyncStorage`, or any custom storage adapter. Subscribes to store mutations, serializes selected properties into a versioned JSON envelope, and writes to storage. On init (or manual rehydrate), reads from storage and applies the state back. +Persist store state to storage with transforms, versioning, migration, cross-tab sync, and SSR support. Getters and methods are automatically excluded. ```typescript -import {createClassyStore} from '@codebelt/classy-store'; import {persist} from '@codebelt/classy-store/utils'; -class TodoStore { - todos: { text: string; done: boolean }[] = []; - filter: 'all' | 'done' | 'pending' = 'all'; +const handle = persist(todoStore, {name: 'todo-store'}); +``` - get remaining() { - return this.todos.filter((todo) => !todo.done).length; - } - - addTodo(text: string) { - this.todos.push({text, done: false}); - } -} +> See the [Persist Tutorial](./PERSIST_TUTORIAL.md) and [Persist Architecture](./PERSIST_ARCHITECTURE.md). -const todoStore = createClassyStore(new TodoStore()); +### `devtools(store, options?)` -// Persist all data properties to localStorage. -// Getters (remaining) and methods (addTodo) are automatically excluded. -const handle = persist(todoStore, { - name: 'todo-store', -}); +Connect a store to Redux DevTools for state inspection and time-travel debugging. + +```typescript +import {devtools} from '@codebelt/classy-store/utils'; -// On next page load, todos and filter are restored automatically. +const disconnect = devtools(myStore, {name: 'MyStore'}); ``` -**Options:** - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `name` | `string` | *required* | Unique storage key | -| `storage` | `StorageAdapter` | `localStorage` | Any sync or async adapter with `getItem`/`setItem`/`removeItem` | -| `properties` | `Array>` | all data props | Which properties to persist (getters and methods always excluded) | -| `debounce` | `number` | `0` | Debounce writes to storage (ms) | -| `version` | `number` | `0` | Schema version number for migration | -| `migrate` | `(state, oldVersion) => state` | — | Transform old persisted data to current shape | -| `merge` | `'shallow' \| 'replace' \| fn` | `'shallow'` | How to merge persisted state with current store state | -| `skipHydration` | `boolean` | `false` | Defer hydration for SSR (call `handle.rehydrate()` manually) | -| `syncTabs` | `boolean` | auto | Sync state across browser tabs via `window.storage` event | -| `expireIn` | `number` | — | TTL in milliseconds; stored data older than this is skipped during hydration. Resets on every write. | -| `clearOnExpire` | `boolean` | `false` | Remove expired key from storage automatically during hydration | - -**Return value (`PersistHandle`):** - -| Property | Type | Description | -|----------|------|-------------| -| `unsubscribe()` | `() => void` | Stop persisting and clean up all listeners | -| `hydrated` | `Promise` | Resolves when initial hydration completes | -| `isHydrated` | `boolean` | Whether hydration has completed | -| `save()` | `() => Promise` | Immediate write to storage (bypasses debounce) | -| `clear()` | `() => Promise` | Remove this store's data from storage | -| `rehydrate()` | `() => Promise` | Manually re-read from storage and apply | -| `isExpired` | `boolean` | Whether the last hydration found expired data (requires `expireIn`) | - -**Per-property transforms** handle non-JSON types like `Date` or `ReactiveMap`: +> See the [DevTools Tutorial](./DEVTOOLS_TUTORIAL.md). + +### `subscribeKey(store, key, callback)` + +Subscribe to changes on a single property. Fires only when the watched key changes. ```typescript -persist(sessionStore, { - name: 'session', - properties: [ - 'token', // plain key — no transform needed - { - key: 'expiresAt', - serialize: (date) => date.toISOString(), - deserialize: (stored) => new Date(stored as string), - }, - ], +import {subscribeKey} from '@codebelt/classy-store/utils'; + +const unsub = subscribeKey(store, 'theme', (value, prev) => { + console.log(`Theme: ${prev} → ${value}`); }); ``` -**Cross-tab sync** is enabled by default with `localStorage`. When another tab writes to the same key, this tab auto-rehydrates. +> See the [subscribeKey Tutorial](./SUBSCRIBE_KEY_TUTORIAL.md). + +### `withHistory(store, options?)` -**SSR support** via `skipHydration`: +Add undo/redo capability via a snapshot stack with pause/resume and configurable limits. ```typescript -const handle = persist(todoStore, { - name: 'todos', - skipHydration: true, -}); +import {withHistory} from '@codebelt/classy-store/utils'; -// In a React component: -useEffect(() => { - handle.rehydrate(); -}, []); +const history = withHistory(store, {limit: 100}); +history.undo(); +history.redo(); ``` -> For a comprehensive walkthrough, see the [Persist Tutorial](./PERSIST_TUTORIAL.md). For internal design details, see [Persist Architecture](./PERSIST_ARCHITECTURE.md). +> See the [withHistory Tutorial](./HISTORY_TUTORIAL.md). ## Patterns @@ -534,6 +495,8 @@ For `Map` and `Set` semantics, use [`reactiveMap()`](#reactivemapk-vinitial) and | Immutable snapshots | Yes | No | No | Yes | | Structural sharing | Yes | N/A | N/A | Yes | | Built-in persistence | Yes (per-property transforms, versioning, cross-tab sync) | Yes (middleware) | No (separate pkg) | No (manual) | +| DevTools integration | Yes (`devtools()`) | Yes (middleware) | Yes (separate pkg) | Yes (`devtools()`) | +| Undo/Redo | Yes (`withHistory()`) | No (manual) | No (manual) | No (manual) | | Bundle size | ~3.5KB | ~1.2KB | ~16KB | ~3KB | ## Vision diff --git a/website/sidebars.ts b/website/sidebars.ts index 3807cc9..05c8801 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -7,7 +7,13 @@ const sidebars: SidebarsConfig = { type: 'category', label: 'Tutorials', collapsed: false, - items: ['TUTORIAL', 'PERSIST_TUTORIAL'], + items: [ + 'TUTORIAL', + 'PERSIST_TUTORIAL', + 'DEVTOOLS_TUTORIAL', + 'SUBSCRIBE_KEY_TUTORIAL', + 'HISTORY_TUTORIAL', + ], }, { type: 'category', From 7c2dc430937601fb8360316843095db7d7a85cd5 Mon Sep 17 00:00:00 2001 From: codeBelt Date: Tue, 17 Feb 2026 07:34:07 -0600 Subject: [PATCH 4/7] docs: update architecture and documentation for `useLocalStore` and utilities - Added `useLocalStore` details to architecture and docs. - Expanded file structures to include `devtools`, `subscribeKey`, and `withHistory` utilities. - Refined examples and tutorials for consistency and clarity. --- website/docs/ARCHITECTURE.md | 121 ++++++++++++++++----------- website/docs/PERSIST_ARCHITECTURE.md | 19 ++--- website/docs/index.md | 24 ++++++ 3 files changed, 106 insertions(+), 58 deletions(-) diff --git a/website/docs/ARCHITECTURE.md b/website/docs/ARCHITECTURE.md index 7b0e155..bd36f45 100644 --- a/website/docs/ARCHITECTURE.md +++ b/website/docs/ARCHITECTURE.md @@ -29,8 +29,9 @@ flowchart TB GetterEval["Getters: memoized per-snapshot + cross-snapshot caching"] end - subgraph layer3 ["Layer 3: React Hook (useStore.ts)"] + subgraph layer3 ["Layer 3: React Hook (react.ts)"] UseStore["useStore(store, selector?)"] + UseLocalStore["useLocalStore(factory)"] USES["useSyncExternalStore"] ProxyCompare["proxy-compare: track which snapshot props selector reads"] IsChanged["isChanged(prev, next, affected): fine-grained diff"] @@ -63,45 +64,53 @@ flowchart TB ## File Map ``` -packages/store/ -├── src/ -│ ├── index.ts # Barrel export: createClassyStore, useStore, snapshot, subscribe, getVersion, shallowEqual, Snapshot, reactiveMap, reactiveSet, ReactiveMap, ReactiveSet -│ ├── collections/ -│ │ ├── collections.ts # ReactiveMap and ReactiveSet implementations -│ │ └── 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, useStore integration -│ ├── react/ -│ │ ├── react.ts # Layer 3: React Hook — useStore() -│ │ ├── 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 -│ │ ├── equality/ -│ │ │ ├── equality.ts # shallowEqual -│ │ │ └── equality.test.ts # tests for shallowEqual -│ │ ├── 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 -│ ├── 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 +src/ +├── index.ts # Barrel export: createClassyStore, useStore, snapshot, subscribe, getVersion, shallowEqual, Snapshot, reactiveMap, reactiveSet, ReactiveMap, ReactiveSet +├── collections/ +│ ├── collections.ts # ReactiveMap and ReactiveSet implementations +│ └── 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, useStore integration +├── react/ +│ ├── react.ts # Layer 3: React Hook — useStore(), 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 ``` ## Layer 1: Write Proxy (`core.ts`) @@ -322,7 +331,7 @@ flowchart TD - **Selector mode:** `useStore(store, (store) => store.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 (`useStore.ts`) +## Layer 3: React Hook (`react.ts`) ### Overview @@ -406,20 +415,36 @@ sequenceDiagram ```mermaid graph LR - useStore["useStore.ts"] --> core["core.ts"] - useStore --> snapshot["snapshot.ts"] - useStore --> proxyCompare["proxy-compare"] + react["react.ts"] --> core["core.ts"] + react --> snapshot["snapshot.ts"] + react --> proxyCompare["proxy-compare"] snapshot --> core snapshot --> types["types.ts"] - snapshot --> utils["utils.ts"] + snapshot --> internal["utils/internal/internal.ts"] core --> types - core --> utils + core --> internal index["index.ts"] --> core index --> snapshot - index --> useStore - index --> utils + index --> react + index --> internal index --> types + subgraph utilities ["Utilities (utils/)"] + persist["persist.ts"] + devtools["devtools.ts"] + subscribeKey["subscribe-key.ts"] + withHistory["history.ts"] + end + + persist --> core + persist --> snapshot + persist --> internal + devtools --> core + devtools --> snapshot + subscribeKey --> core + subscribeKey --> snapshot + withHistory --> core + withHistory --> snapshot ``` ## Key Design Decisions diff --git a/website/docs/PERSIST_ARCHITECTURE.md b/website/docs/PERSIST_ARCHITECTURE.md index 08167e9..36ff990 100644 --- a/website/docs/PERSIST_ARCHITECTURE.md +++ b/website/docs/PERSIST_ARCHITECTURE.md @@ -47,16 +47,15 @@ The key insight: writing `proxy[key] = value` during hydration flows through the ## File Structure ``` -packages/store/ - src/ - index.ts # Existing barrel (unchanged) - utils/ - index.ts # Barrel: export { persist } from './persist/persist' - persist/ - persist.ts # persist(), types, and all logic - persist.test.ts # tests with mock storage adapters - package.json # "./utils" export entry - tsdown.config.ts # 'src/utils/index.ts' in entry array +src/ + index.ts # Existing barrel (unchanged) + utils/ + index.ts # Barrel: persist, devtools, subscribeKey, withHistory + persist/ + persist.ts # persist(), types, and all logic + persist.test.ts # tests with mock storage adapters +package.json # "./utils" export entry +tsdown.config.ts # 'src/utils/index.ts' in entry array ``` ## Internal State diff --git a/website/docs/index.md b/website/docs/index.md index 1c62796..968f5f5 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -148,6 +148,30 @@ const userData = useStore(myStore, (store) => ({ }), shallowEqual); ``` +### `useLocalStore(factory)` + +Creates a component-scoped reactive store. Each component instance gets its own isolated store, garbage collected on unmount. + +```tsx +import {useLocalStore, useStore} from '@codebelt/classy-store/react'; + +class CounterStore { + count = 0; + increment() { this.count++; } +} + +function Counter() { + const store = useLocalStore(() => new CounterStore()); + const count = useStore(store, (s) => s.count); + + return ; +} +``` + +The factory runs once per mount. Subsequent re-renders reuse the same store instance. + +> See the [Local Stores](./TUTORIAL.md#local-stores) section in the Tutorial for persistence patterns and more examples. + ### `snapshot(store)` Creates a deeply frozen, immutable snapshot of the current state. Used internally by `useStore` but also available directly. From 6b101bc93df4fe5a1d5f2d0e0a3ea2d6fa9da4c0 Mon Sep 17 00:00:00 2001 From: codeBelt Date: Tue, 17 Feb 2026 07:53:22 -0600 Subject: [PATCH 5/7] refactor(utils): improve state handling with try-finally and enhance safety checks - Wrapped state application logic in `try-finally` blocks to ensure cleanup (`isTimeTraveling`, `paused`, `hydrating`) regardless of errors. - Improved safety by skipping operations during invalid states (e.g., `hydration`/`disposed` in `persist`). - Added `shallowEqual` utility export to utils index. --- src/utils/devtools/devtools.ts | 29 ++++++++++++++++------------- src/utils/history/history.ts | 14 ++++++++++---- src/utils/index.ts | 1 + src/utils/persist/persist.ts | 23 ++++++++++++++++------- 4 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/utils/devtools/devtools.ts b/src/utils/devtools/devtools.ts index 72d3db2..6345a7e 100644 --- a/src/utils/devtools/devtools.ts +++ b/src/utils/devtools/devtools.ts @@ -90,22 +90,25 @@ export function devtools( const newState = JSON.parse(message.state) as Record; isTimeTraveling = true; - // Apply state back to the proxy, skipping getters and methods - for (const key of Object.keys(newState)) { - // Skip getters - if (findGetterDescriptor(proxyStore, key)?.get) continue; - // Skip methods - if ( - typeof (proxyStore as Record)[key] === 'function' - ) { - continue; + try { + // Apply state back to the proxy, skipping getters and methods + for (const key of Object.keys(newState)) { + // Skip getters + if (findGetterDescriptor(proxyStore, key)?.get) continue; + // Skip methods + if ( + typeof (proxyStore as Record)[key] === + 'function' + ) { + continue; + } + (proxyStore as Record)[key] = newState[key]; } - (proxyStore as Record)[key] = newState[key]; + } finally { + isTimeTraveling = false; } - - isTimeTraveling = false; } catch { - isTimeTraveling = false; + // JSON.parse failed — ignore corrupted DevTools state. } } } diff --git a/src/utils/history/history.ts b/src/utils/history/history.ts index e65bf78..c660827 100644 --- a/src/utils/history/history.ts +++ b/src/utils/history/history.ts @@ -96,16 +96,22 @@ export function withHistory( if (pointer <= 0) return; pointer--; paused = true; - applySnapshot(history[pointer]); - paused = false; + try { + applySnapshot(history[pointer]); + } finally { + paused = false; + } }, redo() { if (pointer >= history.length - 1) return; pointer++; paused = true; - applySnapshot(history[pointer]); - paused = false; + try { + applySnapshot(history[pointer]); + } finally { + paused = false; + } }, get canUndo() { diff --git a/src/utils/index.ts b/src/utils/index.ts index 040f8a6..f3c341f 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -22,3 +22,4 @@ export type { } from './persist/persist'; export {persist} from './persist/persist'; export {subscribeKey} from './subscribe-key/subscribe-key'; +export {shallowEqual} from './equality/equality'; diff --git a/src/utils/persist/persist.ts b/src/utils/persist/persist.ts index 84a418e..d3675fe 100644 --- a/src/utils/persist/persist.ts +++ b/src/utils/persist/persist.ts @@ -211,8 +211,7 @@ function resolveProperties( for (const key of Object.keys(snap)) { // Skip getters (they live on the prototype, but snapshot installs them). // We check the original store's target for getter descriptors. - if (findGetterDescriptor(Object.getPrototypeOf(proxyStore), key)?.get) - continue; + if (findGetterDescriptor(proxyStore, key)?.get) continue; // Skip functions (methods). const value = (proxyStore as Record)[key]; if (typeof value === 'function') continue; @@ -308,6 +307,7 @@ export function persist( // ── State ──────────────────────────────────────────────────────────────── let disposed = false; + let hydrating = false; let debounceTimer: ReturnType | null = null; let hydratedFlag = false; let expiredFlag = false; @@ -352,7 +352,7 @@ export function persist( /** Schedule a debounced write (or write immediately if debounce is 0). */ function scheduleWrite(): void { - if (disposed) return; + if (disposed || hydrating) return; if (debounceMs <= 0) { void writeToStorage(); @@ -427,10 +427,12 @@ export function persist( let merged: Record; if (typeof merge === 'function') { merged = merge(state, currentState); + } else if (merge === 'replace') { + // Only use persisted keys — new defaults not in storage are dropped. + merged = state; } else { - // Both 'shallow' and 'replace' assign persisted keys onto the store. - // The difference is conceptual for nested objects, but at this level - // both just assign the persisted value per key. + // 'shallow': persisted values overwrite current, but properties not + // in storage keep their current (default) value. merged = {...currentState, ...state}; } @@ -446,7 +448,12 @@ export function persist( async function hydrateFromStorage(): Promise { const raw = await storage.getItem(name); if (raw !== null) { - applyPersistedState(raw); + hydrating = true; + try { + applyPersistedState(raw); + } finally { + hydrating = false; + } } } @@ -539,10 +546,12 @@ export function persist( }, async clear() { + if (disposed) return; await storage.removeItem(name); }, async rehydrate() { + expiredFlag = false; await hydrateFromStorage(); if (!hydratedFlag) { hydratedFlag = true; From 580838e674976aa50e43e747bc00ce5827de94fa Mon Sep 17 00:00:00 2001 From: codeBelt Date: Tue, 17 Feb 2026 08:02:40 -0600 Subject: [PATCH 6/7] test(utils): add extensive test coverage for edge cases in utilities - Added additional tests for `devtools` to cover time travel, error handling, and message payloads. - Extended test cases for `persist` to handle null states, invalid envelopes, and unsubscribe behavior. - Introduced more cases for `subscribeKey` with various data types and subscriber behavior. - Enhanced `shallowEqual` tests to include edge cases such as array vs object comparison and handling undefined values. --- src/utils/devtools/devtools.test.ts | 228 ++++++++++++++++++ src/utils/devtools/devtools.ts | 34 +-- src/utils/equality/equality.test.ts | 48 ++++ src/utils/history/history.test.ts | 165 +++++++++++++ src/utils/index.ts | 2 +- src/utils/internal/internal.test.ts | 88 +++++++ src/utils/persist/persist.test.ts | 161 ++++++++++++- src/utils/persist/persist.ts | 11 +- src/utils/subscribe-key/subscribe-key.test.ts | 130 ++++++++++ 9 files changed, 843 insertions(+), 24 deletions(-) diff --git a/src/utils/devtools/devtools.test.ts b/src/utils/devtools/devtools.test.ts index 00ebb32..402c4ab 100644 --- a/src/utils/devtools/devtools.test.ts +++ b/src/utils/devtools/devtools.test.ts @@ -189,6 +189,234 @@ describe('devtools', () => { expect(store.doubled).toBe(6); }); + it('handles JUMP_TO_ACTION time-travel', async () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + devtools(store); + + store.count = 10; + await tick(); + + conn._listener?.({ + type: 'DISPATCH', + payload: {type: 'JUMP_TO_ACTION'}, + state: JSON.stringify({count: 3}), + }); + await tick(); + + expect(store.count).toBe(3); + }); + + it('skips methods during time-travel restore', async () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + increment() { + this.count++; + } + } + + const store = createClassyStore(new Store()); + devtools(store); + + conn._listener?.({ + type: 'DISPATCH', + payload: {type: 'JUMP_TO_STATE'}, + state: JSON.stringify({count: 5, increment: 'should be skipped'}), + }); + await tick(); + + expect(store.count).toBe(5); + expect(typeof store.increment).toBe('function'); + }); + + it('does not send state updates during time-travel', async () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + devtools(store); + + store.count = 5; + await tick(); + const sendCountBefore = conn.send.mock.calls.length; + + // Simulate time-travel — should NOT trigger a send + conn._listener?.({ + type: 'DISPATCH', + payload: {type: 'JUMP_TO_STATE'}, + state: JSON.stringify({count: 0}), + }); + await tick(); + + expect(conn.send.mock.calls.length).toBe(sendCountBefore); + }); + + it('ignores non-DISPATCH messages', async () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + devtools(store); + + conn._listener?.({ + type: 'ACTION', + state: JSON.stringify({count: 999}), + }); + await tick(); + + expect(store.count).toBe(0); + }); + + it('ignores DISPATCH messages without state', async () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + devtools(store); + + conn._listener?.({ + type: 'DISPATCH', + payload: {type: 'JUMP_TO_STATE'}, + // no state field + }); + await tick(); + + expect(store.count).toBe(0); + }); + + it('ignores DISPATCH messages with non-jump payload types', async () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + devtools(store); + + conn._listener?.({ + type: 'DISPATCH', + payload: {type: 'COMMIT'}, + state: JSON.stringify({count: 999}), + }); + await tick(); + + expect(store.count).toBe(0); + }); + + it('handles corrupted JSON in time-travel state gracefully', async () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 5; + } + + const store = createClassyStore(new Store()); + devtools(store); + + // Should not throw + conn._listener?.({ + type: 'DISPATCH', + payload: {type: 'JUMP_TO_STATE'}, + state: 'not-valid-json!!!', + }); + await tick(); + + expect(store.count).toBe(5); // unchanged + }); + + it('uses default name ClassyStore when no name provided', () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + devtools(store); + + expect(ext.connect).toHaveBeenCalledWith({name: 'ClassyStore'}); + }); + + it('handles connection.subscribe returning {unsubscribe} object', async () => { + const unsubMock = mock(() => {}); + const conn = createMockConnection(); + // Override subscribe to return an object with unsubscribe method + conn.subscribe = mock((listener: (message: unknown) => void) => { + conn._listener = listener; + return {unsubscribe: unsubMock}; + }); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const dispose = devtools(store); + + dispose(); + + expect(unsubMock).toHaveBeenCalledTimes(1); + }); + + it('sends multiple state updates for sequential mutations', async () => { + const conn = createMockConnection(); + const ext = createMockExtension(conn); + setExtension(ext); + + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + devtools(store); + + store.count = 1; + await tick(); + store.count = 2; + await tick(); + store.count = 3; + await tick(); + + expect(conn.send).toHaveBeenCalledTimes(3); + const lastState = conn.send.mock.calls[2][1] as Record; + expect(lastState.count).toBe(3); + }); + it('disposes correctly (unsubscribes from store and devtools)', async () => { const conn = createMockConnection(); const ext = createMockExtension(conn); diff --git a/src/utils/devtools/devtools.ts b/src/utils/devtools/devtools.ts index 6345a7e..2071320 100644 --- a/src/utils/devtools/devtools.ts +++ b/src/utils/devtools/devtools.ts @@ -90,25 +90,27 @@ export function devtools( const newState = JSON.parse(message.state) as Record; isTimeTraveling = true; - try { - // Apply state back to the proxy, skipping getters and methods - for (const key of Object.keys(newState)) { - // Skip getters - if (findGetterDescriptor(proxyStore, key)?.get) continue; - // Skip methods - if ( - typeof (proxyStore as Record)[key] === - 'function' - ) { - continue; - } - (proxyStore as Record)[key] = newState[key]; + // Apply state back to the proxy, skipping getters and methods + for (const key of Object.keys(newState)) { + // Skip getters + if (findGetterDescriptor(proxyStore, key)?.get) continue; + // Skip methods + if ( + typeof (proxyStore as Record)[key] === 'function' + ) { + continue; } - } finally { - isTimeTraveling = false; + (proxyStore as Record)[key] = newState[key]; } + + // Reset after microtask so the batched subscription callback + // (which fires via queueMicrotask) still sees the flag as true. + queueMicrotask(() => { + isTimeTraveling = false; + }); } catch { - // JSON.parse failed — ignore corrupted DevTools state. + // JSON.parse or property assignment failed — ignore and reset flag. + isTimeTraveling = false; } } } diff --git a/src/utils/equality/equality.test.ts b/src/utils/equality/equality.test.ts index bf7b7ac..30d1310 100644 --- a/src/utils/equality/equality.test.ts +++ b/src/utils/equality/equality.test.ts @@ -79,4 +79,52 @@ describe('shallowEqual', () => { it('treats +0 and -0 as not equal (Object.is semantics)', () => { expect(shallowEqual(+0, -0)).toBe(false); }); + + // ── Additional edge cases ─────────────────────────────────────────── + + it('returns false for array vs non-array-like object', () => { + expect(shallowEqual([1, 2] as unknown, {a: 1, b: 2} as unknown)).toBe( + false, + ); + }); + + it('returns true for empty objects', () => { + expect(shallowEqual({}, {})).toBe(true); + }); + + it('returns true for empty arrays', () => { + expect(shallowEqual([], [])).toBe(true); + }); + + it('returns false for undefined vs null', () => { + expect(shallowEqual(undefined, null as unknown as undefined)).toBe(false); + }); + + it('handles objects with undefined values', () => { + expect(shallowEqual({a: undefined}, {a: undefined})).toBe(true); + expect( + shallowEqual({a: undefined}, {a: null} as unknown as {a: undefined}), + ).toBe(false); + }); + + it('compares nested arrays by reference only', () => { + const arr1 = [1, 2]; + const arr2 = [1, 2]; + expect(shallowEqual({a: arr1}, {a: arr1})).toBe(true); + expect(shallowEqual({a: arr1}, {a: arr2})).toBe(false); + }); + + it('handles arrays with NaN elements', () => { + expect(shallowEqual([Number.NaN], [Number.NaN])).toBe(true); + expect(shallowEqual([Number.NaN, 1], [Number.NaN, 2])).toBe(false); + }); + + it('returns false when one is array and the other is not', () => { + expect(shallowEqual([1] as unknown, 'not-array' as unknown)).toBe(false); + expect(shallowEqual('not-array' as unknown, [1] as unknown)).toBe(false); + }); + + it('handles objects with symbol-like string keys', () => { + expect(shallowEqual({'Symbol(foo)': 1}, {'Symbol(foo)': 1})).toBe(true); + }); }); diff --git a/src/utils/history/history.test.ts b/src/utils/history/history.test.ts index c8d5c10..4f1aeaf 100644 --- a/src/utils/history/history.test.ts +++ b/src/utils/history/history.test.ts @@ -247,6 +247,171 @@ describe('withHistory', () => { h.dispose(); }); + it('undo at the beginning is a no-op', () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + h.undo(); // should not throw or change anything + expect(store.count).toBe(0); + expect(h.canUndo).toBe(false); + + h.dispose(); + }); + + it('redo at the end is a no-op', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.count = 1; + await tick(); + + h.redo(); // already at the end — no-op + expect(store.count).toBe(1); + expect(h.canRedo).toBe(false); + + h.dispose(); + }); + + it('skips methods during state restoration', async () => { + class Store { + count = 0; + increment() { + this.count++; + } + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.increment(); + await tick(); + + expect(store.count).toBe(1); + + h.undo(); + expect(store.count).toBe(0); + expect(typeof store.increment).toBe('function'); + + h.dispose(); + }); + + it('resume after pause captures a fresh snapshot on next mutation', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.count = 1; + await tick(); + + h.pause(); + store.count = 50; + await tick(); + store.count = 100; + await tick(); + h.resume(); + + store.count = 200; + await tick(); + + // History: [0, 1, 200] — 50 and 100 skipped + h.undo(); + expect(store.count).toBe(1); + h.undo(); + expect(store.count).toBe(0); + expect(h.canUndo).toBe(false); + + h.dispose(); + }); + + it('multiple undo-redo cycles work correctly', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.count = 1; + await tick(); + store.count = 2; + await tick(); + + // Cycle 1 + h.undo(); + expect(store.count).toBe(1); + h.redo(); + expect(store.count).toBe(2); + + // Cycle 2 + h.undo(); + h.undo(); + expect(store.count).toBe(0); + h.redo(); + h.redo(); + expect(store.count).toBe(2); + + h.dispose(); + }); + + it('handles limit of 2 (minimum useful history)', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store, {limit: 2}); + + store.count = 1; + await tick(); + // history: [0, 1] — at limit + + store.count = 2; + await tick(); + // history: [1, 2] — shifted + + h.undo(); + expect(store.count).toBe(1); + expect(h.canUndo).toBe(false); + + h.redo(); + expect(store.count).toBe(2); + + h.dispose(); + }); + + it('batched mutations (within one tick) produce one history entry', async () => { + class Store { + x = 0; + y = 0; + } + + const store = createClassyStore(new Store()); + const h = withHistory(store); + + store.x = 10; + store.y = 20; + await tick(); + + // Should be one history entry for both changes + h.undo(); + expect(store.x).toBe(0); + expect(store.y).toBe(0); + expect(h.canUndo).toBe(false); + + h.dispose(); + }); + it('dispose stops recording and cleans up', async () => { class Store { count = 0; diff --git a/src/utils/index.ts b/src/utils/index.ts index f3c341f..5c44d31 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -12,6 +12,7 @@ export type {DevtoolsOptions} from './devtools/devtools'; export {devtools} from './devtools/devtools'; +export {shallowEqual} from './equality/equality'; export type {HistoryHandle, HistoryOptions} from './history/history'; export {withHistory} from './history/history'; export type { @@ -22,4 +23,3 @@ export type { } from './persist/persist'; export {persist} from './persist/persist'; export {subscribeKey} from './subscribe-key/subscribe-key'; -export {shallowEqual} from './equality/equality'; diff --git a/src/utils/internal/internal.test.ts b/src/utils/internal/internal.test.ts index ee1a99f..4cfa1a3 100644 --- a/src/utils/internal/internal.test.ts +++ b/src/utils/internal/internal.test.ts @@ -150,4 +150,92 @@ describe('findGetterDescriptor', () => { const obj = {x: 10, y: 20}; expect(findGetterDescriptor(obj, 'x')).toBeUndefined(); }); + + it('finds getter defined via Object.defineProperty', () => { + const obj = {}; + Object.defineProperty(obj, 'computed', { + get() { + return 42; + }, + configurable: true, + }); + const desc = findGetterDescriptor(obj, 'computed'); + expect(desc).toBeDefined(); + expect(desc?.get?.call(obj)).toBe(42); + }); + + it('finds getter through multiple levels of inheritance', () => { + class A { + get val() { + return 'A'; + } + } + class B extends A {} + class C extends B {} + + const instance = new C(); + const desc = findGetterDescriptor(instance, 'val'); + expect(desc).toBeDefined(); + expect(desc?.get?.call(instance)).toBe('A'); + }); + + it('returns undefined for symbol properties without getters', () => { + const sym = Symbol('test'); + const obj = {[sym]: 42}; + expect(findGetterDescriptor(obj, sym)).toBeUndefined(); + }); +}); + +// ── isPlainObject — additional ──────────────────────────────────────────────── + +describe('isPlainObject — additional', () => { + it('returns false for functions', () => { + expect(isPlainObject(() => {})).toBe(false); + expect(isPlainObject(() => {})).toBe(false); + }); + + it('returns true for Object.create(Object.prototype)', () => { + expect(isPlainObject(Object.create(Object.prototype))).toBe(true); + }); + + it('returns false for Object.create with custom proto', () => { + const proto = {custom: true}; + expect(isPlainObject(Object.create(proto))).toBe(false); + }); +}); + +// ── canProxy — additional ──────────────────────────────────────────────────── + +describe('canProxy — additional', () => { + it('returns false for Promise', () => { + expect(canProxy(Promise.resolve())).toBe(false); + }); + + it('returns false for WeakMap and WeakSet', () => { + expect(canProxy(new WeakMap())).toBe(false); + expect(canProxy(new WeakSet())).toBe(false); + }); + + it('returns true for nested arrays', () => { + expect( + canProxy([ + [1, 2], + [3, 4], + ]), + ).toBe(true); + }); + + it('returns false for functions', () => { + expect(canProxy(() => {})).toBe(false); + }); + + it('PROXYABLE on parent class allows child instances', () => { + class Parent { + static [PROXYABLE] = true; + } + class Child extends Parent { + value = 0; + } + expect(canProxy(new Child())).toBe(true); + }); }); diff --git a/src/utils/persist/persist.test.ts b/src/utils/persist/persist.test.ts index 16385f9..2992c62 100644 --- a/src/utils/persist/persist.test.ts +++ b/src/utils/persist/persist.test.ts @@ -433,7 +433,7 @@ describe('persist()', () => { expect(s.sidebar).toBe(true); // kept (not in storage) }); - it('replace merge: same behavior for flat stores', async () => { + it('replace merge: only uses persisted keys, drops missing defaults', async () => { const storage = createMockStorage(); storage.data.set( 'test', @@ -445,7 +445,28 @@ describe('persist()', () => { await handle.hydrated; expect(s.theme).toBe('dark'); - expect(s.fontSize).toBe(14); // not in storage, kept at default + // With 'replace', fontSize is not in persisted state so it should NOT be applied + // (only persisted keys are used). The store keeps its default since 'fontSize' + // is not in the merged result and the apply loop skips keys not in merged. + expect(s.fontSize).toBe(14); + }); + + it('replace merge: does not spread current defaults into merged state', async () => { + const storage = createMockStorage(); + // Persist only has 'a', the store has 'a' and 'b' + storage.data.set( + 'test', + JSON.stringify({version: 0, state: {a: 'from-storage'}}), + ); + + const s = createClassyStore({a: 'default-a', b: 'default-b'}); + const handle = persist(s, {name: 'test', storage, merge: 'replace'}); + await handle.hydrated; + + expect(s.a).toBe('from-storage'); + // 'b' is not in persisted state and replace doesn't merge current defaults, + // so 'b' stays at its default because the apply loop checks `key in merged` + expect(s.b).toBe('default-b'); }); it('custom merge function', async () => { @@ -1000,6 +1021,142 @@ describe('persist()', () => { } }); + it('handles null state in envelope gracefully', async () => { + const storage = createMockStorage(); + storage.data.set('test', JSON.stringify({version: 0, state: null})); + + const s = createClassyStore({count: 0}); + const handle = persist(s, {name: 'test', storage}); + await handle.hydrated; + + expect(s.count).toBe(0); // invalid state skipped + }); + + it('handles array as envelope gracefully (not an object with state)', async () => { + const storage = createMockStorage(); + storage.data.set('test', JSON.stringify([1, 2, 3])); + + const s = createClassyStore({count: 0}); + const handle = persist(s, {name: 'test', storage}); + await handle.hydrated; + + expect(s.count).toBe(0); // invalid envelope skipped + }); + + it('unsubscribe 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 + }); + + it('save after unsubscribe is a no-op', async () => { + const storage = createMockStorage(); + const s = createClassyStore({count: 0}); + const handle = persist(s, {name: 'test', storage}); + await handle.hydrated; + + handle.unsubscribe(); + s.count = 999; + await handle.save(); // should not throw or write + + 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 () => { + const storage = createMockStorage(); + const s = createClassyStore({count: 0}); + const handle = persist(s, {name: 'test', storage}); + + s.count = 5; + await tick(); + + handle.unsubscribe(); + await handle.clear(); // should be no-op + + // Storage should still have the data + expect(storage.data.has('test')).toBe(true); + }); + + it('rehydrate resets isExpired flag', async () => { + const storage = createMockStorage(); + // Start with expired data + storage.data.set( + 'test', + JSON.stringify({ + version: 0, + state: {count: 42}, + expiresAt: Date.now() - 1000, + }), + ); + + const s = createClassyStore({count: 0}); + const handle = persist(s, {name: 'test', storage, expireIn: 60_000}); + await handle.hydrated; + expect(handle.isExpired).toBe(true); + + // Now put valid data in storage + storage.data.set( + 'test', + JSON.stringify({ + version: 0, + state: {count: 77}, + expiresAt: Date.now() + 60_000, + }), + ); + + await handle.rehydrate(); + expect(handle.isExpired).toBe(false); + expect(s.count).toBe(77); + }); + + it('hydration does not trigger a write-back to storage', async () => { + const storage = createMockStorage(); + const setItemSpy = mock(storage.setItem.bind(storage)); + storage.setItem = setItemSpy; + + storage.data.set( + 'test', + JSON.stringify({version: 0, state: {count: 42}}), + ); + + const s = createClassyStore({count: 0}); + const handle = persist(s, {name: 'test', storage}); + await handle.hydrated; + await tick(); + + // setItem should NOT have been called during hydration + expect(setItemSpy).not.toHaveBeenCalled(); + }); + + it('persists store with inherited getters from a base class', async () => { + class Base { + get label(): string { + return `count-${(this as unknown as {count: number}).count}`; + } + } + + class MyStore extends Base { + count = 0; + } + + const storage = createMockStorage(); + const s = createClassyStore(new MyStore()); + persist(s, {name: 'test', storage}); + + s.count = 10; + await tick(); + + const stored = parseStored(storage, 'test'); + expect(stored?.state.count).toBe(10); + expect(stored?.state).not.toHaveProperty('label'); + }); + it('multiple persists on the same store with different keys', async () => { const storage1 = createMockStorage(); const storage2 = createMockStorage(); diff --git a/src/utils/persist/persist.ts b/src/utils/persist/persist.ts index d3675fe..2386068 100644 --- a/src/utils/persist/persist.ts +++ b/src/utils/persist/persist.ts @@ -386,6 +386,7 @@ export function persist( if ( !envelope || typeof envelope !== 'object' || + envelope.state === null || typeof envelope.state !== 'object' ) { return; @@ -449,11 +450,11 @@ export function persist( const raw = await storage.getItem(name); if (raw !== null) { hydrating = true; - try { - applyPersistedState(raw); - } finally { - hydrating = false; - } + applyPersistedState(raw); + // Reset after microtask so the batched subscription callback + // (which fires via queueMicrotask) still sees the flag as true. + await new Promise((r) => queueMicrotask(r)); + hydrating = false; } } diff --git a/src/utils/subscribe-key/subscribe-key.test.ts b/src/utils/subscribe-key/subscribe-key.test.ts index 746ac1d..9243587 100644 --- a/src/utils/subscribe-key/subscribe-key.test.ts +++ b/src/utils/subscribe-key/subscribe-key.test.ts @@ -112,6 +112,136 @@ describe('subscribeKey', () => { expect(cb).toHaveBeenCalledTimes(1); }); + it('works with boolean values', async () => { + class Store { + active = false; + } + + const store = createClassyStore(new Store()); + const cb = mock((_value: boolean, _prev: boolean) => {}); + + subscribeKey(store, 'active', cb); + + store.active = true; + await tick(); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(true, false); + + store.active = false; + await tick(); + + expect(cb).toHaveBeenCalledTimes(2); + expect(cb).toHaveBeenCalledWith(false, true); + }); + + it('works with string values', async () => { + class Store { + status = 'idle'; + } + + const store = createClassyStore(new Store()); + const values: string[] = []; + + subscribeKey(store, 'status', (v) => values.push(v)); + + store.status = 'loading'; + await tick(); + store.status = 'done'; + await tick(); + + expect(values).toEqual(['loading', 'done']); + }); + + it('works with null and undefined transitions', async () => { + class Store { + data: string | null = null; + } + + const store = createClassyStore(new Store()); + const cb = mock((_value: string | null, _prev: string | null) => {}); + + subscribeKey(store, 'data', cb); + + store.data = 'hello'; + await tick(); + expect(cb).toHaveBeenCalledWith('hello', null); + + store.data = null; + await tick(); + expect(cb).toHaveBeenCalledWith(null, 'hello'); + }); + + it('can subscribe to multiple keys independently', async () => { + class Store { + count = 0; + name = 'hello'; + } + + const store = createClassyStore(new Store()); + const countCb = mock(() => {}); + const nameCb = mock(() => {}); + + subscribeKey(store, 'count', countCb); + subscribeKey(store, 'name', nameCb); + + store.count = 1; + await tick(); + + expect(countCb).toHaveBeenCalledTimes(1); + expect(nameCb).toHaveBeenCalledTimes(0); + + store.name = 'world'; + await tick(); + + expect(countCb).toHaveBeenCalledTimes(1); + expect(nameCb).toHaveBeenCalledTimes(1); + }); + + it('multiple subscribers on the same key all fire', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const cb1 = mock(() => {}); + const cb2 = mock(() => {}); + + subscribeKey(store, 'count', cb1); + subscribeKey(store, 'count', cb2); + + store.count = 5; + await tick(); + + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + }); + + it('unsubscribing one subscriber does not affect others', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const cb1 = mock(() => {}); + const cb2 = mock(() => {}); + + const unsub1 = subscribeKey(store, 'count', cb1); + subscribeKey(store, 'count', cb2); + + store.count = 1; + await tick(); + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + + unsub1(); + + store.count = 2; + await tick(); + expect(cb1).toHaveBeenCalledTimes(1); // no more calls + expect(cb2).toHaveBeenCalledTimes(2); // still fires + }); + it('does not fire when the same value is assigned', async () => { class Store { count = 42; From 056da8ae959b4c46293fd6460b03d29f1c966919 Mon Sep 17 00:00:00 2001 From: codeBelt Date: Tue, 17 Feb 2026 09:34:33 -0600 Subject: [PATCH 7/7] test(core/react/utils/collections): extend test suites for edge cases and computed behavior - Added extensive tests for `useStore` covering error handling, multiple stores, selector edge cases, and derived selectors. - Enhanced `computed getter` memoization tests, including nested dependencies and structural sharing across versions. - Expanded test cases for `snapshot` behavior, including object freezing, deleted properties, and specialized structures. - Introduced test cases for `ReactiveMap` and `ReactiveSet` with numeric keys, `NaN`, object keys, and snapshot consistency. - Improved coverage for version tracking, child proxy management, and error scenarios in `core`. --- src/collections/collections.test.ts | 155 +++++++++++++++++++ src/core/core.test.ts | 224 ++++++++++++++++++++++++++++ src/core/core.ts | 34 ++++- src/react/react.test.tsx | 162 ++++++++++++++++++++ src/snapshot/snapshot.test.ts | 189 +++++++++++++++++++++++ src/types.ts | 9 +- src/utils/internal/internal.test.ts | 1 + 7 files changed, 772 insertions(+), 2 deletions(-) diff --git a/src/collections/collections.test.ts b/src/collections/collections.test.ts index df9ed57..9c042ca 100644 --- a/src/collections/collections.test.ts +++ b/src/collections/collections.test.ts @@ -260,3 +260,158 @@ describe('collections inside class store', () => { expect(s.labels.has('bug')).toBe(true); }); }); + +// ── ReactiveMap — additional edge cases ──────────────────────────────────── + +describe('reactiveMap() — edge cases', () => { + test('supports numeric keys', () => { + const m = reactiveMap(); + m.set(1, 'one'); + m.set(2, 'two'); + expect(m.get(1)).toBe('one'); + expect(m.size).toBe(2); + expect(m.delete(1)).toBe(true); + expect(m.size).toBe(1); + }); + + test('supports object keys with Object.is comparison', () => { + const key1 = {id: 1}; + const key2 = {id: 2}; + const m = reactiveMap(); + m.set(key1, 'first'); + m.set(key2, 'second'); + + expect(m.get(key1)).toBe('first'); + expect(m.get(key2)).toBe('second'); + + // Different object with same shape is NOT the same key + expect(m.get({id: 1})).toBeUndefined(); + }); + + test('set() returns this for chaining', () => { + const m = reactiveMap(); + const result = m.set('a', 1); + expect(result).toBe(m); + + // Chaining + m.set('b', 2).set('c', 3); + expect(m.size).toBe(3); + }); + + test('handles NaN as key (Object.is(NaN, NaN) is true)', () => { + const m = reactiveMap(); + m.set(Number.NaN, 'nan-value'); + + expect(m.has(Number.NaN)).toBe(true); + expect(m.get(Number.NaN)).toBe('nan-value'); + expect(m.size).toBe(1); + + // Overwriting NaN key + m.set(Number.NaN, 'updated'); + expect(m.size).toBe(1); + expect(m.get(Number.NaN)).toBe('updated'); + }); + + test('delete returns false for non-existent key', () => { + const m = reactiveMap(); + expect(m.delete('missing')).toBe(false); + }); + + test('forEach receives the map as third argument', () => { + const m = reactiveMap([['a', 1]] as [string, number][]); + m.forEach((_v, _k, map) => { + expect(map).toBe(m); + }); + }); + + test('snapshot of mutated ReactiveMap reflects latest state', async () => { + const s = createClassyStore({m: reactiveMap()}); + s.m.set('x', 10); + s.m.set('y', 20); + await flush(); + + const snap = snapshot(s); + expect(snap.m._entries).toEqual([ + ['x', 10], + ['y', 20], + ]); + }); +}); + +// ── ReactiveSet — additional edge cases ──────────────────────────────────── + +describe('reactiveSet() — edge cases', () => { + test('add() returns this for chaining', () => { + const s = reactiveSet(); + const result = s.add(1); + expect(result).toBe(s); + + s.add(2).add(3); + expect(s.size).toBe(3); + }); + + test('handles NaN values (Object.is(NaN, NaN) is true)', () => { + const s = reactiveSet(); + s.add(Number.NaN); + + expect(s.has(Number.NaN)).toBe(true); + expect(s.size).toBe(1); + + // Adding NaN again should be no-op + s.add(Number.NaN); + expect(s.size).toBe(1); + + expect(s.delete(Number.NaN)).toBe(true); + expect(s.size).toBe(0); + }); + + test('delete returns false for non-existent value', () => { + const s = reactiveSet(); + expect(s.delete('missing')).toBe(false); + }); + + test('forEach receives the set as third argument', () => { + const s = reactiveSet([1]); + s.forEach((_v, _k, set) => { + expect(set).toBe(s); + }); + }); + + test('entries returns [value, value] pairs matching Set spec', () => { + const s = reactiveSet([10, 20]); + expect([...s.entries()]).toEqual([ + [10, 10], + [20, 20], + ]); + }); + + test('keys and values return the same sequence', () => { + const s = reactiveSet(['a', 'b', 'c']); + expect([...s.keys()]).toEqual([...s.values()]); + }); + + test('clear on empty set does not throw', () => { + const s = reactiveSet(); + expect(() => s.clear()).not.toThrow(); + }); + + test('snapshot of mutated ReactiveSet reflects latest state', async () => { + const s = createClassyStore({tags: reactiveSet()}); + s.tags.add('a'); + s.tags.add('b'); + s.tags.delete('a'); + await flush(); + + const snap = snapshot(s); + expect(snap.tags._items).toEqual(['b']); + }); + + test('add duplicate after delete re-adds the value', () => { + const s = reactiveSet([1, 2, 3]); + s.delete(2); + expect(s.has(2)).toBe(false); + s.add(2); + expect(s.has(2)).toBe(true); + expect(s.size).toBe(3); + }); +}); diff --git a/src/core/core.test.ts b/src/core/core.test.ts index fd1f0ae..c2e5e49 100644 --- a/src/core/core.test.ts +++ b/src/core/core.test.ts @@ -462,4 +462,228 @@ describe('createClassyStore() — core reactivity', () => { expect(listener).toHaveBeenCalledTimes(1); // all batched }); }); + + // ── Computed getter memoization ─────────────────────────────────────── + + describe('computed getter memoization', () => { + it('memoizes getter result when deps have not changed', () => { + let callCount = 0; + + class Store { + count = 5; + get expensive() { + callCount++; + return this.count * 2; + } + } + + const s = createClassyStore(new Store()); + + expect(s.expensive).toBe(10); + expect(callCount).toBe(1); + + // Accessing again without mutation should use cache + expect(s.expensive).toBe(10); + expect(callCount).toBe(1); + }); + + it('recomputes getter when dependency changes', async () => { + let callCount = 0; + + class Store { + count = 5; + get doubled() { + callCount++; + return this.count * 2; + } + } + + const s = createClassyStore(new Store()); + expect(s.doubled).toBe(10); + expect(callCount).toBe(1); + + s.count = 10; + // Getter should recompute because count changed + expect(s.doubled).toBe(20); + expect(callCount).toBe(2); + }); + + it('getter that reads another getter (nested computed)', () => { + class Store { + count = 3; + get doubled() { + return this.count * 2; + } + get quadrupled() { + return this.doubled * 2; + } + } + + const s = createClassyStore(new Store()); + expect(s.quadrupled).toBe(12); + + s.count = 5; + expect(s.quadrupled).toBe(20); + }); + + it('getter with nested object dependency recomputes on child mutation', async () => { + class Store { + items = [1, 2, 3]; + get total() { + return this.items.reduce((a: number, b: number) => a + b, 0); + } + } + + const s = createClassyStore(new Store()); + expect(s.total).toBe(6); + + s.items.push(4); + expect(s.total).toBe(10); + }); + + it('getter invalidates when property is replaced entirely', async () => { + class Store { + data = {value: 1}; + get label() { + return `value: ${this.data.value}`; + } + } + + const s = createClassyStore(new Store()); + expect(s.label).toBe('value: 1'); + + // Replace the entire object + s.data = {value: 99}; + expect(s.label).toBe('value: 99'); + }); + }); + + // ── Error handling ──────────────────────────────────────────────────── + + describe('error handling', () => { + it('getInternal throws for a non-store object', () => { + const plainObject = {count: 0}; + expect(() => subscribe(plainObject, () => {})).toThrow( + /not a store proxy/, + ); + }); + + it('getInternal throws for a primitive wrapper', () => { + expect(() => subscribe({} as object, () => {})).toThrow( + /not a store proxy/, + ); + }); + + it('getVersion throws for a non-store object', () => { + expect(() => getVersion({})).toThrow(/not a store proxy/); + }); + }); + + // ── Child proxy management ──────────────────────────────────────────── + + describe('child proxy management', () => { + it('replacing a nested object creates a new child proxy', async () => { + const s = createClassyStore({nested: {a: 1}}); + const listener = mock(() => {}); + subscribe(s, listener); + + const oldRef = s.nested; + s.nested = {a: 2}; + const newRef = s.nested; + + // Should be different proxy references + expect(oldRef).not.toBe(newRef); + expect(newRef.a).toBe(2); + + await flush(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('mutations on old child proxy after replacement do not trigger notifications', async () => { + const s = createClassyStore({nested: {a: 1}}); + const listener = mock(() => {}); + + const oldNested = s.nested; // get child proxy + s.nested = {a: 2}; // replace — old child proxy detached + + subscribe(s, listener); + + // Mutate the old detached proxy reference (directly on target) + // This shouldn't crash, but won't trigger listener on the store + // because the child is no longer linked. + // Note: old proxy still has its own internal, so mutations work on it + // but the store's root won't be notified since the child is orphaned. + oldNested.a = 999; + await flush(); + + // The store's nested should still be the new value + expect(s.nested.a).toBe(2); + }); + + it('deeply nested replacement triggers root listener', async () => { + const s = createClassyStore({ + level1: {level2: {level3: {value: 'deep'}}}, + }); + const listener = mock(() => {}); + subscribe(s, listener); + + s.level1.level2.level3.value = 'changed'; + await flush(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(s.level1.level2.level3.value).toBe('changed'); + }); + }); + + // ── Version tracking ────────────────────────────────────────────────── + + describe('version tracking', () => { + it('version does not change when same value is set', () => { + const s = createClassyStore({count: 0}); + const v1 = getVersion(s); + + s.count = 0; // noop — same value + const v2 = getVersion(s); + + expect(v2).toBe(v1); + }); + + it('version increments on nested mutation', async () => { + const s = createClassyStore({nested: {value: 1}}); + const v1 = getVersion(s); + + s.nested.value = 2; + const v2 = getVersion(s); + + expect(v2).toBeGreaterThan(v1); + }); + + it('version increments on delete', async () => { + const s = createClassyStore({a: 1, b: 2} as Record); + const v1 = getVersion(s); + + delete s.b; + const v2 = getVersion(s); + + expect(v2).toBeGreaterThan(v1); + }); + + it('multiple rapid mutations produce one notification but multiple version bumps', async () => { + const s = createClassyStore({count: 0}); + const listener = mock(() => {}); + subscribe(s, listener); + + const v1 = getVersion(s); + s.count = 1; + s.count = 2; + s.count = 3; + const v2 = getVersion(s); + + await flush(); + + expect(v2).toBeGreaterThan(v1); + expect(listener).toHaveBeenCalledTimes(1); // batched + expect(s.count).toBe(3); + }); + }); }); diff --git a/src/core/core.ts b/src/core/core.ts index 6ef6131..b47ddc5 100644 --- a/src/core/core.ts +++ b/src/core/core.ts @@ -54,6 +54,23 @@ function recordDep( } } +/** + * Record a computed getter dependency on the active tracker (if any). + * Unlike `recordDep`, this creates a `'computed'` dep entry that validates + * through the computed cache rather than re-executing the raw getter. + */ +function recordComputedDep( + internal: StoreInternal, + prop: string | symbol, + value: unknown, +): void { + const tracker = activeTracker(); + if (!tracker) return; + if (tracker.internal !== internal) return; + + tracker.deps.push({kind: 'computed', internal, prop, value}); +} + /** * Check whether all dependencies from a previous computation are still valid. */ @@ -69,6 +86,12 @@ function areDepsValid(deps: DepEntry[]): boolean { ) return false; if (dep.internal.version !== dep.version) return false; + } else if (dep.kind === 'computed') { + // Validate through the computed cache — never re-execute the raw getter. + const cached = dep.internal.computedCache.get(dep.prop); + if (!cached) return false; // no cache → must recompute + if (!areDepsValid(cached.deps)) return false; // nested deps changed + if (!Object.is(cached.value, dep.value)) return false; // value changed } else { // Check that the property still exists — if it was deleted and // dep.value was `undefined`, Object.is(undefined, undefined) would @@ -215,7 +238,16 @@ function createStoreProxy( const getterDesc = findGetterDescriptor(_target, prop); if (getterDesc?.get) { // Memoized: evaluate with dependency tracking, return cached if deps unchanged. - return evaluateComputed(internal, prop, getterDesc.get, receiver); + const value = evaluateComputed( + internal, + prop, + getterDesc.get, + receiver, + ); + // Record as a computed dependency for any parent getter currently tracking. + // Uses 'computed' dep kind so validation goes through the cache, not raw getter. + recordComputedDep(internal, prop, value); + return value; } const value = Reflect.get(_target, prop); diff --git a/src/react/react.test.tsx b/src/react/react.test.tsx index 30dcb9f..32a5462 100644 --- a/src/react/react.test.tsx +++ b/src/react/react.test.tsx @@ -522,3 +522,165 @@ describe('useLocalStore', () => { expect(renderCount).toHaveBeenCalledTimes(3); }); }); + +// ── Error handling ─────────────────────────────────────────────────────────── + +describe('useStore — error handling', () => { + afterEach(teardown); + + it('throws when given a non-store proxy', () => { + const plainObj = {count: 0}; + + function BadComponent() { + const count = useStore( + plainObj as never, + (s: {count: number}) => s.count, + ); + return
{count}
; + } + + setup(); + expect(() => render()).toThrow(/not a store proxy/); + }); +}); + +// ── Multiple stores in one component ───────────────────────────────────────── + +describe('useStore — multiple stores', () => { + afterEach(teardown); + + it('renders from two independent stores', () => { + const storeA = createClassyStore({count: 10}); + const storeB = createClassyStore({name: 'hello'}); + + function Display() { + const count = useStore(storeA, (s) => s.count); + const name = useStore(storeB, (s) => s.name); + return ( +
+ {count}-{name} +
+ ); + } + + setup(); + render(); + expect(container.textContent).toBe('10-hello'); + }); + + it('re-renders independently when different stores mutate', async () => { + const storeA = createClassyStore({count: 0}); + const storeB = createClassyStore({name: 'hello'}); + const renderCount = mock(() => {}); + + function Display() { + const count = useStore(storeA, (s) => s.count); + const name = useStore(storeB, (s) => s.name); + renderCount(); + return ( +
+ {count}-{name} +
+ ); + } + + setup(); + render(); + expect(renderCount).toHaveBeenCalledTimes(1); + + await act(async () => { + storeA.count = 5; + await flush(); + }); + + expect(container.textContent).toBe('5-hello'); + expect(renderCount).toHaveBeenCalledTimes(2); + + await act(async () => { + storeB.name = 'world'; + await flush(); + }); + + expect(container.textContent).toBe('5-world'); + expect(renderCount).toHaveBeenCalledTimes(3); + }); +}); + +// ── Selector edge cases ────────────────────────────────────────────────────── + +describe('useStore — selector edge cases', () => { + afterEach(teardown); + + it('handles selector returning undefined', () => { + const s = createClassyStore({data: null as string | null}); + + function Display() { + const data = useStore(s, (snap) => snap.data); + return
{data ?? 'none'}
; + } + + setup(); + render(); + expect(container.textContent).toBe('none'); + }); + + it('handles selector returning a boolean', async () => { + class Store { + count = 0; + get isPositive() { + return this.count > 0; + } + } + const s = createClassyStore(new Store()); + const renderCount = mock(() => {}); + + function Display() { + const isPositive = useStore(s, (snap) => snap.isPositive); + renderCount(); + return
{isPositive ? 'yes' : 'no'}
; + } + + setup(); + render(); + expect(container.textContent).toBe('no'); + expect(renderCount).toHaveBeenCalledTimes(1); + + // Set to 1 → isPositive becomes true + await act(async () => { + s.count = 1; + await flush(); + }); + expect(container.textContent).toBe('yes'); + expect(renderCount).toHaveBeenCalledTimes(2); + + // Set to 2 → isPositive still true → no re-render + await act(async () => { + s.count = 2; + await flush(); + }); + expect(renderCount).toHaveBeenCalledTimes(2); + }); + + it('derived selector (computed from multiple fields)', async () => { + const s = createClassyStore({firstName: 'John', lastName: 'Doe'}); + + function Display() { + const fullName = useStore( + s, + (snap) => `${snap.firstName} ${snap.lastName}`, + ); + return
{fullName}
; + } + + setup(); + render(); + expect(container.textContent).toBe('John Doe'); + + await act(async () => { + s.firstName = 'Jane'; + await flush(); + }); + + expect(container.textContent).toBe('Jane Doe'); + }); +}); diff --git a/src/snapshot/snapshot.test.ts b/src/snapshot/snapshot.test.ts index fcbdb54..92612ee 100644 --- a/src/snapshot/snapshot.test.ts +++ b/src/snapshot/snapshot.test.ts @@ -264,4 +264,193 @@ describe('snapshot()', () => { expect(snap.label).toBe('tag:5'); // derived getter }); }); + + // ── Cross-snapshot getter memoization ────────────────────────────────── + + describe('cross-snapshot getter memoization', () => { + it('getter result is stable across snapshots when deps unchanged', async () => { + class Store { + items = [1, 2, 3]; + label = 'hello'; + get total() { + return this.items.reduce((a: number, b: number) => a + b, 0); + } + } + + const s = createClassyStore(new Store()); + const snap1 = snapshot(s); + const total1 = snap1.total; + + // Mutate a property the getter does NOT depend on + s.label = 'world'; + await flush(); + + const snap2 = snapshot(s); + const total2 = snap2.total; + + // items didn't change → structural sharing → same total reference + expect(total1).toBe(total2); + // But snap1.items should be the same ref as snap2.items + expect(snap1.items).toBe(snap2.items); + }); + + it('getter result changes when deps change', async () => { + class Store { + count = 5; + get doubled() { + return this.count * 2; + } + } + + const s = createClassyStore(new Store()); + const snap1 = snapshot(s); + + s.count = 10; + await flush(); + + const snap2 = snapshot(s); + expect(snap1.doubled).toBe(10); + expect(snap2.doubled).toBe(20); + }); + + it('same getter accessed multiple times on same snapshot returns same value', () => { + class Store { + count = 5; + get computed() { + return {value: this.count}; // new object each call + } + } + + const s = createClassyStore(new Store()); + const snap = snapshot(s); + + const result1 = snap.computed; + const result2 = snap.computed; + + // Per-snapshot cache should return the same reference + expect(result1).toBe(result2); + }); + }); + + // ── Leaf types (non-proxyable) ──────────────────────────────────────── + + describe('leaf types', () => { + it('Date values pass through unchanged', () => { + const date = new Date('2026-01-01'); + const s = createClassyStore({created: date}); + const snap = snapshot(s); + + expect(snap.created).toBe(date); // same reference + expect(snap.created instanceof Date).toBe(true); + }); + + it('RegExp values pass through unchanged', () => { + const regex = /test/gi; + const s = createClassyStore({pattern: regex}); + const snap = snapshot(s); + + expect(snap.pattern).toBe(regex); + }); + + it('null and undefined pass through', () => { + const s = createClassyStore({ + a: null as string | null, + b: undefined as number | undefined, + }); + const snap = snapshot(s); + + expect(snap.a).toBeNull(); + expect(snap.b).toBeUndefined(); + }); + + it('functions are excluded from snapshot keys (methods)', () => { + class Store { + count = 0; + increment() { + this.count++; + } + } + + const s = createClassyStore(new Store()); + const snap = snapshot(s); + + // The snapshot should not have the method as an own enumerable property + // (it lives on the prototype). It shouldn't be in Object.keys. + const keys = Object.keys(snap); + expect(keys).not.toContain('increment'); + }); + }); + + // ── Untracked nested objects ────────────────────────────────────────── + + describe('untracked nested objects', () => { + it('deep-clones untracked nested objects', () => { + const s = createClassyStore({data: {nested: {value: 1}}}); + + // Access data but not data.nested through the proxy + const snap = snapshot(s); + + expect(snap.data.nested.value).toBe(1); + expect(Object.isFrozen(snap.data)).toBe(true); + expect(Object.isFrozen(snap.data.nested)).toBe(true); + }); + + it('snapshot of deeply nested arrays freezes all levels', () => { + const s = createClassyStore({ + matrix: [ + [1, 2], + [3, 4], + ], + }); + const snap = snapshot(s); + + expect(Object.isFrozen(snap.matrix)).toBe(true); + expect(Object.isFrozen(snap.matrix[0])).toBe(true); + expect(Object.isFrozen(snap.matrix[1])).toBe(true); + expect(snap.matrix[0]).toEqual([1, 2]); + }); + }); + + // ── Snapshot after delete ───────────────────────────────────────────── + + describe('snapshot after delete', () => { + it('deleted property is absent from snapshot', async () => { + const s = createClassyStore({a: 1, b: 2} as Record); + const snap1 = snapshot(s); + + delete s.b; + await flush(); + + const snap2 = snapshot(s); + expect(snap1.b).toBe(2); + expect('b' in snap2).toBe(false); + }); + }); + + // ── Snapshot with empty/special structures ───────────────────────────── + + describe('special structures', () => { + it('snapshot of store with empty array', () => { + const s = createClassyStore({items: [] as number[]}); + const snap = snapshot(s); + expect(snap.items).toEqual([]); + expect(Object.isFrozen(snap.items)).toBe(true); + }); + + it('snapshot of store with nested empty objects', () => { + const s = createClassyStore({config: {a: {}, b: {}}}); + const snap = snapshot(s); + expect(snap.config.a).toEqual({}); + expect(snap.config.b).toEqual({}); + expect(Object.isFrozen(snap.config.a)).toBe(true); + }); + + it('snapshot is taken synchronously (before flush)', () => { + const s = createClassyStore({count: 0}); + s.count = 42; + // No flush — snapshot should already reflect the mutation + const snap = snapshot(s); + expect(snap.count).toBe(42); + }); + }); }); diff --git a/src/types.ts b/src/types.ts index e8dd9fa..3dc944c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,7 +58,14 @@ export type DepEntry = /** Property name on the parent. */ prop: string | symbol; } - | {kind: 'value'; target: object; prop: string | symbol; value: unknown}; + | {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 = { diff --git a/src/utils/internal/internal.test.ts b/src/utils/internal/internal.test.ts index 4cfa1a3..e83cf67 100644 --- a/src/utils/internal/internal.test.ts +++ b/src/utils/internal/internal.test.ts @@ -230,6 +230,7 @@ describe('canProxy — additional', () => { }); it('PROXYABLE on parent class allows child instances', () => { + // biome-ignore lint/complexity/noStaticOnlyClass: test needs a minimal parent with only the static PROXYABLE symbol class Parent { static [PROXYABLE] = true; }