From da523bda1edf504b8b3423ab6127ddd67302827d Mon Sep 17 00:00:00 2001 From: a067334 Date: Wed, 10 Jun 2026 15:36:45 -0500 Subject: [PATCH 1/2] Add synchronization options to store subscriptions for immediate notifications --- README.md | 13 ++ bun.lock | 28 +-- examples/nextjs/package.json | 2 +- .../dashboard/DashboardOverview.tsx | 8 +- .../src/components/editor/RecipeEditor.tsx | 2 +- .../src/components/recipes/RecipeCard.tsx | 2 +- .../src/components/shopping/ShoppingStats.tsx | 4 +- .../reactivity/ReactiveFundamentalsDemo.tsx | 50 ++++- .../demos/shallow-equal/ShallowEqualDemo.tsx | 4 +- packages/classy-store/src/core/core.test.ts | 63 ++++++ packages/classy-store/src/core/core.ts | 50 +++-- .../frameworks/react/react.behavior.test.tsx | 4 +- .../src/frameworks/react/react.test.tsx | 109 ++++++++++- .../src/frameworks/react/react.ts | 61 ++++-- packages/classy-store/src/index.ts | 2 +- packages/classy-store/src/types.ts | 12 +- packages/classy-store/src/utils/index.ts | 1 + .../utils/subscribe-key/subscribe-key.test.ts | 19 ++ .../src/utils/subscribe-key/subscribe-key.ts | 26 ++- website/docs/ARCHITECTURE.md | 37 ++-- website/docs/TUTORIAL.md | 39 +++- website/docs/index.md | 48 ++++- website/static/llms-full.txt | 184 ++++++++++++------ website/static/llms.txt | 8 +- 24 files changed, 618 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index 5369501..dcfe064 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,19 @@ function AddButton() { } ``` +For React controlled inputs, opt the field subscription into synchronous timing so React can preserve caret position and IME composition: + +```tsx +const name = useClassyStore(formStore, (state) => state.name, {sync: true}); + +return ( + formStore.setName(event.target.value)} + /> +); +``` + ## 📦 Installation ```bash diff --git a/bun.lock b/bun.lock index 427c749..b4a6012 100644 --- a/bun.lock +++ b/bun.lock @@ -39,7 +39,7 @@ "version": "0.1.0", "dependencies": { "@codebelt/classy-store": "workspace:*", - "next": "16.1.6", + "next": "16.2.9", "react": "19.2.3", "react-dom": "19.2.3", }, @@ -115,7 +115,7 @@ }, "packages/classy-store": { "name": "@codebelt/classy-store", - "version": "0.3.1", + "version": "0.5.0", "dependencies": { "proxy-compare": "3.0.1", }, @@ -694,23 +694,23 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], + "@next/env": ["@next/env@16.2.9", "", {}, "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg=="], - "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw=="], - "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="], + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w=="], - "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="], + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw=="], - "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="], + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA=="], - "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="], + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg=="], - "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="], + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw=="], - "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="], + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ=="], - "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w=="], "@ngtools/webpack": ["@ngtools/webpack@19.2.25", "", { "peerDependencies": { "@angular/compiler-cli": "^19.0.0 || ^19.2.0-next.0", "typescript": ">=5.5 <5.9", "webpack": "^5.54.0" } }, "sha512-krPpAcVTaArR4lfvkMNjUGaTeeatC9zJPL5wJu2fG8R3UmBcEyI/Hoi8rjMlrKgzGut7MfoO3IFyeUEt3+gG0A=="], @@ -1770,7 +1770,7 @@ "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], - "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], + "next": ["next@16.2.9", "", { "dependencies": { "@next/env": "16.2.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.9", "@next/swc-darwin-x64": "16.2.9", "@next/swc-linux-arm64-gnu": "16.2.9", "@next/swc-linux-arm64-musl": "16.2.9", "@next/swc-linux-x64-gnu": "16.2.9", "@next/swc-linux-x64-musl": "16.2.9", "@next/swc-win32-arm64-msvc": "16.2.9", "@next/swc-win32-x64-msvc": "16.2.9", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww=="], "node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="], @@ -2504,7 +2504,7 @@ "classy-store-nextjs-example/react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], - "classy-store-react-example/@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "classy-store-react-example/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "classy-store-react-example/tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], @@ -2752,7 +2752,7 @@ "cacache/tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], - "classy-store-react-example/@types/bun/bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "classy-store-react-example/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "cli-truncate/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json index 3eb7755..9599791 100644 --- a/examples/nextjs/package.json +++ b/examples/nextjs/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@codebelt/classy-store": "workspace:*", - "next": "16.1.6", + "next": "16.2.9", "react": "19.2.3", "react-dom": "19.2.3" }, diff --git a/examples/nextjs/src/components/dashboard/DashboardOverview.tsx b/examples/nextjs/src/components/dashboard/DashboardOverview.tsx index d624bdf..0e75363 100644 --- a/examples/nextjs/src/components/dashboard/DashboardOverview.tsx +++ b/examples/nextjs/src/components/dashboard/DashboardOverview.tsx @@ -68,7 +68,7 @@ export function DashboardOverview() { count: state.recipeCount, avgTime: state.averageTotalTime, }), - shallowEqual, + {isEqual: shallowEqual}, ); const shoppingStats = useClassyStore( @@ -77,7 +77,7 @@ export function DashboardOverview() { total: state.totalItems, unchecked: state.uncheckedCount, }), - shallowEqual, + {isEqual: shallowEqual}, ); const plannerStats = useClassyStore( @@ -86,7 +86,7 @@ export function DashboardOverview() { meals: state.totalMealsPlanned, pct: state.completionPercentage, }), - shallowEqual, + {isEqual: shallowEqual}, ); const cards = [ @@ -127,7 +127,7 @@ export function DashboardOverview() { code={`const stats = useClassyStore(recipeStore, (state) => ({ count: state.recipeCount, avgTime: state.averageTotalTime, -}), shallowEqual);`} + }), {isEqual: shallowEqual});`} /> diff --git a/examples/nextjs/src/components/editor/RecipeEditor.tsx b/examples/nextjs/src/components/editor/RecipeEditor.tsx index 3664138..0ebad77 100644 --- a/examples/nextjs/src/components/editor/RecipeEditor.tsx +++ b/examples/nextjs/src/components/editor/RecipeEditor.tsx @@ -17,7 +17,7 @@ export function RecipeEditor() { if (!historyRef.current) { historyRef.current = withHistory(store, {limit: 30}); } - const snap = useClassyStore(store); + const snap = useClassyStore(store, {sync: true}); return (
diff --git a/examples/nextjs/src/components/recipes/RecipeCard.tsx b/examples/nextjs/src/components/recipes/RecipeCard.tsx index 8d1d708..d6d4a2a 100644 --- a/examples/nextjs/src/components/recipes/RecipeCard.tsx +++ b/examples/nextjs/src/components/recipes/RecipeCard.tsx @@ -27,7 +27,7 @@ export function RecipeCard({recipeId}: {recipeId: string}) { ingredientCount: r.ingredients.length, }; }, - shallowEqual, + {isEqual: shallowEqual}, ); if (!recipe) return null; diff --git a/examples/nextjs/src/components/shopping/ShoppingStats.tsx b/examples/nextjs/src/components/shopping/ShoppingStats.tsx index 77d4986..98149fc 100644 --- a/examples/nextjs/src/components/shopping/ShoppingStats.tsx +++ b/examples/nextjs/src/components/shopping/ShoppingStats.tsx @@ -14,7 +14,7 @@ export function ShoppingStats() { checked: state.checkedCount, lastAction: state.lastAction, }), - shallowEqual, + {isEqual: shallowEqual}, ); return ( @@ -27,7 +27,7 @@ export function ShoppingStats() { unchecked: state.uncheckedCount, checked: state.checkedCount, lastAction: state.lastAction, -}), shallowEqual);`} +}), {isEqual: shallowEqual});`} />
diff --git a/examples/react/src/demos/reactivity/ReactiveFundamentalsDemo.tsx b/examples/react/src/demos/reactivity/ReactiveFundamentalsDemo.tsx index 5736046..a755b8d 100644 --- a/examples/react/src/demos/reactivity/ReactiveFundamentalsDemo.tsx +++ b/examples/react/src/demos/reactivity/ReactiveFundamentalsDemo.tsx @@ -9,6 +9,20 @@ import {CounterStore, counterStore} from '../../stores/counterStore'; const batchStore = createClassyStore(new CounterStore()); +class DemoFormStore { + name = 'Alice'; + + setName(value: string) { + this.name = value; + } + + reset() { + this.name = 'Alice'; + } +} + +const formStore = createClassyStore(new DemoFormStore()); + const names = ['World', 'Bun', 'React', 'Store']; let nameIndex = 0; @@ -44,6 +58,17 @@ function BatchCard() { // 1000 mutations → 1 render (microtask batching) const count = useClassyStore(batchStore, (state) => state.count); return
{count}
; +} + +function NameInput() { + const name = useClassyStore(formStore, (state) => state.name, { sync: true }); + + return ( + formStore.setName(event.target.value)} + /> + ); }`; function CountCard() { @@ -100,13 +125,34 @@ function BatchCard() { ); } +function NameInputCard() { + const name = useClassyStore(formStore, (state) => state.name, {sync: true}); + const renders = useRenderCount(); + + return ( +
+ + +
+ ); +} + export function ReactiveFundamentalsDemo() { const [lastBatch, setLastBatch] = useState(false); return ( +
@@ -144,6 +191,7 @@ export function ReactiveFundamentalsDemo() { onClick={() => { counterStore.reset(); batchStore.reset(); + formStore.reset(); setLastBatch(false); }} > diff --git a/examples/react/src/demos/shallow-equal/ShallowEqualDemo.tsx b/examples/react/src/demos/shallow-equal/ShallowEqualDemo.tsx index c99f702..06d5333 100644 --- a/examples/react/src/demos/shallow-equal/ShallowEqualDemo.tsx +++ b/examples/react/src/demos/shallow-equal/ShallowEqualDemo.tsx @@ -44,7 +44,7 @@ const name = useClassyStore(profileStore, (state) => ({ const name = useClassyStore( profileStore, (state) => ({ first: state.firstName, last: state.lastName }), - shallowEqual + { isEqual: shallowEqual } );`; const firstNames = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve']; @@ -82,7 +82,7 @@ function NameCardWith() { const name = useClassyStore( profileStore, (state) => ({first: state.firstName, last: state.lastName}), - shallowEqual, + {isEqual: shallowEqual}, ); const renders = useRenderCount(); diff --git a/packages/classy-store/src/core/core.test.ts b/packages/classy-store/src/core/core.test.ts index 29111d0..1c8f1ee 100644 --- a/packages/classy-store/src/core/core.test.ts +++ b/packages/classy-store/src/core/core.test.ts @@ -277,6 +277,51 @@ describe('createClassyStore() — core reactivity', () => { expect(listener2).toHaveBeenCalledTimes(1); }); + it('sync listeners fire immediately without waiting for the batch flush', async () => { + const s = createClassyStore({count: 0}); + const listener = mock(() => {}); + subscribe(s, listener, {sync: true}); + + s.count = 1; + + expect(listener).toHaveBeenCalledTimes(1); + await flush(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('sync listeners fire per mutation while batched listeners stay deduped', async () => { + const s = createClassyStore({a: 0, b: 0}); + const syncListener = mock(() => {}); + const batchedListener = mock(() => {}); + subscribe(s, syncListener, {sync: true}); + subscribe(s, batchedListener); + + s.a = 1; + s.b = 2; + + expect(syncListener).toHaveBeenCalledTimes(2); + expect(batchedListener).toHaveBeenCalledTimes(0); + + await flush(); + + expect(syncListener).toHaveBeenCalledTimes(2); + expect(batchedListener).toHaveBeenCalledTimes(1); + }); + + it('unsubscribe stops sync notifications', async () => { + const s = createClassyStore({count: 0}); + const listener = mock(() => {}); + const unsub = subscribe(s, listener, {sync: true}); + + s.count = 1; + expect(listener).toHaveBeenCalledTimes(1); + + unsub(); + s.count = 2; + await flush(); + expect(listener).toHaveBeenCalledTimes(1); + }); + it('subscribe on a child proxy fires when the child mutates', async () => { const s = createClassyStore({user: {name: 'Alice'}}); const listener = mock(() => {}); @@ -746,6 +791,24 @@ describe('createClassyStore() — core reactivity', () => { expect(secondListener).toHaveBeenCalledTimes(1); }); + + it('calls remaining sync listeners even if an earlier one throws', () => { + const s = createClassyStore({count: 0}); + const secondListener = mock(() => {}); + + subscribe( + s, + () => { + throw new Error('boom'); + }, + {sync: true}, + ); + subscribe(s, secondListener, {sync: true}); + + s.count = 1; + + expect(secondListener).toHaveBeenCalledTimes(1); + }); }); describe('bound method cache invalidation', () => { diff --git a/packages/classy-store/src/core/core.ts b/packages/classy-store/src/core/core.ts index 3b435da..c570440 100644 --- a/packages/classy-store/src/core/core.ts +++ b/packages/classy-store/src/core/core.ts @@ -1,4 +1,4 @@ -import type {DepEntry, StoreInternal} from '../types'; +import type {DepEntry, StoreInternal, SubscribeOptions} from '../types'; import {canProxy, findGetterDescriptor} from '../utils/internal/internal'; // ── Global state ────────────────────────────────────────────────────────────── @@ -164,16 +164,16 @@ export function getInternal(proxy: object): StoreInternal { return internal; } -// ── Notification batching ───────────────────────────────────────────────────── +// ── Mutation notification ───────────────────────────────────────────────────── /** * Bump version from the mutated node up to the root, invalidate snapshot caches, - * and schedule a single microtask notification (deduped at the root level). + * notify sync subscribers, and schedule a single batched microtask notification. * * Version propagation is what enables structural sharing in snapshots: unchanged * children keep their old version, so the snapshot cache returns the same frozen ref. */ -function scheduleNotify(internal: StoreInternal): void { +function notifyMutation(internal: StoreInternal): void { let current: StoreInternal | null = internal; while (current) { current.version = ++globalVersion; @@ -181,11 +181,21 @@ function scheduleNotify(internal: StoreInternal): void { } const root = getRoot(internal); + for (const listener of Array.from(root.syncListeners)) { + try { + listener(); + } catch (e) { + console.error(e); + } + } + + if (root.batchedListeners.size === 0) return; + if (!root.notifyScheduled) { root.notifyScheduled = true; queueMicrotask(() => { root.notifyScheduled = false; - for (const listener of root.listeners) { + for (const listener of Array.from(root.batchedListeners)) { try { listener(); } catch (e) { @@ -212,10 +222,10 @@ function getRoot(internal: StoreInternal): StoreInternal { * * This is the core of the library's reactivity. The proxy intercepts: * - **SET**: compares old/new with `Object.is`, cleans up child proxies on replacement, - * forwards the write, and schedules a batched notification. + * forwards the write, and notifies subscribers. * - **GET**: detects class getters (memoized), binds methods to the proxy, lazily wraps * nested objects/arrays in child proxies, and records dependencies for computed getters. - * - **DELETE**: cleans up child proxies and schedules notification. + * - **DELETE**: cleans up child proxies and notifies subscribers. * * Nested objects are recursively wrapped on first access (lazy deep proxy). */ @@ -226,7 +236,8 @@ function createStoreProxy( const internal: StoreInternal = { target, version: ++globalVersion, - listeners: new Set(), + batchedListeners: new Set(), + syncListeners: new Set(), childProxies: new Map(), childInternals: new Map(), parent, @@ -255,7 +266,7 @@ function createStoreProxy( boundMethods.delete(prop); Reflect.set(_target, prop, value); - scheduleNotify(internal); + notifyMutation(internal); return true; }, @@ -318,7 +329,7 @@ function createStoreProxy( boundMethods.delete(prop); const deleted = Reflect.deleteProperty(_target, prop); if (deleted) { - scheduleNotify(internal); + notifyMutation(internal); } return deleted; }, @@ -352,21 +363,28 @@ export function createClassyStore(instance: T): T { } /** - * Subscribe to store changes. The callback fires once per batched mutation - * (coalesced via `queueMicrotask`), not once per individual property write. + * Subscribe to store changes. By default, the callback fires once per batched + * mutation turn (coalesced via `queueMicrotask`). Pass `{sync: true}` to notify + * immediately after each mutation. * * @param proxy - A reactive proxy created by `createClassyStore()`. - * @param callback - Invoked after each batched mutation. + * @param callback - Invoked when the store changes. + * @param options - Controls subscriber notification timing. * @returns An unsubscribe function. Call it to stop receiving notifications. */ -export function subscribe(proxy: object, callback: () => void): () => void { +export function subscribe( + proxy: object, + callback: () => void, + options?: SubscribeOptions, +): () => void { const internal = getInternal(proxy); // Always subscribe on the root so notifications fire regardless of // whether the user subscribes to the root proxy or a child proxy. const root = getRoot(internal); - root.listeners.add(callback); + const listeners = options?.sync ? root.syncListeners : root.batchedListeners; + listeners.add(callback); return () => { - root.listeners.delete(callback); + listeners.delete(callback); }; } diff --git a/packages/classy-store/src/frameworks/react/react.behavior.test.tsx b/packages/classy-store/src/frameworks/react/react.behavior.test.tsx index e59ea72..4bc5698 100644 --- a/packages/classy-store/src/frameworks/react/react.behavior.test.tsx +++ b/packages/classy-store/src/frameworks/react/react.behavior.test.tsx @@ -690,7 +690,7 @@ describe('shallowEqual as useClassyStore isEqual — integration', () => { const data = useClassyStore( s, (state) => ({count: state.count, name: state.name}), - shallowEqual, + {isEqual: shallowEqual}, ); renderCount(); return ( @@ -721,7 +721,7 @@ describe('shallowEqual as useClassyStore isEqual — integration', () => { const data = useClassyStore( s, (state) => ({count: state.count, name: state.name}), - shallowEqual, + {isEqual: shallowEqual}, ); renderCount(); return ( diff --git a/packages/classy-store/src/frameworks/react/react.test.tsx b/packages/classy-store/src/frameworks/react/react.test.tsx index e64d59b..68d7bc0 100644 --- a/packages/classy-store/src/frameworks/react/react.test.tsx +++ b/packages/classy-store/src/frameworks/react/react.test.tsx @@ -79,6 +79,37 @@ describe('useClassyStore — selector mode', () => { expect(renderCount).toHaveBeenCalledTimes(2); }); + it('supports sync selector subscriptions', () => { + class Counter { + count = 0; + increment() { + this.count++; + } + } + const store = createClassyStore(new Counter()); + const renderCount = mock(() => {}); + + function Display() { + const count = useClassyStore(store, (state) => state.count, { + sync: true, + }); + renderCount(); + return
{count}
; + } + + setup(); + render(); + expect(container.textContent).toBe('0'); + expect(renderCount).toHaveBeenCalledTimes(1); + + act(() => { + store.increment(); + }); + + expect(container.textContent).toBe('1'); + expect(renderCount).toHaveBeenCalledTimes(2); + }); + it('does NOT re-render when unrelated prop changes', async () => { const s = createClassyStore({count: 0, name: 'hello'}); const renderCount = mock(() => {}); @@ -197,7 +228,7 @@ describe('useClassyStore — selector mode', () => { const firstItem = useClassyStore( s, (state) => ({name: state.items[0]?.name}), - (a, b) => a.name === b.name, + {isEqual: (previous, next) => previous.name === next.name}, ); renderCount(); return
{firstItem.name}
; @@ -260,6 +291,29 @@ describe('useClassyStore — auto-tracked mode', () => { expect(renderCount).toHaveBeenCalledTimes(2); }); + it('supports sync auto-tracked subscriptions', () => { + const store = createClassyStore({count: 0, name: 'hello'}); + const renderCount = mock(() => {}); + + function Display() { + const snap = useClassyStore(store, {sync: true}); + renderCount(); + return
{snap.count}
; + } + + setup(); + render(); + expect(container.textContent).toBe('0'); + expect(renderCount).toHaveBeenCalledTimes(1); + + act(() => { + store.count = 5; + }); + + expect(container.textContent).toBe('5'); + expect(renderCount).toHaveBeenCalledTimes(2); + }); + it('does NOT re-render when non-accessed property changes', async () => { const s = createClassyStore({count: 0, name: 'hello'}); const renderCount = mock(() => {}); @@ -795,6 +849,32 @@ describe('createStoreHook', () => { expect(container.textContent).toBe('1'); }); + it('supports sync selector options', () => { + class Counter { + count = 0; + increment() { + this.count++; + } + } + const store = createClassyStore(new Counter()); + const useCounter = createStoreHook(store); + + function Display() { + const count = useCounter((state) => state.count, {sync: true}); + return
{count}
; + } + + setup(); + render(); + expect(container.textContent).toBe('0'); + + act(() => { + store.increment(); + }); + + expect(container.textContent).toBe('1'); + }); + it('auto-tracked (selectorless) mode', async () => { const store = createClassyStore({count: 0, name: 'hello'}); const useStore = createStoreHook(store); @@ -820,16 +900,35 @@ describe('createStoreHook', () => { expect(renderCount).toHaveBeenCalledTimes(2); }); + it('supports sync auto-tracked options', () => { + const store = createClassyStore({count: 0, name: 'hello'}); + const useStore = createStoreHook(store); + + function Display() { + const snap = useStore({sync: true}); + return
{snap.count}
; + } + + setup(); + render(); + expect(container.textContent).toBe('0'); + + act(() => { + store.count = 5; + }); + + expect(container.textContent).toBe('5'); + }); + it('supports custom isEqual', async () => { const store = createClassyStore({items: [{id: 1, name: 'a'}]}); const useStore = createStoreHook(store); const renderCount = mock(() => {}); function List() { - const first = useStore( - (s) => ({name: s.items[0]?.name}), - (a, b) => a.name === b.name, - ); + const first = useStore((s) => ({name: s.items[0]?.name}), { + isEqual: (previous, next) => previous.name === next.name, + }); renderCount(); return
{first.name}
; } diff --git a/packages/classy-store/src/frameworks/react/react.ts b/packages/classy-store/src/frameworks/react/react.ts index 2339361..cf8d46e 100644 --- a/packages/classy-store/src/frameworks/react/react.ts +++ b/packages/classy-store/src/frameworks/react/react.ts @@ -8,22 +8,32 @@ import { import {snapshot} from '../../snapshot/snapshot'; import type {Snapshot} from '../../types'; +type EqualityFn = (a: T, b: T) => boolean; + +export type UseClassyStoreOptions = { + sync?: boolean; +}; + +export type UseClassyStoreSelectorOptions = UseClassyStoreOptions & { + isEqual?: EqualityFn; +}; + // ── Overloads ───────────────────────────────────────────────────────────────── /** * Subscribe to a store proxy with an explicit selector. * * Re-renders only when the selected value changes (compared via `Object.is` - * by default, or a custom `isEqual`). + * by default, or `options.isEqual`). * * @param proxyStore - A reactive proxy created by `createClassyStore()`. * @param selector - Picks data from the immutable snapshot. - * @param isEqual - Optional custom equality function (default: `Object.is`). + * @param options - Controls equality and subscriber notification timing. */ export function useClassyStore( proxyStore: T, selector: (snap: Snapshot) => S, - isEqual?: (a: S, b: S) => boolean, + options?: UseClassyStoreSelectorOptions, ): S; /** @@ -33,23 +43,40 @@ export function useClassyStore( * The component only re-renders when a property it actually read changes. * * @param proxyStore - A reactive proxy created by `createClassyStore()`. + * @param options - Controls subscriber notification timing. */ -export function useClassyStore(proxyStore: T): Snapshot; +export function useClassyStore( + proxyStore: T, + options?: UseClassyStoreOptions, +): Snapshot; // ── Implementation ──────────────────────────────────────────────────────────── export function useClassyStore( proxyStore: T, - selector?: (snap: Snapshot) => S, - isEqual?: (a: S, b: S) => boolean, + selectorOrOptions?: ((snap: Snapshot) => S) | UseClassyStoreOptions, + selectorOptions?: UseClassyStoreSelectorOptions, ): Snapshot | S { // Validate that the argument is actually a store proxy (throws if not). getInternal(proxyStore); + const selector = + typeof selectorOrOptions === 'function' ? selectorOrOptions : undefined; + const options: + | UseClassyStoreOptions + | UseClassyStoreSelectorOptions + | undefined = + typeof selectorOrOptions === 'function' + ? selectorOptions + : selectorOrOptions; + const sync = options?.sync === true; + const isEqual = selectorOptions?.isEqual; + // Stable subscribe function (internal identity never changes for a given store). const subscribe = useCallback( - (onStoreChange: () => void) => coreSubscribe(proxyStore, onStoreChange), - [proxyStore], + (onStoreChange: () => void) => + coreSubscribe(proxyStore, onStoreChange, sync ? {sync: true} : undefined), + [proxyStore, sync], ); // ── Refs used by both modes (always allocated to satisfy Rules of Hooks) ── @@ -97,7 +124,7 @@ export function useClassyStore( * * Fast-paths when the snapshot reference hasn't changed (O(1)). Otherwise * runs the selector against the new snapshot and compares the result to the - * previous one via `Object.is` (or a custom `isEqual`). Returns the previous + * previous one via `Object.is` (or `options.isEqual`). Returns the previous * result reference when equal, preventing unnecessary React re-renders. * * Pure function -- no hooks, safe to call from `useSyncExternalStore`. @@ -108,7 +135,7 @@ function getSelectorSnapshot( resultRef: React.RefObject, hasResultRef: React.RefObject, selector: (snap: Snapshot) => S, - isEqual?: (a: S, b: S) => boolean, + isEqual?: EqualityFn, ): S { const nextSnap = snapshot(proxyStore); @@ -207,19 +234,19 @@ export function createStoreHook(proxyStore: T) { // Fail fast at creation time rather than on first render. getInternal(proxyStore); - function useStore(): Snapshot; + function useStore(options?: UseClassyStoreOptions): Snapshot; function useStore( selector: (snap: Snapshot) => S, - isEqual?: (a: S, b: S) => boolean, + options?: UseClassyStoreSelectorOptions, ): S; function useStore( - selector?: (snap: Snapshot) => S, - isEqual?: (a: S, b: S) => boolean, - ) { + selectorOrOptions?: ((snap: Snapshot) => S) | UseClassyStoreOptions, + options?: UseClassyStoreSelectorOptions, + ): Snapshot | S { return useClassyStore( proxyStore, - selector as (snap: Snapshot) => S, - isEqual, + selectorOrOptions as (snap: Snapshot) => S, + options, ); } return useStore; diff --git a/packages/classy-store/src/index.ts b/packages/classy-store/src/index.ts index 9b86642..18b9fe0 100644 --- a/packages/classy-store/src/index.ts +++ b/packages/classy-store/src/index.ts @@ -7,5 +7,5 @@ */ export {createClassyStore, getVersion, subscribe} from './core/core'; export {snapshot} from './snapshot/snapshot'; -export type {Snapshot} from './types'; +export type {Snapshot, SubscribeOptions} from './types'; export {shallowEqual} from './utils/equality/equality'; diff --git a/packages/classy-store/src/types.ts b/packages/classy-store/src/types.ts index 3dc944c..02ebe5f 100644 --- a/packages/classy-store/src/types.ts +++ b/packages/classy-store/src/types.ts @@ -73,6 +73,12 @@ export type ComputedEntry = { deps: DepEntry[]; }; +/** Options that control how store subscribers are notified. */ +export type SubscribeOptions = { + /** Notify this subscriber immediately instead of waiting for the batched microtask. */ + sync?: boolean; +}; + // ── Store internal bookkeeping ─────────────────────────────────────────────── /** Internal bookkeeping for a store proxy, stored in a WeakMap keyed by the proxy. */ @@ -81,8 +87,10 @@ export type StoreInternal = { target: object; /** Monotonically increasing version counter. */ version: number; - /** Set of subscriber callbacks notified on mutation. */ - listeners: Set<() => void>; + /** Subscriber callbacks notified once per batched mutation turn. */ + batchedListeners: Set<() => void>; + /** Subscriber callbacks notified immediately on each mutation. */ + syncListeners: Set<() => void>; /** Cached child proxies for nested plain objects/arrays (keyed by property name). */ childProxies: Map; /** Cached child internals for propagating version bumps up the tree. */ diff --git a/packages/classy-store/src/utils/index.ts b/packages/classy-store/src/utils/index.ts index 4eaa362..3a9cd0a 100644 --- a/packages/classy-store/src/utils/index.ts +++ b/packages/classy-store/src/utils/index.ts @@ -6,6 +6,7 @@ * @module @codebelt/classy-store/utils */ +export type {SubscribeOptions} from '../types'; export type {DevtoolsOptions} from './devtools/devtools'; export {devtools} from './devtools/devtools'; export type {HistoryHandle, HistoryOptions} from './history/history'; diff --git a/packages/classy-store/src/utils/subscribe-key/subscribe-key.test.ts b/packages/classy-store/src/utils/subscribe-key/subscribe-key.test.ts index 9243587..e8eec3a 100644 --- a/packages/classy-store/src/utils/subscribe-key/subscribe-key.test.ts +++ b/packages/classy-store/src/utils/subscribe-key/subscribe-key.test.ts @@ -217,6 +217,25 @@ describe('subscribeKey', () => { expect(cb2).toHaveBeenCalledTimes(1); }); + it('supports sync notifications', async () => { + class Store { + count = 0; + } + + const store = createClassyStore(new Store()); + const cb = mock((_value: number, _prev: number) => {}); + + subscribeKey(store, 'count', cb, {sync: true}); + + store.count = 1; + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith(1, 0); + + await tick(); + expect(cb).toHaveBeenCalledTimes(1); + }); + it('unsubscribing one subscriber does not affect others', async () => { class Store { count = 0; diff --git a/packages/classy-store/src/utils/subscribe-key/subscribe-key.ts b/packages/classy-store/src/utils/subscribe-key/subscribe-key.ts index 20dd488..06dfb9e 100644 --- a/packages/classy-store/src/utils/subscribe-key/subscribe-key.ts +++ b/packages/classy-store/src/utils/subscribe-key/subscribe-key.ts @@ -1,6 +1,6 @@ import {subscribe} from '../../core/core'; import {snapshot} from '../../snapshot/snapshot'; -import type {Snapshot} from '../../types'; +import type {Snapshot, SubscribeOptions} from '../../types'; /** * Subscribe to changes on a single property of a store proxy. @@ -12,6 +12,7 @@ import type {Snapshot} from '../../types'; * @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. + * @param options - Controls subscriber notification timing. * @returns An unsubscribe function. */ export function subscribeKey< @@ -21,17 +22,22 @@ export function subscribeKey< proxyStore: T, key: K, callback: (value: Snapshot[K], previousValue: Snapshot[K]) => void, + options?: SubscribeOptions, ): () => void { let previousValue = snapshot(proxyStore)[key]; - return subscribe(proxyStore, () => { - const snap = snapshot(proxyStore); - const currentValue = snap[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); - } - }); + if (!Object.is(currentValue, previousValue)) { + const prev = previousValue; + previousValue = currentValue; + callback(currentValue, prev); + } + }, + options, + ); } diff --git a/website/docs/ARCHITECTURE.md b/website/docs/ARCHITECTURE.md index bfb3893..0176b1e 100644 --- a/website/docs/ARCHITECTURE.md +++ b/website/docs/ARCHITECTURE.md @@ -14,10 +14,11 @@ flowchart TB subgraph layer1 ["Layer 1: Write Proxy (core.ts)"] storeFn["createClassyStore(instance)"] - SetTrap["SET trap: forward write → bump version → schedule notify"] + SetTrap["SET trap: forward write → bump version → notify subscribers"] GetTrap["GET trap: return value, bind methods, lazy-wrap nested objects, memoize getters"] DeleteTrap["DELETE trap: same as SET"] Batch["queueMicrotask: coalesce synchronous mutations → 1 notification"] + SyncNotify["sync listeners: notify immediately per mutation"] DeepProxy["Lazy deep proxy: nested objects/arrays wrapped on first access"] end @@ -46,6 +47,7 @@ flowchart TB storeFn --> SetTrap storeFn --> GetTrap storeFn --> DeleteTrap + SetTrap --> SyncNotify SetTrap --> Batch Batch --> SnapshotFn GetTrap --> DeepProxy @@ -118,7 +120,7 @@ PERSIST_ARCHITECTURE.md # Persist utility internals ### Overview -The `createClassyStore()` function wraps a class instance in an ES6 Proxy. All mutations — property writes, array operations, nested object changes — are intercepted and batched into a single notification per microtask. +The `createClassyStore()` function wraps a class instance in an ES6 Proxy. All mutations — property writes, array operations, nested object changes — are intercepted. Subscribers are batched by default into a single notification per microtask, with an opt-in synchronous path for controlled React inputs and other code that must observe each mutation immediately. ### Data Flow: Mutation → Notification @@ -128,20 +130,23 @@ sequenceDiagram participant Proxy as Write Proxy participant Target as Raw Target participant Internal as StoreInternal + participant Sync as Sync Subscribers participant Micro as queueMicrotask - participant Listeners as Subscribers + participant Batched as Batched Subscribers User->>Proxy: store.count = 5 Proxy->>Target: Reflect.set(target, 'count', 5) Proxy->>Internal: bumpVersion(internal) up to root Proxy->>Internal: snapshotCache = null (invalidate) - Proxy->>Micro: scheduleNotify() (if not already scheduled) + Proxy->>Sync: notify sync listeners immediately + Proxy->>Micro: schedule batched notify (if not already scheduled) Note over Micro: Coalesces all sync mutations User->>Proxy: store.name = 'new' Proxy->>Target: Reflect.set(target, 'name', 'new') Proxy->>Internal: bumpVersion (version increments again) + Proxy->>Sync: notify sync listeners again Note over Micro: Still same microtask batch - Micro->>Listeners: notify all (once) + Micro->>Batched: notify batched listeners once ``` ### Internal State Storage @@ -152,12 +157,12 @@ All internal bookkeeping is stored in a `WeakMap`: type StoreInternal = { target: object; // Raw class instance version: number; // Monotonically increasing - listeners: Set<() => void>; // Subscriber callbacks + batchedListeners: Set<() => void>; // Subscribers notified once per microtask + syncListeners: Set<() => void>; // Subscribers notified immediately per mutation childProxies: Map; // Cached child proxies childInternals: Map; parent: StoreInternal | null; // For version propagation notifyScheduled: boolean; // Batch dedup flag - snapshotCache: [number, object] | null; // Version-stamped snapshot cache computedCache: Map; // Memoized getter cache }; ``` @@ -169,7 +174,7 @@ type StoreInternal = { 2. Clean up child proxy if the property is being replaced 3. Forward write to raw target via `Reflect.set` 4. Bump version for this node and all ancestors -5. Schedule notification via `queueMicrotask` (deduped) +5. Notify sync listeners immediately, then schedule batched listeners via `queueMicrotask` (deduped) **GET trap (priority order):** 1. **Memoized getter detection** — walk prototype chain with `Object.getOwnPropertyDescriptor`. If a getter is found, call `evaluateComputed()` which checks dependency validity and returns the cached result or re-evaluates with dependency tracking. @@ -177,7 +182,7 @@ type StoreInternal = { 3. **Nested objects/arrays** — if value passes `canProxy()` (plain object or array), lazily wrap in a child proxy. Child proxies are cached in `childProxies` Map. Also records a dependency if a getter is currently being tracked. 4. **Primitives** — return as-is. Also records a dependency if a getter is currently being tracked. -**DELETE trap:** Same pattern as SET — clean up child proxy, delete from target, bump version, schedule notify. +**DELETE trap:** Same pattern as SET — clean up child proxy, delete from target, bump version, notify sync listeners, and schedule batched listeners. ### Batching via `queueMicrotask` @@ -190,6 +195,8 @@ store.items.push('a') → microtask fires → notifies listeners ONCE ``` +Subscribers opt into immediate timing with `{sync: true}`. Sync subscribers run after version propagation and before the batched microtask. They are intentionally not deduped: two writes mean two sync notifications. React controlled inputs use this timing so `useSyncExternalStore` can observe the new external-store value during the input event turn, preserving caret position and IME composition. + ### Version Propagation When a nested property mutates, versions bump from the mutated node up to the root: @@ -336,7 +343,7 @@ flowchart TD ### Overview -`useClassyStore` uses `useSyncExternalStore` for tear-free React integration. It supports two modes: +`useClassyStore` uses `useSyncExternalStore` for tear-free React integration. Subscriptions are batched by default, and can opt into synchronous notification with `{sync: true}` for controlled inputs that need React to observe the store update during the input event turn. It supports two modes: ### Mode 1: Selector @@ -367,9 +374,17 @@ sequenceDiagram **Equality chain:** 1. Same snapshot reference? → skip selector entirely (O(1)) -2. Run selector → compare result with `Object.is` (or custom `isEqual`) +2. Run selector → compare result with `Object.is` (or `options.isEqual`) 3. Same result → return previous reference (no re-render) +**Controlled input timing:** + +```typescript +const name = useClassyStore(formStore, (state) => state.name, {sync: true}); +``` + +The core store still batches subscribers by default with `queueMicrotask`. A controlled input subscription can opt into sync timing so the `useSyncExternalStore` callback runs in the same mutation turn as the input event, preserving caret position and IME composition. + ### Mode 2: Auto-tracked (selectorless) ```typescript diff --git a/website/docs/TUTORIAL.md b/website/docs/TUTORIAL.md index 4f065a0..2740d70 100644 --- a/website/docs/TUTORIAL.md +++ b/website/docs/TUTORIAL.md @@ -138,7 +138,7 @@ import {useClassyStore} from '@codebelt/classy-store/react'; const active = useClassyStore( todoStore, (state) => state.items.filter((item) => !item.done), - shallowEqual, + {isEqual: shallowEqual}, ); ``` @@ -162,6 +162,41 @@ const remaining = useClassyStore(todoStore, (state) => state.remaining); - **Use auto-tracked mode** when you'd need 3+ selectors in one component or when you're exploring the API. - **Add `shallowEqual`** when your selector returns objects/arrays and you're seeing unnecessary re-renders. +### Controlled inputs with `sync` + +Classy Store batches subscriber notifications by default. That is the right default for most UI because multiple synchronous mutations collapse into one settled update. + +Controlled React inputs are the important exception. React needs the latest external-store value during the input event turn so it can preserve the DOM value, caret position, and IME composition state. If the store waits until the next microtask to notify React, typing in the middle of a controlled input can move the caret or interrupt composition for languages such as Korean, Japanese, and Chinese. + +Use `{sync: true}` for the specific store reads that control form fields: + +```tsx +class ProfileFormStore { + name = ''; + + setName(value: string) { + this.name = value; + } +} + +const profileFormStore = createClassyStore(new ProfileFormStore()); + +function NameInput() { + const name = useClassyStore(profileFormStore, (state) => state.name, { + sync: true, + }); + + return ( + profileFormStore.setName(event.target.value)} + /> + ); +} +``` + +Use `sync` narrowly for controlled ``, `