From ca3a78edcb9b7ec1a5b582fee30eb49be2cfe31c Mon Sep 17 00:00:00 2001 From: codeBelt Date: Mon, 16 Feb 2026 23:17:55 -0600 Subject: [PATCH 1/9] 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/9] 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/9] 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/9] 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/9] 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/9] 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 ff53f9b407cf7800544b95e130fb071dc29111b8 Mon Sep 17 00:00:00 2001 From: codeBelt Date: Tue, 17 Feb 2026 09:59:52 -0600 Subject: [PATCH 7/9] refactor(examples): restructure and modernize examples for clarity and modularity --- bun.lock | 3 + examples/rendering/bun.lock | 251 ++++++++++++- examples/rendering/package.json | 1 + examples/rendering/src/App.tsx | 120 ++---- examples/rendering/src/PersistPage.tsx | 23 -- .../rendering/src/components/ApiSignature.tsx | 18 + examples/rendering/src/components/Button.tsx | 40 ++ .../rendering/src/components/CodeBlock.tsx | 41 +++ .../src/components/DemoContainer.tsx | 88 +++++ examples/rendering/src/components/Layout.tsx | 57 +++ .../rendering/src/components/NavSidebar.tsx | 109 ++++++ .../rendering/src/{ => components}/Panel.tsx | 0 .../src/{ => components}/ReactScan.tsx | 2 +- .../src/{ => components}/RenderBadge.tsx | 0 examples/rendering/src/components/TipBox.tsx | 54 +++ .../collections}/CollectionsDemo.tsx | 68 ++-- .../src/demos/devtools/DevtoolsDemo.tsx | 158 ++++++++ .../src/demos/history/UndoRedoDemo.tsx | 182 +++++++++ .../demos/local-store/LocalCounterDemo.tsx | 94 +++++ .../persist}/KitchenSinkPersistDemo.tsx | 165 +++++---- .../{ => demos/persist}/SimplePersistDemo.tsx | 45 ++- .../src/{ => demos/reactivity}/AsyncDemo.tsx | 62 +++- .../reactivity}/ReactiveFundamentalsDemo.tsx | 81 ++-- .../demos/shallow-equal/ShallowEqualDemo.tsx | 213 +++++++++++ .../snapshots}/StructuralSharingDemo.tsx | 75 ++-- .../demos/subscribe-key/SubscribeKeyDemo.tsx | 157 ++++++++ examples/rendering/src/hooks/useHashRoute.ts | 18 + .../src/{ => hooks}/useRenderCount.ts | 0 .../rendering/src/pages/CollectionsPage.tsx | 32 ++ examples/rendering/src/pages/DevtoolsPage.tsx | 33 ++ examples/rendering/src/pages/HistoryPage.tsx | 31 ++ examples/rendering/src/pages/OverviewPage.tsx | 348 ++++++++++++++++++ examples/rendering/src/pages/PersistPage.tsx | 38 ++ .../rendering/src/pages/ReactivityPage.tsx | 31 ++ .../rendering/src/pages/ShallowEqualPage.tsx | 54 +++ .../rendering/src/pages/SnapshotsPage.tsx | 27 ++ .../rendering/src/pages/SubscribeKeyPage.tsx | 33 ++ .../rendering/src/pages/UseLocalStorePage.tsx | 31 ++ examples/rendering/src/stores.ts | 155 -------- .../rendering/src/stores/collectionStore.ts | 59 +++ examples/rendering/src/stores/counterStore.ts | 37 ++ .../rendering/src/stores/documentStore.ts | 27 ++ examples/rendering/src/stores/historyStore.ts | 24 ++ .../src/stores/localStoreExamples.ts | 23 ++ .../src/{ => stores}/persistStores.ts | 10 +- examples/rendering/src/stores/postStore.ts | 38 ++ .../rendering/src/stores/shallowEqualStore.ts | 36 ++ .../rendering/src/stores/subscribeKeyStore.ts | 19 + src/utils/internal/internal.test.ts | 1 + 49 files changed, 2755 insertions(+), 457 deletions(-) delete mode 100644 examples/rendering/src/PersistPage.tsx create mode 100644 examples/rendering/src/components/ApiSignature.tsx create mode 100644 examples/rendering/src/components/Button.tsx create mode 100644 examples/rendering/src/components/CodeBlock.tsx create mode 100644 examples/rendering/src/components/DemoContainer.tsx create mode 100644 examples/rendering/src/components/Layout.tsx create mode 100644 examples/rendering/src/components/NavSidebar.tsx rename examples/rendering/src/{ => components}/Panel.tsx (100%) rename examples/rendering/src/{ => components}/ReactScan.tsx (82%) rename examples/rendering/src/{ => components}/RenderBadge.tsx (100%) create mode 100644 examples/rendering/src/components/TipBox.tsx rename examples/rendering/src/{ => demos/collections}/CollectionsDemo.tsx (77%) create mode 100644 examples/rendering/src/demos/devtools/DevtoolsDemo.tsx create mode 100644 examples/rendering/src/demos/history/UndoRedoDemo.tsx create mode 100644 examples/rendering/src/demos/local-store/LocalCounterDemo.tsx rename examples/rendering/src/{ => demos/persist}/KitchenSinkPersistDemo.tsx (72%) rename examples/rendering/src/{ => demos/persist}/SimplePersistDemo.tsx (85%) rename examples/rendering/src/{ => demos/reactivity}/AsyncDemo.tsx (73%) rename examples/rendering/src/{ => demos/reactivity}/ReactiveFundamentalsDemo.tsx (64%) create mode 100644 examples/rendering/src/demos/shallow-equal/ShallowEqualDemo.tsx rename examples/rendering/src/{ => demos/snapshots}/StructuralSharingDemo.tsx (79%) create mode 100644 examples/rendering/src/demos/subscribe-key/SubscribeKeyDemo.tsx create mode 100644 examples/rendering/src/hooks/useHashRoute.ts rename examples/rendering/src/{ => hooks}/useRenderCount.ts (100%) create mode 100644 examples/rendering/src/pages/CollectionsPage.tsx create mode 100644 examples/rendering/src/pages/DevtoolsPage.tsx create mode 100644 examples/rendering/src/pages/HistoryPage.tsx create mode 100644 examples/rendering/src/pages/OverviewPage.tsx create mode 100644 examples/rendering/src/pages/PersistPage.tsx create mode 100644 examples/rendering/src/pages/ReactivityPage.tsx create mode 100644 examples/rendering/src/pages/ShallowEqualPage.tsx create mode 100644 examples/rendering/src/pages/SnapshotsPage.tsx create mode 100644 examples/rendering/src/pages/SubscribeKeyPage.tsx create mode 100644 examples/rendering/src/pages/UseLocalStorePage.tsx delete mode 100644 examples/rendering/src/stores.ts create mode 100644 examples/rendering/src/stores/collectionStore.ts create mode 100644 examples/rendering/src/stores/counterStore.ts create mode 100644 examples/rendering/src/stores/documentStore.ts create mode 100644 examples/rendering/src/stores/historyStore.ts create mode 100644 examples/rendering/src/stores/localStoreExamples.ts rename examples/rendering/src/{ => stores}/persistStores.ts (94%) create mode 100644 examples/rendering/src/stores/postStore.ts create mode 100644 examples/rendering/src/stores/shallowEqualStore.ts create mode 100644 examples/rendering/src/stores/subscribeKeyStore.ts diff --git a/bun.lock b/bun.lock index 6bf89a1..046165f 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,9 @@ "peerDependencies": { "react": ">=18.0.0", }, + "optionalPeers": [ + "react", + ], }, }, "packages": { diff --git a/examples/rendering/bun.lock b/examples/rendering/bun.lock index e4e5124..f5cc0f3 100644 --- a/examples/rendering/bun.lock +++ b/examples/rendering/bun.lock @@ -3,10 +3,11 @@ "configVersion": 1, "workspaces": { "": { - "name": "classystore-demo", + "name": "classy-store-demo", "dependencies": { "@codebelt/classy-store": "file:../..", "bun-plugin-tailwind": "0.1.2", + "prism-react-renderer": "^2.4.1", "proxy-compare": "3.0.1", "react": "19.2.4", "react-dom": "19.2.4", @@ -47,35 +48,75 @@ "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@biomejs/biome": ["@biomejs/biome@2.3.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.15", "@biomejs/cli-darwin-x64": "2.3.15", "@biomejs/cli-linux-arm64": "2.3.15", "@biomejs/cli-linux-arm64-musl": "2.3.15", "@biomejs/cli-linux-x64": "2.3.15", "@biomejs/cli-linux-x64-musl": "2.3.15", "@biomejs/cli-win32-arm64": "2.3.15", "@biomejs/cli-win32-x64": "2.3.15" }, "bin": { "biome": "bin/biome" } }, "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g=="], + "@biomejs/biome": ["@biomejs/biome@2.4.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.0", "@biomejs/cli-darwin-x64": "2.4.0", "@biomejs/cli-linux-arm64": "2.4.0", "@biomejs/cli-linux-arm64-musl": "2.4.0", "@biomejs/cli-linux-x64": "2.4.0", "@biomejs/cli-linux-x64-musl": "2.4.0", "@biomejs/cli-win32-arm64": "2.4.0", "@biomejs/cli-win32-x64": "2.4.0" }, "bin": { "biome": "bin/biome" } }, "sha512-iluT61cORUDIC5i/y42ljyQraCemmmcgbMLLCnYO+yh+2hjTmcMFcwY8G0zTzWCsPb3t3AyKc+0t/VuhPZULUg=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-L+YpOtPSuU0etomfvFTPWRsa7+8ejaJL3yaROEoT/96HDJbR6OsvZQk0C8JUYou+XFdP+JcGxqZknkp4n934RA=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Aq+S7ffpb5ynTyLgtnEjG+W6xuTd2F7FdC7J6ShpvRhZwJhjzwITGF9vrqoOnw0sv1XWkt2Q1Rpg+hleg/Xg7Q=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-u2p54IhvNAWB+h7+rxCZe3reNfQYFK+ppDw+q0yegrGclFYnDPZAntv/PqgUacpC3uxTeuWFgWW7RFe3lHuxOA=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1rhDUq8sf7xX3tg7vbnU3WVfanKCKi40OXc4VleBMzRStmQHdeBY46aFP6VdwEomcVjyNiu+Zcr3LZtAdrZrjQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WVFOhsnzhrbMGOSIcB9yFdRV2oG2KkRRhIZiunI9gJqSU3ax9ErdnTxRfJUxZUI9NbzVxC60OCXNcu+mXfF/Tw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Omo0xhl63z47X+CrE5viEWKJhejJyndl577VoXg763U/aoATrK3r5+8DPh02GokWPeODX1Hek00OtjjooGan9w=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-aqRwW0LJLV1v1NzyLvLWQhdLmDSAV1vUh+OBdYJaa8f28XBn5BZavo+WTfqgEzALZxlNfBmu6NGO6Al3MbCULw=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-g47s+V+OqsGxbSZN3lpav6WYOk0PIc3aCBAq+p6dwSynL3K5MA6Cg6nkzDOlu28GEHwbakW+BllzHCJCxnfK5Q=="], + + "@changesets/apply-release-plan": ["@changesets/apply-release-plan@7.0.14", "", { "dependencies": { "@changesets/config": "^3.1.2", "@changesets/get-version-range-type": "^0.4.0", "@changesets/git": "^3.0.4", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "detect-indent": "^6.0.0", "fs-extra": "^7.0.1", "lodash.startcase": "^4.4.0", "outdent": "^0.5.0", "prettier": "^2.7.1", "resolve-from": "^5.0.0", "semver": "^7.5.3" } }, "sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA=="], + + "@changesets/assemble-release-plan": ["@changesets/assemble-release-plan@6.0.9", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "semver": "^7.5.3" } }, "sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ=="], + + "@changesets/changelog-git": ["@changesets/changelog-git@0.2.1", "", { "dependencies": { "@changesets/types": "^6.1.0" } }, "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q=="], + + "@changesets/changelog-github": ["@changesets/changelog-github@0.5.2", "", { "dependencies": { "@changesets/get-github-info": "^0.7.0", "@changesets/types": "^6.1.0", "dotenv": "^8.1.0" } }, "sha512-HeGeDl8HaIGj9fQHo/tv5XKQ2SNEi9+9yl1Bss1jttPqeiASRXhfi0A2wv8yFKCp07kR1gpOI5ge6+CWNm1jPw=="], + + "@changesets/cli": ["@changesets/cli@2.29.8", "", { "dependencies": { "@changesets/apply-release-plan": "^7.0.14", "@changesets/assemble-release-plan": "^6.0.9", "@changesets/changelog-git": "^0.2.1", "@changesets/config": "^3.1.2", "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/get-release-plan": "^4.0.14", "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.6", "@changesets/should-skip-package": "^0.1.2", "@changesets/types": "^6.1.0", "@changesets/write": "^0.4.0", "@inquirer/external-editor": "^1.0.2", "@manypkg/get-packages": "^1.1.3", "ansi-colors": "^4.1.3", "ci-info": "^3.7.0", "enquirer": "^2.4.1", "fs-extra": "^7.0.1", "mri": "^1.2.0", "p-limit": "^2.2.0", "package-manager-detector": "^0.2.0", "picocolors": "^1.1.0", "resolve-from": "^5.0.0", "semver": "^7.5.3", "spawndamnit": "^3.0.1", "term-size": "^2.1.0" }, "bin": { "changeset": "bin.js" } }, "sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw=="], + "@changesets/config": ["@changesets/config@3.1.2", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/get-dependents-graph": "^2.1.3", "@changesets/logger": "^0.1.1", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1", "micromatch": "^4.0.8" } }, "sha512-CYiRhA4bWKemdYi/uwImjPxqWNpqGPNbEBdX1BdONALFIDK7MCUj6FPkzD+z9gJcvDFUQJn9aDVf4UG7OT6Kog=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-RkyeSosBtn3C3Un8zQnl9upX0Qbq4E3QmBa0qjpOh1MebRbHhNlRC16jk8HdTe/9ym5zlfnpbb8cKXzW+vlTxw=="], + "@changesets/errors": ["@changesets/errors@0.2.0", "", { "dependencies": { "extendable-error": "^0.1.5" } }, "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-FN83KxrdVWANOn5tDmW6UBC0grojchbGmcEz6JkRs2YY6DY63sTZhwkQ56x6YtKhDVV1Unz7FJexy8o7KwuIhg=="], + "@changesets/get-dependents-graph": ["@changesets/get-dependents-graph@2.1.3", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "picocolors": "^1.1.0", "semver": "^7.5.3" } }, "sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-SSSIj2yMkFdSkXqASzIBdjySBXOe65RJlhKEDlri7MN19RC4cpez+C0kEwPrhXOTgJbwQR9QH1F4+VnHkC35pg=="], + "@changesets/get-github-info": ["@changesets/get-github-info@0.7.0", "", { "dependencies": { "dataloader": "^1.4.0", "node-fetch": "^2.5.0" } }, "sha512-+i67Bmhfj9V4KfDeS1+Tz3iF32btKZB2AAx+cYMqDSRFP7r3/ZdGbjCo+c6qkyViN9ygDuBjzageuPGJtKGe5A=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.15", "", { "os": "linux", "cpu": "x64" }, "sha512-T8n9p8aiIKOrAD7SwC7opiBM1LYGrE5G3OQRXWgbeo/merBk8m+uxJ1nOXMPzfYyFLfPlKF92QS06KN1UW+Zbg=="], + "@changesets/get-release-plan": ["@changesets/get-release-plan@4.0.14", "", { "dependencies": { "@changesets/assemble-release-plan": "^6.0.9", "@changesets/config": "^3.1.2", "@changesets/pre": "^2.0.2", "@changesets/read": "^0.6.6", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.15", "", { "os": "linux", "cpu": "x64" }, "sha512-dbjPzTh+ijmmNwojFYbQNMFp332019ZDioBYAMMJj5Ux9d8MkM+u+J68SBJGVwVeSHMYj+T9504CoxEzQxrdNw=="], + "@changesets/get-version-range-type": ["@changesets/get-version-range-type@0.4.0", "", {}, "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-puMuenu/2brQdgqtQ7geNwQlNVxiABKEZJhMRX6AGWcmrMO8EObMXniFQywy2b81qmC+q+SDvlOpspNwz0WiOA=="], + "@changesets/git": ["@changesets/git@3.0.4", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@manypkg/get-packages": "^1.1.3", "is-subdir": "^1.1.1", "micromatch": "^4.0.8", "spawndamnit": "^3.0.1" } }, "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.15", "", { "os": "win32", "cpu": "x64" }, "sha512-kDZr/hgg+igo5Emi0LcjlgfkoGZtgIpJKhnvKTRmMBv6FF/3SDyEV4khBwqNebZIyMZTzvpca9sQNSXJ39pI2A=="], + "@changesets/logger": ["@changesets/logger@0.1.1", "", { "dependencies": { "picocolors": "^1.1.0" } }, "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg=="], + + "@changesets/parse": ["@changesets/parse@0.4.2", "", { "dependencies": { "@changesets/types": "^6.1.0", "js-yaml": "^4.1.1" } }, "sha512-Uo5MC5mfg4OM0jU3up66fmSn6/NE9INK+8/Vn/7sMVcdWg46zfbvvUSjD9EMonVqPi9fbrJH9SXHn48Tr1f2yA=="], + + "@changesets/pre": ["@changesets/pre@2.0.2", "", { "dependencies": { "@changesets/errors": "^0.2.0", "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3", "fs-extra": "^7.0.1" } }, "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug=="], + + "@changesets/read": ["@changesets/read@0.6.6", "", { "dependencies": { "@changesets/git": "^3.0.4", "@changesets/logger": "^0.1.1", "@changesets/parse": "^0.4.2", "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "p-filter": "^2.1.0", "picocolors": "^1.1.0" } }, "sha512-P5QaN9hJSQQKJShzzpBT13FzOSPyHbqdoIBUd2DJdgvnECCyO6LmAOWSV+O8se2TaZJVwSXjL+v9yhb+a9JeJg=="], + + "@changesets/should-skip-package": ["@changesets/should-skip-package@0.1.2", "", { "dependencies": { "@changesets/types": "^6.1.0", "@manypkg/get-packages": "^1.1.3" } }, "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw=="], + + "@changesets/types": ["@changesets/types@6.1.0", "", {}, "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA=="], + + "@changesets/write": ["@changesets/write@0.4.0", "", { "dependencies": { "@changesets/types": "^6.1.0", "fs-extra": "^7.0.1", "human-id": "^4.1.1", "prettier": "^2.7.1" } }, "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q=="], "@clack/core": ["@clack/core@0.3.5", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ=="], "@clack/prompts": ["@clack/prompts@0.8.2", "", { "dependencies": { "@clack/core": "0.3.5", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-6b9Ab2UiZwJYA9iMyboYyW9yJvAO9V753ZhS+DHKEjZRKAxPPOb7MXXu84lsPFG+vZt6FRFniZ8rXi+zCIw4yQ=="], - "@codebelt/classy-store": ["@codebelt/classy-store@file:../..", { "dependencies": { "proxy-compare": "3.0.1" }, "devDependencies": { "@biomejs/biome": "2.3.15", "@happy-dom/global-registrator": "20.6.1", "@types/bun": "1.3.9", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "react": "19.2.4", "react-dom": "19.2.4", "tsdown": "0.20.3", "typescript": "5.9.3" }, "peerDependencies": { "react": ">=18.0.0" } }], + "@codebelt/classy-store": ["@codebelt/classy-store@file:../..", { "dependencies": { "proxy-compare": "3.0.1" }, "devDependencies": { "@biomejs/biome": "2.4.0", "@changesets/changelog-github": "0.5.2", "@changesets/cli": "2.29.8", "@happy-dom/global-registrator": "20.6.1", "@types/bun": "1.3.9", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "react": "19.2.4", "react-dom": "19.2.4", "tsdown": "0.20.3", "typescript": "5.9.3" }, "peerDependencies": { "react": ">=18.0.0" }, "optionalPeers": ["react"] }], "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -137,6 +178,8 @@ "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.6.1", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.6.1" } }, "sha512-4Aji+soqukwUxq2DgHmkjxdGnG7hEiJuprqDlW4Wu6AQ0t8U9ItlICcM5to89pulIsEGrF1CkCoNrufQTcqb8A=="], + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -147,8 +190,18 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], + + "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-df7smckMWSUfaT5mzwN9Lfpd3ZGkOqo+vmQ8VV2a32gl14v6uZ/qeeo+1RlANXn8M0uzXPWWCkrKZIWSZUR0qw=="], "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-YiLxfsPzQqaVvT2a+nxH9do0YfUjrlxF3tKP0b1DDgvfgCcVKGsrQH3Wa82qHgL4dnT8h2bqi94JxXESEuPmcA=="], @@ -221,6 +274,8 @@ "@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -233,16 +288,28 @@ "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="], + "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], + "bippy": ["bippy@0.3.34", "", { "dependencies": { "@types/react-reconciler": "^0.28.9" }, "peerDependencies": { "react": ">=17.0.1" } }, "sha512-vmptmU/20UdIWHHhq7qCSHhHzK7Ro3YJ1utU0fBG7ujUc58LEfTtilKxcF0IOgSjT5XLcm7CBzDjbv4lcKApGQ=="], "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "bun": ["bun@1.3.9", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.9", "@oven/bun-darwin-x64": "1.3.9", "@oven/bun-darwin-x64-baseline": "1.3.9", "@oven/bun-linux-aarch64": "1.3.9", "@oven/bun-linux-aarch64-musl": "1.3.9", "@oven/bun-linux-x64": "1.3.9", "@oven/bun-linux-x64-baseline": "1.3.9", "@oven/bun-linux-x64-musl": "1.3.9", "@oven/bun-linux-x64-musl-baseline": "1.3.9", "@oven/bun-windows-x64": "1.3.9", "@oven/bun-windows-x64-baseline": "1.3.9" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-v5hkh1us7sMNjfimWE70flYbD5I1/qWQaqmJ45q2qk5H/7muQVa478LSVRSFyGTBUBog2LsPQnfIRdjyWJRY+A=="], @@ -255,75 +322,173 @@ "caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="], + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + + "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "dataloader": ["dataloader@1.4.0", "", {}, "sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], + + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + + "dotenv": ["dotenv@8.6.0", "", {}, "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g=="], + "dts-resolver": ["dts-resolver@2.1.3", "", { "peerDependencies": { "oxc-resolver": ">=11.0.0" }, "optionalPeers": ["oxc-resolver"] }, "sha512-bihc7jPC90VrosXNzK0LTE2cuLP6jr0Ro8jk+kMugHReJVLIpHz/xadeq3MhuwyO4TD4OA3L1Q8pBBFRc08Tsw=="], "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.6.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^6.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ=="], "hookable": ["hookable@6.0.1", "", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="], + "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], + + "is-windows": ["is-windows@1.0.2", "", {}, "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], + + "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="], + + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], "preact": ["preact@10.28.3", "", {}, "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA=="], + "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + + "prism-react-renderer": ["prism-react-renderer@2.4.1", "", { "dependencies": { "@types/prismjs": "^1.26.0", "clsx": "^2.0.0" }, "peerDependencies": { "react": ">=16.0.0" } }, "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig=="], + "proxy-compare": ["proxy-compare@3.0.1", "", {}, "sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q=="], - "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], @@ -331,24 +496,56 @@ "react-scan": ["react-scan@0.4.3", "", { "dependencies": { "@babel/core": "^7.26.0", "@babel/generator": "^7.26.2", "@babel/types": "^7.26.0", "@clack/core": "^0.3.5", "@clack/prompts": "^0.8.2", "@pivanov/utils": "0.0.2", "@preact/signals": "^1.3.1", "@rollup/pluginutils": "^5.1.3", "@types/node": "^20.17.9", "bippy": "^0.3.8", "esbuild": "^0.25.0", "estree-walker": "^3.0.3", "kleur": "^4.1.5", "mri": "^1.2.0", "playwright": "^1.49.0", "preact": "^10.25.1", "tsx": "^4.19.3" }, "optionalDependencies": { "unplugin": "2.1.0" }, "peerDependencies": { "@remix-run/react": ">=1.0.0", "next": ">=13.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-router": "^5.0.0 || ^6.0.0 || ^7.0.0", "react-router-dom": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@remix-run/react", "next", "react-router", "react-router-dom"], "bin": { "react-scan": "bin/cli.js" } }, "sha512-jhAQuQ1nja6HUYrSpbmNFHqZPsRCXk8Yqu0lHoRIw9eb8N96uTfXCpVyQhTTnJ/nWqnwuvxbpKVG/oWZT8+iTQ=="], + "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], + + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "rolldown": ["rolldown@1.0.0-rc.3", "", { "dependencies": { "@oxc-project/types": "=0.112.0", "@rolldown/pluginutils": "1.0.0-rc.3" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-x64": "1.0.0-rc.3", "@rolldown/binding-freebsd-x64": "1.0.0-rc.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.3", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.3", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.3", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.3", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.3", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.3" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw=="], "rolldown-plugin-dts": ["rolldown-plugin-dts@0.22.1", "", { "dependencies": { "@babel/generator": "8.0.0-rc.1", "@babel/helper-validator-identifier": "8.0.0-rc.1", "@babel/parser": "8.0.0-rc.1", "@babel/types": "8.0.0-rc.1", "ast-kit": "^3.0.0-beta.1", "birpc": "^4.0.0", "dts-resolver": "^2.1.3", "get-tsconfig": "^4.13.1", "obug": "^2.1.1" }, "peerDependencies": { "@ts-macro/tsc": "^0.3.6", "@typescript/native-preview": ">=7.0.0-dev.20250601.1", "rolldown": "^1.0.0-rc.3", "typescript": "^5.0.0", "vue-tsc": "~3.2.0" }, "optionalPeers": ["@ts-macro/tsc", "@typescript/native-preview", "typescript", "vue-tsc"] }, "sha512-5E0AiM5RSQhU6cjtkDFWH6laW4IrMu0j1Mo8x04Xo1ALHmaRMs9/7zej7P3RrryVHW/DdZAp85MA7Be55p0iUw=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], + + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + "term-size": ["term-size@2.2.1", "", {}, "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg=="], + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "tsdown": ["tsdown@0.20.3", "", { "dependencies": { "ansis": "^4.2.0", "cac": "^6.7.14", "defu": "^6.1.4", "empathic": "^2.0.0", "hookable": "^6.0.1", "import-without-cache": "^0.2.5", "obug": "^2.1.1", "picomatch": "^4.0.3", "rolldown": "1.0.0-rc.3", "rolldown-plugin-dts": "^0.22.1", "semver": "^7.7.3", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tree-kill": "^1.2.2", "unconfig-core": "^7.4.2", "unrun": "^0.2.27" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@vitejs/devtools": "*", "publint": "^0.3.0", "typescript": "^5.0.0", "unplugin-lightningcss": "^0.4.0", "unplugin-unused": "^0.5.0" }, "optionalPeers": ["@arethetypeswrong/core", "@vitejs/devtools", "publint", "typescript", "unplugin-lightningcss", "unplugin-unused"], "bin": { "tsdown": "dist/run.mjs" } }, "sha512-qWOUXSbe4jN8JZEgrkc/uhJpC8VN2QpNu3eZkBWwNuTEjc/Ik1kcc54ycfcQ5QPRHeu9OQXaLfCI3o7pEJgB2w=="], @@ -363,16 +560,24 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "unplugin": ["unplugin@2.1.0", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-us4j03/499KhbGP8BU7Hrzrgseo+KdfJYWcbcajCOqsAyb8Gk0Yn2kiUIcZISYCb1JFaZfIuG3b42HmguVOKCQ=="], "unrun": ["unrun@0.2.27", "", { "dependencies": { "rolldown": "1.0.0-rc.3" }, "peerDependencies": { "synckit": "^0.11.11" }, "optionalPeers": ["synckit"], "bin": { "unrun": "dist/cli.mjs" } }, "sha512-Mmur1UJpIbfxasLOhPRvox/QS4xBiDii71hMP7smfRthGcwFL2OAmYRgduLANOAU4LUkvVamuP+02U+c90jlrw=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -381,10 +586,24 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@manypkg/find-root/@types/node": ["@types/node@12.20.55", "", {}, "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ=="], + + "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + + "@manypkg/get-packages/@changesets/types": ["@changesets/types@4.1.0", "", {}, "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw=="], + + "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + + "@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "ast-kit/@babel/parser": ["@babel/parser@8.0.0-rc.1", "", { "dependencies": { "@babel/types": "^8.0.0-rc.1" }, "bin": "./bin/babel-parser.js" }, "sha512-6HyyU5l1yK/7h9Ki52i5h6mDAx4qJdiLQO4FdCyJNoB/gy3T3GGJdhQzzbZgvgZCugYBvwtQiWRt94QKedHnkA=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "rolldown-plugin-dts/@babel/generator": ["@babel/generator@8.0.0-rc.1", "", { "dependencies": { "@babel/parser": "^8.0.0-rc.1", "@babel/types": "^8.0.0-rc.1", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-3ypWOOiC4AYHKr8vYRVtWtWmyvcoItHtVqF8paFax+ydpmUdPsJpLBkBBs5ItmhdrwC3a0ZSqqFAdzls4ODP3w=="], "rolldown-plugin-dts/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.1", "", {}, "sha512-I4YnARytXC2RzkLNVnf5qFNFMzp679qZpmtw/V3Jt2uGnWiIxyJtaukjG7R8pSx8nG2NamICpGfljQsogj+FbQ=="], @@ -397,9 +616,13 @@ "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], + "ast-kit/@babel/parser/@babel/types": ["@babel/types@8.0.0-rc.1", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0-rc.1", "@babel/helper-validator-identifier": "^8.0.0-rc.1" } }, "sha512-ubmJ6TShyaD69VE9DQrlXcdkvJbmwWPB8qYj0H2kaJi29O7vJT9ajSdBd2W8CG34pwL9pYA74fi7RHC1qbLoVQ=="], - "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], @@ -453,7 +676,7 @@ "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + "ast-kit/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], "ast-kit/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.0-rc.1", "", {}, "sha512-I4YnARytXC2RzkLNVnf5qFNFMzp679qZpmtw/V3Jt2uGnWiIxyJtaukjG7R8pSx8nG2NamICpGfljQsogj+FbQ=="], } diff --git a/examples/rendering/package.json b/examples/rendering/package.json index eab751c..84d2205 100644 --- a/examples/rendering/package.json +++ b/examples/rendering/package.json @@ -11,6 +11,7 @@ "dependencies": { "@codebelt/classy-store": "file:../..", "bun-plugin-tailwind": "0.1.2", + "prism-react-renderer": "^2.4.1", "proxy-compare": "3.0.1", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/examples/rendering/src/App.tsx b/examples/rendering/src/App.tsx index 2261477..b2d21ed 100644 --- a/examples/rendering/src/App.tsx +++ b/examples/rendering/src/App.tsx @@ -1,101 +1,41 @@ // This component must be the top-most import in this file! -import {ReactScan} from './ReactScan'; +import {ReactScan} from './components/ReactScan'; import './index.css'; -import {useEffect, useState} from 'react'; -import {AsyncDemo} from './AsyncDemo'; -import {CollectionsDemo} from './CollectionsDemo'; -import {PersistPage} from './PersistPage'; -import {ReactiveFundamentalsDemo} from './ReactiveFundamentalsDemo'; -import {StructuralSharingDemo} from './StructuralSharingDemo'; - -// ── Hash Router ───────────────────────────────────────────────────────────── - -function getRoute(): string { - const hash = globalThis.location?.hash ?? ''; - return hash.replace(/^#/, '') || '/'; -} - -function useHashRoute() { - const [route, setRoute] = useState(getRoute); - - useEffect(() => { - const handler = () => setRoute(getRoute()); - globalThis.addEventListener('hashchange', handler); - return () => globalThis.removeEventListener('hashchange', handler); - }, []); - - return route; -} - -// ── Navigation ────────────────────────────────────────────────────────────── - -function NavBar({route}: {route: string}) { - const links = [ - {href: '#/', label: 'Reactivity', active: route === '/'}, - {href: '#/persist', label: 'Persist', active: route === '/persist'}, - ]; - - return ( - - ); -} - -// ── Reactivity Demos Page ─────────────────────────────────────────────────── - -function ReactivityPage() { - return ( - <> -
-

- @codebelt/classy-store -

-

- Class-based reactive state for React. Watch the render badges to see - exactly when each component re-renders. -

-
- -
- - -
- -
- -
- -
- -
- - ); -} - -// ── App ───────────────────────────────────────────────────────────────────── +import {Layout} from './components/Layout'; +import {useHashRoute} from './hooks/useHashRoute'; +import {CollectionsPage} from './pages/CollectionsPage'; +import {DevtoolsPage} from './pages/DevtoolsPage'; +import {HistoryPage} from './pages/HistoryPage'; +import {OverviewPage} from './pages/OverviewPage'; +import {PersistPage} from './pages/PersistPage'; +import {ReactivityPage} from './pages/ReactivityPage'; +import {ShallowEqualPage} from './pages/ShallowEqualPage'; +import {SnapshotsPage} from './pages/SnapshotsPage'; +import {SubscribeKeyPage} from './pages/SubscribeKeyPage'; +import {UseLocalStorePage} from './pages/UseLocalStorePage'; + +const routes: Record = { + '/': OverviewPage, + '/reactivity': ReactivityPage, + '/collections': CollectionsPage, + '/snapshots': SnapshotsPage, + '/use-local-store': UseLocalStorePage, + '/persist': PersistPage, + '/history': HistoryPage, + '/devtools': DevtoolsPage, + '/subscribe-key': SubscribeKeyPage, + '/shallow-equal': ShallowEqualPage, +}; export function App() { const route = useHashRoute(); + const Page = routes[route] ?? OverviewPage; return ( -
+ - - {route === '/persist' ? : } -
+ + ); } diff --git a/examples/rendering/src/PersistPage.tsx b/examples/rendering/src/PersistPage.tsx deleted file mode 100644 index 07e431d..0000000 --- a/examples/rendering/src/PersistPage.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import {KitchenSinkPersistDemo} from './KitchenSinkPersistDemo'; -import {SimplePersistDemo} from './SimplePersistDemo'; - -export function PersistPage() { - return ( - <> -
-

- @codebelt/classy-store -

-

- Persist store state to localStorage with transforms, versioning, - debounce, and cross-tab sync. -

-
- -
- - -
- - ); -} diff --git a/examples/rendering/src/components/ApiSignature.tsx b/examples/rendering/src/components/ApiSignature.tsx new file mode 100644 index 0000000..0ee6d58 --- /dev/null +++ b/examples/rendering/src/components/ApiSignature.tsx @@ -0,0 +1,18 @@ +export function ApiSignature({ + importPath, + signature, +}: { + importPath: string; + signature: string; +}) { + return ( +
+
+ import {'{'}{' '} + {signature.split('(')[0]} {'}'}{' '} + from '{importPath}' +
+
{signature}
+
+ ); +} diff --git a/examples/rendering/src/components/Button.tsx b/examples/rendering/src/components/Button.tsx new file mode 100644 index 0000000..00bea78 --- /dev/null +++ b/examples/rendering/src/components/Button.tsx @@ -0,0 +1,40 @@ +import type {ButtonHTMLAttributes, ReactNode} from 'react'; + +type Variant = 'primary' | 'secondary' | 'danger' | 'ghost'; +type Size = 'sm' | 'md'; + +const variantStyles: Record = { + primary: 'bg-indigo-500 text-white hover:bg-indigo-400', + secondary: 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600', + danger: 'bg-rose-600 text-white hover:bg-rose-500', + ghost: + 'bg-transparent text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50', +}; + +const sizeStyles: Record = { + sm: 'px-2.5 py-1 text-xs', + md: 'px-3 py-1.5 text-sm', +}; + +export function Button({ + variant = 'primary', + size = 'md', + children, + className = '', + ...props +}: { + variant?: Variant; + size?: Size; + children: ReactNode; + className?: string; +} & ButtonHTMLAttributes) { + return ( + + ); +} diff --git a/examples/rendering/src/components/CodeBlock.tsx b/examples/rendering/src/components/CodeBlock.tsx new file mode 100644 index 0000000..59ff3f9 --- /dev/null +++ b/examples/rendering/src/components/CodeBlock.tsx @@ -0,0 +1,41 @@ +import {Highlight, themes} from 'prism-react-renderer'; + +export function CodeBlock({ + code, + language = 'tsx', + title, + compact = false, +}: { + code: string; + language?: string; + title?: string; + compact?: boolean; +}) { + const trimmed = code.trim(); + + return ( +
+ {title && ( +
+ {title} +
+ )} + + {({tokens, getLineProps, getTokenProps}) => ( +
+            {tokens.map((line, i) => (
+              
+ {line.map((token, key) => ( + + ))} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/examples/rendering/src/components/DemoContainer.tsx b/examples/rendering/src/components/DemoContainer.tsx new file mode 100644 index 0000000..f539fd6 --- /dev/null +++ b/examples/rendering/src/components/DemoContainer.tsx @@ -0,0 +1,88 @@ +import {type ReactNode, useState} from 'react'; +import {CodeBlock} from './CodeBlock'; + +interface CodeTab { + label: string; + code: string; + language?: string; +} + +export function DemoContainer({ + title, + description, + children, + codeTabs, +}: { + title: string; + description?: string; + children: ReactNode; + codeTabs?: CodeTab[]; +}) { + const [activeTab, setActiveTab] = useState(0); + const [showCode, setShowCode] = useState(true); + + if (!codeTabs || codeTabs.length === 0) { + return ( +
+

{title}

+ {description && ( +

{description}

+ )} + {children} +
+ ); + } + + return ( +
+
+ {/* Live demo */} +
+

{title}

+ {description && ( +

{description}

+ )} + {children} +
+ + {/* Code panel */} +
+
+
+ {codeTabs.map((tab, i) => ( + + ))} +
+ +
+ {showCode && codeTabs[activeTab] && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/examples/rendering/src/components/Layout.tsx b/examples/rendering/src/components/Layout.tsx new file mode 100644 index 0000000..469b63e --- /dev/null +++ b/examples/rendering/src/components/Layout.tsx @@ -0,0 +1,57 @@ +import {type ReactNode, useState} from 'react'; +import {NavSidebar} from './NavSidebar'; + +export function Layout({ + route, + children, +}: { + route: string; + children: ReactNode; +}) { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ setSidebarOpen(false)} + /> + +
+ {/* Mobile header */} +
+ + + classy-store + +
+ +
{children}
+
+
+ ); +} diff --git a/examples/rendering/src/components/NavSidebar.tsx b/examples/rendering/src/components/NavSidebar.tsx new file mode 100644 index 0000000..800d56b --- /dev/null +++ b/examples/rendering/src/components/NavSidebar.tsx @@ -0,0 +1,109 @@ +const navGroups = [ + { + label: 'Getting Started', + items: [{href: '#/', label: 'Overview', route: '/'}], + }, + { + label: 'Core', + items: [ + {href: '#/reactivity', label: 'Reactivity', route: '/reactivity'}, + {href: '#/collections', label: 'Collections', route: '/collections'}, + {href: '#/snapshots', label: 'Snapshots', route: '/snapshots'}, + ], + }, + { + label: 'React', + items: [ + { + href: '#/use-local-store', + label: 'useLocalStore', + route: '/use-local-store', + }, + ], + }, + { + label: 'Utilities', + items: [ + {href: '#/persist', label: 'Persist', route: '/persist'}, + {href: '#/history', label: 'History', route: '/history'}, + {href: '#/devtools', label: 'DevTools', route: '/devtools'}, + { + href: '#/subscribe-key', + label: 'subscribeKey', + route: '/subscribe-key', + }, + { + href: '#/shallow-equal', + label: 'shallowEqual', + route: '/shallow-equal', + }, + ], + }, +]; + +export function NavSidebar({ + route, + open, + onClose, +}: { + route: string; + open: boolean; + onClose: () => void; +}) { + return ( + <> + {/* Mobile overlay */} + {open && ( + - - + + - + ); } diff --git a/examples/rendering/src/demos/devtools/DevtoolsDemo.tsx b/examples/rendering/src/demos/devtools/DevtoolsDemo.tsx new file mode 100644 index 0000000..62a1020 --- /dev/null +++ b/examples/rendering/src/demos/devtools/DevtoolsDemo.tsx @@ -0,0 +1,158 @@ +import {createClassyStore} from '@codebelt/classy-store'; +import {useStore} from '@codebelt/classy-store/react'; +import {devtools} from '@codebelt/classy-store/utils'; +import {useEffect, useState} from 'react'; +import {Button} from '../../components/Button'; +import {DemoContainer} from '../../components/DemoContainer'; +import {RenderBadge} from '../../components/RenderBadge'; +import {useRenderCount} from '../../hooks/useRenderCount'; + +class DevtoolsCounterStore { + count = 0; + label = 'DevTools Counter'; + + increment() { + this.count++; + } + + decrement() { + this.count--; + } + + setLabel(label: string) { + this.label = label; + } + + reset() { + this.count = 0; + this.label = 'DevTools Counter'; + } +} + +const devtoolsCounterStore = createClassyStore(new DevtoolsCounterStore()); + +const STORE_CODE = `class DevtoolsCounterStore { + count = 0; + label = 'DevTools Counter'; + + increment() { this.count++; } + decrement() { this.count--; } + setLabel(label: string) { this.label = label; } +} + +const store = createClassyStore( + new DevtoolsCounterStore() +);`; + +const COMPONENT_CODE = `import { devtools } from '@codebelt/classy-store/utils'; + +// Connect to Redux DevTools +useEffect(() => { + const dispose = devtools(store, { + name: 'MyCounter', + }); + return dispose; // cleanup on unmount +}, []); + +// That's it! Open Redux DevTools to inspect +// state changes, time-travel, and dispatch.`; + +function DevtoolsCounter() { + const snap = useStore(devtoolsCounterStore); + const renders = useRenderCount(); + const [connected, setConnected] = useState(false); + + useEffect(() => { + const dispose = devtools(devtoolsCounterStore, { + name: 'DemoCounter', + }); + setConnected(true); + return () => { + dispose(); + setConnected(false); + }; + }, []); + + return ( +
+
+
+ + {connected ? 'connected' : 'disconnected'} + + Redux DevTools +
+ +
+ +
+
+
+ {snap.label} +
+
+ {snap.count} +
+
+
+ +
+ + + +
+ +
+
+ Setup Instructions +
+
    +
  1. + Install the{' '} + Redux DevTools{' '} + browser extension +
  2. +
  3. Open browser DevTools (F12)
  4. +
  5. + Go to the Redux tab +
  6. +
  7. + Click the buttons above and watch state changes appear in the + timeline +
  8. +
  9. + Use{' '} + time-travel to + jump between states +
  10. +
+
+
+ ); +} + +export function DevtoolsDemo() { + return ( + + + + ); +} diff --git a/examples/rendering/src/demos/history/UndoRedoDemo.tsx b/examples/rendering/src/demos/history/UndoRedoDemo.tsx new file mode 100644 index 0000000..cabb1cc --- /dev/null +++ b/examples/rendering/src/demos/history/UndoRedoDemo.tsx @@ -0,0 +1,182 @@ +import {useStore} from '@codebelt/classy-store/react'; +import {useState} from 'react'; +import {Button} from '../../components/Button'; +import {DemoContainer} from '../../components/DemoContainer'; +import {RenderBadge} from '../../components/RenderBadge'; +import {useRenderCount} from '../../hooks/useRenderCount'; +import {history, textEditorStore} from '../../stores/historyStore'; + +const STORE_CODE = `class TextEditorStore { + title = 'Untitled Document'; + content = 'Start typing here...'; + + setTitle(title: string) { this.title = title; } + setContent(content: string) { this.content = content; } + reset() { + this.title = 'Untitled Document'; + this.content = 'Start typing here...'; + } +} + +const store = createClassyStore(new TextEditorStore()); +const history = withHistory(store, { limit: 50 });`; + +const COMPONENT_CODE = `// Undo/redo controls +history.undo(); +history.redo(); + +// Check availability +history.canUndo; // boolean +history.canRedo; // boolean + +// Batch multiple edits into one history entry +history.pause(); +store.setTitle('New Title'); +store.setContent('New content'); +history.resume(); // single history entry`; + +function TitleEditor() { + const title = useStore(textEditorStore, (s) => s.title); + const renders = useRenderCount(); + + return ( +
+
+ + Title + + +
+ textEditorStore.setTitle(e.target.value)} + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-200 outline-none focus:border-indigo-500/50" + /> +
+ ); +} + +function ContentEditor() { + const content = useStore(textEditorStore, (s) => s.content); + const renders = useRenderCount(); + + return ( +
+
+ + Content + + +
+