- Selectors, microtask batching, and async data fetching β watch the
+ Selectors, React render batching, and async data fetching β watch the
render badges to see exactly which components re-render.
+ Edits stay in their scope. Refreshing the parent preserves drafts; reset
+ starts a new session. Unmounting and mounting the right editor creates a
+ fresh draft.
+
+
+
+
+
+ This nested editor has its own provider. Resetting the left scope
+ also remounts its nested scope.
+
+
+
+
+ {showRight && (
+
+ )}
+
+
+ );
+}
diff --git a/examples/react/src/demos/scoped-store/store/components/editorStoreProvider/EditorStoreProvider.tsx b/examples/react/src/demos/scoped-store/store/components/editorStoreProvider/EditorStoreProvider.tsx
new file mode 100644
index 0000000..0a1b457
--- /dev/null
+++ b/examples/react/src/demos/scoped-store/store/components/editorStoreProvider/EditorStoreProvider.tsx
@@ -0,0 +1,45 @@
+import {
+ prepareStoreForHydration,
+ useLocalStore,
+} from '@codebelt/classy-store/react';
+import {persist, type StorageAdapter} from '@codebelt/classy-store/utils';
+import {type ReactNode, useEffect} from 'react';
+import {type EditorData, EditorStore} from '../../editorStore';
+import {EditorStoreContext} from '../../editorStoreContext';
+
+type Props = {
+ initialData: EditorData;
+ children: ReactNode;
+ persistence?: {name: string; storage: StorageAdapter};
+};
+
+/** Own one store per mount. Change the provider's key to start a new session. */
+export function EditorStoreProvider({
+ initialData,
+ children,
+ persistence,
+}: Props) {
+ const store = prepareStoreForHydration(
+ useLocalStore(() => new EditorStore(initialData)),
+ );
+ const name = persistence?.name;
+ const storage = persistence?.storage;
+
+ useEffect(() => {
+ if (name === undefined || storage === undefined) return;
+ const handle = persist(store, {
+ name,
+ storage,
+ properties: ['title', 'body'],
+ skipHydration: true,
+ });
+ void handle.rehydrate();
+ return () => handle.unsubscribe();
+ }, [store, name, storage]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/examples/react/src/demos/scoped-store/store/editorStore.ts b/examples/react/src/demos/scoped-store/store/editorStore.ts
new file mode 100644
index 0000000..0866529
--- /dev/null
+++ b/examples/react/src/demos/scoped-store/store/editorStore.ts
@@ -0,0 +1,22 @@
+export type EditorData = {
+ title: string;
+ body: string;
+};
+
+export class EditorStore {
+ title: string;
+ body: string;
+
+ constructor(initialData: EditorData) {
+ this.title = initialData.title;
+ this.body = initialData.body;
+ }
+
+ setTitle(title: string) {
+ this.title = title;
+ }
+
+ setBody(body: string) {
+ this.body = body;
+ }
+}
diff --git a/examples/react/src/demos/scoped-store/store/editorStoreContext.ts b/examples/react/src/demos/scoped-store/store/editorStoreContext.ts
new file mode 100644
index 0000000..2642caa
--- /dev/null
+++ b/examples/react/src/demos/scoped-store/store/editorStoreContext.ts
@@ -0,0 +1,5 @@
+import {createContext} from 'react';
+import type {EditorStore} from './editorStore';
+
+// Share the context definition, never a mutable store instance, across requests.
+export const EditorStoreContext = createContext(null);
diff --git a/examples/react/src/demos/scoped-store/store/hooks/useEditorStore.ts b/examples/react/src/demos/scoped-store/store/hooks/useEditorStore.ts
new file mode 100644
index 0000000..f0e6951
--- /dev/null
+++ b/examples/react/src/demos/scoped-store/store/hooks/useEditorStore.ts
@@ -0,0 +1,11 @@
+import {useContext} from 'react';
+import {EditorStoreContext} from '../editorStoreContext';
+
+/** Get the nearest scope's mutable store; useClassyStore subscribes to its data. */
+export function useEditorStore() {
+ const store = useContext(EditorStoreContext);
+ if (!store) {
+ throw new Error('useEditorStore must be used within EditorStoreProvider');
+ }
+ return store;
+}
diff --git a/examples/react/src/pages/scopedStores/ScopedStoresPage.tsx b/examples/react/src/pages/scopedStores/ScopedStoresPage.tsx
new file mode 100644
index 0000000..9b7576b
--- /dev/null
+++ b/examples/react/src/pages/scopedStores/ScopedStoresPage.tsx
@@ -0,0 +1,23 @@
+import {TipBox} from '../../components/TipBox';
+import {ScopedStoresDemo} from '../../demos/scoped-store/components/scopedStoresDemo/ScopedStoresDemo';
+
+export function ScopedStoresPage() {
+ return (
+
+
+
Scoped stores
+
+ Share one typed store within a subtree using React context. Multiple
+ forms and editors can use the same store class with independent state.
+
+
+
+
+ Context chooses the store instance; useClassyStore subscribes to its
+ values. The provider owns creation, hydration preparation, and optional
+ persistence cleanup. Changing initialData alone preserves the current
+ draft; change the provider key to reset it.
+
+
+ );
+}
diff --git a/examples/react/tsconfig.scoping.json b/examples/react/tsconfig.scoping.json
new file mode 100644
index 0000000..2b24eba
--- /dev/null
+++ b/examples/react/tsconfig.scoping.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "paths": {
+ "@codebelt/classy-store": ["../../packages/classy-store/src/index.ts"],
+ "@codebelt/classy-store/react": [
+ "../../packages/classy-store/src/frameworks/react/react.ts"
+ ],
+ "@codebelt/classy-store/utils": [
+ "../../packages/classy-store/src/utils/index.ts"
+ ]
+ }
+ },
+ "include": [
+ "src/demos/scoped-store/**/*.ts",
+ "src/demos/scoped-store/**/*.tsx",
+ "src/pages/scopedStores/**/*.tsx"
+ ]
+}
diff --git a/package.json b/package.json
index 74fb046..a44681b 100644
--- a/package.json
+++ b/package.json
@@ -18,7 +18,7 @@
"---------- Lint --------------------------------------------------------": "",
"lint": "biome check",
"lint:fix": "biome check --write",
- "typecheck": "bun run --filter @codebelt/classy-store typecheck",
+ "typecheck": "bun run --filter @codebelt/classy-store typecheck && bun run --filter @codebelt/classy-store typecheck:scoping",
"---------- Release (Changesets) ----------------------------------------": "",
"changeset": "changeset",
"changeset:add": "changeset add",
diff --git a/packages/classy-store/package.json b/packages/classy-store/package.json
index 79fcbbc..900e61d 100644
--- a/packages/classy-store/package.json
+++ b/packages/classy-store/package.json
@@ -111,6 +111,7 @@
"test:browser": "playwright test --config tests/browser/playwright.config.ts",
"---------- Lint --------------------------------------------------------": "",
"typecheck": "tsc --noEmit && tsc --project tests/browser/tsconfig.json --noEmit && tsc --project tests/compiler/tsconfig.json --noEmit",
+ "typecheck:scoping": "tsc --project ../../examples/react/tsconfig.scoping.json --noEmit",
"prepublishOnly": "cp ../../README.md .",
"-----------------------------------------------------------------------": ""
},
diff --git a/packages/classy-store/src/frameworks/react/react.ts b/packages/classy-store/src/frameworks/react/react.ts
index bc5755d..f296c0d 100644
--- a/packages/classy-store/src/frameworks/react/react.ts
+++ b/packages/classy-store/src/frameworks/react/react.ts
@@ -278,8 +278,9 @@ export function createStoreHook(proxyStore: T) {
/**
* 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`).
+ * component. Unmounting releases React's reference; the store can be collected
+ * when no external references remain. Clean up application subscriptions and
+ * other effects separately; this hook does not call a store's dispose method.
*
* The factory is a `useState` initializer and must be pure. React StrictMode
* may invoke it more than once in development.
diff --git a/packages/classy-store/src/utils/persist/persist.test.ts b/packages/classy-store/src/utils/persist/persist.test.ts
index 868a6d8..b416f1e 100644
--- a/packages/classy-store/src/utils/persist/persist.test.ts
+++ b/packages/classy-store/src/utils/persist/persist.test.ts
@@ -1327,6 +1327,93 @@ describe('persist()', () => {
});
});
+ describe('pending hydration cleanup', () => {
+ it.each([
+ false,
+ true,
+ ])('ignores a pending read after unsubscribe (skipHydration=%s)', async (skipHydration) => {
+ let resolveRead!: (value: string | null) => void;
+ const pendingRead = new Promise((resolve) => {
+ resolveRead = resolve;
+ });
+ const storage = {
+ getItem: () => pendingRead,
+ setItem: mock(() => {}),
+ removeItem: mock(() => {}),
+ };
+ const store = createClassyStore({title: 'Current'});
+ const handle = persist(store, {name: 'editor', storage, skipHydration});
+ const completion = skipHydration ? handle.rehydrate() : handle.hydrated;
+ handle.unsubscribe();
+ resolveRead(JSON.stringify({version: 0, state: {title: 'Retired'}}));
+ await completion;
+ await handle.hydrated;
+
+ expect(store.title).toBe('Current');
+ expect(storage.setItem).not.toHaveBeenCalled();
+ expect(storage.removeItem).not.toHaveBeenCalled();
+ expect(handle.isHydrated).toBe(true);
+ });
+
+ it.each([
+ false,
+ true,
+ ])('settles a failed pending read without callbacks after unsubscribe (skipHydration=%s)', async (skipHydration) => {
+ let rejectRead!: (error: Error) => void;
+ const pendingRead = new Promise((_resolve, reject) => {
+ rejectRead = reject;
+ });
+ const onError = mock(() => {});
+ const storage = {
+ getItem: () => pendingRead,
+ setItem: mock(() => {}),
+ removeItem: mock(() => {}),
+ };
+ const store = createClassyStore({title: 'Current'});
+ const handle = persist(store, {
+ name: 'editor',
+ storage,
+ skipHydration,
+ onError,
+ });
+ const completion = skipHydration ? handle.rehydrate() : handle.hydrated;
+ handle.unsubscribe();
+ rejectRead(new Error('Late storage failure'));
+ await completion;
+ await handle.hydrated;
+
+ expect(onError).not.toHaveBeenCalled();
+ expect(store.title).toBe('Current');
+ expect(handle.isHydrated).toBe(true);
+ });
+
+ it('does not clear storage from an expired read that finishes after cleanup', async () => {
+ let resolveRead!: (value: string) => void;
+ const storage = {
+ getItem: () =>
+ new Promise((resolve) => {
+ resolveRead = resolve;
+ }),
+ setItem: mock(() => {}),
+ removeItem: mock(() => {}),
+ };
+ const store = createClassyStore({title: 'Current'});
+ const handle = persist(store, {
+ name: 'editor',
+ storage,
+ clearOnExpire: true,
+ });
+ handle.unsubscribe();
+ resolveRead(
+ JSON.stringify({version: 0, expiresAt: 0, state: {title: 'Expired'}}),
+ );
+ await handle.hydrated;
+
+ expect(storage.removeItem).not.toHaveBeenCalled();
+ expect(handle.isExpired).toBe(false);
+ });
+ });
+
// ββ onError callback ββββββββββββββββββββββββββββββββββββββββββββββββββββ
describe('onError callback', () => {
diff --git a/packages/classy-store/src/utils/persist/persist.ts b/packages/classy-store/src/utils/persist/persist.ts
index d68667b..f6dfac2 100644
--- a/packages/classy-store/src/utils/persist/persist.ts
+++ b/packages/classy-store/src/utils/persist/persist.ts
@@ -152,7 +152,11 @@ export type PersistOperation = 'write' | 'read' | 'remove' | 'parse';
* Handle returned by `persist()`. Provides control over the persist lifecycle.
*/
export type PersistHandle = {
- /** Stop persisting and clean up (unsubscribe + cancel pending debounce + remove storage event listener). */
+ /**
+ * Stop persisting, cancel pending debounce, and remove the storage listener.
+ * Pending read results/errors are ignored; their promises still settle.
+ * Already-issued adapter I/O is not cancelled.
+ */
unsubscribe: () => void;
/** Promise that resolves when initial hydration from storage is complete. */
@@ -442,9 +446,12 @@ export function persist(
try {
raw = await storage.getItem(name);
} catch (error) {
- reportError(error, 'read');
+ if (!disposed) reportError(error, 'read');
return;
}
+ // Effect cleanup can dispose this handle while storage is still reading.
+ // A retired scope must not apply data or clear a newer scope's storage.
+ if (disposed) return;
if (raw !== null) {
// A debounced local write may be pending from a mutation issued before
// we got around to hydrating; the freshly-loaded state would be
diff --git a/packages/classy-store/tests/compatibility/README.md b/packages/classy-store/tests/compatibility/README.md
index 73ac109..0b42ae1 100644
--- a/packages/classy-store/tests/compatibility/README.md
+++ b/packages/classy-store/tests/compatibility/README.md
@@ -21,5 +21,7 @@ Each consumer verifies:
- Strict Mode mounting, selector changes, store switching, synchronous multi-property React updates, and ordinary batched subscriptions against the installed package.
- The source SSR/hydration regression suites against the installed package, including DOM reuse, independent request state, and persistence updates before a Suspense boundary's first store read. Direct and bound hooks must hydrate with the prepared baseline and then commit the persisted value. Source-only internal listener counts are omitted; public unmount and persistence cleanup assertions remain.
- TypeScript NodeNext resolution through both `.mts` and `.cts` consumers: selector inference, nested readonly snapshots, mutable stores and `prepareStoreForHydration` return types, and compile-time rejection of React `sync` options.
+- The real scoped editor context example, copied directly from `examples/react/src/demos/scoped-store/store` into each consumer. Runtime coverage checks independent instances, nested providers, stable ownership, keyed resets, persistence cleanup, request isolation, and matching hydration. Delayed Suspense tests prove the provider can restore persistence before a child's first store read without replacing server DOM, with both selector and auto-tracked consumers.
+- Strict TypeScript checks for the copied context/provider example and its scoping fixtures, including typed mutable actions and immutable snapshot reads. The example uses the installed package exclusively; no source imports or additional public helpers are substituted.
Tests intentionally use the React 18 `react-dom/test-utils` fallback when `React.act` is unavailable. React is external to the published build, so each consumer uses only its selected React version.
diff --git a/packages/classy-store/tests/compatibility/runCompatibility.mjs b/packages/classy-store/tests/compatibility/runCompatibility.mjs
index 91e4a2d..090eb06 100644
--- a/packages/classy-store/tests/compatibility/runCompatibility.mjs
+++ b/packages/classy-store/tests/compatibility/runCompatibility.mjs
@@ -2,6 +2,7 @@ import {spawnSync} from 'node:child_process';
import {
cpSync,
mkdtempSync,
+ readdirSync,
readFileSync,
rmSync,
writeFileSync,
@@ -12,6 +13,14 @@ import {fileURLToPath} from 'node:url';
const fixturesDirectory = dirname(fileURLToPath(import.meta.url));
const packageDirectory = resolve(fixturesDirectory, '../..');
+const scopingFixturesDirectory = resolve(fixturesDirectory, '../scoping');
+const scopedExampleDirectory = resolve(
+ packageDirectory,
+ '../../examples/react/src/demos/scoped-store/store',
+);
+const scopingTestFiles = readdirSync(scopingFixturesDirectory)
+ .filter((filename) => filename.endsWith('.test.tsx'))
+ .sort();
const packageManifest = JSON.parse(
readFileSync(join(packageDirectory, 'package.json'), 'utf8'),
);
@@ -74,6 +83,13 @@ try {
for (const reactVersion of reactVersions) {
const consumerDirectory = join(temporaryDirectory, `react-${reactVersion}`);
cpSync(fixturesDirectory, consumerDirectory, {recursive: true});
+ // Exercise the actual application example against the installed package.
+ // Keeping this a copy, rather than a rewritten fixture, catches drift in
+ // provider ownership, context types, and effect cleanup.
+ cpSync(scopingFixturesDirectory, consumerDirectory, {recursive: true});
+ cpSync(scopedExampleDirectory, join(consumerDirectory, 'example'), {
+ recursive: true,
+ });
const reactTypesVersion = reactVersion.startsWith('18.')
? '18.3.27'
: packageManifest.devDependencies['@types/react'];
@@ -91,6 +107,7 @@ try {
'react-dom': reactVersion,
'@types/react': reactTypesVersion,
'@types/react-dom': reactDomTypesVersion,
+ '@types/bun': packageManifest.devDependencies['@types/bun'],
'@happy-dom/global-registrator':
packageManifest.devDependencies['@happy-dom/global-registrator'],
typescript: packageManifest.devDependencies.typescript,
@@ -178,6 +195,7 @@ try {
'./consumer.test.tsx',
'./ssr.test.tsx',
'./hydration.test.tsx',
+ ...scopingTestFiles.map((filename) => `./${filename}`),
],
consumerDirectory,
);
@@ -198,6 +216,27 @@ try {
],
consumerDirectory,
);
+ run(
+ join(consumerDirectory, 'node_modules/.bin/tsc'),
+ [
+ '--noEmit',
+ '--strict',
+ '--skipLibCheck',
+ '--target',
+ 'ES2022',
+ '--jsx',
+ 'react-jsx',
+ '--module',
+ 'ESNext',
+ '--moduleResolution',
+ 'Bundler',
+ '--types',
+ 'bun',
+ 'scoping.types.fixture.tsx',
+ ...scopingTestFiles,
+ ],
+ consumerDirectory,
+ );
}
} finally {
if (process.env.KEEP_COMPATIBILITY_TEMP === '1') {
diff --git a/packages/classy-store/tests/scoping/README.md b/packages/classy-store/tests/scoping/README.md
new file mode 100644
index 0000000..08ce068
--- /dev/null
+++ b/packages/classy-store/tests/scoping/README.md
@@ -0,0 +1,33 @@
+# Scoped React context checks
+
+These tests exercise the editor context example in
+`examples/react/src/demos/scoped-store/store` against the published package shape.
+The compatibility runner copies that directory unchanged into each temporary
+consumer as `example/`, alongside these fixtures and the React-version-aware
+`reactAct.ts` helper. The `./example/...` imports intentionally resolve only in
+those consumers; there is no second implementation of the example in this folder.
+
+From the repository root:
+
+```sh
+bun run test:react:compatibility
+```
+
+The default matrix covers React 18.0, 18.3, and the library's pinned React version.
+See `../compatibility/README.md` for selected versions and retained consumers.
+
+- `scoping.lifecycle.test.tsx` checks independent providers, nested lookup,
+ stable stores, explicit keyed resets, missing-provider errors, and cleanup.
+- `scoping.ssr.test.tsx` checks request isolation, nested server contexts,
+ matching hydration, and persistence updates before a suspended consumer's
+ first hook read. Selector and auto-tracked consumers must retain the server
+ DOM and then catch up to the persisted state.
+- `scoping.qa.test.tsx` independently checks pending persistence reads during
+ Strict Mode replay, provider replacement, keyed resets, and unmount, as well
+ as abandoned renders and memoized consumers.
+- `scoping.types.fixture.tsx` checks the copied example's inferred store,
+ mutable actions, readonly snapshots, and provider props. The runner also
+ typechecks every scoping runtime test and its helper components.
+
+These are application ownership patterns built from existing public APIs;
+they do not add a context abstraction to the library.
diff --git a/packages/classy-store/tests/scoping/lifecycleStorage.ts b/packages/classy-store/tests/scoping/lifecycleStorage.ts
new file mode 100644
index 0000000..64f6485
--- /dev/null
+++ b/packages/classy-store/tests/scoping/lifecycleStorage.ts
@@ -0,0 +1,24 @@
+import type {StorageAdapter} from '@codebelt/classy-store/utils';
+
+export function createLifecycleStorage() {
+ const values = new Map();
+ const reads: string[] = [];
+ const writes: {name: string; value: string}[] = [];
+ const removals: string[] = [];
+ const storage: StorageAdapter = {
+ getItem(name) {
+ reads.push(name);
+ return values.get(name) ?? null;
+ },
+ setItem(name, value) {
+ writes.push({name, value});
+ values.set(name, value);
+ },
+ removeItem(name) {
+ removals.push(name);
+ values.delete(name);
+ },
+ };
+
+ return {storage, values, reads, writes, removals};
+}
diff --git a/packages/classy-store/tests/scoping/scoping.lifecycle.test.tsx b/packages/classy-store/tests/scoping/scoping.lifecycle.test.tsx
new file mode 100644
index 0000000..3384de3
--- /dev/null
+++ b/packages/classy-store/tests/scoping/scoping.lifecycle.test.tsx
@@ -0,0 +1,371 @@
+import {afterEach, describe, expect, test} from 'bun:test';
+import {type ReactNode, StrictMode} from 'react';
+import {createRoot, type Root} from 'react-dom/client';
+import {renderToString} from 'react-dom/server';
+import {EditorStoreProvider} from './example/components/editorStoreProvider/EditorStoreProvider';
+import type {EditorStore} from './example/editorStore';
+import {createLifecycleStorage} from './lifecycleStorage';
+import {act} from './reactAct';
+import {ScopedProbe} from './testHelpers/scopedProbe/ScopedProbe';
+
+const mountedRoots = new Map();
+const firstData = {title: 'First', body: 'First body'};
+const secondData = {title: 'Second', body: 'Second body'};
+
+async function mount(element: ReactNode) {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ mountedRoots.set(root, container);
+ await act(async () => root.render(element));
+ return {root, container};
+}
+
+async function unmount(root: Root) {
+ const container = mountedRoots.get(root);
+ await act(async () => root.unmount());
+ container?.remove();
+ mountedRoots.delete(root);
+}
+
+function readScope(container: HTMLElement, name: string) {
+ return container.querySelector(`[data-scope="${name}"]`)?.textContent;
+}
+
+function readRequired(values: Map, name: string): Value {
+ const value = values.get(name);
+ if (value === undefined) throw new Error(`Missing captured value: ${name}`);
+ return value;
+}
+
+afterEach(async () => {
+ for (const root of mountedRoots.keys()) await unmount(root);
+});
+
+describe('typed context store lifecycle', () => {
+ test('isolates sibling instances and their persistence namespaces', async () => {
+ const {storage, writes, values} = createLifecycleStorage();
+ const stores = new Map();
+ const {container} = await mount(
+ <>
+
+ stores.set('first', store)}
+ />
+
+
+ stores.set('second', store)}
+ />
+
+ >,
+ );
+ const first = readRequired(stores, 'first');
+ const second = readRequired(stores, 'second');
+ expect(first).not.toBe(second);
+ expect(readScope(container, 'first')).toBe(
+ 'First:First body|First:First body',
+ );
+ expect(readScope(container, 'second')).toBe(
+ 'Second:Second body|Second:Second body',
+ );
+
+ await act(async () => {
+ first.setTitle('Edited first');
+ first.setBody('Edited body');
+ });
+ expect(readScope(container, 'first')).toBe(
+ 'Edited first:Edited body|Edited first:Edited body',
+ );
+ expect(readScope(container, 'second')).toBe(
+ 'Second:Second body|Second:Second body',
+ );
+ expect(writes.map(({name}) => name)).toEqual(['editor:first']);
+
+ await act(async () => second.setTitle('Edited second'));
+ expect(readScope(container, 'first')).toBe(
+ 'Edited first:Edited body|Edited first:Edited body',
+ );
+ expect(readScope(container, 'second')).toBe(
+ 'Edited second:Second body|Edited second:Second body',
+ );
+ expect(JSON.parse(readRequired(values, 'editor:first')).state).toEqual({
+ title: 'Edited first',
+ body: 'Edited body',
+ });
+ expect(JSON.parse(readRequired(values, 'editor:second')).state).toEqual({
+ title: 'Edited second',
+ body: 'Second body',
+ });
+ });
+
+ test('routes nested consumers to the nearest provider and restores the outer scope', async () => {
+ const stores = new Map();
+ const {container} = await mount(
+
+ stores.set('before', store)}
+ />
+
+ stores.set('inside', store)}
+ />
+
+ stores.set('after', store)}
+ />
+ ,
+ );
+ expect(stores.get('before')).toBe(stores.get('after'));
+ expect(stores.get('inside')).not.toBe(stores.get('before'));
+
+ await act(async () =>
+ readRequired(stores, 'inside').setTitle('Nested edit'),
+ );
+ expect(readScope(container, 'before')).toBe(
+ 'First:First body|First:First body',
+ );
+ expect(readScope(container, 'inside')).toBe(
+ 'Nested edit:Second body|Nested edit:Second body',
+ );
+ expect(readScope(container, 'after')).toBe(
+ 'First:First body|First:First body',
+ );
+
+ await act(async () => readRequired(stores, 'before').setBody('Outer edit'));
+ expect(readScope(container, 'before')).toBe(
+ 'First:Outer edit|First:Outer edit',
+ );
+ expect(readScope(container, 'inside')).toBe(
+ 'Nested edit:Second body|Nested edit:Second body',
+ );
+ expect(readScope(container, 'after')).toBe(
+ 'First:Outer edit|First:Outer edit',
+ );
+ });
+
+ test('reports a clear missing-provider error', () => {
+ expect(() => renderToString()).toThrow(
+ 'useEditorStore must be used within EditorStoreProvider',
+ );
+ });
+
+ test('preserves the store and edits when parent renders pass new initial data', async () => {
+ const captures: EditorStore[] = [];
+ const capture = (store: EditorStore) => captures.push(store);
+ const {root, container} = await mount(
+
+
+ ,
+ );
+ const store = captures[0];
+ await act(async () => store.setTitle('Unsaved edit'));
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ expect(captures).toEqual([store]);
+ expect(readScope(container, 'editor')).toBe(
+ 'Unsaved edit:First body|Unsaved edit:First body',
+ );
+ await act(async () => store.setBody('Still connected'));
+ expect(readScope(container, 'editor')).toBe(
+ 'Unsaved edit:Still connected|Unsaved edit:Still connected',
+ );
+ });
+
+ test('resets with a provider key and disconnects the previous store', async () => {
+ const {storage, reads, writes} = createLifecycleStorage();
+ const captures: EditorStore[] = [];
+ const commits: string[] = [];
+ const capture = (store: EditorStore) => captures.push(store);
+ const commit = (value: string) => commits.push(value);
+ const {root, container} = await mount(
+
+
+ ,
+ );
+ const previousStore = captures[0];
+ await act(async () => previousStore.setTitle('Old edit'));
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ const currentStore = captures[1];
+ expect(currentStore).not.toBe(previousStore);
+ expect(readScope(container, 'editor')).toBe(
+ 'Second:Second body|Second:Second body',
+ );
+ expect(reads).toEqual(['first', 'second']);
+ writes.length = 0;
+ commits.length = 0;
+ await act(async () => previousStore.setTitle('Detached edit'));
+ expect(writes).toEqual([]);
+ expect(commits).toEqual([]);
+ expect(readScope(container, 'editor')).toBe(
+ 'Second:Second body|Second:Second body',
+ );
+ await act(async () => currentStore.setBody('Current edit'));
+ expect(readScope(container, 'editor')).toBe(
+ 'Second:Current edit|Second:Current edit',
+ );
+ expect(writes.map(({name}) => name)).toEqual(['second']);
+ });
+
+ test('maintains one active persistence effect through StrictMode replay and unmount', async () => {
+ const {storage, reads, writes} = createLifecycleStorage();
+ const captures: EditorStore[] = [];
+ const {root, container} = await mount(
+
+
+ captures.push(store)}
+ />
+
+ ,
+ );
+ expect(reads).toEqual(['strict', 'strict']);
+ expect(new Set(captures).size).toBe(1);
+ const store = captures[0];
+ await act(async () => store.setTitle('Strict edit'));
+ expect(readScope(container, 'editor')).toBe(
+ 'Strict edit:First body|Strict edit:First body',
+ );
+ expect(writes).toHaveLength(1);
+ await unmount(root);
+ writes.length = 0;
+ await act(async () => store.setBody('After unmount'));
+ expect(writes).toEqual([]);
+ });
+
+ test('does not restart persistence for an equivalent options object', async () => {
+ const {storage, reads, writes} = createLifecycleStorage();
+ const stores: EditorStore[] = [];
+ const capture = (store: EditorStore) => stores.push(store);
+ const {root, container} = await mount(
+
+
+ ,
+ );
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ expect(reads).toEqual(['stable']);
+ expect(stores).toHaveLength(1);
+ await act(async () => stores[0].setBody('Edited'));
+ expect(writes).toHaveLength(1);
+ expect(readScope(container, 'editor')).toBe('First:Edited|First:Edited');
+ });
+
+ test('cleans up when persistence is removed and reconnects without recreating the store', async () => {
+ const {storage, reads, writes} = createLifecycleStorage();
+ const stores: EditorStore[] = [];
+ const capture = (store: EditorStore) => stores.push(store);
+ const {root, container} = await mount(
+
+
+ ,
+ );
+ const store = stores[0];
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await act(async () => store.setTitle('Temporary edit'));
+ expect(writes).toEqual([]);
+ expect(readScope(container, 'editor')).toBe(
+ 'Temporary edit:First body|Temporary edit:First body',
+ );
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ expect(stores).toEqual([store]);
+ expect(reads).toEqual(['before', 'after']);
+ await act(async () => store.setBody('Reconnected'));
+ expect(writes.map(({name}) => name)).toEqual(['after']);
+ });
+
+ test('a keyed remount restores saved data when the persistence namespace stays the same', async () => {
+ const {storage, removals} = createLifecycleStorage();
+ const stores: EditorStore[] = [];
+ const capture = (store: EditorStore) => stores.push(store);
+ const {root, container} = await mount(
+
+
+ ,
+ );
+ await act(async () => stores[0].setTitle('Saved edit'));
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ expect(stores[1]).not.toBe(stores[0]);
+ expect(removals).toEqual([]);
+ expect(readScope(container, 'editor')).toBe(
+ 'Saved edit:First body|Saved edit:First body',
+ );
+ });
+});
diff --git a/packages/classy-store/tests/scoping/scoping.qa.test.tsx b/packages/classy-store/tests/scoping/scoping.qa.test.tsx
new file mode 100644
index 0000000..8ee04e6
--- /dev/null
+++ b/packages/classy-store/tests/scoping/scoping.qa.test.tsx
@@ -0,0 +1,238 @@
+import {afterEach, describe, expect, mock, test} from 'bun:test';
+import type {StorageAdapter} from '@codebelt/classy-store/utils';
+import {type ReactNode, StrictMode, Suspense} from 'react';
+import {createRoot, type Root} from 'react-dom/client';
+import {EditorStoreProvider} from './example/components/editorStoreProvider/EditorStoreProvider';
+import type {EditorStore} from './example/editorStore';
+import {act} from './reactAct';
+import {QaMemoSelector} from './testHelpers/scopedQa/qaMemoSelector/QaMemoSelector';
+import {QaScopeProbe} from './testHelpers/scopedQa/qaScopeProbe/QaScopeProbe';
+
+const mounted = new Map();
+const initialData = {title: 'Initial', body: 'Body'};
+
+function mount(element: ReactNode) {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ mounted.set(root, container);
+ act(() => root.render(element));
+ return {root, container};
+}
+
+function unmount(root: Root) {
+ act(() => root.unmount());
+ mounted.get(root)?.remove();
+ mounted.delete(root);
+}
+
+function createPendingStorage() {
+ const reads: {name: string; resolve: (value: string | null) => void}[] = [];
+ const storage: StorageAdapter = {
+ getItem: mock(
+ (name: string) =>
+ new Promise((resolve) => {
+ reads.push({name, resolve});
+ }),
+ ),
+ setItem: mock((_name: string, _value: string) => {}),
+ removeItem: mock((_name: string) => {}),
+ };
+ return {storage, reads};
+}
+
+function saved(title: string) {
+ return JSON.stringify({version: 0, state: {title, body: 'Saved body'}});
+}
+
+afterEach(() => {
+ for (const root of mounted.keys()) unmount(root);
+});
+
+describe('independent scoped-store lifecycle QA', () => {
+ test('ignores a late hydration result from the disposed first StrictMode effect', async () => {
+ const {storage, reads} = createPendingStorage();
+ let currentStore!: EditorStore;
+ const onStore = (store: EditorStore) => {
+ currentStore = store;
+ };
+ const {container} = mount(
+
+
+
+
+ ,
+ );
+ expect(reads).toHaveLength(2);
+ await act(async () => reads[1]?.resolve(saved('Current')));
+ expect(container.textContent).toBe('Current|Saved body');
+ await act(async () => reads[0]?.resolve(saved('Stale')));
+ expect(currentStore.title).toBe('Current');
+ expect(container.textContent).toBe('Current|Saved body');
+ expect(storage.setItem).not.toHaveBeenCalled();
+
+ await act(async () => currentStore.setTitle('User edit'));
+ expect(storage.setItem).toHaveBeenCalledTimes(1);
+ expect(container.textContent).toBe('User edit|Saved body');
+ });
+
+ test('ignores a pending hydration after a storage key changes on the same store', async () => {
+ const {storage, reads} = createPendingStorage();
+ let currentStore!: EditorStore;
+ const onStore = (store: EditorStore) => {
+ currentStore = store;
+ };
+ const view = (name: string) => (
+
+
+
+ );
+ const {root, container} = mount(view('previous'));
+ const originalStore = currentStore;
+ act(() => root.render(view('current')));
+ expect(currentStore).toBe(originalStore);
+ expect(reads.map((read) => read.name)).toEqual(['previous', 'current']);
+ await act(async () => reads[1]?.resolve(saved('Current')));
+ await act(async () => reads[0]?.resolve(saved('Previous')));
+ expect(container.textContent).toBe('Current|Saved body');
+ expect(storage.setItem).not.toHaveBeenCalled();
+ await act(async () => currentStore.setBody('Changed'));
+ expect(storage.setItem).toHaveBeenCalledTimes(1);
+ expect(storage.setItem).toHaveBeenCalledWith(
+ 'current',
+ JSON.stringify({
+ version: 0,
+ state: {title: 'Current', body: 'Changed'},
+ }),
+ );
+ });
+
+ test('keeps a reset store and its retired predecessor isolated from late persistence', async () => {
+ const {storage, reads} = createPendingStorage();
+ let currentStore!: EditorStore;
+ const onStore = (store: EditorStore) => {
+ currentStore = store;
+ };
+ const view = (session: string) => (
+
+
+
+ );
+ const {root, container} = mount(view('previous'));
+ const retiredStore = currentStore;
+ act(() => root.render(view('current')));
+ expect(currentStore).not.toBe(retiredStore);
+ await act(async () => reads[1]?.resolve(saved('Current')));
+ await act(async () => reads[0]?.resolve(saved('Retired')));
+ expect(retiredStore.title).toBe('Initial');
+ expect(container.textContent).toBe('Current|Saved body');
+ expect(storage.setItem).not.toHaveBeenCalled();
+ await act(async () => retiredStore.setTitle('Detached edit'));
+ expect(container.textContent).toBe('Current|Saved body');
+ expect(storage.setItem).not.toHaveBeenCalled();
+ });
+
+ test('does not mutate a disposed store when its pending hydration completes after unmount', async () => {
+ const {storage, reads} = createPendingStorage();
+ let retiredStore!: EditorStore;
+ const {root} = mount(
+
+ {
+ retiredStore = store;
+ }}
+ />
+ ,
+ );
+ expect(reads).toHaveLength(1);
+ unmount(root);
+ await act(async () => reads[0]?.resolve(saved('Too late')));
+ expect(retiredStore.title).toBe('Initial');
+ expect(retiredStore.body).toBe('Body');
+ expect(storage.setItem).not.toHaveBeenCalled();
+ });
+
+ test('never starts persistence for an initial render abandoned while suspended', async () => {
+ const {storage} = createPendingStorage();
+ let released = false;
+ let release!: () => void;
+ const promise = new Promise((resolve) => {
+ release = resolve;
+ });
+ const onStore = mock((_store: EditorStore) => {});
+ const suspend = () => {
+ if (!released) throw promise;
+ };
+ const {root, container} = mount(
+
+
+
+
+ ,
+ );
+ expect(container.textContent).toBe('Loading');
+ expect(onStore).not.toHaveBeenCalled();
+ expect(storage.getItem).not.toHaveBeenCalled();
+ act(() => root.render(null));
+ await act(async () => {
+ released = true;
+ release();
+ });
+ expect(onStore).not.toHaveBeenCalled();
+ expect(storage.getItem).not.toHaveBeenCalled();
+ expect(storage.setItem).not.toHaveBeenCalled();
+ });
+
+ test('memoized selectors and tracked consumers stay live while provider props change', async () => {
+ let currentStore!: EditorStore;
+ const onStore = (store: EditorStore) => {
+ currentStore = store;
+ };
+ const view = (title: string) => (
+
+
+
+
+ );
+ const {root, container} = mount(view('Initial'));
+ const originalStore = currentStore;
+ act(() => root.render(view('Ignored new prop')));
+ expect(currentStore).toBe(originalStore);
+ expect(container.querySelector('[data-qa-tracked]')?.textContent).toBe(
+ 'Initial|Body',
+ );
+ expect(container.querySelector('[data-qa-selected]')?.textContent).toBe(
+ 'Initial',
+ );
+ await act(async () => currentStore.setTitle('Updated'));
+ expect(container.querySelector('[data-qa-tracked]')?.textContent).toBe(
+ 'Updated|Body',
+ );
+ expect(container.querySelector('[data-qa-selected]')?.textContent).toBe(
+ 'Updated',
+ );
+ await act(async () => currentStore.setBody('Updated body'));
+ expect(container.querySelector('[data-qa-tracked]')?.textContent).toBe(
+ 'Updated|Updated body',
+ );
+ expect(container.querySelector('[data-qa-selected]')?.textContent).toBe(
+ 'Updated',
+ );
+ });
+});
diff --git a/packages/classy-store/tests/scoping/scoping.ssr.test.tsx b/packages/classy-store/tests/scoping/scoping.ssr.test.tsx
new file mode 100644
index 0000000..0508169
--- /dev/null
+++ b/packages/classy-store/tests/scoping/scoping.ssr.test.tsx
@@ -0,0 +1,242 @@
+import {afterEach, describe, expect, mock, test} from 'bun:test';
+import {hydrateRoot, type Root} from 'react-dom/client';
+import {renderToString} from 'react-dom/server';
+import {EditorStoreProvider} from './example/components/editorStoreProvider/EditorStoreProvider';
+import type {EditorStore} from './example/editorStore';
+import {act} from './reactAct';
+import type {
+ HydrationGate,
+ ScopedSsrMode,
+} from './testHelpers/scopedSsr/scopedSsr.types';
+import {ScopedSsrProbe} from './testHelpers/scopedSsr/scopedSsrProbe/ScopedSsrProbe';
+import {ScopedSsrTree} from './testHelpers/scopedSsr/scopedSsrTree/ScopedSsrTree';
+import {ScopedSsrValue} from './testHelpers/scopedSsr/scopedSsrValue/ScopedSsrValue';
+
+function createGate(initiallyReady = false): HydrationGate {
+ let ready = initiallyReady;
+ let releasePromise = () => {};
+ const promise = new Promise((resolvePromise) => {
+ releasePromise = resolvePromise;
+ });
+ return {
+ read() {
+ if (!ready) throw promise;
+ },
+ release() {
+ ready = true;
+ releasePromise();
+ },
+ };
+}
+
+const modes: ScopedSsrMode[] = ['selector', 'auto-tracking'];
+const mounted = new Map();
+
+afterEach(async () => {
+ for (const [root, container] of mounted) {
+ await act(async () => root.unmount());
+ container.remove();
+ }
+ mounted.clear();
+});
+
+describe('scoped context server rendering and hydration', () => {
+ test('creates isolated request stores without starting persistence on the server', () => {
+ const stores: EditorStore[] = [];
+ const initialData = {title: 'Shared input', body: 'Original'};
+ const storage = {
+ getItem: mock(() => null),
+ setItem: mock(() => {}),
+ removeItem: mock(() => {}),
+ };
+ const element = (
+ stores.push(store)}
+ />
+ );
+ const firstHtml = renderToString(element);
+ stores[0].setTitle('Changed first request');
+ stores[0].setBody('Private first request');
+ const secondHtml = renderToString(element);
+
+ expect(secondHtml).toBe(firstHtml);
+ expect(stores).toHaveLength(2);
+ expect(stores[0]).not.toBe(stores[1]);
+ expect(stores[1].title).toBe('Shared input');
+ expect(stores[1].body).toBe('Original');
+ expect(initialData).toEqual({title: 'Shared input', body: 'Original'});
+ expect(storage.getItem).not.toHaveBeenCalled();
+ expect(storage.setItem).not.toHaveBeenCalled();
+ expect(storage.removeItem).not.toHaveBeenCalled();
+ });
+
+ test('resolves nested providers independently during server rendering', () => {
+ const outerStores: EditorStore[] = [];
+ const innerStores: EditorStore[] = [];
+ const outerHtml = renderToString(
+
+ outerStores.push(store)} />
+
+
+ innerStores.push(store)} />
+
+
+ outerStores.push(store)} />
+ ,
+ );
+ expect(outerHtml).toContain('Outer|Request');
+ expect(outerHtml).toContain('Inner|Draft');
+ expect(outerStores).toHaveLength(2);
+ expect(outerStores[0]).toBe(outerStores[1]);
+ expect(innerStores).toHaveLength(1);
+ expect(innerStores[0]).not.toBe(outerStores[0]);
+ innerStores[0].setTitle('Inner only');
+ expect(outerStores[0].title).toBe('Outer');
+ });
+
+ test.each(
+ modes,
+ )('hydrates matching %s context state without replacing server DOM', async (mode) => {
+ const serverStores: EditorStore[] = [];
+ const clientStores: EditorStore[] = [];
+ const initialData = {title: 'Server', body: 'Request data'};
+ const container = document.createElement('div');
+ container.innerHTML = renderToString(
+ serverStores.push(store)}
+ />,
+ );
+ document.body.appendChild(container);
+ const serverElement = container.querySelector('[data-scoped-value]');
+ const onRecoverableError = mock(() => {});
+ const onCommit = mock((value: string) => value);
+
+ await act(async () => {
+ const root = hydrateRoot(
+ container,
+ clientStores.push(store)}
+ onCommit={onCommit}
+ />,
+ {onRecoverableError},
+ );
+ mounted.set(root, container);
+ });
+ expect(onRecoverableError).not.toHaveBeenCalled();
+ expect(container.querySelector('[data-scoped-value]')).toBe(serverElement);
+ expect(serverElement?.textContent).toBe('Server|Request data');
+ expect(clientStores[0]).not.toBe(serverStores[0]);
+ expect(onCommit.mock.calls.map(([value]) => value)).toEqual([
+ 'Server|Request data',
+ ]);
+
+ await act(async () => {
+ clientStores[0].setTitle('Client edit');
+ clientStores[0].setBody('Updated body');
+ });
+ expect(serverElement?.textContent).toBe('Client edit|Updated body');
+ expect(serverStores[0].title).toBe('Server');
+ expect(serverStores[0].body).toBe('Request data');
+ });
+
+ test.each(
+ modes,
+ )('restores persistence before a delayed %s context consumer first hydrates', async (mode) => {
+ const initialData = {title: 'Server', body: 'Initial body'};
+ let serverStore!: EditorStore;
+ let clientStore!: EditorStore;
+ const container = document.createElement('div');
+ container.innerHTML = renderToString(
+ {
+ serverStore = store;
+ }}
+ />,
+ );
+ document.body.appendChild(container);
+ const serverElement = container.querySelector('[data-scoped-value]');
+ const gate = createGate();
+ const onRead = mock((value: string) => value);
+ const onCommit = mock((value: string) => value);
+ const onRecoverableError = mock(() => {});
+ const storage = {
+ getItem: mock(async () =>
+ JSON.stringify({
+ version: 0,
+ state: {title: 'Saved', body: 'Persisted body'},
+ }),
+ ),
+ setItem: mock(() => {}),
+ removeItem: mock(() => {}),
+ };
+ let root!: Root;
+ await act(async () => {
+ root = hydrateRoot(
+ container,
+ {
+ clientStore = store;
+ }}
+ onRead={onRead}
+ onCommit={onCommit}
+ />,
+ {onRecoverableError},
+ );
+ mounted.set(root, container);
+ });
+
+ // The provider owns persistence outside the suspended subtree. This proves
+ // restoration finished before the first context consumer's store read.
+ expect(storage.getItem).toHaveBeenCalledTimes(1);
+ expect(clientStore.title).toBe('Saved');
+ expect(clientStore.body).toBe('Persisted body');
+ expect(onRead).not.toHaveBeenCalled();
+ expect(onCommit).not.toHaveBeenCalled();
+ expect(serverElement?.textContent).toBe('Server|Initial body');
+ expect(container.querySelector('[data-scoped-value]')).toBe(serverElement);
+
+ await act(async () => gate.release());
+
+ expect(onRecoverableError).not.toHaveBeenCalled();
+ expect(container.querySelector('[data-scoped-value]')).toBe(serverElement);
+ expect(serverElement?.textContent).toBe('Saved|Persisted body');
+ expect(onRead.mock.calls[0][0]).toBe('Server|Initial body');
+ expect(onCommit.mock.calls.map(([value]) => value)).toEqual([
+ 'Server|Initial body',
+ 'Saved|Persisted body',
+ ]);
+ expect(storage.setItem).not.toHaveBeenCalled();
+ expect(serverStore.title).toBe('Server');
+ expect(serverStore.body).toBe('Initial body');
+
+ await act(async () => {
+ clientStore.setTitle('Edited');
+ clientStore.setBody('Client body');
+ });
+ expect(serverElement?.textContent).toBe('Edited|Client body');
+ expect(storage.setItem).toHaveBeenCalledTimes(1);
+
+ const commitsBeforeUnmount = onCommit.mock.calls.length;
+ await act(async () => root.unmount());
+ mounted.delete(root);
+ container.remove();
+ clientStore.setTitle('Detached');
+ await Promise.resolve();
+ expect(storage.setItem).toHaveBeenCalledTimes(1);
+ expect(onCommit).toHaveBeenCalledTimes(commitsBeforeUnmount);
+ });
+});
diff --git a/packages/classy-store/tests/scoping/scoping.types.fixture.tsx b/packages/classy-store/tests/scoping/scoping.types.fixture.tsx
new file mode 100644
index 0000000..394fe8a
--- /dev/null
+++ b/packages/classy-store/tests/scoping/scoping.types.fixture.tsx
@@ -0,0 +1,69 @@
+import type {Snapshot} from '@codebelt/classy-store';
+import {useClassyStore} from '@codebelt/classy-store/react';
+import type {ComponentProps} from 'react';
+import type {EditorStoreProvider} from './example/components/editorStoreProvider/EditorStoreProvider';
+import type {EditorStore} from './example/editorStore';
+import {useEditorStore} from './example/hooks/useEditorStore';
+
+type Equal =
+ (() => Value extends Left ? 1 : 2) extends <
+ Value,
+ >() => Value extends Right ? 1 : 2
+ ? true
+ : false;
+type Expect = Condition;
+type ProviderProps = ComponentProps;
+
+export function useScopedTypeAssertions() {
+ const store = useEditorStore();
+ const selected = useClassyStore(store, (state) => state.title);
+ const tracked = useClassyStore(store);
+ const assertions: [
+ Expect>,
+ Expect>,
+ Expect>>,
+ ] = [true, true, true];
+ store.setTitle('Typed title');
+ store.setBody('Typed body');
+ store.title = 'Mutable commands';
+
+ // @ts-expect-error The strict context hook never returns an optional store.
+ const missingStore: undefined = store;
+ // @ts-expect-error Commands retain their argument types.
+ store.setTitle(123);
+ // @ts-expect-error Auto-tracked snapshots stay readonly through context.
+ tracked.title = 'Readonly';
+ // @ts-expect-error Selectors receive readonly snapshots.
+ useClassyStore(store, (state) => (state.body = 'Readonly'));
+ // @ts-expect-error The selected field is inferred as a string.
+ const wrongSelection: number = selected;
+
+ return {assertions, missingStore, wrongSelection};
+}
+
+const validProps: ProviderProps = {
+ initialData: {title: 'Title', body: 'Body'},
+ children: null,
+};
+const missingBody: ProviderProps = {
+ // @ts-expect-error Initial data requires all editor fields.
+ initialData: {title: 'Title'},
+ children: null,
+};
+const missingStorage: ProviderProps = {
+ ...validProps,
+ // @ts-expect-error Persistence requires a storage adapter and key together.
+ persistence: {name: 'editor'},
+};
+const invalidInitialData: ProviderProps = {
+ // @ts-expect-error Initial fields retain their string types.
+ initialData: {title: 1, body: 'Body'},
+ children: null,
+};
+
+export const scopedProviderTypeAssertions = {
+ validProps,
+ missingBody,
+ missingStorage,
+ invalidInitialData,
+};
diff --git a/packages/classy-store/tests/scoping/testHelpers/scopedProbe/ScopedProbe.tsx b/packages/classy-store/tests/scoping/testHelpers/scopedProbe/ScopedProbe.tsx
new file mode 100644
index 0000000..d63ea59
--- /dev/null
+++ b/packages/classy-store/tests/scoping/testHelpers/scopedProbe/ScopedProbe.tsx
@@ -0,0 +1,29 @@
+import {useClassyStore} from '@codebelt/classy-store/react';
+import {useEffect} from 'react';
+import type {EditorStore} from '../../example/editorStore';
+import {useEditorStore} from '../../example/hooks/useEditorStore';
+
+type Props = {
+ name: string;
+ onStore?: (store: EditorStore) => void;
+ onCommit?: (value: string) => void;
+};
+
+export function ScopedProbe({name, onStore, onCommit}: Props) {
+ const store = useEditorStore();
+ const tracked = useClassyStore(store);
+ const selected = useClassyStore(
+ store,
+ (state) => `${state.title}:${state.body}`,
+ );
+ const value = `${tracked.title}:${tracked.body}|${selected}`;
+
+ useEffect(() => {
+ onStore?.(store);
+ }, [onStore, store]);
+ useEffect(() => {
+ onCommit?.(value);
+ });
+
+ return ;
+}
diff --git a/packages/classy-store/tests/scoping/testHelpers/scopedQa/qaMemoSelector/QaMemoSelector.tsx b/packages/classy-store/tests/scoping/testHelpers/scopedQa/qaMemoSelector/QaMemoSelector.tsx
new file mode 100644
index 0000000..ce058e1
--- /dev/null
+++ b/packages/classy-store/tests/scoping/testHelpers/scopedQa/qaMemoSelector/QaMemoSelector.tsx
@@ -0,0 +1,9 @@
+import {useClassyStore} from '@codebelt/classy-store/react';
+import {memo} from 'react';
+import {useEditorStore} from '../../../example/hooks/useEditorStore';
+
+export const QaMemoSelector = memo(function QaMemoSelector() {
+ const store = useEditorStore();
+ const title = useClassyStore(store, (state) => state.title);
+ return ;
+});
diff --git a/packages/classy-store/tests/scoping/testHelpers/scopedQa/qaScopeProbe/QaScopeProbe.tsx b/packages/classy-store/tests/scoping/testHelpers/scopedQa/qaScopeProbe/QaScopeProbe.tsx
new file mode 100644
index 0000000..4224938
--- /dev/null
+++ b/packages/classy-store/tests/scoping/testHelpers/scopedQa/qaScopeProbe/QaScopeProbe.tsx
@@ -0,0 +1,24 @@
+import {useClassyStore} from '@codebelt/classy-store/react';
+import {memo, useEffect} from 'react';
+import type {EditorStore} from '../../../example/editorStore';
+import {useEditorStore} from '../../../example/hooks/useEditorStore';
+
+type Props = {
+ onStore: (store: EditorStore) => void;
+ suspend?: () => void;
+};
+
+export const QaScopeProbe = memo(function QaScopeProbe({
+ onStore,
+ suspend,
+}: Props) {
+ const store = useEditorStore();
+ const state = useClassyStore(store);
+ suspend?.();
+ useEffect(() => onStore(store), [store, onStore]);
+ return (
+
+ );
+});
diff --git a/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsr.types.ts b/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsr.types.ts
new file mode 100644
index 0000000..3526efa
--- /dev/null
+++ b/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsr.types.ts
@@ -0,0 +1,14 @@
+import type {EditorStore} from '../../example/editorStore';
+
+export type HydrationGate = {
+ read: () => void;
+ release: () => void;
+};
+
+export type ScopedSsrMode = 'selector' | 'auto-tracking';
+
+export type ScopedSsrObservation = {
+ onStore?: (store: EditorStore) => void;
+ onRead?: (value: string) => void;
+ onCommit?: (value: string) => void;
+};
diff --git a/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrProbe/ScopedSsrProbe.tsx b/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrProbe/ScopedSsrProbe.tsx
new file mode 100644
index 0000000..88fd558
--- /dev/null
+++ b/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrProbe/ScopedSsrProbe.tsx
@@ -0,0 +1,10 @@
+import {useEditorStore} from '../../../example/hooks/useEditorStore';
+import type {ScopedSsrObservation} from '../scopedSsr.types';
+
+type Props = Pick;
+
+export function ScopedSsrProbe({onStore}: Props) {
+ const store = useEditorStore();
+ onStore?.(store);
+ return null;
+}
diff --git a/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrSelectedValue/ScopedSsrSelectedValue.tsx b/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrSelectedValue/ScopedSsrSelectedValue.tsx
new file mode 100644
index 0000000..c3793f8
--- /dev/null
+++ b/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrSelectedValue/ScopedSsrSelectedValue.tsx
@@ -0,0 +1,19 @@
+import {useClassyStore} from '@codebelt/classy-store/react';
+import {useEffect} from 'react';
+import {useEditorStore} from '../../../example/hooks/useEditorStore';
+import type {ScopedSsrObservation} from '../scopedSsr.types';
+
+type Props = Pick;
+
+export function ScopedSsrSelectedValue({onRead, onCommit}: Props) {
+ const store = useEditorStore();
+ const value = useClassyStore(
+ store,
+ (state) => `${state.title}|${state.body}`,
+ );
+ onRead?.(value);
+ useEffect(() => {
+ onCommit?.(value);
+ }, [onCommit, value]);
+ return ;
+}
diff --git a/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrTrackedValue/ScopedSsrTrackedValue.tsx b/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrTrackedValue/ScopedSsrTrackedValue.tsx
new file mode 100644
index 0000000..435f619
--- /dev/null
+++ b/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrTrackedValue/ScopedSsrTrackedValue.tsx
@@ -0,0 +1,17 @@
+import {useClassyStore} from '@codebelt/classy-store/react';
+import {useEffect} from 'react';
+import {useEditorStore} from '../../../example/hooks/useEditorStore';
+import type {ScopedSsrObservation} from '../scopedSsr.types';
+
+type Props = Pick;
+
+export function ScopedSsrTrackedValue({onRead, onCommit}: Props) {
+ const store = useEditorStore();
+ const state = useClassyStore(store);
+ const value = `${state.title}|${state.body}`;
+ onRead?.(value);
+ useEffect(() => {
+ onCommit?.(value);
+ }, [onCommit, value]);
+ return ;
+}
diff --git a/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrTree/ScopedSsrTree.tsx b/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrTree/ScopedSsrTree.tsx
new file mode 100644
index 0000000..3f8c094
--- /dev/null
+++ b/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrTree/ScopedSsrTree.tsx
@@ -0,0 +1,45 @@
+import type {StorageAdapter} from '@codebelt/classy-store/utils';
+import {Suspense} from 'react';
+import {EditorStoreProvider} from '../../../example/components/editorStoreProvider/EditorStoreProvider';
+import type {EditorData} from '../../../example/editorStore';
+import type {
+ HydrationGate,
+ ScopedSsrMode,
+ ScopedSsrObservation,
+} from '../scopedSsr.types';
+import {ScopedSsrProbe} from '../scopedSsrProbe/ScopedSsrProbe';
+import {ScopedSsrValue} from '../scopedSsrValue/ScopedSsrValue';
+
+type Props = ScopedSsrObservation & {
+ initialData: EditorData;
+ gate?: HydrationGate;
+ mode: ScopedSsrMode;
+ persistence?: {name: string; storage: StorageAdapter};
+};
+
+export function ScopedSsrTree({
+ initialData,
+ gate,
+ mode,
+ persistence,
+ onStore,
+ onRead,
+ onCommit,
+}: Props) {
+ return (
+
+
+
+
Request shell
+ Loading editor}>
+
+
+
+
+ );
+}
diff --git a/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrValue/ScopedSsrValue.tsx b/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrValue/ScopedSsrValue.tsx
new file mode 100644
index 0000000..195a88f
--- /dev/null
+++ b/packages/classy-store/tests/scoping/testHelpers/scopedSsr/scopedSsrValue/ScopedSsrValue.tsx
@@ -0,0 +1,21 @@
+import type {
+ HydrationGate,
+ ScopedSsrMode,
+ ScopedSsrObservation,
+} from '../scopedSsr.types';
+import {ScopedSsrSelectedValue} from '../scopedSsrSelectedValue/ScopedSsrSelectedValue';
+import {ScopedSsrTrackedValue} from '../scopedSsrTrackedValue/ScopedSsrTrackedValue';
+
+type Props = ScopedSsrObservation & {
+ gate?: HydrationGate;
+ mode: ScopedSsrMode;
+};
+
+export function ScopedSsrValue({gate, mode, onRead, onCommit}: Props) {
+ gate?.read();
+ return mode === 'selector' ? (
+
+ ) : (
+
+ );
+}
diff --git a/website/docs/PERSIST_TUTORIAL.md b/website/docs/PERSIST_TUTORIAL.md
index 8f89ed5..11487d1 100644
--- a/website/docs/PERSIST_TUTORIAL.md
+++ b/website/docs/PERSIST_TUTORIAL.md
@@ -530,6 +530,8 @@ handle.unsubscribe();
todoStore.addTodo('This is not persisted');
```
+Pending storage reads that finish after unsubscribe no longer apply state, clear expired entries, or invoke read-error callbacks. Hydration promises still settle when the read finishes. This prevents a retired React effect from overwriting a newer scope during Strict Mode replay, resets, or storage configuration changes. Already-issued adapter I/O is not cancelled; cleanup cannot undo a write already sent to storage. See the [typed context lifecycle example](./REACT_SCOPING.md).
+
---
## Multiple Persistence Targets
diff --git a/website/docs/REACT_SCOPING.md b/website/docs/REACT_SCOPING.md
new file mode 100644
index 0000000..d690c35
--- /dev/null
+++ b/website/docs/REACT_SCOPING.md
@@ -0,0 +1,152 @@
+# React store scoping and lifecycle
+
+Use React context to choose a store instance for a subtree, then `useClassyStore` to subscribe to that instance. This is useful for independent forms, editors, nested workspaces, and request-scoped server rendering.
+
+The runnable [scoped editors demo](https://codebelt.github.io/classy-store/demos/#/scoped-stores) shows two independent editors and a nested editor. Its [source](https://github.com/codeBelt/classy-store/tree/main/examples/react/src/demos/scoped-store) is an application example built from existing APIs; there is no new context factory to install.
+
+## Define a typed context
+
+Share the context definition across the application. Create store instances inside each provider, so server requests and sibling providers cannot accidentally share mutable state.
+
+```ts
+// editorStore.ts
+export type EditorData = {title: string; body: string};
+
+export class EditorStore {
+ title: string;
+ body: string;
+
+ constructor(initialData: EditorData) {
+ this.title = initialData.title;
+ this.body = initialData.body;
+ }
+
+ setTitle(title: string) { this.title = title; }
+ setBody(body: string) { this.body = body; }
+}
+```
+
+```ts
+// editorStoreContext.ts
+import {createContext} from 'react';
+import type {EditorStore} from './editorStore';
+
+export const EditorStoreContext = createContext(null);
+```
+
+```ts
+// hooks/useEditorStore.ts
+import {useContext} from 'react';
+import {EditorStoreContext} from '../editorStoreContext';
+
+export function useEditorStore() {
+ const store = useContext(EditorStoreContext);
+ if (!store) {
+ throw new Error('useEditorStore must be used within EditorStoreProvider');
+ }
+ return store;
+}
+```
+
+The hook returns a typed mutable `EditorStore`, with its methods intact. It fails clearly when a provider is missing. React resolves the [closest matching provider](https://react.dev/reference/react/useContext), so a nested provider overrides the instance only for its own subtree.
+
+## Let the provider own creation and effects
+
+The example provider initializes once per mount and captures the initial hydration data before starting persistence:
+
+```tsx
+// components/editorStoreProvider/EditorStoreProvider.tsx
+import {prepareStoreForHydration, useLocalStore} from '@codebelt/classy-store/react';
+import {persist, type StorageAdapter} from '@codebelt/classy-store/utils';
+import {type ReactNode, useEffect} from 'react';
+import {type EditorData, EditorStore} from '../../editorStore';
+import {EditorStoreContext} from '../../editorStoreContext';
+
+type Props = {
+ initialData: EditorData;
+ children: ReactNode;
+ persistence?: {name: string; storage: StorageAdapter};
+};
+
+export function EditorStoreProvider({initialData, children, persistence}: Props) {
+ const store = prepareStoreForHydration(
+ useLocalStore(() => new EditorStore(initialData)),
+ );
+ const name = persistence?.name;
+ const storage = persistence?.storage;
+
+ useEffect(() => {
+ if (name === undefined || storage === undefined) return;
+ const handle = persist(store, {
+ name,
+ storage,
+ properties: ['title', 'body'],
+ skipHydration: true,
+ });
+ void handle.rehydrate();
+ return () => handle.unsubscribe();
+ }, [store, name, storage]);
+
+ return (
+
+ {children}
+
+ );
+}
+```
+
+Keep constructors and initializers pure: Strict Mode can evaluate them more than once. Start subscriptions, timers, and storage work in effects with cleanup. The persistence effect depends on the storage adapter and name, so passing a new options object with those same values does not restart it. Keep a custom storage adapter's identity stable too.
+
+The context holds the stable store proxy. Consumers read snapshots through the existing React hook and send edits through store methods:
+
+```tsx
+// components/editorTitle/EditorTitle.tsx
+import {useClassyStore} from '@codebelt/classy-store/react';
+import {useEditorStore} from '../../hooks/useEditorStore';
+
+export function EditorTitle() {
+ const store = useEditorStore();
+ const title = useClassyStore(store, (state) => state.title);
+ return store.setTitle(event.target.value)} />;
+}
+```
+
+Auto-tracking also works: `const state = useClassyStore(useEditorStore())`. Reading fields directly from the mutable store during render does not subscribe to mutations. Avoid creating a module-level bound hook for one store if consumers must select different instances through context.
+
+## Preserve drafts and reset intentionally
+
+`initialData` is an initializer. New props or an ordinary parent render leave the existing store and draft intact. Use a [changed React key](https://react.dev/learn/preserving-and-resetting-state#resetting-state-with-a-key) to create a fresh session:
+
+```tsx
+
+
+
+```
+
+Change `documentId` when moving to a different document; increment `revision` for an explicit reset. A key change remounts the provider's whole subtree, including nested providers. Resetting a nested provider leaves its parent and siblings intact.
+
+| Event | Store and effect behavior |
+| --- | --- |
+| Parent render or changed `initialData`, same key | Preserve the existing store and edits |
+| Provider key changes | Create a new store and clean up the old subtree |
+| Nested provider | Consumers inside it use its independent store |
+| Storage name or adapter changes | Keep the store; detach old persistence and load from the new configuration |
+| Persistence removed | Keep current state and stop persistence |
+| Provider unmounts | React releases its store reference and effect cleanup detaches persistence |
+
+A reset does **not** clear saved data. If the new provider uses the same persistence key, it restores that saved draft. To reset saved data as well, first unmount or disable the old persistence owner, await `storage.removeItem(name)`, then remount. Use distinct storage keys for independent documents or users. Different React providers using the same storage key still share that storage namespace.
+
+Cleanup prevents pending storage reads from applying after unsubscribe, including the discarded first effect in Strict Mode. Such reads still finish and their promises settle; the adapter's I/O is not cancelled. Already-issued writes cannot be undone by cleanup. External code retaining a store can still mutate it after unmount, but its disposed persistence owner and unmounted consumers no longer react. `useLocalStore` does not automatically call application-specific `dispose()` methods.
+
+## Server rendering and hydration
+
+Render a fresh provider for each server request. Transfer only its initial data through the framework's serialization facility, then initialize the client provider from that same data. The provider prepares the initial snapshot before any persistence effect, so a delayed Suspense boundary can hydrate correctly even after another part of the tree restores browser storage. See the [SSR guide](./REACT_SSR.md) for transport and streaming details.
+
+Browser storage belongs in the client setup; do not access `localStorage` at module initialization on the server. Effects do not run during server rendering. The server and client's initial markup must match regardless of whether browser persistence is enabled.
+
+## Regression coverage
+
+`bun run test:react:compatibility` copies the actual example provider, context, hook, and class into isolated installed-package consumers on React 18.0, 18.3, and 19.3. Tests cover sibling and nested isolation, missing providers, stable initialization, keyed resets, Strict Mode, persistence replacement/removal, pending-read cleanup, request isolation, and delayed Suspense hydration with DOM reuse. Strict type checks verify mutable commands, readonly snapshots, selector inference, and provider props.
diff --git a/website/docs/REACT_SSR.md b/website/docs/REACT_SSR.md
index 16c0c1c..f83ee52 100644
--- a/website/docs/REACT_SSR.md
+++ b/website/docs/REACT_SSR.md
@@ -2,6 +2,8 @@
Create one store for each server request and reconstruct a client store from the same initial data. Pass the store through props or React context. A module-level store on the server shares mutable state across requests and can expose one user's data to another.
+The [typed context example](./REACT_SCOPING.md) provides a complete provider pattern with independent instances, nested scopes, keyed resets, and persistence cleanup.
+
Call `prepareStoreForHydration(store)` after initializing the store with the data used to render the page, on both the server and client. It captures that initial snapshot before persistence or other live updates begin. All direct and bound hooks use the captured data during server rendering and client hydration, then read the current live store. React requires the initial server and client data to match; see [React's external-store SSR guidance](https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering).
## Initialize from request data
diff --git a/website/docs/index.md b/website/docs/index.md
index e5dbe45..9c130bb 100644
--- a/website/docs/index.md
+++ b/website/docs/index.md
@@ -219,6 +219,8 @@ function Counter() {
Subsequent re-renders reuse the same store instance. Keep the factory pure: React StrictMode can invoke the initializer more than once in development.
+For a shared subtree, use the [typed context and lifecycle example](./REACT_SCOPING.md). It covers independent and nested instances, explicit resets with React keys, effect cleanup, persistence, and SSR. `useLocalStore` owns the instance reference; application side effects still need cleanup in their effects.
+
> See the [Local Stores](./TUTORIAL.md#local-stores) section in the Tutorial for persistence patterns and more examples.
### `createStoreHook(store)`
diff --git a/website/sidebars.ts b/website/sidebars.ts
index 2a04e02..a8b0d9c 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -10,6 +10,7 @@ const sidebars: SidebarsConfig = {
items: [
'TUTORIAL',
'REACT_SSR',
+ 'REACT_SCOPING',
'PERSIST_TUTORIAL',
'DEVTOOLS_TUTORIAL',
'SUBSCRIBE_KEY_TUTORIAL',
diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt
index 69f1a78..f5c1493 100644
--- a/website/static/llms-full.txt
+++ b/website/static/llms-full.txt
@@ -229,6 +229,8 @@ function Counter() {
Subsequent re-renders reuse the same store instance. Keep the factory pure: React StrictMode can invoke the initializer more than once in development.
+For a shared subtree, use the [typed context and lifecycle example](./REACT_SCOPING.md). It covers independent and nested instances, explicit resets with React keys, effect cleanup, persistence, and SSR. `useLocalStore` owns the instance reference; application side effects still need cleanup in their effects.
+
> See the [Local Stores](./TUTORIAL.md#local-stores) section in the Tutorial for persistence patterns and more examples.
### `createStoreHook(store)`
@@ -1312,6 +1314,8 @@ SOURCE: https://codebelt.github.io/classy-store/docs/REACT_SSR
Create one store for each server request and reconstruct a client store from the same initial data. Pass the store through props or React context. A module-level store on the server shares mutable state across requests and can expose one user's data to another.
+The [typed context example](./REACT_SCOPING.md) provides a complete provider pattern with independent instances, nested scopes, keyed resets, and persistence cleanup.
+
Call `prepareStoreForHydration(store)` after initializing the store with the data used to render the page, on both the server and client. It captures that initial snapshot before persistence or other live updates begin. All direct and bound hooks use the captured data during server rendering and client hydration, then read the current live store. React requires the initial server and client data to match; see [React's external-store SSR guidance](https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering).
## Initialize from request data
@@ -1413,6 +1417,163 @@ Client-only apps need no preparation. `createRoot` reads current state even for
React subscriptions notify synchronously by default, including after hydration. Ordinary `subscribe(store, listener)` callbacks remain batched. The React hooks do not have a `sync` option. A normal sequence of mutations in an event can be batched into one React commit, but selectors may run after each mutation; this is not a transaction API.
+---
+SOURCE: https://codebelt.github.io/classy-store/docs/REACT_SCOPING
+---
+
+# React store scoping and lifecycle
+
+Use React context to choose a store instance for a subtree, then `useClassyStore` to subscribe to that instance. This is useful for independent forms, editors, nested workspaces, and request-scoped server rendering.
+
+The runnable [scoped editors demo](https://codebelt.github.io/classy-store/demos/#/scoped-stores) shows two independent editors and a nested editor. Its [source](https://github.com/codeBelt/classy-store/tree/main/examples/react/src/demos/scoped-store) is an application example built from existing APIs; there is no new context factory to install.
+
+## Define a typed context
+
+Share the context definition across the application. Create store instances inside each provider, so server requests and sibling providers cannot accidentally share mutable state.
+
+```ts
+// editorStore.ts
+export type EditorData = {title: string; body: string};
+
+export class EditorStore {
+ title: string;
+ body: string;
+
+ constructor(initialData: EditorData) {
+ this.title = initialData.title;
+ this.body = initialData.body;
+ }
+
+ setTitle(title: string) { this.title = title; }
+ setBody(body: string) { this.body = body; }
+}
+```
+
+```ts
+// editorStoreContext.ts
+import {createContext} from 'react';
+import type {EditorStore} from './editorStore';
+
+export const EditorStoreContext = createContext(null);
+```
+
+```ts
+// hooks/useEditorStore.ts
+import {useContext} from 'react';
+import {EditorStoreContext} from '../editorStoreContext';
+
+export function useEditorStore() {
+ const store = useContext(EditorStoreContext);
+ if (!store) {
+ throw new Error('useEditorStore must be used within EditorStoreProvider');
+ }
+ return store;
+}
+```
+
+The hook returns a typed mutable `EditorStore`, with its methods intact. It fails clearly when a provider is missing. React resolves the [closest matching provider](https://react.dev/reference/react/useContext), so a nested provider overrides the instance only for its own subtree.
+
+## Let the provider own creation and effects
+
+The example provider initializes once per mount and captures the initial hydration data before starting persistence:
+
+```tsx
+// components/editorStoreProvider/EditorStoreProvider.tsx
+import {prepareStoreForHydration, useLocalStore} from '@codebelt/classy-store/react';
+import {persist, type StorageAdapter} from '@codebelt/classy-store/utils';
+import {type ReactNode, useEffect} from 'react';
+import {type EditorData, EditorStore} from '../../editorStore';
+import {EditorStoreContext} from '../../editorStoreContext';
+
+type Props = {
+ initialData: EditorData;
+ children: ReactNode;
+ persistence?: {name: string; storage: StorageAdapter};
+};
+
+export function EditorStoreProvider({initialData, children, persistence}: Props) {
+ const store = prepareStoreForHydration(
+ useLocalStore(() => new EditorStore(initialData)),
+ );
+ const name = persistence?.name;
+ const storage = persistence?.storage;
+
+ useEffect(() => {
+ if (name === undefined || storage === undefined) return;
+ const handle = persist(store, {
+ name,
+ storage,
+ properties: ['title', 'body'],
+ skipHydration: true,
+ });
+ void handle.rehydrate();
+ return () => handle.unsubscribe();
+ }, [store, name, storage]);
+
+ return (
+
+ {children}
+
+ );
+}
+```
+
+Keep constructors and initializers pure: Strict Mode can evaluate them more than once. Start subscriptions, timers, and storage work in effects with cleanup. The persistence effect depends on the storage adapter and name, so passing a new options object with those same values does not restart it. Keep a custom storage adapter's identity stable too.
+
+The context holds the stable store proxy. Consumers read snapshots through the existing React hook and send edits through store methods:
+
+```tsx
+// components/editorTitle/EditorTitle.tsx
+import {useClassyStore} from '@codebelt/classy-store/react';
+import {useEditorStore} from '../../hooks/useEditorStore';
+
+export function EditorTitle() {
+ const store = useEditorStore();
+ const title = useClassyStore(store, (state) => state.title);
+ return store.setTitle(event.target.value)} />;
+}
+```
+
+Auto-tracking also works: `const state = useClassyStore(useEditorStore())`. Reading fields directly from the mutable store during render does not subscribe to mutations. Avoid creating a module-level bound hook for one store if consumers must select different instances through context.
+
+## Preserve drafts and reset intentionally
+
+`initialData` is an initializer. New props or an ordinary parent render leave the existing store and draft intact. Use a [changed React key](https://react.dev/learn/preserving-and-resetting-state#resetting-state-with-a-key) to create a fresh session:
+
+```tsx
+
+
+
+```
+
+Change `documentId` when moving to a different document; increment `revision` for an explicit reset. A key change remounts the provider's whole subtree, including nested providers. Resetting a nested provider leaves its parent and siblings intact.
+
+| Event | Store and effect behavior |
+| --- | --- |
+| Parent render or changed `initialData`, same key | Preserve the existing store and edits |
+| Provider key changes | Create a new store and clean up the old subtree |
+| Nested provider | Consumers inside it use its independent store |
+| Storage name or adapter changes | Keep the store; detach old persistence and load from the new configuration |
+| Persistence removed | Keep current state and stop persistence |
+| Provider unmounts | React releases its store reference and effect cleanup detaches persistence |
+
+A reset does **not** clear saved data. If the new provider uses the same persistence key, it restores that saved draft. To reset saved data as well, first unmount or disable the old persistence owner, await `storage.removeItem(name)`, then remount. Use distinct storage keys for independent documents or users. Different React providers using the same storage key still share that storage namespace.
+
+Cleanup prevents pending storage reads from applying after unsubscribe, including the discarded first effect in Strict Mode. Such reads still finish and their promises settle; the adapter's I/O is not cancelled. Already-issued writes cannot be undone by cleanup. External code retaining a store can still mutate it after unmount, but its disposed persistence owner and unmounted consumers no longer react. `useLocalStore` does not automatically call application-specific `dispose()` methods.
+
+## Server rendering and hydration
+
+Render a fresh provider for each server request. Transfer only its initial data through the framework's serialization facility, then initialize the client provider from that same data. The provider prepares the initial snapshot before any persistence effect, so a delayed Suspense boundary can hydrate correctly even after another part of the tree restores browser storage. See the [SSR guide](./REACT_SSR.md) for transport and streaming details.
+
+Browser storage belongs in the client setup; do not access `localStorage` at module initialization on the server. Effects do not run during server rendering. The server and client's initial markup must match regardless of whether browser persistence is enabled.
+
+## Regression coverage
+
+`bun run test:react:compatibility` copies the actual example provider, context, hook, and class into isolated installed-package consumers on React 18.0, 18.3, and 19.3. Tests cover sibling and nested isolation, missing providers, stable initialization, keyed resets, Strict Mode, persistence replacement/removal, pending-read cleanup, request isolation, and delayed Suspense hydration with DOM reuse. Strict type checks verify mutable commands, readonly snapshots, selector inference, and provider props.
+
---
SOURCE: https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL
---
@@ -2602,7 +2763,7 @@ Auto-detection: `syncTabs` defaults to `true` when the storage adapter is litera
| Method/Property | Behavior |
|---|---|
-| `unsubscribe()` | Full cleanup: unsubscribe + cancel debounce + remove storage listener. Idempotent. |
+| `unsubscribe()` | Unsubscribe, cancel debounce, remove the storage listener, and ignore pending read results/errors. Already-issued adapter I/O is not cancelled. Idempotent. |
| `hydrated` | Promise that resolves when initial hydration completes. |
| `isHydrated` | `boolean`. Becomes `true` after hydration. |
| `isExpired` | `boolean`. `true` when the last hydration found expired data. |
diff --git a/website/static/llms.txt b/website/static/llms.txt
index 11ba3a6..e3c45e1 100644
--- a/website/static/llms.txt
+++ b/website/static/llms.txt
@@ -37,6 +37,7 @@
## Optional: Full Documentation
- [React SSR](https://codebelt.github.io/classy-store/docs/REACT_SSR): `prepareStoreForHydration`, request-scoped stores, and persistence-safe streamed/selective hydration
+- [React store scoping](https://codebelt.github.io/classy-store/docs/REACT_SCOPING): Typed context providers, independent and nested instances, keyed resets, persistence cleanup, and SSR
- [llms-full.txt](https://codebelt.github.io/classy-store/llms-full.txt): All documentation
concatenated into one file for LLM context windows
From 583c53236a45804dc013a9d728abfceb1013e323 Mon Sep 17 00:00:00 2001
From: codeBelt
Date: Tue, 20 Oct 2026 17:25:33 -0500
Subject: [PATCH 5/9] perf(react): cache frozen snapshot targets across
consumers
---
.changeset/react-snapshot-target-cache.md | 5 +
.github/workflows/ci.yml | 6 +
README.md | 2 +
package.json | 1 +
packages/classy-store/package.json | 2 +-
.../react/react.performance.qa.test.tsx | 221 +++++++++++++
.../src/frameworks/react/react.ts | 12 +-
.../react/testHelpers/performanceQa.types.ts | 11 +
.../PerformanceQaComputedView.tsx | 15 +
.../performanceQaView/PerformanceQaView.tsx | 33 ++
.../snapshotTargetCache.test.ts | 85 +++++
.../snapshotTargetCache.ts | 31 ++
.../classy-store/tests/compiler/README.md | 8 +
.../tests/compiler/compiler.scale.test.tsx | 250 +++++++++++++++
.../scaleSelectorField/ScaleSelectorField.tsx | 35 ++
.../scaleSelectorRow/ScaleSelectorRow.tsx | 32 ++
.../scaleTrackedField/ScaleTrackedField.tsx | 36 +++
.../scaleTrackedRow/ScaleTrackedRow.tsx | 30 ++
.../tests/compiler/runCompiler.mjs | 28 +-
.../tests/compiler/scale.store.ts | 25 ++
.../tests/compiler/scale.types.ts | 8 +
.../classy-store/tests/performance/README.md | 84 +++++
.../classy-store/tests/performance/RESULTS.md | 71 +++++
.../classy-store/tests/performance/client.tsx | 89 ++++++
.../components/scaleApp/ScaleApp.tsx | 41 +++
.../selectorField/SelectorField.tsx | 22 ++
.../components/selectorRow/SelectorRow.tsx | 17 +
.../components/trackedField/TrackedField.tsx | 19 ++
.../components/trackedRow/TrackedRow.tsx | 15 +
.../tests/performance/performance.store.ts | 16 +
.../tests/performance/performance.types.ts | 47 +++
.../tests/performance/performanceMetrics.ts | 48 +++
.../tests/performance/runPerformance.ts | 301 ++++++++++++++++++
.../tests/performance/tsconfig.json | 5 +
34 files changed, 1643 insertions(+), 8 deletions(-)
create mode 100644 .changeset/react-snapshot-target-cache.md
create mode 100644 packages/classy-store/src/frameworks/react/react.performance.qa.test.tsx
create mode 100644 packages/classy-store/src/frameworks/react/testHelpers/performanceQa.types.ts
create mode 100644 packages/classy-store/src/frameworks/react/testHelpers/performanceQaComputedView/PerformanceQaComputedView.tsx
create mode 100644 packages/classy-store/src/frameworks/react/testHelpers/performanceQaView/PerformanceQaView.tsx
create mode 100644 packages/classy-store/src/frameworks/react/utils/snapshotTargetCache/snapshotTargetCache.test.ts
create mode 100644 packages/classy-store/src/frameworks/react/utils/snapshotTargetCache/snapshotTargetCache.ts
create mode 100644 packages/classy-store/tests/compiler/compiler.scale.test.tsx
create mode 100644 packages/classy-store/tests/compiler/fixtures/scaleSelectorField/ScaleSelectorField.tsx
create mode 100644 packages/classy-store/tests/compiler/fixtures/scaleSelectorRow/ScaleSelectorRow.tsx
create mode 100644 packages/classy-store/tests/compiler/fixtures/scaleTrackedField/ScaleTrackedField.tsx
create mode 100644 packages/classy-store/tests/compiler/fixtures/scaleTrackedRow/ScaleTrackedRow.tsx
create mode 100644 packages/classy-store/tests/compiler/scale.store.ts
create mode 100644 packages/classy-store/tests/compiler/scale.types.ts
create mode 100644 packages/classy-store/tests/performance/README.md
create mode 100644 packages/classy-store/tests/performance/RESULTS.md
create mode 100644 packages/classy-store/tests/performance/client.tsx
create mode 100644 packages/classy-store/tests/performance/components/scaleApp/ScaleApp.tsx
create mode 100644 packages/classy-store/tests/performance/components/selectorField/SelectorField.tsx
create mode 100644 packages/classy-store/tests/performance/components/selectorRow/SelectorRow.tsx
create mode 100644 packages/classy-store/tests/performance/components/trackedField/TrackedField.tsx
create mode 100644 packages/classy-store/tests/performance/components/trackedRow/TrackedRow.tsx
create mode 100644 packages/classy-store/tests/performance/performance.store.ts
create mode 100644 packages/classy-store/tests/performance/performance.types.ts
create mode 100644 packages/classy-store/tests/performance/performanceMetrics.ts
create mode 100644 packages/classy-store/tests/performance/runPerformance.ts
create mode 100644 packages/classy-store/tests/performance/tsconfig.json
diff --git a/.changeset/react-snapshot-target-cache.md b/.changeset/react-snapshot-target-cache.md
new file mode 100644
index 0000000..19d65ff
--- /dev/null
+++ b/.changeset/react-snapshot-target-cache.md
@@ -0,0 +1,5 @@
+---
+'@codebelt/classy-store': patch
+---
+
+Reuse guarded backing copies of frozen snapshots across React auto-tracked consumers. Large forms and lists avoid repeatedly scanning and copying the same snapshot array while each hook retains its own tracked reads. Reflective mutations of tracking targets are rejected, and mutable computed values remain uncached.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6587cd0..b4f3ad5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,3 +22,9 @@ jobs:
- run: bunx playwright install --with-deps chromium firefox webkit
working-directory: packages/classy-store
- run: bun run test:browser
+ - name: React scale commit budgets (production, Compiler off/on)
+ run: bun packages/classy-store/tests/performance/runPerformance.ts
+ env:
+ BENCHMARK_SAMPLES: '1'
+ BENCHMARK_ITERATIONS: '3'
+ BENCHMARK_LABEL: ci-smoke
diff --git a/README.md b/README.md
index 0a6515b..e19f5f7 100644
--- a/README.md
+++ b/README.md
@@ -120,3 +120,5 @@ This library wouldn't exist without the ideas pioneered by these projects. Each
## π€ Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md) for details on how to contribute and the release workflow.
+
+Run `bun run benchmark:react` to compare large forms and lists with selectors, auto-tracking, and React Compiler. See the [benchmark methodology](./packages/classy-store/tests/performance/README.md) for measurements and reproduction settings.
diff --git a/package.json b/package.json
index a44681b..b2148f0 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"test:browser": "bun run --filter @codebelt/classy-store test:browser",
"test:react:compatibility": "bun run build && node packages/classy-store/tests/compatibility/runCompatibility.mjs",
"test:react:compiler": "bun run build && node packages/classy-store/tests/compiler/runCompiler.mjs",
+ "benchmark:react": "bun run build && bun packages/classy-store/tests/performance/runPerformance.ts",
"---------- Lint --------------------------------------------------------": "",
"lint": "biome check",
"lint:fix": "biome check --write",
diff --git a/packages/classy-store/package.json b/packages/classy-store/package.json
index 900e61d..a959a68 100644
--- a/packages/classy-store/package.json
+++ b/packages/classy-store/package.json
@@ -110,7 +110,7 @@
"test": "bun test src",
"test:browser": "playwright test --config tests/browser/playwright.config.ts",
"---------- Lint --------------------------------------------------------": "",
- "typecheck": "tsc --noEmit && tsc --project tests/browser/tsconfig.json --noEmit && tsc --project tests/compiler/tsconfig.json --noEmit",
+ "typecheck": "tsc --noEmit && tsc --project tests/browser/tsconfig.json --noEmit && tsc --project tests/compiler/tsconfig.json --noEmit && tsc --project tests/performance/tsconfig.json --noEmit",
"typecheck:scoping": "tsc --project ../../examples/react/tsconfig.scoping.json --noEmit",
"prepublishOnly": "cp ../../README.md .",
"-----------------------------------------------------------------------": ""
diff --git a/packages/classy-store/src/frameworks/react/react.performance.qa.test.tsx b/packages/classy-store/src/frameworks/react/react.performance.qa.test.tsx
new file mode 100644
index 0000000..0de328f
--- /dev/null
+++ b/packages/classy-store/src/frameworks/react/react.performance.qa.test.tsx
@@ -0,0 +1,221 @@
+import {afterEach, describe, expect, test} from 'bun:test';
+import {act, createElement, type ReactNode} from 'react';
+import {createRoot, type Root} from 'react-dom/client';
+import {createClassyStore, getInternal} from '../../core/core';
+import {snapshot} from '../../snapshot/snapshot';
+import type {Snapshot} from '../../types';
+import type {
+ PerformanceQaCommit,
+ PerformanceQaStore,
+} from './testHelpers/performanceQa.types';
+import {PerformanceQaComputedView} from './testHelpers/performanceQaComputedView/PerformanceQaComputedView';
+import {PerformanceQaView} from './testHelpers/performanceQaView/PerformanceQaView';
+
+const mounted = new Map();
+
+function mount(element: ReactNode) {
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ mounted.set(root, container);
+ act(() => root.render(element));
+ return {root, container};
+}
+
+function createStore(size = 2) {
+ return createClassyStore({
+ rows: Array.from({length: size}, (_, index) => ({
+ name: `Row ${index}`,
+ ignored: 0,
+ })),
+ ignored: 0,
+ });
+}
+
+function createCommits() {
+ const counts = new Map();
+ const states = new Map>();
+ const onCommit: PerformanceQaCommit = (id, state) => {
+ counts.set(id, (counts.get(id) ?? 0) + 1);
+ states.set(id, state);
+ };
+ return {counts, states, onCommit};
+}
+
+afterEach(() => {
+ for (const [root, container] of mounted) {
+ act(() => root.unmount());
+ container.remove();
+ }
+ mounted.clear();
+});
+
+describe('useClassyStore performance independent QA', () => {
+ test('keeps shared snapshot targets independent across many consumer read sets', () => {
+ const store = createStore(128);
+ const {counts, states, onCommit} = createCommits();
+ const {container} = mount(
+ Array.from({length: 128}, (_, index) =>
+ createElement(PerformanceQaView, {
+ key: index,
+ id: String(index),
+ store,
+ index,
+ onCommit,
+ }),
+ ),
+ );
+ expect([...counts.values()]).toEqual(Array(128).fill(1));
+ expect(states.get('0')).not.toBe(states.get('1'));
+
+ act(() => {
+ store.rows[37].name = 'Changed';
+ });
+ expect([...counts.entries()].filter(([, count]) => count > 1)).toEqual([
+ ['37', 2],
+ ]);
+ expect(container.querySelector('[data-consumer="37"]')?.textContent).toBe(
+ ':Changed',
+ );
+
+ act(() => {
+ store.ignored += 1;
+ store.rows[37].ignored += 1;
+ store.rows[90].ignored += 1;
+ });
+ expect([...counts.entries()].filter(([, count]) => count > 1)).toEqual([
+ ['37', 2],
+ ]);
+
+ act(() => {
+ store.rows[90].name = 'Other';
+ });
+ expect([...counts.entries()].filter(([, count]) => count > 1)).toEqual([
+ ['37', 2],
+ ['90', 2],
+ ]);
+ });
+
+ test.each([
+ {
+ description: 'root',
+ select: (state: Snapshot) => state,
+ },
+ {
+ description: 'array',
+ select: (state: Snapshot) => state.rows,
+ },
+ {
+ description: 'nested row',
+ select: (state: Snapshot) => state.rows[0],
+ },
+ ])('protects shared $description targets starting with the first consumer', ({
+ select,
+ }) => {
+ const store = createStore();
+ const {states, counts, onCommit} = createCommits();
+ const {container} = mount([
+ ,
+ ,
+ ]);
+
+ for (const id of ['first', 'second']) {
+ const state = states.get(id);
+ expect(state).toBeDefined();
+ if (!state) throw new Error('Missing committed snapshot');
+ const target = select(state);
+ const prototype = Object.getPrototypeOf(target);
+ expect(
+ Reflect.defineProperty(target, 'injected', {value: 'poison'}),
+ ).toBe(false);
+ expect(Reflect.setPrototypeOf(target, {injected: 'poison'})).toBe(false);
+ expect(Reflect.preventExtensions(target)).toBe(false);
+ expect(Reflect.set(target, 'injected', 'poison')).toBe(false);
+ expect(
+ Reflect.deleteProperty(target, Array.isArray(target) ? '0' : 'ignored'),
+ ).toBe(false);
+ expect(Object.getPrototypeOf(target)).toBe(prototype);
+ expect('injected' in target).toBe(false);
+ }
+
+ expect(snapshot(store).rows.map((row) => row.name)).toEqual([
+ 'Row 0',
+ 'Row 1',
+ ]);
+ expect(container.textContent).toBe(':Row 0:Row 1');
+ act(() => {
+ store.rows[0].name = 'Still live';
+ });
+ expect(container.textContent).toBe(':Still live:Row 1');
+ expect(counts.get('first')).toBe(2);
+ expect(counts.get('second')).toBe(1);
+ });
+
+ test('keeps target reuse independent when a consumer changes stores and unmounts', () => {
+ const firstStore = createStore();
+ const secondStore = createStore();
+ const {counts, onCommit} = createCommits();
+ const view = (store: PerformanceQaStore) => (
+
+ );
+ const {root, container} = mount(view(firstStore));
+ act(() => root.render(view(secondStore)));
+ expect(getInternal(firstStore).syncListeners.size).toBe(0);
+ expect(getInternal(secondStore).syncListeners.size).toBe(1);
+ const before = counts.get('current');
+ act(() => {
+ firstStore.rows[0].name = 'Retired';
+ });
+ expect(counts.get('current')).toBe(before);
+ expect(container.textContent).toBe(':Row 0');
+ act(() => {
+ secondStore.rows[0].name = 'Current';
+ });
+ expect(container.textContent).toBe(':Current');
+ act(() => root.render(null));
+ expect(getInternal(secondStore).syncListeners.size).toBe(0);
+ });
+
+ test('rechecks mutable getter results if they become frozen after first access', () => {
+ class DerivedStore {
+ name = 'Initial';
+
+ get derived() {
+ return {child: {name: this.name}};
+ }
+ }
+ const store = createClassyStore(new DerivedStore());
+ const derived = snapshot(store).derived;
+ expect(Object.isFrozen(derived)).toBe(false);
+ const {root, container} = mount(
+ ,
+ );
+ expect(container.textContent).toBe('first:Initial');
+ Object.freeze(derived);
+ act(() =>
+ root.render(),
+ );
+ expect(container.textContent).toBe('again:Initial');
+ act(() => {
+ store.name = 'Updated';
+ });
+ expect(container.textContent).toBe('again:Updated');
+ });
+});
diff --git a/packages/classy-store/src/frameworks/react/react.ts b/packages/classy-store/src/frameworks/react/react.ts
index f296c0d..2c938eb 100644
--- a/packages/classy-store/src/frameworks/react/react.ts
+++ b/packages/classy-store/src/frameworks/react/react.ts
@@ -14,6 +14,7 @@ import {
} from '../../core/core';
import {snapshot} from '../../snapshot/snapshot';
import type {Snapshot} from '../../types';
+import {SnapshotTargetCache} from './utils/snapshotTargetCache/snapshotTargetCache';
import {TrackingReadMap} from './utils/trackingReadMap/trackingReadMap';
type EqualityFn = (a: T, b: T) => boolean;
@@ -26,6 +27,10 @@ export type UseClassyStoreSelectorOptions = {
// consumers have hydrated and changed the live store.
const hydrationSnapshots = new WeakMap();
+// A frozen snapshot's backing copy is identical for every consumer. Without
+// this cache, each row rescans and copies the entire containing list on access.
+const snapshotTargets = new SnapshotTargetCache();
+
/**
* Capture the initial snapshot used for server rendering and client hydration.
*
@@ -179,7 +184,12 @@ export function useClassyStore(
return selector
? result
- : (createProxy(result as Snapshot, affected, proxyCache) as Snapshot);
+ : (createProxy(
+ result as Snapshot,
+ affected,
+ proxyCache,
+ snapshotTargets,
+ ) as Snapshot);
}
// ββ Selector mode logic (pure function, no hooks) βββββββββββββββββββββββββββββ
diff --git a/packages/classy-store/src/frameworks/react/testHelpers/performanceQa.types.ts b/packages/classy-store/src/frameworks/react/testHelpers/performanceQa.types.ts
new file mode 100644
index 0000000..7a93c87
--- /dev/null
+++ b/packages/classy-store/src/frameworks/react/testHelpers/performanceQa.types.ts
@@ -0,0 +1,11 @@
+import type {Snapshot} from '../../../types';
+
+export type PerformanceQaStore = {
+ rows: {name: string; ignored: number}[];
+ ignored: number;
+};
+
+export type PerformanceQaCommit = (
+ id: string,
+ state: Snapshot,
+) => void;
diff --git a/packages/classy-store/src/frameworks/react/testHelpers/performanceQaComputedView/PerformanceQaComputedView.tsx b/packages/classy-store/src/frameworks/react/testHelpers/performanceQaComputedView/PerformanceQaComputedView.tsx
new file mode 100644
index 0000000..00713d4
--- /dev/null
+++ b/packages/classy-store/src/frameworks/react/testHelpers/performanceQaComputedView/PerformanceQaComputedView.tsx
@@ -0,0 +1,15 @@
+import {useClassyStore} from '../../react';
+
+type Props = {
+ store: {derived: {child: {name: string}}};
+ label: string;
+};
+
+export function PerformanceQaComputedView({store, label}: Props) {
+ const state = useClassyStore(store);
+ return (
+
+ );
+}
diff --git a/packages/classy-store/src/frameworks/react/testHelpers/performanceQaView/PerformanceQaView.tsx b/packages/classy-store/src/frameworks/react/testHelpers/performanceQaView/PerformanceQaView.tsx
new file mode 100644
index 0000000..c068102
--- /dev/null
+++ b/packages/classy-store/src/frameworks/react/testHelpers/performanceQaView/PerformanceQaView.tsx
@@ -0,0 +1,33 @@
+import {useEffect} from 'react';
+import {useClassyStore} from '../../react';
+import type {
+ PerformanceQaCommit,
+ PerformanceQaStore,
+} from '../performanceQa.types';
+
+type Props = {
+ id: string;
+ store: PerformanceQaStore;
+ index: number;
+ label?: string;
+ onCommit: PerformanceQaCommit;
+};
+
+export function PerformanceQaView({
+ id,
+ store,
+ index,
+ label = '',
+ onCommit,
+}: Props) {
+ const state = useClassyStore(store);
+ const name = state.rows[index].name;
+ useEffect(() => {
+ onCommit(id, state);
+ });
+ return (
+
+ );
+}
diff --git a/packages/classy-store/src/frameworks/react/utils/snapshotTargetCache/snapshotTargetCache.test.ts b/packages/classy-store/src/frameworks/react/utils/snapshotTargetCache/snapshotTargetCache.test.ts
new file mode 100644
index 0000000..b8301fa
--- /dev/null
+++ b/packages/classy-store/src/frameworks/react/utils/snapshotTargetCache/snapshotTargetCache.test.ts
@@ -0,0 +1,85 @@
+import {describe, expect, spyOn, test} from 'bun:test';
+import {createProxy, isChanged} from 'proxy-compare';
+import {SnapshotTargetCache} from './snapshotTargetCache';
+
+describe('SnapshotTargetCache', () => {
+ test('scans a shared frozen list once across hundreds of independent readers', () => {
+ const items = Object.freeze(
+ Array.from({length: 1000}, (_, index) => Object.freeze({value: index})),
+ );
+ const source = Object.freeze({items});
+ const cache = new SnapshotTargetCache();
+ const descriptors = Object.getOwnPropertyDescriptors;
+ let listScans = 0;
+ const descriptorSpy = spyOn(
+ Object,
+ 'getOwnPropertyDescriptors',
+ ).mockImplementation((target) => {
+ if (target === items) listScans += 1;
+ return descriptors(target);
+ });
+ try {
+ for (let index = 0; index < 1000; index += 1) {
+ const reads = new WeakMap();
+ const proxyCache = new WeakMap();
+ const tracked = createProxy(source, reads, proxyCache, cache);
+ expect(tracked.items[index].value).toBe(index);
+ expect(createProxy(source, reads, proxyCache, cache)).toBe(tracked);
+ }
+ expect(listScans).toBe(1);
+ } finally {
+ descriptorSpy.mockRestore();
+ }
+ });
+
+ test('keeps reader dependencies separate while sharing backing copies', () => {
+ const source = Object.freeze({first: 1, second: 2});
+ const cache = new SnapshotTargetCache();
+ const firstReads = new WeakMap();
+ const secondReads = new WeakMap();
+ const first = createProxy(source, firstReads, new WeakMap(), cache);
+ const second = createProxy(source, secondReads, new WeakMap(), cache);
+ expect(first.first).toBe(1);
+ expect(second.second).toBe(2);
+ const next = Object.freeze({first: 3, second: 2});
+ expect(isChanged(source, next, firstReads)).toBe(true);
+ expect(isChanged(source, next, secondReads)).toBe(false);
+ });
+
+ test('protects both the initial and cached copies against reflective mutation', () => {
+ const source = Object.freeze({nested: Object.freeze({value: 1})});
+ const cache = new SnapshotTargetCache();
+ const first = createProxy(source, new WeakMap(), new WeakMap(), cache);
+ const second = createProxy(source, new WeakMap(), new WeakMap(), cache);
+ for (const tracked of [first, second]) {
+ expect(
+ Reflect.defineProperty(tracked, 'nested', {value: {value: 2}}),
+ ).toBe(false);
+ expect(Reflect.setPrototypeOf(tracked, null)).toBe(false);
+ expect(Reflect.preventExtensions(tracked)).toBe(false);
+ expect(tracked.nested.value).toBe(1);
+ expect(Reflect.set(tracked.nested, 'value', 2)).toBe(false);
+ expect(Reflect.deleteProperty(tracked.nested, 'value')).toBe(false);
+ }
+ expect(source.nested.value).toBe(1);
+ });
+
+ test('rechecks mutable getter results when their descriptors change', () => {
+ const mutable = {nested: {value: 1}};
+ const source = Object.freeze({
+ get data() {
+ return mutable;
+ },
+ });
+ const cache = new SnapshotTargetCache();
+ const tracked = createProxy(source, new WeakMap(), new WeakMap(), cache);
+ expect(tracked.data.nested.value).toBe(1);
+ expect(cache.has(mutable)).toBe(false);
+ Object.freeze(mutable);
+ expect(tracked.data.nested.value).toBe(1);
+ expect(cache.has(mutable)).toBe(true);
+ expect(
+ Reflect.defineProperty(tracked.data, 'nested', {value: {value: 2}}),
+ ).toBe(false);
+ });
+});
diff --git a/packages/classy-store/src/frameworks/react/utils/snapshotTargetCache/snapshotTargetCache.ts b/packages/classy-store/src/frameworks/react/utils/snapshotTargetCache/snapshotTargetCache.ts
new file mode 100644
index 0000000..de0ef4a
--- /dev/null
+++ b/packages/classy-store/src/frameworks/react/utils/snapshotTargetCache/snapshotTargetCache.ts
@@ -0,0 +1,31 @@
+const readonlyTarget: ProxyHandler = {
+ set: () => false,
+ deleteProperty: () => false,
+ defineProperty: () => false,
+ setPrototypeOf: () => false,
+ preventExtensions: () => false,
+};
+
+/**
+ * Share proxy-compare's descriptor scans and backing copies of frozen snapshots.
+ * Tracking proxies and read sets remain private to each hook. Weak keys let
+ * unreachable snapshots and their backing copies be collected together.
+ */
+export class SnapshotTargetCache extends WeakMap {
+ override set(key: object, value: unknown): this {
+ // proxy-compare 3's target-cache entries are [original, optional copy].
+ // Getter results can be mutable: never cache their descriptor decisions.
+ const entry = value as [object, object?];
+ if (!Object.isFrozen(entry[0])) return this;
+
+ if (entry[1]) {
+ // Copies need configurable descriptors so nested tracking proxies obey
+ // Proxy invariants. Upstream blocks assignment/deletion, but reflection
+ // could otherwise corrupt this shared copy. Guard the copy, not the
+ // per-consumer proxy. Update the tuple in place: createProxy's first call
+ // continues using this same tuple immediately after cache.set().
+ entry[1] = new Proxy(entry[1], readonlyTarget);
+ }
+ return super.set(key, entry);
+ }
+}
diff --git a/packages/classy-store/tests/compiler/README.md b/packages/classy-store/tests/compiler/README.md
index 4a01aa1..989be64 100644
--- a/packages/classy-store/tests/compiler/README.md
+++ b/packages/classy-store/tests/compiler/README.md
@@ -32,6 +32,14 @@ Independent QA fixtures under `fixtures/` are compiled by the same pipeline and
run alongside the main regressions. Browser fixtures reuse `compileConsumer`
for controlled inputs and actual browser scheduling.
+The scale regressions run against the same installed package both before and
+after the Compiler transform. They mount 256 controlled fields and 512 list rows
+with selectors and auto-tracking, then assert exact committed-render counts for
+relevant, unrelated, no-op, and multi-property updates. Parent/local renders
+exercise memoized dependency retention; removing and remounting rows verifies
+that later updates stay isolated. These tests use layout-effect commit counters,
+not render-time side effects or wall-clock thresholds.
+
Set `KEEP_COMPILER_TEMP=1` to retain the installed consumers, transformed source,
and compilation records. Set `BUN_EXECUTABLE` when testing with a specific Bun
installation. The matrix needs npm registry access for its isolated consumers.
diff --git a/packages/classy-store/tests/compiler/compiler.scale.test.tsx b/packages/classy-store/tests/compiler/compiler.scale.test.tsx
new file mode 100644
index 0000000..49e3a92
--- /dev/null
+++ b/packages/classy-store/tests/compiler/compiler.scale.test.tsx
@@ -0,0 +1,250 @@
+import {afterEach, describe, expect, test} from 'bun:test';
+import type {ComponentType} from 'react';
+import {createRoot, type Root} from 'react-dom/client';
+import {ScaleSelectorField} from './fixtures/scaleSelectorField/ScaleSelectorField';
+import {ScaleSelectorRow} from './fixtures/scaleSelectorRow/ScaleSelectorRow';
+import {ScaleTrackedField} from './fixtures/scaleTrackedField/ScaleTrackedField';
+import {ScaleTrackedRow} from './fixtures/scaleTrackedRow/ScaleTrackedRow';
+import {act} from './reactAct';
+import {createScaleStore} from './scale.store';
+import type {ScaleConsumerProps} from './scale.types';
+
+const mounted = new Map();
+const formSize = 256;
+const listSize = 512;
+const fieldCases = [
+ {description: 'selectors', Component: ScaleSelectorField},
+ {description: 'auto-tracking', Component: ScaleTrackedField},
+];
+const rowCases = [
+ {description: 'selectors', Component: ScaleSelectorRow},
+ {description: 'auto-tracking', Component: ScaleTrackedRow},
+];
+
+afterEach(async () => {
+ for (const [root, container] of mounted) {
+ await act(async () => root.unmount());
+ container.remove();
+ }
+ mounted.clear();
+});
+
+async function mountConsumers(
+ Component: ComponentType,
+ size: number,
+ kind: 'form' | 'list',
+) {
+ const store = createScaleStore(size);
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ mounted.set(root, container);
+ const commits: number[] = [];
+ const onCommit = (index: number) => commits.push(index);
+ const consumers = Array.from({length: size}, (_, index) => ({
+ id: `consumer-${index}`,
+ index,
+ }));
+ const render = (label = 'Initial', count = size) => {
+ const children = consumers
+ .slice(0, count)
+ .map(({id, index}) => (
+
+ ));
+ root.render(
+ kind === 'list' ?
+
+
+
+ );
+}
diff --git a/packages/classy-store/tests/next/fixture/app/workspace/layout.tsx b/packages/classy-store/tests/next/fixture/app/workspace/layout.tsx
new file mode 100644
index 0000000..d441dd7
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/app/workspace/layout.tsx
@@ -0,0 +1,20 @@
+import type {ReactNode} from 'react';
+import {WorkspaceRefresh} from '../../components/pages/workspace/workspaceRefresh/WorkspaceRefresh';
+import {FixtureStoreProvider} from '../../components/shared/fixtureStoreProvider/FixtureStoreProvider';
+import {StoreFields} from '../../components/shared/storeFields/StoreFields';
+
+export default function WorkspaceLayout({children}: {children: ReactNode}) {
+ return (
+
+
+
Shared workspace
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/classy-store/tests/next/fixture/components/pages/stream/serverStream/ServerStream.tsx b/packages/classy-store/tests/next/fixture/components/pages/stream/serverStream/ServerStream.tsx
new file mode 100644
index 0000000..88270af
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/components/pages/stream/serverStream/ServerStream.tsx
@@ -0,0 +1,9 @@
+import {waitForServerGate} from '../../../../utils/serverGate/serverGate';
+import {StreamGate} from '../streamGate/StreamGate';
+
+type Props = {ticket?: string};
+
+export async function ServerStream({ticket}: Props) {
+ if (ticket) await waitForServerGate(ticket);
+ return ;
+}
diff --git a/packages/classy-store/tests/next/fixture/components/pages/stream/streamFields/StreamFields.tsx b/packages/classy-store/tests/next/fixture/components/pages/stream/streamFields/StreamFields.tsx
new file mode 100644
index 0000000..619a2ba
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/components/pages/stream/streamFields/StreamFields.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import {useEffect} from 'react';
+import {getDiagnostics} from '../../../../utils/diagnostics/diagnostics';
+import {StoreFields} from '../../../shared/storeFields/StoreFields';
+
+export function StreamFields() {
+ useEffect(() => {
+ getDiagnostics().streamChildHydrated = true;
+ }, []);
+ return ;
+}
diff --git a/packages/classy-store/tests/next/fixture/components/pages/stream/streamGate/StreamGate.tsx b/packages/classy-store/tests/next/fixture/components/pages/stream/streamGate/StreamGate.tsx
new file mode 100644
index 0000000..35696ce
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/components/pages/stream/streamGate/StreamGate.tsx
@@ -0,0 +1,10 @@
+'use client';
+
+import {waitForClientGate} from '../../../../utils/clientGate/clientGate';
+import {StreamFields} from '../streamFields/StreamFields';
+
+export function StreamGate() {
+ // Model a delayed client chunk: no consumer hook runs before gate release.
+ waitForClientGate();
+ return ;
+}
diff --git a/packages/classy-store/tests/next/fixture/components/pages/workspace/workspaceRefresh/WorkspaceRefresh.tsx b/packages/classy-store/tests/next/fixture/components/pages/workspace/workspaceRefresh/WorkspaceRefresh.tsx
new file mode 100644
index 0000000..98b5d5e
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/components/pages/workspace/workspaceRefresh/WorkspaceRefresh.tsx
@@ -0,0 +1,16 @@
+'use client';
+
+import {useRouter} from 'next/navigation';
+
+export function WorkspaceRefresh() {
+ const router = useRouter();
+ return (
+
+ );
+}
diff --git a/packages/classy-store/tests/next/fixture/components/shared/fixtureStoreProvider/FixtureStoreProvider.tsx b/packages/classy-store/tests/next/fixture/components/shared/fixtureStoreProvider/FixtureStoreProvider.tsx
new file mode 100644
index 0000000..b0d1a85
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/components/shared/fixtureStoreProvider/FixtureStoreProvider.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import {
+ prepareStoreForHydration,
+ useLocalStore,
+} from '@codebelt/classy-store/react';
+import {persist} from '@codebelt/classy-store/utils';
+import {type ReactNode, useEffect, version} from 'react';
+import {FixtureStore} from '../../../stores/fixtureStore/fixtureStore.store';
+import {FixtureStoreContext} from '../../../stores/fixtureStore/fixtureStoreContext';
+import type {StoreData, StoreScope} from '../../../types/fixture.types';
+import {getDiagnostics} from '../../../utils/diagnostics/diagnostics';
+import {rememberDisposedStore} from '../../../utils/disposedStores/disposedStores';
+import {StoreSelector} from '../storeSelector/StoreSelector';
+import {StoreTracked} from '../storeTracked/StoreTracked';
+
+type Props = {
+ initialData: StoreData;
+ scope: StoreScope;
+ children: ReactNode;
+};
+
+export function FixtureStoreProvider({initialData, scope, children}: Props) {
+ const store = prepareStoreForHydration(
+ useLocalStore(() => new FixtureStore(initialData)),
+ );
+
+ useEffect(() => {
+ let active = true;
+ window.__classyNextCompilerSources = {
+ selector: Function.prototype.toString.call(StoreSelector),
+ tracked: Function.prototype.toString.call(StoreTracked),
+ };
+ window.__classyNextReactVersion = version;
+ const diagnostics = getDiagnostics();
+ diagnostics[`${scope}Mounts`]++;
+ diagnostics.activePersistence++;
+ if (scope === 'stream') {
+ diagnostics.streamParentHydrated = true;
+ diagnostics.streamRestored = false;
+ diagnostics.streamChildHydrated = false;
+ }
+ const handle = persist(store, {
+ name: `classy-next-${scope}`,
+ skipHydration: true,
+ storage: {
+ getItem: (name) => localStorage.getItem(name),
+ setItem: (name, value) => {
+ diagnostics.persistenceWrites++;
+ localStorage.setItem(name, value);
+ },
+ removeItem: (name) => localStorage.removeItem(name),
+ },
+ });
+ // Workspace stores start afresh when their scope ends. Only the streaming
+ // fixture opts into restoring saved data during hydration.
+ if (scope === 'stream') {
+ void handle.rehydrate().then(() => {
+ if (!active) return;
+ diagnostics.persistenceRestores++;
+ diagnostics.streamRestored = true;
+ diagnostics.streamRestoredValue = `${store.text}:${store.count}`;
+ });
+ }
+ return () => {
+ active = false;
+ handle.unsubscribe();
+ rememberDisposedStore(scope, store);
+ diagnostics[`${scope}Disposes`]++;
+ diagnostics.activePersistence--;
+ };
+ }, [scope, store]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/classy-store/tests/next/fixture/components/shared/navigation/Navigation.tsx b/packages/classy-store/tests/next/fixture/components/shared/navigation/Navigation.tsx
new file mode 100644
index 0000000..328fa3c
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/components/shared/navigation/Navigation.tsx
@@ -0,0 +1,20 @@
+import Link from 'next/link';
+
+export function Navigation() {
+ return (
+
+ );
+}
diff --git a/packages/classy-store/tests/next/fixture/components/shared/storeFields/StoreFields.tsx b/packages/classy-store/tests/next/fixture/components/shared/storeFields/StoreFields.tsx
new file mode 100644
index 0000000..a0770e0
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/components/shared/storeFields/StoreFields.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import {useClassyStore} from '@codebelt/classy-store/react';
+import {useFixtureStore} from '../../../hooks/useFixtureStore/useFixtureStore';
+import {StoreSelector} from '../storeSelector/StoreSelector';
+import {StoreTracked} from '../storeTracked/StoreTracked';
+
+type Props = {prefix: string; includeCount?: boolean};
+
+export function StoreFields({prefix, includeCount = false}: Props) {
+ const store = useFixtureStore();
+ const state = useClassyStore(store);
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/classy-store/tests/next/fixture/components/shared/storeSelector/StoreSelector.tsx b/packages/classy-store/tests/next/fixture/components/shared/storeSelector/StoreSelector.tsx
new file mode 100644
index 0000000..09f9eac
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/components/shared/storeSelector/StoreSelector.tsx
@@ -0,0 +1,14 @@
+'use client';
+
+import {useClassyStore} from '@codebelt/classy-store/react';
+import {useFixtureStore} from '../../../hooks/useFixtureStore/useFixtureStore';
+
+type Props = {testId: string; includeCount?: boolean};
+
+export function StoreSelector({testId, includeCount = false}: Props) {
+ const store = useFixtureStore();
+ const value = useClassyStore(store, (state) =>
+ includeCount ? `${state.text}:${state.count}` : state.text,
+ );
+ return ;
+}
diff --git a/packages/classy-store/tests/next/fixture/components/shared/storeTracked/StoreTracked.tsx b/packages/classy-store/tests/next/fixture/components/shared/storeTracked/StoreTracked.tsx
new file mode 100644
index 0000000..43d2111
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/components/shared/storeTracked/StoreTracked.tsx
@@ -0,0 +1,12 @@
+'use client';
+
+import {useClassyStore} from '@codebelt/classy-store/react';
+import {useFixtureStore} from '../../../hooks/useFixtureStore/useFixtureStore';
+
+type Props = {testId: string; includeCount?: boolean};
+
+export function StoreTracked({testId, includeCount = false}: Props) {
+ const state = useClassyStore(useFixtureStore());
+ const value = includeCount ? `${state.text}:${state.count}` : state.text;
+ return ;
+}
diff --git a/packages/classy-store/tests/next/fixture/hooks/useFixtureStore/useFixtureStore.ts b/packages/classy-store/tests/next/fixture/hooks/useFixtureStore/useFixtureStore.ts
new file mode 100644
index 0000000..504db5c
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/hooks/useFixtureStore/useFixtureStore.ts
@@ -0,0 +1,10 @@
+'use client';
+
+import {useContext} from 'react';
+import {FixtureStoreContext} from '../../stores/fixtureStore/fixtureStoreContext';
+
+export function useFixtureStore() {
+ const store = useContext(FixtureStoreContext);
+ if (!store) throw new Error('FixtureStoreProvider is required');
+ return store;
+}
diff --git a/packages/classy-store/tests/next/fixture/next.config.mjs b/packages/classy-store/tests/next/fixture/next.config.mjs
new file mode 100644
index 0000000..32d9fd9
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/next.config.mjs
@@ -0,0 +1,10 @@
+const compiler = process.env.NEXT_TEST_COMPILER === 'on';
+
+export default {
+ reactCompiler: compiler,
+ // The default App Router navigation contract. Activity-preserved routes are
+ // a separate opt-in feature and are not represented as unmount/reset tests.
+ cacheComponents: false,
+ distDir: compiler ? '.next-on' : '.next-off',
+ poweredByHeader: false,
+};
diff --git a/packages/classy-store/tests/next/fixture/stores/fixtureStore/fixtureStore.store.ts b/packages/classy-store/tests/next/fixture/stores/fixtureStore/fixtureStore.store.ts
new file mode 100644
index 0000000..1f31a44
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/stores/fixtureStore/fixtureStore.store.ts
@@ -0,0 +1,11 @@
+import type {StoreData} from '../../types/fixture.types';
+
+export class FixtureStore {
+ text: string;
+ count: number;
+
+ constructor(initialData: StoreData) {
+ this.text = initialData.text;
+ this.count = initialData.count;
+ }
+}
diff --git a/packages/classy-store/tests/next/fixture/stores/fixtureStore/fixtureStoreContext.ts b/packages/classy-store/tests/next/fixture/stores/fixtureStore/fixtureStoreContext.ts
new file mode 100644
index 0000000..65a71ed
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/stores/fixtureStore/fixtureStoreContext.ts
@@ -0,0 +1,6 @@
+'use client';
+
+import {createContext} from 'react';
+import type {FixtureStore} from './fixtureStore.store';
+
+export const FixtureStoreContext = createContext(null);
diff --git a/packages/classy-store/tests/next/fixture/tsconfig.json b/packages/classy-store/tests/next/fixture/tsconfig.json
new file mode 100644
index 0000000..e922a74
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "allowJs": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [{"name": "next"}]
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next-off/types/**/*.ts",
+ ".next-on/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/classy-store/tests/next/fixture/types/fixture.types.ts b/packages/classy-store/tests/next/fixture/types/fixture.types.ts
new file mode 100644
index 0000000..d0a1508
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/types/fixture.types.ts
@@ -0,0 +1,31 @@
+export type StoreData = {text: string; count: number};
+
+export type StoreScope = 'shared' | 'route' | 'stream' | 'request';
+
+export type FixtureDiagnostics = {
+ sharedMounts: number;
+ sharedDisposes: number;
+ routeMounts: number;
+ routeDisposes: number;
+ streamMounts: number;
+ streamDisposes: number;
+ requestMounts: number;
+ requestDisposes: number;
+ activePersistence: number;
+ persistenceRestores: number;
+ persistenceWrites: number;
+ streamParentHydrated: boolean;
+ streamRestored: boolean;
+ streamChildHydrated: boolean;
+ streamRestoredValue: string;
+};
+
+declare global {
+ interface Window {
+ __classyNextReactVersion?: string;
+ __classyNextDiagnostics?: FixtureDiagnostics;
+ __classyNextReleaseStream?: () => void;
+ __classyNextMutateDisposedStores?: () => number;
+ __classyNextCompilerSources?: {selector: string; tracked: string};
+ }
+}
diff --git a/packages/classy-store/tests/next/fixture/utils/clientGate/clientGate.ts b/packages/classy-store/tests/next/fixture/utils/clientGate/clientGate.ts
new file mode 100644
index 0000000..6fbf17d
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/utils/clientGate/clientGate.ts
@@ -0,0 +1,13 @@
+let ready = false;
+let pending: Promise | undefined;
+
+export function waitForClientGate() {
+ if (typeof window === 'undefined' || ready) return;
+ pending ??= new Promise((resolve) => {
+ window.__classyNextReleaseStream = () => {
+ ready = true;
+ resolve();
+ };
+ });
+ throw pending;
+}
diff --git a/packages/classy-store/tests/next/fixture/utils/diagnostics/diagnostics.ts b/packages/classy-store/tests/next/fixture/utils/diagnostics/diagnostics.ts
new file mode 100644
index 0000000..603f5e0
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/utils/diagnostics/diagnostics.ts
@@ -0,0 +1,23 @@
+import type {FixtureDiagnostics} from '../../types/fixture.types';
+
+// Diagnostics deliberately contain primitives, never stores or snapshots.
+export function getDiagnostics(): FixtureDiagnostics {
+ window.__classyNextDiagnostics ??= {
+ sharedMounts: 0,
+ sharedDisposes: 0,
+ routeMounts: 0,
+ routeDisposes: 0,
+ streamMounts: 0,
+ streamDisposes: 0,
+ requestMounts: 0,
+ requestDisposes: 0,
+ activePersistence: 0,
+ persistenceRestores: 0,
+ persistenceWrites: 0,
+ streamParentHydrated: false,
+ streamRestored: false,
+ streamChildHydrated: false,
+ streamRestoredValue: '',
+ };
+ return window.__classyNextDiagnostics;
+}
diff --git a/packages/classy-store/tests/next/fixture/utils/disposedStores/disposedStores.ts b/packages/classy-store/tests/next/fixture/utils/disposedStores/disposedStores.ts
new file mode 100644
index 0000000..9f61f53
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/utils/disposedStores/disposedStores.ts
@@ -0,0 +1,21 @@
+import type {FixtureStore} from '../../stores/fixtureStore/fixtureStore.store';
+import type {StoreScope} from '../../types/fixture.types';
+
+// Keep at most one deliberately retained store per workspace scope so browser
+// tests can mutate it after unmount and verify persistence actually unsubscribed.
+// The probe releases both references immediately after those mutations.
+const disposedStores = new Map();
+
+export function rememberDisposedStore(scope: StoreScope, store: FixtureStore) {
+ if (scope !== 'shared' && scope !== 'route') return;
+ disposedStores.set(scope, store);
+ window.__classyNextMutateDisposedStores = () => {
+ const count = disposedStores.size;
+ for (const disposed of disposedStores.values()) {
+ disposed.count++;
+ disposed.text = `disposed:${disposed.count}`;
+ }
+ disposedStores.clear();
+ return count;
+ };
+}
diff --git a/packages/classy-store/tests/next/fixture/utils/serverGate/serverGate.ts b/packages/classy-store/tests/next/fixture/utils/serverGate/serverGate.ts
new file mode 100644
index 0000000..d5fa4f6
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/utils/serverGate/serverGate.ts
@@ -0,0 +1,36 @@
+import type {ServerGate} from './serverGate.types';
+
+// Next can compile route handlers and RSC modules separately. This test-only
+// registry shares their gate signals within the production server process.
+const registry = globalThis as typeof globalThis & {
+ __classyNextServerGates?: Map;
+};
+
+function getGate(ticket: string): ServerGate {
+ registry.__classyNextServerGates ??= new Map();
+ const previous = registry.__classyNextServerGates.get(ticket);
+ if (previous) return previous;
+ let release = () => {};
+ const pending = new Promise((resolve) => {
+ release = resolve;
+ });
+ const timer = setTimeout(() => {
+ release();
+ registry.__classyNextServerGates?.delete(ticket);
+ }, 30_000);
+ timer.unref();
+ const gate = {pending, release, timer};
+ registry.__classyNextServerGates.set(ticket, gate);
+ return gate;
+}
+
+export async function waitForServerGate(ticket: string) {
+ const gate = getGate(ticket);
+ await gate.pending;
+ clearTimeout(gate.timer);
+ registry.__classyNextServerGates?.delete(ticket);
+}
+
+export function releaseServerGate(ticket: string) {
+ registry.__classyNextServerGates?.get(ticket)?.release();
+}
diff --git a/packages/classy-store/tests/next/fixture/utils/serverGate/serverGate.types.ts b/packages/classy-store/tests/next/fixture/utils/serverGate/serverGate.types.ts
new file mode 100644
index 0000000..64a7728
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/utils/serverGate/serverGate.types.ts
@@ -0,0 +1,5 @@
+export type ServerGate = {
+ pending: Promise;
+ release: () => void;
+ timer: ReturnType;
+};
diff --git a/packages/classy-store/tests/next/fixture/utils/serverProof/serverProof.ts b/packages/classy-store/tests/next/fixture/utils/serverProof/serverProof.ts
new file mode 100644
index 0000000..2ce075c
--- /dev/null
+++ b/packages/classy-store/tests/next/fixture/utils/serverProof/serverProof.ts
@@ -0,0 +1,34 @@
+import {createClassyStore, snapshot} from '@codebelt/classy-store';
+import {reactiveMap, reactiveSet} from '@codebelt/classy-store/collections';
+import {prepareStoreForHydration} from '@codebelt/classy-store/react/server';
+import {subscribeKey} from '@codebelt/classy-store/utils';
+
+export async function createServerProof(seed: string) {
+ // Every request owns its store. Only plain data crosses the RSC boundary.
+ const store = prepareStoreForHydration(
+ createClassyStore({
+ text: seed,
+ values: reactiveMap([['seed', seed]]),
+ tags: reactiveSet([seed]),
+ }),
+ );
+ let updates = 0;
+ const unsubscribe = subscribeKey(store, 'text', () => {
+ updates++;
+ });
+ store.text = `${seed}:updated`;
+ // Yield to other HTTP requests while this request's mutable store is alive.
+ // A microtask-only yield would let an accidental module singleton escape the
+ // concurrent-request regression because each handler could finish in order.
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ unsubscribe();
+ const current = snapshot(store);
+ if (current.values.get('seed') !== seed || !current.tags.has(seed)) {
+ throw new Error('Server collections lost request data');
+ }
+ return {
+ seed,
+ proof: `${seed}|${current.text}|${updates}`,
+ initialData: {text: `server:${seed}`, count: 0},
+ };
+}
diff --git a/packages/classy-store/tests/next/next.spec.ts b/packages/classy-store/tests/next/next.spec.ts
new file mode 100644
index 0000000..39d8e95
--- /dev/null
+++ b/packages/classy-store/tests/next/next.spec.ts
@@ -0,0 +1,378 @@
+import {writeFile} from 'node:fs/promises';
+import {expect, type Page} from '@playwright/test';
+import {analyzeCompilerEvidence} from './compilerEvidence';
+import {test} from './next.test';
+import type {NextDiagnostics} from './next.types';
+
+async function diagnostics(page: Page): Promise {
+ return page.evaluate(() => Reflect.get(window, '__classyNextDiagnostics'));
+}
+
+async function expectTextConsumers(page: Page, scope: string, text: string) {
+ await expect(page.getByTestId(`${scope}-input`)).toHaveValue(text);
+ await expect(page.getByTestId(`${scope}-selector`)).toHaveText(text);
+ await expect(page.getByTestId(`${scope}-tracked`)).toHaveText(text);
+}
+
+test('the production consumers prove whether React Compiler transformed their hooks', async ({
+ page,
+}, testInfo) => {
+ await page.goto('/workspace/alpha');
+ await page.waitForFunction(() =>
+ Boolean(Reflect.get(window, '__classyNextCompilerSources')),
+ );
+ const sources: {selector: string; tracked: string} = await page.evaluate(() =>
+ Reflect.get(window, '__classyNextCompilerSources'),
+ );
+ expect(Object.keys(sources).sort()).toEqual(['selector', 'tracked']);
+ // Next App Router can use its vendored runtime rather than npm React's version.
+ const reactVersion = await page.evaluate(() =>
+ Reflect.get(window, '__classyNextReactVersion'),
+ );
+ expect(reactVersion).toEqual(expect.stringMatching(/^\d+\.\d+\.\d+/));
+ const evidence = Object.fromEntries(
+ Object.entries(sources).map(([consumer, source]) => [
+ consumer,
+ analyzeCompilerEvidence(source),
+ ]),
+ );
+ const sourcePath = testInfo.outputPath('production-consumer-functions.json');
+ await writeFile(
+ sourcePath,
+ JSON.stringify({reactVersion, sources, evidence}, null, 2),
+ );
+ await testInfo.attach('production-consumer-functions', {
+ path: sourcePath,
+ contentType: 'application/json',
+ });
+ for (const [consumer, source] of Object.entries(sources)) {
+ expect(source, `${consumer} has an inspectable function body`).toContain(
+ 'function',
+ );
+ const {slotCount, readSlots, writtenSlots} = evidence[consumer];
+ if (process.env.NEXT_TEST_COMPILER === 'on') {
+ expect(
+ slotCount,
+ `${consumer} allocates Compiler memo-cache slots`,
+ ).toBeGreaterThan(0);
+ expect(
+ readSlots.length,
+ `${consumer} reads cached slots`,
+ ).toBeGreaterThan(0);
+ expect(
+ writtenSlots.length,
+ `${consumer} writes cached slots`,
+ ).toBeGreaterThan(0);
+ expect(Math.max(...readSlots, ...writtenSlots)).toBeLessThan(slotCount);
+ } else {
+ expect(
+ slotCount,
+ `${consumer} has no Compiler memo-cache allocation`,
+ ).toBe(0);
+ }
+ }
+});
+
+test('packed server imports create isolated request stores', async ({
+ request,
+}) => {
+ const seeds = Array.from({length: 6}, () => crypto.randomUUID());
+ const responses = await Promise.all(
+ seeds.map(async (seed) => {
+ const response = await request.get(`/?seed=${seed}`);
+ expect(response.status()).toBe(200);
+ return {seed, html: await response.text()};
+ }),
+ );
+ for (const {seed, html} of responses) {
+ expect(html).toContain(`data-testid="request-seed">${seed}`);
+ expect(html).toContain(
+ `data-testid="server-imports">${seed}|${seed}:updated|1`,
+ );
+ for (const consumer of ['selector', 'tracked']) {
+ expect(html).toContain(
+ `data-testid="request-${consumer}">server:${seed}`,
+ );
+ }
+ for (const otherSeed of seeds) {
+ if (otherSeed !== seed) expect(html).not.toContain(otherSeed);
+ }
+ }
+});
+
+test('selectors and auto-tracking keep real controlled inputs and caret positions current', async ({
+ page,
+}) => {
+ await page.goto('/workspace/alpha');
+ await expectTextConsumers(page, 'workspace-shared', 'shared:initial');
+ await expectTextConsumers(page, 'route', 'alpha:initial');
+ const sharedInput = page.getByTestId('workspace-shared-input');
+ await sharedInput.fill('Shared');
+ await sharedInput.evaluate((element: HTMLInputElement) =>
+ element.setSelectionRange(2, 2),
+ );
+ await sharedInput.pressSequentially('XY');
+ await expectTextConsumers(page, 'workspace-shared', 'ShXYared');
+ expect(
+ await sharedInput.evaluate(
+ (element: HTMLInputElement) => element.selectionStart,
+ ),
+ ).toBe(4);
+
+ const routeInput = page.getByTestId('route-input');
+ await routeInput.fill('Local');
+ await routeInput.evaluate((element: HTMLInputElement) =>
+ element.setSelectionRange(1, 1),
+ );
+ await routeInput.pressSequentially('12');
+ await expectTextConsumers(page, 'route', 'L12ocal');
+ expect(
+ await routeInput.evaluate(
+ (element: HTMLInputElement) => element.selectionStart,
+ ),
+ ).toBe(3);
+ await expectTextConsumers(page, 'workspace-shared', 'ShXYared');
+});
+
+test('layout stores survive client navigation while route stores reset and clean up', async ({
+ page,
+}) => {
+ await page.goto('/workspace/alpha');
+ await expect(page.getByTestId('current-route')).toHaveText('alpha');
+ await expect.poll(async () => (await diagnostics(page)).routeMounts).toBe(1);
+ expect(await diagnostics(page)).toMatchObject({
+ sharedMounts: 1,
+ activePersistence: 2,
+ });
+ const documentSentinel = crypto.randomUUID();
+ await page.evaluate((sentinel) => {
+ Reflect.set(window, '__classyNextDocumentSentinel', sentinel);
+ }, documentSentinel);
+ await page.getByTestId('workspace-shared-input').fill('shared across routes');
+ await page.getByTestId('route-input').fill('alpha edited');
+
+ await page.getByTestId('link-beta').click();
+ await expect(page).toHaveURL(/\/workspace\/beta$/);
+ await expect(page.getByTestId('current-route')).toHaveText('beta');
+ await expectTextConsumers(page, 'workspace-shared', 'shared across routes');
+ await expectTextConsumers(page, 'route', 'beta:initial');
+ await expect
+ .poll(async () => (await diagnostics(page)).routeDisposes)
+ .toBe(1);
+ await page.getByTestId('route-input').fill('beta edited');
+
+ await page.goBack();
+ await expect(page).toHaveURL(/\/workspace\/alpha$/);
+ await expectTextConsumers(page, 'workspace-shared', 'shared across routes');
+ await expectTextConsumers(page, 'route', 'alpha:initial');
+ await expect
+ .poll(async () => (await diagnostics(page)).routeDisposes)
+ .toBe(2);
+
+ await page.goForward();
+ await expect(page).toHaveURL(/\/workspace\/beta$/);
+ await expectTextConsumers(page, 'workspace-shared', 'shared across routes');
+ await expectTextConsumers(page, 'route', 'beta:initial');
+ await expect
+ .poll(async () => (await diagnostics(page)).routeDisposes)
+ .toBe(3);
+ await page.getByTestId('route-input').fill('beta refreshed');
+ const beforeRefresh = await diagnostics(page);
+ const previousRequest = await page
+ .getByTestId('route-request-id')
+ .textContent();
+ const refreshed = page.waitForResponse(
+ (response) =>
+ response.url().includes('/workspace/beta') &&
+ response.request().headers().rsc === '1',
+ );
+ await page.getByTestId('workspace-refresh').click();
+ expect((await refreshed).ok()).toBe(true);
+ await expect(page.getByTestId('route-request-id')).not.toHaveText(
+ previousRequest ?? '',
+ );
+ await expectTextConsumers(page, 'workspace-shared', 'shared across routes');
+ await expectTextConsumers(page, 'route', 'beta refreshed');
+ expect(await diagnostics(page)).toMatchObject({
+ sharedMounts: 1,
+ sharedDisposes: 0,
+ routeMounts: beforeRefresh.routeMounts,
+ routeDisposes: beforeRefresh.routeDisposes,
+ activePersistence: 2,
+ });
+
+ await page.getByTestId('link-home').click();
+ await expect(page.getByTestId('server-imports')).toBeVisible();
+ await expect
+ .poll(async () => (await diagnostics(page)).sharedDisposes)
+ .toBe(1);
+ await expect
+ .poll(async () => (await diagnostics(page)).routeDisposes)
+ .toBe(4);
+ await expect
+ .poll(async () => (await diagnostics(page)).activePersistence)
+ .toBe(1);
+ const writesBeforeRetiredStoreMutations = (await diagnostics(page))
+ .persistenceWrites;
+ expect(
+ await page.evaluate(async () => {
+ const mutated = Reflect.get(window, '__classyNextMutateDisposedStores')();
+ await Promise.resolve();
+ await Promise.resolve();
+ return mutated;
+ }),
+ ).toBe(2);
+ expect((await diagnostics(page)).persistenceWrites).toBe(
+ writesBeforeRetiredStoreMutations,
+ );
+ expect(
+ await page.evaluate(() =>
+ Reflect.get(window, '__classyNextDocumentSentinel'),
+ ),
+ ).toBe(documentSentinel);
+
+ await page.getByTestId('link-alpha').click();
+ await expectTextConsumers(page, 'workspace-shared', 'shared:initial');
+ await expectTextConsumers(page, 'route', 'alpha:initial');
+ await expect.poll(async () => (await diagnostics(page)).sharedMounts).toBe(2);
+ await expect.poll(async () => (await diagnostics(page)).routeMounts).toBe(5);
+ expect(await diagnostics(page)).toMatchObject({activePersistence: 2});
+});
+
+test('the production response streams its fallback before the server boundary resolves', async ({
+ baseURL,
+ request,
+}) => {
+ const ticket = crypto.randomUUID();
+ const seed = crypto.randomUUID();
+ const response = await fetch(
+ new URL(`/stream?seed=${seed}&ticket=${ticket}`, baseURL),
+ {signal: AbortSignal.timeout(20_000)},
+ );
+ expect(response.status).toBe(200);
+ const reader = response.body?.getReader();
+ if (!reader) throw new Error('The streamed response has no readable body');
+ const decoder = new TextDecoder();
+ let html = '';
+ let released = false;
+ try {
+ while (!html.includes('data-testid="stream-fallback"')) {
+ const chunk = await reader.read();
+ expect(
+ chunk.done,
+ 'The fallback must arrive while the server gate is closed',
+ ).toBe(false);
+ html += decoder.decode(chunk.value, {stream: true});
+ }
+ expect(html).not.toContain('data-testid="stream-selector"');
+ expect(html).not.toContain('data-testid="stream-tracked"');
+ const release = await request.post('/api/stream-gate', {data: {ticket}});
+ expect(release.ok()).toBe(true);
+ released = true;
+ for (;;) {
+ const chunk = await reader.read();
+ if (chunk.done) break;
+ html += decoder.decode(chunk.value, {stream: true});
+ }
+ html += decoder.decode();
+ expect(html).toContain(
+ `data-testid="stream-selector">server:${seed}:0`,
+ );
+ expect(html).toContain(
+ `data-testid="stream-tracked">server:${seed}:0`,
+ );
+ expect(html.indexOf('data-testid="stream-selector"')).toBeGreaterThan(
+ html.indexOf('data-testid="stream-fallback"'),
+ );
+ } finally {
+ if (!released) await request.post('/api/stream-gate', {data: {ticket}});
+ await reader.cancel();
+ }
+});
+
+test('persistence restores before a streamed boundary hydrates without replacing server DOM', async ({
+ page,
+ request,
+}) => {
+ const ticket = crypto.randomUUID();
+ const seed = crypto.randomUUID();
+ await page.addInitScript((persistedText) => {
+ localStorage.setItem(
+ 'classy-next-stream',
+ JSON.stringify({version: 0, state: {text: persistedText, count: 7}}),
+ );
+ }, `persisted:${seed}`);
+ await page.goto(`/stream?seed=${seed}&ticket=${ticket}`, {
+ waitUntil: 'commit',
+ });
+ await expect(page.getByTestId('stream-fallback')).toBeVisible();
+ expect((await request.post('/api/stream-gate', {data: {ticket}})).ok()).toBe(
+ true,
+ );
+ await expect(page.getByTestId('stream-selector')).toHaveText(
+ `server:${seed}:0`,
+ );
+ await expect(page.getByTestId('stream-tracked')).toHaveText(
+ `server:${seed}:0`,
+ );
+ await expect(page.getByTestId('stream-input')).toHaveValue(`server:${seed}`);
+ await expect
+ .poll(async () => (await diagnostics(page)).streamRestored)
+ .toBe(true);
+ await expect(page.getByTestId('stream-restored-value')).toHaveText(
+ `persisted:${seed}:7`,
+ );
+ await page.waitForFunction(
+ () =>
+ typeof Reflect.get(window, '__classyNextReleaseStream') === 'function',
+ );
+ expect(await diagnostics(page)).toMatchObject({
+ streamParentHydrated: true,
+ streamChildHydrated: false,
+ streamRestoredValue: `persisted:${seed}:7`,
+ persistenceRestores: 1,
+ });
+ const serverElements = await page.evaluateHandle(() => [
+ document.querySelector('[data-testid="stream-selector"]'),
+ document.querySelector('[data-testid="stream-tracked"]'),
+ document.querySelector('[data-testid="stream-input"]'),
+ ]);
+ await page.evaluate(() => Reflect.get(window, '__classyNextReleaseStream')());
+ await expect(page.getByTestId('stream-selector')).toHaveText(
+ `persisted:${seed}:7`,
+ );
+ await expect(page.getByTestId('stream-tracked')).toHaveText(
+ `persisted:${seed}:7`,
+ );
+ await expect(page.getByTestId('stream-input')).toHaveValue(
+ `persisted:${seed}`,
+ );
+ await expect
+ .poll(async () => (await diagnostics(page)).streamChildHydrated)
+ .toBe(true);
+ expect(
+ await serverElements.evaluate((elements) => {
+ const current = [
+ document.querySelector('[data-testid="stream-selector"]'),
+ document.querySelector('[data-testid="stream-tracked"]'),
+ document.querySelector('[data-testid="stream-input"]'),
+ ];
+ return elements.every(
+ (element, index) => element !== null && element === current[index],
+ );
+ }),
+ ).toBe(true);
+ await serverElements.dispose();
+ const input = page.getByTestId('stream-input');
+ await input.fill('Edited');
+ await input.evaluate((element: HTMLInputElement) =>
+ element.setSelectionRange(2, 2),
+ );
+ await input.pressSequentially('XY');
+ await expect(input).toHaveValue('EdXYited');
+ await expect(page.getByTestId('stream-selector')).toHaveText('EdXYited:10');
+ await expect(page.getByTestId('stream-tracked')).toHaveText('EdXYited:10');
+ expect(
+ await input.evaluate((element: HTMLInputElement) => element.selectionStart),
+ ).toBe(4);
+});
diff --git a/packages/classy-store/tests/next/next.test.ts b/packages/classy-store/tests/next/next.test.ts
new file mode 100644
index 0000000..521c13e
--- /dev/null
+++ b/packages/classy-store/tests/next/next.test.ts
@@ -0,0 +1,19 @@
+import {test as base, expect} from '@playwright/test';
+
+export const test = base.extend<{browserErrors: undefined}>({
+ browserErrors: [
+ async ({page}, use) => {
+ const errors: string[] = [];
+ page.on('pageerror', (error) => errors.push(error.message));
+ page.on('console', (message) => {
+ if (message.type() === 'error') errors.push(message.text());
+ });
+ await use(undefined);
+ expect(
+ errors,
+ 'Browser exceptions, hydration errors, and console errors',
+ ).toEqual([]);
+ },
+ {auto: true},
+ ],
+});
diff --git a/packages/classy-store/tests/next/next.types.ts b/packages/classy-store/tests/next/next.types.ts
new file mode 100644
index 0000000..9aa0026
--- /dev/null
+++ b/packages/classy-store/tests/next/next.types.ts
@@ -0,0 +1,20 @@
+export type NextDiagnostics = {
+ sharedMounts: number;
+ sharedDisposes: number;
+ routeMounts: number;
+ routeDisposes: number;
+ persistenceRestores: number;
+ persistenceWrites: number;
+ activePersistence: number;
+ streamParentHydrated: boolean;
+ streamRestored: boolean;
+ streamChildHydrated: boolean;
+ streamRestoredValue: string;
+};
+
+export type CompilerEvidence = {
+ cacheVariable: string | null;
+ slotCount: number;
+ readSlots: number[];
+ writtenSlots: number[];
+};
diff --git a/packages/classy-store/tests/next/playwright.config.ts b/packages/classy-store/tests/next/playwright.config.ts
new file mode 100644
index 0000000..339b836
--- /dev/null
+++ b/packages/classy-store/tests/next/playwright.config.ts
@@ -0,0 +1,30 @@
+import {fileURLToPath} from 'node:url';
+import {defineConfig, devices} from '@playwright/test';
+
+const baseURL = process.env.NEXT_TEST_URL;
+if (!baseURL)
+ throw new Error('NEXT_TEST_URL must identify the production Next server');
+const compiler = process.env.NEXT_TEST_COMPILER;
+if (compiler !== 'on' && compiler !== 'off') {
+ throw new Error('NEXT_TEST_COMPILER must be on or off');
+}
+
+export default defineConfig({
+ testDir: '.',
+ testMatch: 'next.spec.ts',
+ fullyParallel: false,
+ workers: 1,
+ forbidOnly: Boolean(process.env.CI),
+ retries: 0,
+ timeout: 45_000,
+ reporter: 'list',
+ metadata: {compiler},
+ outputDir:
+ process.env.NEXT_TEST_OUTPUT ??
+ fileURLToPath(new URL('../../../../test-results/next', import.meta.url)),
+ use: {
+ baseURL,
+ trace: 'retain-on-failure',
+ ...devices['Desktop Chrome'],
+ },
+});
diff --git a/packages/classy-store/tests/next/runNext.mjs b/packages/classy-store/tests/next/runNext.mjs
new file mode 100644
index 0000000..ff0dff8
--- /dev/null
+++ b/packages/classy-store/tests/next/runNext.mjs
@@ -0,0 +1,297 @@
+import assert from 'node:assert/strict';
+import {spawn} from 'node:child_process';
+import {createWriteStream} from 'node:fs';
+import {
+ cp,
+ mkdir,
+ mkdtemp,
+ readFile,
+ rename,
+ rm,
+ writeFile,
+} from 'node:fs/promises';
+import {createRequire} from 'node:module';
+import {createServer} from 'node:net';
+import {tmpdir} from 'node:os';
+import {dirname, join, resolve} from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+const directory = dirname(fileURLToPath(import.meta.url));
+const packageDirectory = resolve(directory, '../..');
+const require = createRequire(import.meta.url);
+const manifest = JSON.parse(
+ await readFile(join(packageDirectory, 'package.json'), 'utf8'),
+);
+const consumer = process.env.NEXT_TEST_DIRECTORY
+ ? resolve(process.env.NEXT_TEST_DIRECTORY)
+ : await mkdtemp(join(tmpdir(), 'classy-store-next-'));
+const output = resolve(
+ process.env.NEXT_TEST_OUTPUT ??
+ join(packageDirectory, '../../test-results/next'),
+);
+const modes = (process.env.NEXT_TEST_MODES ?? 'off,on').split(',');
+assert(modes.length && modes.every((mode) => mode === 'off' || mode === 'on'));
+const environment = {
+ ...process.env,
+ NODE_ENV: 'production',
+ NEXT_TELEMETRY_DISABLED: '1',
+};
+await mkdir(consumer, {recursive: true});
+await mkdir(output, {recursive: true});
+// Failed reruns must not leave an earlier successful report beside new logs.
+await rm(join(output, 'report.json'), {force: true});
+
+async function run(
+ label,
+ command,
+ argumentsList,
+ extraEnvironment = {},
+ cwd = consumer,
+) {
+ const log = createWriteStream(join(output, `${label}.log`));
+ const child = spawn(command, argumentsList, {
+ cwd,
+ env: {...environment, ...extraEnvironment},
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let stdout = '';
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk;
+ log.write(chunk);
+ });
+ child.stderr.on('data', (chunk) => log.write(chunk));
+ let forceStop;
+ const timeout = setTimeout(() => {
+ child.kill('SIGTERM');
+ forceStop = setTimeout(() => child.kill('SIGKILL'), 5000);
+ }, 300_000);
+ try {
+ await new Promise((resolveRun, reject) => {
+ child.once('error', reject);
+ child.once('close', (code, signal) =>
+ code === 0
+ ? resolveRun()
+ : reject(
+ new Error(
+ `${label} failed (${code ?? signal}); see ${join(output, `${label}.log`)}`,
+ ),
+ ),
+ );
+ });
+ } finally {
+ clearTimeout(timeout);
+ clearTimeout(forceStop);
+ log.end();
+ }
+ return stdout;
+}
+
+async function availablePort() {
+ const server = createServer();
+ await new Promise((resolvePort, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', resolvePort);
+ });
+ const port = server.address().port;
+ await new Promise((resolveClose, reject) =>
+ server.close((error) => (error ? reject(error) : resolveClose())),
+ );
+ return port;
+}
+
+async function stop(child) {
+ if (!child.pid || child.exitCode !== null || child.signalCode !== null)
+ return;
+ child.kill('SIGTERM');
+ const timeout = setTimeout(() => child.kill('SIGKILL'), 5000);
+ try {
+ await new Promise((resolveExit) => child.once('exit', resolveExit));
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+const dependencies = {
+ next: '16.4.2',
+ react: manifest.devDependencies.react,
+ 'react-dom': manifest.devDependencies['react-dom'],
+ 'babel-plugin-react-compiler':
+ manifest.devDependencies['babel-plugin-react-compiler'],
+};
+const results = [];
+let succeeded = false;
+try {
+ console.log(`Packed Next.js consumer: ${consumer}`);
+ const packed = JSON.parse(
+ await run(
+ 'pack',
+ 'npm',
+ ['pack', '--ignore-scripts', '--json', '--pack-destination', consumer],
+ {},
+ packageDirectory,
+ ),
+ );
+ // Content-address the tarball so a reused consumer cannot silently reuse
+ // npm's installed copy of a previous build with the same package version.
+ const archive = join(consumer, `classy-${packed[0].shasum}.tgz`);
+ await rename(join(consumer, packed[0].filename), archive);
+ await cp(join(directory, 'fixture'), consumer, {recursive: true});
+ await writeFile(
+ join(consumer, 'package.json'),
+ JSON.stringify(
+ {
+ name: 'classy-store-next-consumer',
+ private: true,
+ type: 'module',
+ dependencies: {
+ ...dependencies,
+ '@codebelt/classy-store': `file:${archive}`,
+ },
+ devDependencies: {
+ typescript: manifest.devDependencies.typescript,
+ '@types/react': manifest.devDependencies['@types/react'],
+ '@types/react-dom': manifest.devDependencies['@types/react-dom'],
+ '@types/node': '26.6.5',
+ },
+ },
+ null,
+ 2,
+ ),
+ );
+ // Install from the tarball, never an alias or symlink to this checkout.
+ console.log('Installing the isolated package consumer');
+ await run('install', 'npm', [
+ 'install',
+ '--include=dev',
+ '--ignore-scripts',
+ '--no-audit',
+ '--no-fund',
+ ]);
+ const lock = JSON.parse(
+ await readFile(join(consumer, 'package-lock.json'), 'utf8'),
+ );
+ assert.equal(
+ lock.packages['node_modules/@codebelt/classy-store'].integrity,
+ packed[0].integrity,
+ );
+ const boundaryReport = JSON.parse(
+ await run('boundaries', process.execPath, [
+ join(directory, 'checkBoundaries.mjs'),
+ consumer,
+ ]),
+ );
+ await writeFile(
+ join(output, 'boundaries.json'),
+ JSON.stringify(boundaryReport, null, 2),
+ );
+ const next = join(consumer, 'node_modules/next/dist/bin/next');
+ const playwright = require.resolve('@playwright/test/cli');
+ for (const mode of modes) {
+ const modeEnvironment = {NEXT_TEST_COMPILER: mode};
+ // A reused consumer must not inherit cached type diagnostics or bundled
+ // package code from an earlier tarball with the same published version.
+ await rm(join(consumer, `.next-${mode}`), {recursive: true, force: true});
+ console.log(`Compiler ${mode}: production Next.js build (Turbopack)`);
+ await run(
+ `build-${mode}`,
+ process.execPath,
+ [next, 'build'],
+ modeEnvironment,
+ );
+ const port = await availablePort();
+ const url = `http://127.0.0.1:${port}`;
+ const serverLog = createWriteStream(join(output, `server-${mode}.log`));
+ const server = spawn(
+ process.execPath,
+ [next, 'start', '--hostname', '127.0.0.1', '--port', String(port)],
+ {
+ cwd: consumer,
+ env: {...environment, ...modeEnvironment},
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ );
+ server.stdout.pipe(serverLog, {end: false});
+ server.stderr.pipe(serverLog, {end: false});
+ let serverError;
+ server.on('error', (error) => {
+ serverError = error;
+ });
+ try {
+ let ready = false;
+ let readinessDetail = 'No response received';
+ for (let attempt = 0; attempt < 120; attempt += 1) {
+ if (serverError) throw serverError;
+ if (server.exitCode !== null)
+ throw new Error(`Next server exited; see server-${mode}.log`);
+ try {
+ const response = await fetch(url, {
+ signal: AbortSignal.timeout(1000),
+ });
+ ready = response.ok;
+ readinessDetail = `HTTP ${response.status}`;
+ await response.body?.cancel();
+ } catch (error) {
+ readinessDetail = String(error);
+ }
+ if (ready) break;
+ await new Promise((resolveWait) => setTimeout(resolveWait, 250));
+ }
+ assert(
+ ready,
+ `Next server did not become ready: ${url}: ${readinessDetail}`,
+ );
+ console.log(`Compiler ${mode}: browser integration at ${url}`);
+ const testOutput = await run(
+ `browser-${mode}`,
+ process.execPath,
+ [
+ playwright,
+ 'test',
+ '--config',
+ join(directory, 'playwright.config.ts'),
+ ],
+ {
+ ...modeEnvironment,
+ NEXT_TEST_URL: url,
+ NEXT_CONSUMER_DIRECTORY: consumer,
+ NEXT_TEST_OUTPUT: join(output, mode),
+ },
+ packageDirectory,
+ );
+ console.log(testOutput.trim());
+ results.push({compiler: mode, passed: true});
+ } finally {
+ await stop(server);
+ serverLog.end();
+ }
+ }
+ await writeFile(
+ join(output, 'report.json'),
+ JSON.stringify(
+ {
+ createdAt: new Date().toISOString(),
+ dependencies,
+ library: {version: packed[0].version, integrity: packed[0].integrity},
+ bundler: 'Turbopack',
+ cacheComponents: false,
+ consumer,
+ boundaries: boundaryReport,
+ results,
+ },
+ null,
+ 2,
+ ),
+ );
+ succeeded = true;
+ console.log(`Next.js integration passed; report and logs: ${output}`);
+} finally {
+ if (
+ succeeded &&
+ !process.env.NEXT_TEST_DIRECTORY &&
+ process.env.KEEP_NEXT_TEMP !== '1'
+ ) {
+ await rm(consumer, {recursive: true, force: true});
+ } else {
+ console.log(`Kept Next.js consumer at ${consumer}`);
+ }
+}
diff --git a/packages/classy-store/tests/next/tsconfig.json b/packages/classy-store/tests/next/tsconfig.json
new file mode 100644
index 0000000..afa6c8f
--- /dev/null
+++ b/packages/classy-store/tests/next/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "../.."
+ },
+ "include": ["*.ts"],
+ "exclude": ["fixture"]
+}
diff --git a/packages/classy-store/tsdown.config.ts b/packages/classy-store/tsdown.config.ts
index e7c4a7a..4abfb5a 100644
--- a/packages/classy-store/tsdown.config.ts
+++ b/packages/classy-store/tsdown.config.ts
@@ -5,6 +5,7 @@ export default defineConfig({
'src/index.ts',
'src/collections/index.ts',
'src/frameworks/react/react.ts',
+ 'src/frameworks/react/server.ts',
'src/frameworks/vue/vue.ts',
'src/frameworks/svelte/svelte.ts',
'src/frameworks/angular/angular.ts',
diff --git a/website/docs/REACT_NEXT.md b/website/docs/REACT_NEXT.md
new file mode 100644
index 0000000..5781f87
--- /dev/null
+++ b/website/docs/REACT_NEXT.md
@@ -0,0 +1,141 @@
+# Next.js App Router
+
+Pass initial data from a Server Component to a Client Component, then construct
+the class store inside the client boundary. That component also renders on the
+server for the initial request, so prepare its snapshot before persistence or
+other effects start. The browser constructs its own store from the same data.
+
+Do not pass a store proxy or class instance across the React Server Component
+boundary. Use a plain data object containing the fields the client needs.
+React defines the supported [serializable prop types](https://react.dev/reference/rsc/use-client#serializable-types-returned-by-server-components).
+
+## Package boundaries
+
+| Entry | App Router use |
+| --- | --- |
+| `@codebelt/classy-store` | Core store creation and snapshots in server or client modules. |
+| `@codebelt/classy-store/collections` | Reactive collections in server or client modules. |
+| `@codebelt/classy-store/utils` | Server-safe imports; configure browser storage and effects inside Client Components. |
+| `@codebelt/classy-store/react` | Client hooks and the compatible `prepareStoreForHydration` export. The published ESM and CommonJS entries carry `use client`. |
+| `@codebelt/classy-store/react/server` | `prepareStoreForHydration` without importing React hooks; safe in Server Components and ordinary SSR code. |
+
+Components that use hooks still need their own client boundary. Do not call
+`useClassyStore`, `useLocalStore`, or `createStoreHook` in a Server Component.
+Preparing a store in an RSC module does not transfer that store or its capture
+to the browser; initialize and prepare the browser counterpart separately.
+Both preparation exports use the same capture cache within a module graph.
+
+## A route-owned store
+
+Define the class in a regular module that has no browser effects:
+
+```ts
+// stores/profile/profile.store.ts
+export type ProfileData = {name: string; visits: number};
+
+export class ProfileStore {
+ name: string;
+ visits: number;
+
+ constructor(initialData: ProfileData) {
+ this.name = initialData.name;
+ this.visits = initialData.visits;
+ }
+
+ recordVisit() {
+ this.visits++;
+ }
+}
+```
+
+Construct and prepare it inside the Client Component:
+
+```tsx
+// components/profileEditor/ProfileEditor.tsx
+'use client';
+
+import {
+ prepareStoreForHydration,
+ useClassyStore,
+ useLocalStore,
+} from '@codebelt/classy-store/react';
+import {type ProfileData, ProfileStore} from '../../stores/profile/profile.store';
+
+export function ProfileEditor({initialData}: {initialData: ProfileData}) {
+ const store = prepareStoreForHydration(
+ useLocalStore(() => new ProfileStore(initialData)),
+ );
+ const state = useClassyStore(store);
+
+ return (
+
+ { store.name = event.target.value; }}
+ />
+
+
+ );
+}
+```
+
+The Server Component supplies data and an intentional reset key:
+
+```tsx
+// app/profile/page.tsx
+import {ProfileEditor} from '../../components/profileEditor/ProfileEditor';
+
+type Props = {searchParams: Promise<{name?: string}>};
+
+export default async function ProfilePage({searchParams}: Props) {
+ const {name = 'Ada'} = await searchParams;
+ return ;
+}
+```
+
+The key determines when a mounted editor gets a new store. A new `initialData`
+object alone does not reset `useLocalStore` or its prepared snapshot. Avoid
+module-level server stores; construct request state inside the request instead.
+
+## Streaming, persistence, and navigation
+
+Attach persistence in an effect after preparation, call `rehydrate()` there,
+and return `unsubscribe()` from cleanup. The [SSR guide](./REACT_SSR.md) shows
+this pattern. If persistence restores before a streamed boundary hydrates,
+the boundary first reads the prepared server data, then catches up to live
+state. Keep the initial server and browser payloads identical.
+
+For a store shared across routes, place its Client Provider in the appropriate
+layout. Put route-specific providers below that layout and use keys when a
+route identity change should reset them. See the [typed context example](./REACT_SCOPING.md).
+`router.refresh()` merges new server output; it does not inherently reset a
+mounted client store. Choose explicitly whether refreshed data should update
+the existing store or replace its provider via a key.
+
+The integration tests use the default App Router configuration with
+`cacheComponents: false`. With Cache Components enabled, Next.js can use React
+Activity to preserve hidden route state while cleaning up its effects. Returning
+to a hidden route can resume the same store; effect cleanup does not necessarily
+mean the instance was destroyed. See [Next.js state preservation](https://nextjs.org/docs/app/guides/preserving-ui-state).
+
+## Tested package consumer
+
+Run `bun run test:react:next` from the repository root. It installs the package
+tarball into an isolated Next.js 16.4.2 / React 19.3.1 app and runs clean
+production Turbopack builds with React Compiler off and on. Tests verify the
+actual compiler-generated selector and tracking functions, request isolation,
+real inputs, streamed response ordering, delayed hydration without replacing
+server DOM, navigation, refresh, and persistence cleanup. They also exercise
+the server-safe APIs under React's `react-server` export condition and verify
+shared hydration captures across public entry points.
+
+This matrix covers the stated versions and default navigation behavior. It
+does not establish compatibility with every Next.js version, bundler, or
+Cache Components configuration.
+
+Next App Router can supply its own React runtime. The test evidence records the
+actual browser `React.version`; this Next.js version uses
+`19.3.0-canary-278794d7-20261002`, while the installed React package is 19.3.1.
diff --git a/website/docs/REACT_SSR.md b/website/docs/REACT_SSR.md
index f83ee52..711a1a2 100644
--- a/website/docs/REACT_SSR.md
+++ b/website/docs/REACT_SSR.md
@@ -4,6 +4,8 @@ Create one store for each server request and reconstruct a client store from the
The [typed context example](./REACT_SCOPING.md) provides a complete provider pattern with independent instances, nested scopes, keyed resets, and persistence cleanup.
+For React Server Components, use the server-safe `@codebelt/classy-store/react/server` preparation entry. The `@codebelt/classy-store/react` hooks entry is marked `use client`; its preparation export remains available inside client modules and ordinary SSR. The [Next.js App Router guide](./REACT_NEXT.md) explains where to construct stores and how to cross that boundary with initial data.
+
Call `prepareStoreForHydration(store)` after initializing the store with the data used to render the page, on both the server and client. It captures that initial snapshot before persistence or other live updates begin. All direct and bound hooks use the captured data during server rendering and client hydration, then read the current live store. React requires the initial server and client data to match; see [React's external-store SSR guidance](https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering).
## Initialize from request data
@@ -13,7 +15,7 @@ Keep the serialized payload limited to the data the client needs. Reconstruct th
```ts
// profileStore.ts
import {createClassyStore} from '@codebelt/classy-store';
-import {prepareStoreForHydration} from '@codebelt/classy-store/react';
+import {prepareStoreForHydration} from '@codebelt/classy-store/react/server';
export type ProfileData = {
name: string;
diff --git a/website/docs/index.md b/website/docs/index.md
index 94f6da8..e9f4b98 100644
--- a/website/docs/index.md
+++ b/website/docs/index.md
@@ -199,6 +199,8 @@ Call on each request's store and its client counterpart using matching initial d
Preparation is idempotent, retains the first snapshot for the store's lifetime, and applies to the exact proxy passed in. Client-only apps need no preparation. See [React SSR and hydration](./REACT_SSR.md) for streaming, request isolation, nested proxies, and snapshot leaf semantics.
+The same preparation helper is available from `@codebelt/classy-store/react/server` without importing hooks. Use that entry in React Server Components; the `/react` entry is marked `use client`. See [Next.js App Router](./REACT_NEXT.md) for production package boundaries and navigation patterns.
+
### `useLocalStore(factory)`
Creates a component-scoped reactive store. Each component instance gets its own isolated store, garbage collected on unmount.
diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt
index ae6adc0..4d0839e 100644
--- a/website/static/llms-full.txt
+++ b/website/static/llms-full.txt
@@ -209,6 +209,8 @@ Call on each request's store and its client counterpart using matching initial d
Preparation is idempotent, retains the first snapshot for the store's lifetime, and applies to the exact proxy passed in. Client-only apps need no preparation. See [React SSR and hydration](./REACT_SSR.md) for streaming, request isolation, nested proxies, and snapshot leaf semantics.
+The same preparation helper is available from `@codebelt/classy-store/react/server` without importing hooks. Use that entry in React Server Components; the `/react` entry is marked `use client`. See [Next.js App Router](./REACT_NEXT.md) for production package boundaries and navigation patterns.
+
### `useLocalStore(factory)`
Creates a component-scoped reactive store. Each component instance gets its own isolated store, garbage collected on unmount.
@@ -1318,6 +1320,8 @@ Create one store for each server request and reconstruct a client store from the
The [typed context example](./REACT_SCOPING.md) provides a complete provider pattern with independent instances, nested scopes, keyed resets, and persistence cleanup.
+For React Server Components, use the server-safe `@codebelt/classy-store/react/server` preparation entry. The `@codebelt/classy-store/react` hooks entry is marked `use client`; its preparation export remains available inside client modules and ordinary SSR. The [Next.js App Router guide](./REACT_NEXT.md) explains where to construct stores and how to cross that boundary with initial data.
+
Call `prepareStoreForHydration(store)` after initializing the store with the data used to render the page, on both the server and client. It captures that initial snapshot before persistence or other live updates begin. All direct and bound hooks use the captured data during server rendering and client hydration, then read the current live store. React requires the initial server and client data to match; see [React's external-store SSR guidance](https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering).
## Initialize from request data
@@ -1327,7 +1331,7 @@ Keep the serialized payload limited to the data the client needs. Reconstruct th
```ts
// profileStore.ts
import {createClassyStore} from '@codebelt/classy-store';
-import {prepareStoreForHydration} from '@codebelt/classy-store/react';
+import {prepareStoreForHydration} from '@codebelt/classy-store/react/server';
export type ProfileData = {
name: string;
@@ -3155,3 +3159,149 @@ All adapters:
- Clean up their subscription automatically when the component/owner is destroyed
- Return immutable snapshots (deeply frozen objects)
- Require calling actions directly on the store proxy β not on the returned reactive value
+
+---
+SOURCE: https://codebelt.github.io/classy-store/docs/REACT_NEXT
+---
+
+# Next.js App Router
+
+Pass initial data from a Server Component to a Client Component, then construct
+the class store inside the client boundary. That component also renders on the
+server for the initial request, so prepare its snapshot before persistence or
+other effects start. The browser constructs its own store from the same data.
+
+Do not pass a store proxy or class instance across the React Server Component
+boundary. Use a plain data object containing the fields the client needs.
+React defines the supported [serializable prop types](https://react.dev/reference/rsc/use-client#serializable-types-returned-by-server-components).
+
+## Package boundaries
+
+| Entry | App Router use |
+| --- | --- |
+| `@codebelt/classy-store` | Core store creation and snapshots in server or client modules. |
+| `@codebelt/classy-store/collections` | Reactive collections in server or client modules. |
+| `@codebelt/classy-store/utils` | Server-safe imports; configure browser storage and effects inside Client Components. |
+| `@codebelt/classy-store/react` | Client hooks and the compatible `prepareStoreForHydration` export. The published ESM and CommonJS entries carry `use client`. |
+| `@codebelt/classy-store/react/server` | `prepareStoreForHydration` without importing React hooks; safe in Server Components and ordinary SSR code. |
+
+Components that use hooks still need their own client boundary. Do not call
+`useClassyStore`, `useLocalStore`, or `createStoreHook` in a Server Component.
+Preparing a store in an RSC module does not transfer that store or its capture
+to the browser; initialize and prepare the browser counterpart separately.
+Both preparation exports use the same capture cache within a module graph.
+
+## A route-owned store
+
+Define the class in a regular module that has no browser effects:
+
+```ts
+// stores/profile/profile.store.ts
+export type ProfileData = {name: string; visits: number};
+
+export class ProfileStore {
+ name: string;
+ visits: number;
+
+ constructor(initialData: ProfileData) {
+ this.name = initialData.name;
+ this.visits = initialData.visits;
+ }
+
+ recordVisit() {
+ this.visits++;
+ }
+}
+```
+
+Construct and prepare it inside the Client Component:
+
+```tsx
+// components/profileEditor/ProfileEditor.tsx
+'use client';
+
+import {
+ prepareStoreForHydration,
+ useClassyStore,
+ useLocalStore,
+} from '@codebelt/classy-store/react';
+import {type ProfileData, ProfileStore} from '../../stores/profile/profile.store';
+
+export function ProfileEditor({initialData}: {initialData: ProfileData}) {
+ const store = prepareStoreForHydration(
+ useLocalStore(() => new ProfileStore(initialData)),
+ );
+ const state = useClassyStore(store);
+
+ return (
+
+ { store.name = event.target.value; }}
+ />
+
+
+ );
+}
+```
+
+The Server Component supplies data and an intentional reset key:
+
+```tsx
+// app/profile/page.tsx
+import {ProfileEditor} from '../../components/profileEditor/ProfileEditor';
+
+type Props = {searchParams: Promise<{name?: string}>};
+
+export default async function ProfilePage({searchParams}: Props) {
+ const {name = 'Ada'} = await searchParams;
+ return ;
+}
+```
+
+The key determines when a mounted editor gets a new store. A new `initialData`
+object alone does not reset `useLocalStore` or its prepared snapshot. Avoid
+module-level server stores; construct request state inside the request instead.
+
+## Streaming, persistence, and navigation
+
+Attach persistence in an effect after preparation, call `rehydrate()` there,
+and return `unsubscribe()` from cleanup. The [SSR guide](./REACT_SSR.md) shows
+this pattern. If persistence restores before a streamed boundary hydrates,
+the boundary first reads the prepared server data, then catches up to live
+state. Keep the initial server and browser payloads identical.
+
+For a store shared across routes, place its Client Provider in the appropriate
+layout. Put route-specific providers below that layout and use keys when a
+route identity change should reset them. See the [typed context example](./REACT_SCOPING.md).
+`router.refresh()` merges new server output; it does not inherently reset a
+mounted client store. Choose explicitly whether refreshed data should update
+the existing store or replace its provider via a key.
+
+The integration tests use the default App Router configuration with
+`cacheComponents: false`. With Cache Components enabled, Next.js can use React
+Activity to preserve hidden route state while cleaning up its effects. Returning
+to a hidden route can resume the same store; effect cleanup does not necessarily
+mean the instance was destroyed. See [Next.js state preservation](https://nextjs.org/docs/app/guides/preserving-ui-state).
+
+## Tested package consumer
+
+Run `bun run test:react:next` from the repository root. It installs the package
+tarball into an isolated Next.js 16.4.2 / React 19.3.1 app and runs clean
+production Turbopack builds with React Compiler off and on. Tests verify the
+actual compiler-generated selector and tracking functions, request isolation,
+real inputs, streamed response ordering, delayed hydration without replacing
+server DOM, navigation, refresh, and persistence cleanup. They also exercise
+the server-safe APIs under React's `react-server` export condition and verify
+shared hydration captures across public entry points.
+
+This matrix covers the stated versions and default navigation behavior. It
+does not establish compatibility with every Next.js version, bundler, or
+Cache Components configuration.
+
+Next App Router can supply its own React runtime. The test evidence records the
+actual browser `React.version`; this Next.js version uses
+`19.3.0-canary-278794d7-20261002`, while the installed React package is 19.3.1.
diff --git a/website/static/llms.txt b/website/static/llms.txt
index e3c45e1..de2fc4d 100644
--- a/website/static/llms.txt
+++ b/website/static/llms.txt
@@ -37,6 +37,7 @@
## Optional: Full Documentation
- [React SSR](https://codebelt.github.io/classy-store/docs/REACT_SSR): `prepareStoreForHydration`, request-scoped stores, and persistence-safe streamed/selective hydration
+- [Next.js App Router](https://codebelt.github.io/classy-store/docs/REACT_NEXT): Server-safe package entries, Client Providers, streaming hydration, navigation, and packed production integration tests
- [React store scoping](https://codebelt.github.io/classy-store/docs/REACT_SCOPING): Typed context providers, independent and nested instances, keyed resets, persistence cleanup, and SSR
- [llms-full.txt](https://codebelt.github.io/classy-store/llms-full.txt): All documentation
From 8a112804a4b7af3b53a9ae09875e518ca9255910 Mon Sep 17 00:00:00 2001
From: codeBelt
Date: Tue, 20 Oct 2026 23:04:27 -0500
Subject: [PATCH 8/9] fix(persist): order async operations and reject stale
updates
---
.changeset/persist-operation-ordering.md | 7 +
.../frameworks/react/react.utilities.test.tsx | 238 +++++++++++++++++
.../testHelpers/utilityValue/UtilityValue.tsx | 6 +
.../utils/persist/persist.concurrent.test.ts | 242 ++++++++++++++++++
.../classy-store/src/utils/persist/persist.ts | 91 +++++--
packages/classy-store/tests/browser/README.md | 9 +
.../tests/browser/browser.test.ts | 17 +-
.../tests/browser/persistence.spec.ts | 94 +++++++
.../tests/browser/persistence/client.tsx | 8 +
.../persistenceForm/PersistenceForm.tsx | 36 +++
.../tests/browser/persistence/index.html | 8 +
.../browser/persistence/persistence.store.ts | 54 ++++
packages/classy-store/tests/browser/server.ts | 2 +
.../compatibility/consumer.types.fixture.ts | 54 ++++
website/docs/PERSIST_ARCHITECTURE.md | 46 ++--
website/docs/PERSIST_TUTORIAL.md | 32 ++-
website/static/llms-full.txt | 47 +++-
17 files changed, 930 insertions(+), 61 deletions(-)
create mode 100644 .changeset/persist-operation-ordering.md
create mode 100644 packages/classy-store/src/frameworks/react/react.utilities.test.tsx
create mode 100644 packages/classy-store/src/frameworks/react/testHelpers/utilityValue/UtilityValue.tsx
create mode 100644 packages/classy-store/src/utils/persist/persist.concurrent.test.ts
create mode 100644 packages/classy-store/tests/browser/persistence.spec.ts
create mode 100644 packages/classy-store/tests/browser/persistence/client.tsx
create mode 100644 packages/classy-store/tests/browser/persistence/components/persistenceForm/PersistenceForm.tsx
create mode 100644 packages/classy-store/tests/browser/persistence/index.html
create mode 100644 packages/classy-store/tests/browser/persistence/persistence.store.ts
diff --git a/.changeset/persist-operation-ordering.md b/.changeset/persist-operation-ordering.md
new file mode 100644
index 0000000..41ffc09
--- /dev/null
+++ b/.changeset/persist-operation-ordering.md
@@ -0,0 +1,7 @@
+---
+'@codebelt/classy-store': patch
+---
+
+Keep asynchronous persistence writes and removals in request order. Reads wait for prior writes and ignore results superseded by a newer read, save, clear, or local edit. Clearing storage cancels pending auto-saves, and cleanup skips queued operations that have not started.
+
+Ignore outdated browser storage events so two-tab synchronization cannot overwrite a newer local save with an older event. Add deterministic async regressions and real two-tab React tests in Chromium, Firefox, and WebKit.
diff --git a/packages/classy-store/src/frameworks/react/react.utilities.test.tsx b/packages/classy-store/src/frameworks/react/react.utilities.test.tsx
new file mode 100644
index 0000000..b611760
--- /dev/null
+++ b/packages/classy-store/src/frameworks/react/react.utilities.test.tsx
@@ -0,0 +1,238 @@
+import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test';
+import {act} from 'react';
+import {createRoot} from 'react-dom/client';
+import {
+ type ReactiveMap,
+ type ReactiveSet,
+ reactiveMap,
+ reactiveSet,
+} from '../../collections/index';
+import {createClassyStore, snapshot, subscribe} from '../../index';
+import {
+ devtools,
+ type PersistOptions,
+ persist,
+ withHistory,
+} from '../../utils/index';
+import {UtilityValue} from './testHelpers/utilityValue/UtilityValue';
+
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+const cleanups: Array<() => void> = [];
+let previousExtension: Window['__REDUX_DEVTOOLS_EXTENSION__'];
+
+function disposeAll() {
+ for (const cleanup of cleanups.splice(0).reverse()) cleanup();
+}
+
+beforeEach(() => {
+ previousExtension = window.__REDUX_DEVTOOLS_EXTENSION__;
+});
+
+afterEach(async () => {
+ disposeAll();
+ await flush();
+ if (previousExtension) {
+ window.__REDUX_DEVTOOLS_EXTENSION__ = previousExtension;
+ } else {
+ delete window.__REDUX_DEVTOOLS_EXTENSION__;
+ }
+});
+
+function createStorage() {
+ const data = new Map();
+ return {
+ data,
+ getItem: (name: string) => data.get(name) ?? null,
+ setItem: mock((name: string, value: string) => {
+ data.set(name, value);
+ }),
+ removeItem: (name: string) => {
+ data.delete(name);
+ },
+ };
+}
+
+function installDevtools() {
+ type Extension = NonNullable;
+ type Connection = ReturnType;
+ type Listener = Parameters[0];
+ let listener: Listener | undefined;
+ const unsubscribe = mock(() => {
+ listener = undefined;
+ });
+ const connection = {
+ init: mock((_state: unknown) => {}),
+ send: mock((_action: string | {type: string}, _state: unknown) => {}),
+ subscribe: (nextListener: Listener) => {
+ listener = nextListener;
+ return unsubscribe;
+ },
+ };
+ window.__REDUX_DEVTOOLS_EXTENSION__ = {connect: () => connection};
+ return {
+ connection,
+ unsubscribe,
+ jumpTo(state: {count: number}) {
+ if (!listener) throw new Error('DevTools must be connected');
+ listener({
+ type: 'DISPATCH',
+ payload: {type: 'JUMP_TO_STATE'},
+ state: JSON.stringify(state),
+ });
+ },
+ };
+}
+
+describe('core and React utility happy paths', () => {
+ test('persists edits, undo and redo, then restores the final state in a new store', async () => {
+ const storage = createStorage();
+ const store = createClassyStore({count: 0});
+ const persistence = persist(store, {name: 'draft', storage});
+ cleanups.push(persistence.unsubscribe);
+ await persistence.hydrated;
+ const history = withHistory(store);
+ cleanups.push(history.dispose);
+
+ store.count = 1;
+ await flush();
+ expect(JSON.parse(storage.data.get('draft') ?? '').state.count).toBe(1);
+
+ history.undo();
+ await flush();
+ expect(store.count).toBe(0);
+ expect(JSON.parse(storage.data.get('draft') ?? '').state.count).toBe(0);
+
+ history.redo();
+ await flush();
+ expect(store.count).toBe(1);
+ expect(JSON.parse(storage.data.get('draft') ?? '').state.count).toBe(1);
+ disposeAll();
+
+ const restored = createClassyStore({count: 0});
+ const restoredPersistence = persist(restored, {name: 'draft', storage});
+ cleanups.push(restoredPersistence.unsubscribe);
+ await restoredPersistence.hydrated;
+ expect(restored.count).toBe(1);
+ });
+
+ test('sends edits to DevTools and renders time-travel state in React', async () => {
+ const extension = installDevtools();
+ const store = createClassyStore({count: 0});
+ cleanups.push(devtools(store));
+ const container = document.createElement('div');
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ cleanups.push(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+ act(() => root.render());
+ expect(container.textContent).toBe('0');
+ expect(extension.connection.init).toHaveBeenCalledWith({count: 0});
+
+ await act(async () => {
+ store.count = 3;
+ });
+ expect(container.textContent).toBe('3');
+ expect(extension.connection.send).toHaveBeenCalledWith(
+ {type: 'STORE_UPDATE'},
+ {count: 3},
+ );
+ extension.connection.send.mockClear();
+
+ await act(async () => extension.jumpTo({count: 1}));
+ expect(store.count).toBe(1);
+ expect(container.textContent).toBe('1');
+ expect(extension.connection.send).not.toHaveBeenCalled();
+ });
+
+ test('restores reactive Map and Set transforms and keeps subsequent edits reactive', async () => {
+ const storage = createStorage();
+ const createStore = () =>
+ createClassyStore({
+ users: reactiveMap(),
+ tags: reactiveSet(),
+ });
+ const store = createStore();
+ const options: PersistOptions = {
+ name: 'collections',
+ storage,
+ properties: [
+ {
+ key: 'users',
+ serialize: (value) =>
+ Array.from((value as ReactiveMap).entries()),
+ deserialize: (value) => reactiveMap(value as Array<[string, string]>),
+ },
+ {
+ key: 'tags',
+ serialize: (value) =>
+ Array.from((value as ReactiveSet).values()),
+ deserialize: (value) => reactiveSet(value as string[]),
+ },
+ ],
+ };
+ const persistence = persist(store, options);
+ cleanups.push(persistence.unsubscribe);
+ await persistence.hydrated;
+ store.users.set('ada', 'Ada');
+ store.tags.add('saved');
+ await flush();
+ disposeAll();
+
+ const restored = createStore();
+ const restoredPersistence = persist(restored, options);
+ cleanups.push(restoredPersistence.unsubscribe);
+ await restoredPersistence.hydrated;
+ expect(restored.users.get('ada')).toBe('Ada');
+ expect(restored.tags.has('saved')).toBe(true);
+ const before = snapshot(restored);
+ const onChange = mock(() => {});
+ cleanups.push(subscribe(restored, onChange));
+
+ restored.users.set('grace', 'Grace');
+ restored.tags.add('edited');
+ await flush();
+ expect(onChange).toHaveBeenCalledTimes(1);
+ expect(snapshot(restored).users.get('grace')).toBe('Grace');
+ expect(snapshot(restored).tags.has('edited')).toBe(true);
+ expect(before.users.has('grace')).toBe(false);
+ expect(before.tags.has('edited')).toBe(false);
+ expect(JSON.parse(storage.data.get('collections') ?? '').state).toEqual({
+ users: [
+ ['ada', 'Ada'],
+ ['grace', 'Grace'],
+ ],
+ tags: ['saved', 'edited'],
+ });
+ });
+
+ test('disposes persistence, history and DevTools together without further side effects', async () => {
+ const storage = createStorage();
+ const extension = installDevtools();
+ const store = createClassyStore({count: 0});
+ const persistence = persist(store, {name: 'draft', storage});
+ cleanups.push(persistence.unsubscribe);
+ await persistence.hydrated;
+ const history = withHistory(store);
+ cleanups.push(history.dispose, devtools(store));
+
+ store.count = 1;
+ await flush();
+ expect(storage.setItem).toHaveBeenCalledTimes(1);
+ expect(extension.connection.send).toHaveBeenCalledTimes(1);
+ expect(history.canUndo).toBe(true);
+ disposeAll();
+ storage.setItem.mockClear();
+ extension.connection.send.mockClear();
+
+ store.count = 2;
+ await flush();
+ expect(store.count).toBe(2);
+ expect(storage.setItem).not.toHaveBeenCalled();
+ expect(extension.connection.send).not.toHaveBeenCalled();
+ expect(extension.unsubscribe).toHaveBeenCalledTimes(1);
+ expect(history.canUndo).toBe(false);
+ expect(history.canRedo).toBe(false);
+ });
+});
diff --git a/packages/classy-store/src/frameworks/react/testHelpers/utilityValue/UtilityValue.tsx b/packages/classy-store/src/frameworks/react/testHelpers/utilityValue/UtilityValue.tsx
new file mode 100644
index 0000000..2b82c77
--- /dev/null
+++ b/packages/classy-store/src/frameworks/react/testHelpers/utilityValue/UtilityValue.tsx
@@ -0,0 +1,6 @@
+import {useClassyStore} from '../../react';
+
+export function UtilityValue({store}: {store: {count: number}}) {
+ const state = useClassyStore(store);
+ return ;
+}
diff --git a/packages/classy-store/src/utils/persist/persist.concurrent.test.ts b/packages/classy-store/src/utils/persist/persist.concurrent.test.ts
new file mode 100644
index 0000000..7771b0d
--- /dev/null
+++ b/packages/classy-store/src/utils/persist/persist.concurrent.test.ts
@@ -0,0 +1,242 @@
+import {afterEach, describe, expect, mock, test} from 'bun:test';
+import {createClassyStore} from '../../core/core';
+import {type PersistHandle, persist} from './persist';
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return {promise, resolve, reject};
+}
+
+const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
+const envelope = (count: number) =>
+ JSON.stringify({version: 0, state: {count}});
+const handles: PersistHandle[] = [];
+afterEach(() => {
+ for (const handle of handles.splice(0)) handle.unsubscribe();
+});
+
+describe('persist async operation ordering', () => {
+ test.each([
+ {description: 'successful', fails: false},
+ {description: 'failed', fails: true},
+ ])('keeps a newer save behind a $description pending write', async ({
+ fails,
+ }) => {
+ const firstWrite = deferred();
+ const secondWrite = deferred();
+ let saved: string | null = null;
+ const storage = {
+ getItem: () => saved,
+ setItem: mock(async (_name: string, value: string) => {
+ await (JSON.parse(value).state.count === 1 ? firstWrite : secondWrite)
+ .promise;
+ saved = value;
+ }),
+ removeItem: () => {},
+ };
+ const onError = mock(() => {});
+ const store = createClassyStore({count: 1});
+ const handle = persist(store, {
+ name: 'draft',
+ storage,
+ skipHydration: true,
+ debounce: 1000,
+ onError,
+ });
+ handles.push(handle);
+ const first = handle.save();
+ store.count = 2;
+ await tick();
+ const second = handle.save();
+ store.count = 3; // The queued save must retain its requested value, 2.
+
+ try {
+ expect(storage.setItem).toHaveBeenCalledTimes(1);
+ if (fails) firstWrite.reject(new Error('Write failed'));
+ else firstWrite.resolve();
+ await first;
+ await tick();
+ expect(storage.setItem).toHaveBeenCalledTimes(2);
+ secondWrite.resolve();
+ await second;
+ expect(saved).toBe(envelope(2));
+ expect(onError).toHaveBeenCalledTimes(fails ? 1 : 0);
+ } finally {
+ firstWrite.resolve();
+ secondWrite.resolve();
+ await Promise.all([first, second]);
+ }
+ });
+
+ test('drops queued saves after unsubscribe while an issued write can finish', async () => {
+ const pending = deferred();
+ const storage = {
+ getItem: () => null,
+ setItem: mock(() => pending.promise),
+ removeItem: () => {},
+ };
+ const store = createClassyStore({count: 1});
+ const handle = persist(store, {
+ name: 'draft',
+ storage,
+ skipHydration: true,
+ });
+ handles.push(handle);
+ const first = handle.save();
+ const second = handle.save();
+ handle.unsubscribe();
+ pending.resolve();
+ await Promise.all([first, second]);
+ expect(storage.setItem).toHaveBeenCalledTimes(1);
+ });
+
+ test('clears storage after an already-issued save finishes', async () => {
+ const pending = deferred();
+ let saved: string | null = null;
+ const storage = {
+ getItem: () => saved,
+ setItem: async (_name: string, value: string) => {
+ await pending.promise;
+ saved = value;
+ },
+ removeItem: mock(() => {
+ saved = null;
+ }),
+ };
+ const store = createClassyStore({count: 1});
+ const handle = persist(store, {
+ name: 'draft',
+ storage,
+ skipHydration: true,
+ });
+ handles.push(handle);
+ const saving = handle.save();
+ const clearing = handle.clear();
+ try {
+ expect(storage.removeItem).not.toHaveBeenCalled();
+ } finally {
+ pending.resolve();
+ await Promise.all([saving, clearing]);
+ }
+ expect(saved).toBeNull();
+ expect(storage.removeItem).toHaveBeenCalledTimes(1);
+ });
+
+ test('ignores an older hydration result that finishes after a newer request', async () => {
+ const older = deferred();
+ const newer = deferred();
+ const storage = {
+ getItem: mock()
+ .mockReturnValueOnce(older.promise)
+ .mockReturnValueOnce(newer.promise),
+ setItem: mock(() => {}),
+ removeItem: () => {},
+ };
+ const store = createClassyStore({count: 0});
+ const handle = persist(store, {
+ name: 'draft',
+ storage,
+ skipHydration: true,
+ });
+ handles.push(handle);
+ const first = handle.rehydrate();
+ const second = handle.rehydrate();
+ newer.resolve(envelope(2));
+ await second;
+ older.resolve(envelope(1));
+ await first;
+ expect(store.count).toBe(2);
+ expect(storage.setItem).not.toHaveBeenCalled();
+ });
+
+ test('keeps edits made while initial hydration is reading storage', async () => {
+ const pendingRead = deferred();
+ let saved: string | null = null;
+ const storage = {
+ getItem: () => pendingRead.promise,
+ setItem: (_name: string, value: string) => {
+ saved = value;
+ },
+ removeItem: () => {},
+ };
+ const store = createClassyStore({count: 0});
+ const handle = persist(store, {name: 'draft', storage, debounce: 1000});
+ handles.push(handle);
+ store.count = 2;
+ await tick();
+ pendingRead.resolve(envelope(1));
+ await handle.hydrated;
+ expect(store.count).toBe(2);
+ await handle.save();
+ expect(saved).toBe(envelope(2));
+ });
+
+ test.each([
+ {description: 'batched', debounce: 0},
+ {description: 'debounced', debounce: 20},
+ ])('clear cancels a pending $description save and later edits still persist', async ({
+ debounce,
+ }) => {
+ let saved: string | null = envelope(1);
+ const storage = {
+ getItem: () => saved,
+ setItem: mock((_name: string, value: string) => {
+ saved = value;
+ }),
+ removeItem: () => {
+ saved = null;
+ },
+ };
+ const store = createClassyStore({count: 1});
+ const handle = persist(store, {
+ name: 'draft',
+ storage,
+ debounce,
+ skipHydration: true,
+ });
+ handles.push(handle);
+ store.count = 2;
+ if (debounce) await tick(); // Arm the timer; the zero-delay case still has a batched notification.
+ await handle.clear();
+ await new Promise((resolve) => setTimeout(resolve, debounce + 10));
+ expect(saved).toBeNull();
+ expect(storage.setItem).not.toHaveBeenCalled();
+ store.count = 3;
+ await new Promise((resolve) => setTimeout(resolve, debounce + 10));
+ expect(saved).toBe(envelope(3));
+ });
+
+ test('rehydrates from the result of a previously requested save', async () => {
+ const pendingWrite = deferred();
+ let saved = envelope(0);
+ const storage = {
+ getItem: mock(() => saved),
+ setItem: async (_name: string, value: string) => {
+ await pendingWrite.promise;
+ saved = value;
+ },
+ removeItem: () => {},
+ };
+ const store = createClassyStore({count: 1});
+ const handle = persist(store, {
+ name: 'draft',
+ storage,
+ skipHydration: true,
+ });
+ handles.push(handle);
+ const saving = handle.save();
+ const reading = handle.rehydrate();
+ try {
+ expect(storage.getItem).not.toHaveBeenCalled();
+ } finally {
+ pendingWrite.resolve();
+ await Promise.all([saving, reading]);
+ }
+ expect(store.count).toBe(1);
+ });
+});
diff --git a/packages/classy-store/src/utils/persist/persist.ts b/packages/classy-store/src/utils/persist/persist.ts
index f6dfac2..53a9b2e 100644
--- a/packages/classy-store/src/utils/persist/persist.ts
+++ b/packages/classy-store/src/utils/persist/persist.ts
@@ -1,4 +1,4 @@
-import {subscribe} from '../../core/core';
+import {getVersion, subscribe} from '../../core/core';
import {snapshot} from '../../snapshot/snapshot';
import {cloneSnapshotValue} from '../cloneSnapshot/cloneSnapshot';
import {findGetterDescriptor} from '../internal/internal';
@@ -154,8 +154,8 @@ export type PersistOperation = 'write' | 'read' | 'remove' | 'parse';
export type PersistHandle = {
/**
* Stop persisting, cancel pending debounce, and remove the storage listener.
- * Pending read results/errors are ignored; their promises still settle.
- * Already-issued adapter I/O is not cancelled.
+ * Pending read results/errors and unstarted queued writes are ignored.
+ * Their promises still settle; already-issued adapter I/O is not cancelled.
*/
unsubscribe: () => void;
@@ -165,13 +165,13 @@ export type PersistHandle = {
/** Whether hydration from storage has completed. */
isHydrated: boolean;
- /** Manually trigger a write to storage (bypasses debounce). */
+ /** Save current state, bypassing debounce and waiting behind earlier writes. */
save: () => Promise;
- /** Clear this store's persisted data from storage. */
+ /** Cancel pending auto-saves and remove persisted data after earlier writes. */
clear: () => Promise;
- /** Manually re-hydrate the store from storage. */
+ /** Re-read after prior writes; ignore the result if a newer read/edit supersedes it. */
rehydrate: () => Promise;
/** True if the last hydration found expired data (requires `expireIn`). */
@@ -285,6 +285,9 @@ export function persist(
let debounceTimer: ReturnType | null = null;
let hydratedFlag = false;
let expiredFlag = false;
+ let hydrationRequest = 0;
+ let storageQueue: Promise | undefined;
+ let clearedVersion: number | undefined;
// Hydration promise + resolver.
let resolveHydrated!: () => void;
@@ -304,6 +307,28 @@ export function persist(
}
const propKeys = resolvedProps.map((p) => p.key);
+ /** Keep this handle's writes/removals in order, even with an async adapter. */
+ function enqueueStorageMutation(
+ operation: () => void | Promise,
+ kind: 'write' | 'remove',
+ ): Promise {
+ const execute = async () => {
+ if (disposed) return;
+ try {
+ await operation();
+ } catch (error) {
+ reportError(error, kind);
+ }
+ };
+ // Start the first operation immediately for synchronous storage adapters.
+ const pending = storageQueue ? storageQueue.then(execute) : execute();
+ const completion = pending.finally(() => {
+ if (storageQueue === completion) storageQueue = undefined;
+ });
+ storageQueue = completion;
+ return completion;
+ }
+
// ββ Save logic βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/** Serialize the current store state into a JSON string (versioned envelope). */
@@ -330,9 +355,11 @@ export function persist(
/** Write the current state to storage. */
async function writeToStorage(): Promise {
if (disposed) return;
+ hydrationRequest++;
try {
+ // Capture now; a queued save must represent the state requested by its caller.
const json = serializeState();
- await storage.setItem(name, json);
+ await enqueueStorageMutation(() => storage.setItem(name, json), 'write');
} catch (error) {
reportError(error, 'write');
}
@@ -341,6 +368,8 @@ export function persist(
/** Schedule a debounced write (or write immediately if debounce is 0). */
function scheduleWrite(): void {
if (disposed || hydrating) return;
+ // clear() can run before a mutation's batched notification is delivered.
+ if (getVersion(proxyStore) === clearedVersion) return;
if (debounceMs <= 0) {
void writeToStorage();
@@ -387,9 +416,7 @@ export function persist(
) {
expiredFlag = true;
if (clearOnExpire) {
- void Promise.resolve(storage.removeItem(name)).catch((error) => {
- reportError(error, 'remove');
- });
+ void enqueueStorageMutation(() => storage.removeItem(name), 'remove');
}
return;
}
@@ -442,16 +469,25 @@ export function persist(
/** Read from storage and apply to the store. */
async function hydrateFromStorage(): Promise {
+ const request = ++hydrationRequest;
+ const storeVersion = getVersion(proxyStore);
+ const isCurrent = () =>
+ !disposed &&
+ request === hydrationRequest &&
+ storeVersion === getVersion(proxyStore);
let raw: string | null;
try {
+ // A read requested after save()/clear() observes those operations' results.
+ if (storageQueue) await storageQueue;
+ if (!isCurrent()) return;
raw = await storage.getItem(name);
} catch (error) {
- if (!disposed) reportError(error, 'read');
+ if (isCurrent()) reportError(error, 'read');
return;
}
- // Effect cleanup can dispose this handle while storage is still reading.
- // A retired scope must not apply data or clear a newer scope's storage.
- if (disposed) return;
+ // Cleanup, a newer request, or a local edit can supersede an in-flight read.
+ // Obsolete data must not replace current state or clear newer stored data.
+ if (!isCurrent()) return;
if (raw !== null) {
// A debounced local write may be pending from a mutation issued before
// we got around to hydrating; the freshly-loaded state would be
@@ -461,11 +497,13 @@ export function persist(
debounceTimer = null;
}
hydrating = true;
- 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;
+ try {
+ applyPersistedState(raw);
+ } finally {
+ // Keep the batched notification from writing restored state back.
+ await new Promise((resolve) => queueMicrotask(resolve));
+ hydrating = false;
+ }
}
}
@@ -476,6 +514,11 @@ export function persist(
if (disposed) return;
if (event.key !== name) return;
if (event.newValue === null) return; // cleared
+ // Delivery is asynchronous: another tab or a local save may have already
+ // replaced this event's value. Do not roll the UI back to an obsolete value.
+ if (event.storageArea && event.storageArea.getItem(name) !== event.newValue)
+ return;
+ hydrationRequest++;
// A pending local debounced write would otherwise overwrite the remote
// state once its timer fires.
if (debounceTimer !== null) {
@@ -560,11 +603,13 @@ export function persist(
async clear() {
if (disposed) return;
- try {
- await storage.removeItem(name);
- } catch (error) {
- reportError(error, 'remove');
+ hydrationRequest++;
+ clearedVersion = getVersion(proxyStore);
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ debounceTimer = null;
}
+ await enqueueStorageMutation(() => storage.removeItem(name), 'remove');
},
async rehydrate() {
diff --git a/packages/classy-store/tests/browser/README.md b/packages/classy-store/tests/browser/README.md
index 61a536e..409b2d7 100644
--- a/packages/classy-store/tests/browser/README.md
+++ b/packages/classy-store/tests/browser/README.md
@@ -13,6 +13,15 @@ CI also installs the Linux system dependencies with `playwright install --with-d
Every test fails on browser console errors or uncaught page errors. Native clipboard replacement and subsequent caret behavior are tested in Chromium; Firefox and WebKit explicitly skip those cases because they do not expose the same clipboard permissions API.
+The `/persistence` fixture opens two pages in one browser context and uses real
+`localStorage` events. It verifies bidirectional input updates for selectors and
+auto-tracking, no echo writes, reload restoration, unsubscribe, remote updates
+cancelling pending debounces, and a newer local save winning over an older queued
+storage event. The conflict case uses a capture listener to make the ordering
+deterministic; the event itself is browser-generated and must be trusted. Errors
+are collected from both tabs. These cases run in the existing three-browser CI
+matrix.
+
The four hook forms (selector, auto-tracked, bound selector, and bound auto-tracked) cover middle insertion, selection replacement, deletion, multiline and Unicode input, checkbox changes, composition event sequences, and multi-property/array updates. The latter asserts one React commit in the event handler and one ordinary subscription callback after the microtask flush.
Composition tests combine synthetic boundaries with browser text insertion. They do not automate a native operating-system IME candidate picker; that still needs a manual smoke test. Firefox emits additional composition boundaries during text insertion, so the suite checks balanced boundaries and the final value/caret rather than a browser-specific event count.
diff --git a/packages/classy-store/tests/browser/browser.test.ts b/packages/classy-store/tests/browser/browser.test.ts
index d3fec40..e0701a6 100644
--- a/packages/classy-store/tests/browser/browser.test.ts
+++ b/packages/classy-store/tests/browser/browser.test.ts
@@ -1,14 +1,19 @@
-import {test as base, expect} from '@playwright/test';
+import {test as base, expect, type Page} from '@playwright/test';
export const test = base.extend<{browserErrors: undefined}>({
browserErrors: [
- async ({page}, use) => {
+ async ({page, context}, use) => {
const errors: string[] = [];
- page.on('pageerror', (error) => errors.push(error.message));
- page.on('console', (message) => {
- if (message.type() === 'error') errors.push(message.text());
- });
+ function observe(tab: Page) {
+ tab.on('pageerror', (error) => errors.push(error.message));
+ tab.on('console', (message) => {
+ if (message.type() === 'error') errors.push(message.text());
+ });
+ }
+ observe(page);
+ context.on('page', observe);
await use(undefined);
+ context.off('page', observe);
expect(errors, 'Browser exceptions and console errors').toEqual([]);
},
{auto: true},
diff --git a/packages/classy-store/tests/browser/persistence.spec.ts b/packages/classy-store/tests/browser/persistence.spec.ts
new file mode 100644
index 0000000..25cbfca
--- /dev/null
+++ b/packages/classy-store/tests/browser/persistence.spec.ts
@@ -0,0 +1,94 @@
+import {expect, type Page} from '@playwright/test';
+import {test} from './browser.test';
+
+async function expectDraft(page: Page, text: string) {
+ await expect(page.getByLabel('Draft', {exact: true})).toHaveValue(text);
+ await expect(page.getByTestId('tracked')).toHaveText(text);
+ await expect(page.getByTestId('selected')).toHaveText(text);
+}
+
+test('two tabs synchronize real inputs without echo writes, restore on reload, and unsubscribe', async ({
+ page,
+ context,
+}) => {
+ const other = await context.newPage();
+ await page.goto('/persistence');
+ await other.goto('/persistence');
+ await expectDraft(page, 'Initial');
+ await expectDraft(other, 'Initial');
+
+ await page.getByLabel('Draft', {exact: true}).fill('From first tab');
+ await expectDraft(other, 'From first tab');
+ expect(await other.evaluate(() => window.persistenceProbe.writes)).toBe(0);
+ await other.getByLabel('Draft', {exact: true}).fill('From second tab');
+ await expectDraft(page, 'From second tab');
+ expect(await page.evaluate(() => window.persistenceProbe.writes)).toBe(1);
+ expect(await other.evaluate(() => window.persistenceProbe.writes)).toBe(1);
+ expect(
+ await other.evaluate(() => window.persistenceProbe.trustedEvents),
+ ).toBe(1);
+
+ await page.reload();
+ await expectDraft(page, 'From second tab');
+ await other.getByRole('button', {name: 'Stop persistence'}).click();
+ await expect(other.getByTestId('status')).toHaveText('Disconnected');
+ await page.getByLabel('Draft', {exact: true}).fill('After cleanup');
+ // Wait for actual delivery before asserting that the retired handle ignored it.
+ await expect
+ .poll(() => other.evaluate(() => window.persistenceProbe.events))
+ .toBe(2);
+ await expectDraft(other, 'From second tab');
+ await other.getByLabel('Draft', {exact: true}).fill('Local only');
+ await expectDraft(other, 'Local only');
+ expect(await other.evaluate(() => window.persistenceProbe.writes)).toBe(1);
+ expect(
+ await page.evaluate(
+ () => JSON.parse(localStorage.getItem('two-tab-draft') ?? '').state.text,
+ ),
+ ).toBe('After cleanup');
+});
+
+test('a remote update cancels the other tabβs pending debounce and local editing can resume', async ({
+ page,
+ context,
+}) => {
+ const other = await context.newPage();
+ await page.goto('/persistence?debounce=500');
+ await other.goto('/persistence');
+ await expectDraft(page, 'Initial');
+ await expectDraft(other, 'Initial');
+ await page.getByLabel('Draft', {exact: true}).fill('Pending local edit');
+ await other.getByLabel('Draft', {exact: true}).fill('Remote winner');
+ await expectDraft(page, 'Remote winner');
+ // A negative timer assertion must observe the original debounce deadline.
+ await page.waitForTimeout(650);
+ expect(await page.evaluate(() => window.persistenceProbe.writes)).toBe(0);
+ expect(await other.evaluate(() => window.persistenceProbe.writes)).toBe(1);
+ await page.getByLabel('Draft', {exact: true}).fill('Editing resumed');
+ await expectDraft(other, 'Editing resumed');
+ expect(await page.evaluate(() => window.persistenceProbe.writes)).toBe(1);
+});
+
+test('an older storage event cannot overwrite a newer local save', async ({
+ page,
+ context,
+}) => {
+ const other = await context.newPage();
+ await page.goto('/persistence');
+ await other.goto('/persistence');
+ await expectDraft(other, 'Initial');
+ await other.evaluate(() =>
+ window.persistenceProbe.writeOnNextEvent('Newer local save'),
+ );
+ await page.getByLabel('Draft', {exact: true}).fill('Older remote save');
+ await expectDraft(page, 'Newer local save');
+ await expectDraft(other, 'Newer local save');
+ expect(
+ await other.evaluate(() => window.persistenceProbe.trustedEvents),
+ ).toBe(1);
+ expect(
+ await other.evaluate(
+ () => JSON.parse(localStorage.getItem('two-tab-draft') ?? '').state.text,
+ ),
+ ).toBe('Newer local save');
+});
diff --git a/packages/classy-store/tests/browser/persistence/client.tsx b/packages/classy-store/tests/browser/persistence/client.tsx
new file mode 100644
index 0000000..9a221a8
--- /dev/null
+++ b/packages/classy-store/tests/browser/persistence/client.tsx
@@ -0,0 +1,8 @@
+import {createRoot} from 'react-dom/client';
+import {PersistenceForm} from './components/persistenceForm/PersistenceForm';
+import {persistence} from './persistence.store';
+
+await persistence.hydrated;
+const container = document.getElementById('root');
+if (!container) throw new Error('Missing persistence fixture root');
+createRoot(container).render();
diff --git a/packages/classy-store/tests/browser/persistence/components/persistenceForm/PersistenceForm.tsx b/packages/classy-store/tests/browser/persistence/components/persistenceForm/PersistenceForm.tsx
new file mode 100644
index 0000000..9a4636e
--- /dev/null
+++ b/packages/classy-store/tests/browser/persistence/components/persistenceForm/PersistenceForm.tsx
@@ -0,0 +1,36 @@
+import {useState} from 'react';
+import {useClassyStore} from '../../../../../src/frameworks/react/react';
+import {persistence, persistenceStore} from '../../persistence.store';
+
+export function PersistenceForm() {
+ const tracked = useClassyStore(persistenceStore);
+ const selected = useClassyStore(persistenceStore, (state) => state.text);
+ const [connected, setConnected] = useState(true);
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/classy-store/tests/browser/persistence/index.html b/packages/classy-store/tests/browser/persistence/index.html
new file mode 100644
index 0000000..8b9ebc7
--- /dev/null
+++ b/packages/classy-store/tests/browser/persistence/index.html
@@ -0,0 +1,8 @@
+
+
+ Persistence across tabs
+
+
+
+
+
diff --git a/packages/classy-store/tests/browser/persistence/persistence.store.ts b/packages/classy-store/tests/browser/persistence/persistence.store.ts
new file mode 100644
index 0000000..d28fce2
--- /dev/null
+++ b/packages/classy-store/tests/browser/persistence/persistence.store.ts
@@ -0,0 +1,54 @@
+import {createClassyStore} from '../../../src/index';
+import {persist} from '../../../src/utils/index';
+
+const parameters = new URLSearchParams(location.search);
+export const persistenceKey = parameters.get('key') ?? 'two-tab-draft';
+export const persistenceStore = createClassyStore({text: 'Initial'});
+let nextEventEdit: string | undefined;
+
+export const persistenceProbe = {
+ writes: 0,
+ events: 0,
+ trustedEvents: 0,
+ writeOnNextEvent(text: string) {
+ nextEventEdit = text;
+ },
+};
+
+// A capture listener makes the conflict deterministic: a newer local save
+// happens while an older, genuinely browser-delivered event is being handled.
+window.addEventListener(
+ 'storage',
+ (event) => {
+ if (event.key !== persistenceKey || event.newValue === null) return;
+ persistenceProbe.events++;
+ if (event.isTrusted) persistenceProbe.trustedEvents++;
+ if (nextEventEdit !== undefined) {
+ persistenceStore.text = nextEventEdit;
+ nextEventEdit = undefined;
+ void persistence.save();
+ }
+ },
+ true,
+);
+
+export const persistence = persist(persistenceStore, {
+ name: persistenceKey,
+ storage: {
+ getItem: (name) => localStorage.getItem(name),
+ setItem: (name, value) => {
+ persistenceProbe.writes++;
+ localStorage.setItem(name, value);
+ },
+ removeItem: (name) => localStorage.removeItem(name),
+ },
+ debounce: Number(parameters.get('debounce') ?? 0),
+ syncTabs: true,
+});
+
+declare global {
+ interface Window {
+ persistenceProbe: typeof persistenceProbe;
+ }
+}
+window.persistenceProbe = persistenceProbe;
diff --git a/packages/classy-store/tests/browser/server.ts b/packages/classy-store/tests/browser/server.ts
index 208fc86..2cec8b7 100644
--- a/packages/classy-store/tests/browser/server.ts
+++ b/packages/classy-store/tests/browser/server.ts
@@ -6,6 +6,7 @@ import {
renderStreamingHydrationPage,
} from './hydration/renderHydrationPage';
import page from './index.html';
+import persistencePage from './persistence/index.html';
const hydrationBundle = await Bun.build({
entrypoints: [
@@ -64,6 +65,7 @@ Bun.serve({
port: 4179,
routes: {
'/': page,
+ '/persistence': persistencePage,
'/compiler': () =>
new Response(
'Compiled consumer',
diff --git a/packages/classy-store/tests/compatibility/consumer.types.fixture.ts b/packages/classy-store/tests/compatibility/consumer.types.fixture.ts
index e0a8b18..2fc3cc7 100644
--- a/packages/classy-store/tests/compatibility/consumer.types.fixture.ts
+++ b/packages/classy-store/tests/compatibility/consumer.types.fixture.ts
@@ -4,6 +4,12 @@ import {
snapshot,
subscribe,
} from '@codebelt/classy-store';
+import {
+ type ReactiveMap,
+ type ReactiveSet,
+ reactiveMap,
+ reactiveSet,
+} from '@codebelt/classy-store/collections';
// @ts-expect-error Auto-tracked hooks no longer expose notification options.
import type {UseClassyStoreOptions} from '@codebelt/classy-store/react';
import {
@@ -12,6 +18,13 @@ import {
useClassyStore,
useLocalStore,
} from '@codebelt/classy-store/react';
+import {
+ type PersistHandle,
+ type PersistOptions,
+ persist,
+ type StorageAdapter,
+ subscribeKey,
+} from '@codebelt/classy-store/utils';
class ConsumerStore {
count = 0;
@@ -104,3 +117,44 @@ export function useConsumerTypeAssertions() {
return {assertions, wrongType, removedOptions: null as UseClassyStoreOptions};
}
+
+// Compiled through the packed ESM and CommonJS declarations by the existing
+// compatibility runner; this function is a type fixture and is never invoked.
+export function utilityTypeAssertions(storage: StorageAdapter) {
+ const draft = createClassyStore({title: 'Draft', count: 0});
+ const options: PersistOptions = {
+ name: 'draft',
+ storage,
+ properties: [
+ 'title',
+ {
+ key: 'count',
+ serialize: (value) => {
+ const inferred: Expect> = true;
+ void inferred;
+ return String(value);
+ },
+ deserialize: (stored) => Number(stored),
+ },
+ ],
+ };
+ const persistence = persist(draft, options);
+ const unsubscribe = subscribeKey(draft, 'count', (value, previous) => {
+ const inferred: [
+ Expect>,
+ Expect>,
+ ] = [true, true];
+ return {inferred, value, previous};
+ });
+ const users = reactiveMap([['ada', {name: 'Ada'}]]);
+ const user = users.get('ada');
+ const tags = reactiveSet(['saved']);
+ const assertions: [
+ Expect>,
+ Expect void>>,
+ Expect>>,
+ Expect>,
+ Expect>>,
+ ] = [true, true, true, true, true];
+ return {assertions, persistence, unsubscribe};
+}
diff --git a/website/docs/PERSIST_ARCHITECTURE.md b/website/docs/PERSIST_ARCHITECTURE.md
index 3d83c03..9c185d6 100644
--- a/website/docs/PERSIST_ARCHITECTURE.md
+++ b/website/docs/PERSIST_ARCHITECTURE.md
@@ -4,7 +4,7 @@ Internal design documentation for the `persist()` utility in `@codebelt/classy-s
## Design Principles
-1. **Standalone utility, not middleware.** `persist` is a plain function that consumes two existing public APIs (`subscribe` and `snapshot`) and nothing else. No changes to `core.ts`, `snapshot.ts`, or `useClassyStore` were needed.
+1. **Standalone utility, not middleware.** `persist` consumes the existing `subscribe`, `snapshot`, and `getVersion` APIs. No changes to core reactivity or React hooks are needed.
2. **Tree-shakeable.** `persist` lives in a separate entry point (`@codebelt/classy-store/utils`). Applications that don't use persistence pay zero cost -- the code is never bundled.
@@ -65,7 +65,10 @@ Each `persist()` call creates a closure with the following internal state:
| Variable | Type | Purpose |
|---|---|---|
| `disposed` | `boolean` | Guards against writes after `unsubscribe()` |
-| `debounceTimer` | `ReturnType \| null` | Active debounce timer (cancelled on save/unsubscribe) |
+| `debounceTimer` | `ReturnType \| null` | Active debounce timer (cancelled on save/clear/unsubscribe or accepted hydration) |
+| `storageQueue` | `Promise \| undefined` | Orders this handle's writes/removals and lets reads wait for prior operations |
+| `hydrationRequest` | `number` | Rejects read results superseded by newer requests or storage events |
+| `clearedVersion` | `number \| undefined` | Suppresses a pre-clear mutation's pending batched auto-save |
| `hydratedFlag` | `boolean` | Synchronous hydration status |
| `expiredFlag` | `boolean` | Set to `true` when hydration encounters expired data |
| `resolveHydrated` | `() => void` | Resolves the `hydrated` promise |
@@ -82,7 +85,7 @@ Triggered by `subscribe(proxy, callback)` firing after each batched mutation:
```
1. subscribe callback fires
-2. if disposed β return (guard)
+2. if disposed, applying hydration, or this version was cleared β return
3. if debounce > 0:
cancel existing timer
schedule new timer β goto step 4 after delay
@@ -96,9 +99,15 @@ Triggered by `subscribe(proxy, callback)` firing after each batched mutation:
6. wrap in envelope: { version, state }
7. if expireIn is set β stamp expiresAt = Date.now() + expireIn
8. JSON.stringify(envelope)
-9. await storage.setItem(name, json)
+9. enqueue storage.setItem(name, json) behind prior writes/removals
```
+The serialized value is captured when requested. The first queued operation
+starts immediately, so synchronous adapters keep their normal timing. Each
+operation catches adapter errors and reports them through `onError`; a rejected
+write does not prevent the next operation from starting. Cleanup skips operations
+that have not started. This queue belongs to one handle, not the storage key globally.
+
### Property Resolution
On initialization, `resolveProperties()` determines which keys to persist:
@@ -136,6 +145,13 @@ Triggered in three scenarios:
All three call the same `applyPersistedState(raw)` function:
+Async reads first wait for prior writes/removals. They capture a request number
+and the store version, and only apply their result if both are still current and
+the handle remains active. A newer read/save/clear/storage event changes the
+request number; a local edit changes the store version. Obsolete results and
+read errors are ignored. Applying hydration suppresses echo writes until the
+batched subscription notification has run, with cleanup in `finally`.
+
```
1. JSON.parse(raw) β envelope
2. Validate envelope shape (object with .state)
@@ -171,7 +187,7 @@ Both `'shallow'` and `'replace'` produce the same result for flat stores. The di
- **Corrupted JSON** in storage (`JSON.parse` throws): caught silently, hydration completes with no state changes.
- **Invalid envelope** (missing `.state` or not an object): detected by shape check, skipped silently.
-- **Async errors** during `storage.getItem()`: caught, hydrated promise rejects, `hydratedFlag` still set to `true`.
+- **Adapter read errors** during `storage.getItem()`: reported through `onError` when the request is current; hydration completes. Exceptions from custom migration/merge/transform callbacks can reject hydration.
## Cross-Tab Sync
@@ -189,20 +205,17 @@ sequenceDiagram
Event->>TabB: onStorageEvent fires
TabB->>TabB: if key !== name β ignore
TabB->>TabB: if newValue is null β ignore (cleared)
+ TabB->>TabB: if storageArea now contains another value β ignore (obsolete)
TabB->>TabB: applyPersistedState(newValue)
Note over TabB: proxy[key] = value β SET trap β reactivity
```
Key properties of `window.storage`:
-- Fires in **other** same-origin tabs only (not the tab that wrote). This prevents infinite loops.
+- For `localStorage`, fires in **other** same-origin tabs (not the tab that wrote). The hydration guard prevents echo writes and loops.
- Only works with `localStorage` -- not `sessionStorage`, not async adapters.
- The event payload includes `key`, `oldValue`, `newValue`, and `storageArea`.
-### Auto-detection
-
-If `syncTabs` is not explicitly set:
-- `true` when the storage adapter is literally `globalThis.localStorage` (checked via identity comparison).
-- `false` for everything else (`sessionStorage`, `AsyncStorage`, custom adapters).
+`syncTabs` defaults to `false`; enable it explicitly for localStorage-backed state.
## Cleanup
@@ -218,19 +231,20 @@ If `syncTabs` is not explicitly set:
After `unsubscribe()`:
- Store mutations no longer trigger writes to storage.
- Cross-tab changes to the storage key are ignored.
-- `save()` becomes a no-op.
-- `clear()` and `rehydrate()` still work (they operate on storage directly, not on subscriptions).
+- `save()`, `clear()`, and `rehydrate()` become no-ops.
+- Queued writes/removals that have not started are skipped. Already-issued adapter I/O can still finish.
+- Pending read results/errors are ignored, and their promises settle when the reads finish.
## PersistHandle API
| Method/Property | Behavior |
|---|---|
| `unsubscribe()` | Full cleanup: unsubscribe + cancel debounce + remove storage listener. Idempotent. |
-| `hydrated` | Promise that resolves when initial hydration completes. Rejects on async storage errors. |
+| `hydrated` | Promise that resolves when initial hydration completes or is superseded. Custom hydration callback exceptions can reject it. |
| `isHydrated` | Getter returning `boolean`. Becomes `true` after hydration resolves or rejects. |
| `isExpired` | Getter returning `boolean`. `true` when the last hydration found expired data (requires `expireIn`). Re-evaluated on every `rehydrate()` call. |
-| `save()` | Cancel pending debounce, write current state immediately. No-op if disposed. |
-| `clear()` | Call `storage.removeItem(name)`. Works even after unsubscribe. |
+| `save()` | Cancel pending debounce, capture current state, and enqueue its write. No-op if disposed. |
+| `clear()` | Cancel pending auto-saves and enqueue `storage.removeItem(name)` after earlier writes. No-op if disposed. |
| `rehydrate()` | Re-read from storage, apply to store. Also resolves `hydrated` if not yet resolved (for `skipHydration` flows). |
## Dependency Graph
diff --git a/website/docs/PERSIST_TUTORIAL.md b/website/docs/PERSIST_TUTORIAL.md
index 11487d1..4f7802b 100644
--- a/website/docs/PERSIST_TUTORIAL.md
+++ b/website/docs/PERSIST_TUTORIAL.md
@@ -161,7 +161,7 @@ On restore, `deserialize` converts the ISO string back into a `Date` object befo
### `storage`
-`persist()` handles different storage adapters transparently. The default is `localStorage`. Any object with `getItem`, `setItem`, and `removeItem` (sync or async) works.
+`persist()` requires a storage adapter. Any object with `getItem`, `setItem`, and `removeItem` (sync or async) works, including `localStorage`.
**sessionStorage (Tab-Scoped)**
@@ -225,6 +225,31 @@ persist(uiStore, {
Cross-tab sync only works with `localStorage`. It does not fire for `sessionStorage`, `AsyncStorage`, or custom adapters.
+An event whose value has already been replaced in its storage area is ignored.
+This prevents a delayed event from rolling a newer local save back in the UI.
+Accepted remote updates cancel pending debounced auto-saves and do not write
+the restored state back to storage.
+
+### Overlapping async operations
+
+Each persistence handle orders its writes and removals. A slow earlier save
+cannot finish after a later save and overwrite it. `save()` captures the current
+state when requested and resolves after that write completes; a failed write
+reports through `onError` and does not block later writes.
+
+`rehydrate()` waits for writes/removals already requested through that handle.
+If another read, save, clear, accepted storage event, or local mutation supersedes
+an in-flight read, its result is ignored. In particular, typing while initial
+async hydration is pending keeps the newer local state. An ignored read still
+settles its promise; hydration completion does not guarantee that stored data won.
+
+`clear()` cancels pending auto-saves and removes stored data after earlier issued
+writes finish. Later edits can persist again. `unsubscribe()` skips queued writes
+that have not started; it cannot cancel adapter I/O already in progress.
+
+Ordering is per handle. Separate handles sharing an async storage key still
+need coordination at the application or adapter level.
+
### `expireIn` & `clearOnExpire` (Expiration / TTL)
Use `expireIn` to set a time-to-live (in milliseconds) on persisted data. After the TTL elapses, stored data is skipped during hydration and the store keeps its class defaults.
@@ -514,7 +539,10 @@ window.addEventListener('beforeunload', () => {
});
```
-`save()` cancels the pending debounce timer and writes the current state immediately.
+`save()` cancels the pending debounce timer and requests a write of the current
+state immediately. With async storage it waits behind earlier writes. Await the
+returned promise when completion matters; an async write is not guaranteed to
+finish during page unload.
### `unsubscribe()` (Stop persisting)
diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt
index 4d0839e..5d53b08 100644
--- a/website/static/llms-full.txt
+++ b/website/static/llms-full.txt
@@ -1723,11 +1723,15 @@ persist(todoStore, {
```ts
persist(authStore, {
name: 'auth',
- // syncTabs: true -- default for localStorage
+ storage: localStorage,
+ syncTabs: true,
});
```
-State syncs automatically across browser tabs when using `localStorage`. Disable with `syncTabs: false`.
+Opt in with `syncTabs: true` for localStorage-backed state. It defaults to false.
+Accepted remote updates cancel pending debounced writes without echoing the
+restored state back. An event whose value has already been replaced in its
+storage area is ignored, so delayed events cannot roll back a newer local save.
## Expiration / TTL
@@ -1782,9 +1786,6 @@ await handle.hydrated;
## Cleanup
```ts
-// Stop persisting
-handle.unsubscribe();
-
// Clear stored data
await handle.clear();
@@ -1793,8 +1794,26 @@ handle.save();
// Re-read from storage
await handle.rehydrate();
+
+// Stop persisting; later handle operations become no-ops
+handle.unsubscribe();
```
+## Overlapping async operations
+
+Each handle orders its writes/removals. `save()` captures current state when
+requested and waits behind earlier writes; a failed write reports through
+`onError` without blocking the queue. `rehydrate()` waits for prior requested
+writes/removals, then ignores results superseded by a newer read, save, clear,
+accepted storage event, or local edit. Typing during initial async hydration
+keeps newer local state. Ignored reads still settle their promises.
+
+`clear()` cancels pending auto-saves and removes stored data after earlier issued
+writes finish; later edits can persist again. Cleanup skips queued operations
+that have not started, but cannot cancel already-issued adapter I/O. Ordering
+is per handle; separate handles sharing an async key need application/adapter
+coordination. Async saves are not guaranteed to finish during page unload.
+
## Quick Reference
| What you want | How to do it |
@@ -1806,7 +1825,7 @@ await handle.rehydrate();
| Debounce writes | `debounce: 500` |
| Migrate schema changes | `version: 2, migrate: (state, old) => { ... }` |
| SSR support | `skipHydration: true` + `handle.rehydrate()` in `useEffect` |
-| Cross-tab sync | Enabled by default with `localStorage` |
+| Cross-tab sync | `storage: localStorage, syncTabs: true` |
| Disable cross-tab sync | `syncTabs: false` |
| Use sessionStorage | `storage: sessionStorage` |
| Use AsyncStorage | `storage: AsyncStorage, syncTabs: false` |
@@ -2724,7 +2743,7 @@ Internal design documentation for the `persist()` utility in `@codebelt/classy-s
## Design Principles
-1. **Standalone utility, not middleware.** `persist` is a plain function that consumes two existing public APIs (`subscribe` and `snapshot`) and nothing else.
+1. **Standalone utility, not middleware.** `persist` consumes the existing `subscribe`, `snapshot`, and `getVersion` APIs.
2. **Tree-shakeable.** Lives in `@codebelt/classy-store/utils`. Applications that don't use persistence pay zero cost.
3. **Storage-agnostic.** The `StorageAdapter` interface accepts sync (`localStorage`, `sessionStorage`) and async (`AsyncStorage`, `localForage`) implementations.
4. **Class-aware.** Getters and methods are automatically excluded from persistence. Only source-of-truth data properties are saved.
@@ -2761,21 +2780,21 @@ The `version` field is always present (default `0`). The `expiresAt` field is a
## Cross-Tab Sync
-When `syncTabs` is enabled (default for `localStorage`), the utility listens for the `window.storage` event. The event fires in **other** same-origin tabs only β not the tab that wrote β preventing infinite loops.
-
-Auto-detection: `syncTabs` defaults to `true` when the storage adapter is literally `globalThis.localStorage` (checked via identity comparison), `false` for everything else.
+`syncTabs` defaults to false. When enabled for localStorage-backed state, the
+utility listens for storage events from other same-origin tabs. It ignores
+obsolete event values and suppresses echo writes while applying accepted updates.
## PersistHandle API
| Method/Property | Behavior |
|---|---|
-| `unsubscribe()` | Unsubscribe, cancel debounce, remove the storage listener, and ignore pending read results/errors. Already-issued adapter I/O is not cancelled. Idempotent. |
+| `unsubscribe()` | Cancel debounce/listeners, ignore pending read results/errors, and skip queued operations not yet started. Already-issued adapter I/O is not cancelled. Idempotent. |
| `hydrated` | Promise that resolves when initial hydration completes. |
| `isHydrated` | `boolean`. Becomes `true` after hydration. |
| `isExpired` | `boolean`. `true` when the last hydration found expired data. |
-| `save()` | Cancel pending debounce, write current state immediately. |
-| `clear()` | Call `storage.removeItem(name)`. |
-| `rehydrate()` | Re-read from storage, apply to store. |
+| `save()` | Cancel debounce, capture current state, and enqueue its write. |
+| `clear()` | Cancel pending auto-saves and enqueue removal after prior writes. |
+| `rehydrate()` | Wait for prior writes, read storage, and apply only if the request and store version remain current. |
## Property Resolution
From 4cf9e0d1105a3afda10b4ff4b3b6ba75837d2f1e Mon Sep 17 00:00:00 2001
From: codeBelt
Date: Tue, 20 Oct 2026 23:13:37 -0500
Subject: [PATCH 9/9] docs: prepare 0.7.0 upgrade notes and fix CI build order
---
.changeset/fix-history-and-react-selectors.md | 2 +-
.changeset/release-retired-store-data.md | 4 +-
.changeset/synchronous-react-subscriptions.md | 4 +-
.github/workflows/ci.yml | 3 +-
README.md | 6 +
website/docs/UPGRADING_0_7.md | 105 +++++++++++++++++
website/docs/index.md | 2 +
website/sidebars.ts | 2 +
website/static/llms-full.txt | 110 ++++++++++++++++++
website/static/llms.txt | 1 +
10 files changed, 235 insertions(+), 4 deletions(-)
create mode 100644 website/docs/UPGRADING_0_7.md
diff --git a/.changeset/fix-history-and-react-selectors.md b/.changeset/fix-history-and-react-selectors.md
index 93726ef..9dba4e5 100644
--- a/.changeset/fix-history-and-react-selectors.md
+++ b/.changeset/fix-history-and-react-selectors.md
@@ -2,4 +2,4 @@
'@codebelt/classy-store': patch
---
-Restore mutable nested state after undo/redo while preserving reactive collection methods and keeping recorded snapshots unchanged. Refresh React selections when selectors or equality functions change, even when the store has not mutated. Update the library's development dependencies to their latest stable versions and add regression coverage for both fixes.
+Restore mutable nested state after undo/redo while preserving reactive collection methods and keeping recorded snapshots unchanged. Refresh React selections when selectors or equality functions change, even when the store has not mutated. Update the library's development dependencies and add regression coverage for both fixes.
diff --git a/.changeset/release-retired-store-data.md b/.changeset/release-retired-store-data.md
index d36b4a4..15a2f8f 100644
--- a/.changeset/release-retired-store-data.md
+++ b/.changeset/release-retired-store-data.md
@@ -2,10 +2,12 @@
'@codebelt/classy-store': patch
---
+**Runtime requirement:** auto-tracked React hooks now require native `WeakRef` support (ES2021). Selector hooks do not require `WeakRef`. See the [0.7.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7).
+
Release removed child proxies from their former parents, including children removed by native array-length truncation. Failed writes and deletes preserve the existing child connection; partial array truncations invalidate only the removed entries.
Discard obsolete untracked snapshot clones when a nested object starts using versioned tracking. This releases initial collection data and prevents fresh stores over the same raw object from receiving the stale cached clone.
-Use weak render-read keys and a weak commit queue to prevent auto-tracked React consumers from retaining obsolete collection snapshots through shared child proxies. Isolate stable subscription and selector callbacks from render-local closure contexts so they do not retain initial results for the lifetime of a mount. Auto-tracked React subscriptions now require native WeakRef support (ES2021).
+Use weak render-read keys and a weak commit queue to prevent auto-tracked React consumers from retaining obsolete collection snapshots through shared child proxies. Isolate stable subscription and selector callbacks from render-local closure contexts so they do not retain initial results for the lifetime of a mount.
Add deterministic lifecycle and Compiler regressions plus production Chromium heap-snapshot checks for mounts, resets, rolling replacements, truncation, and intentionally retained detached children with Compiler off and on.
diff --git a/.changeset/synchronous-react-subscriptions.md b/.changeset/synchronous-react-subscriptions.md
index 55e4c86..3e16221 100644
--- a/.changeset/synchronous-react-subscriptions.md
+++ b/.changeset/synchronous-react-subscriptions.md
@@ -2,7 +2,9 @@
"@codebelt/classy-store": minor
---
-React subscriptions now always notify synchronously, fixing controlled-input caret jumps without configuration. Remove the `sync` option from `useClassyStore` and hooks returned by `createStoreHook`, and remove imports of `UseClassyStoreOptions`. Selector options still accept `isEqual`. Ordinary `subscribe()` callbacks remain microtask-batched and retain their existing `sync` option.
+**Upgrade required for React consumers using timing options.** React subscriptions now always notify synchronously, fixing controlled-input caret jumps without configuration. Remove the `sync` option from `useClassyStore` and hooks returned by `createStoreHook`, and remove imports of `UseClassyStoreOptions`. Remove the entire options argument in auto-tracked mode, including an empty `{}`. Selector options still accept `isEqual`. Ordinary `subscribe()` callbacks remain microtask-batched and retain their existing `sync` option.
+
+See the [0.7.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7) for before/after examples, the new native `WeakRef` requirement for auto-tracking, and SSR, Next.js, and persistence guidance.
React selectors may run after each mutation; several writes are not an atomic transaction. React can batch the resulting renders within an event handler. Auto-tracked reads are isolated per render so conditional and abandoned renders do not retain stale dependencies, including for class and null-prototype snapshots. Unchanged tracked references remain stable across parent renders.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cb7ea25..a541f2e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,8 +17,9 @@ jobs:
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run test
- - run: bun run typecheck
+ # Packed-consumer typechecks resolve the package's generated declarations.
- run: bun run build
+ - run: bun run typecheck
- run: bunx playwright install --with-deps chromium firefox webkit
working-directory: packages/classy-store
- run: bun run test:browser
diff --git a/README.md b/README.md
index f7d1776..b926fea 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,12 @@
Visit the **[Documentation Website](https://codebelt.github.io/classy-store/)** for tutorials, API reference, and examples.
+## Upgrading to 0.7.0
+
+React subscriptions now notify synchronously. Remove the React hooks' `sync` option and any `UseClassyStoreOptions` imports. Auto-tracked hooks now require native `WeakRef` support (ES2021). Selector equality options remain available, and ordinary core subscriptions still batch by default.
+
+See the [0.7.0 upgrade guide and release notes](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7) for before/after examples, SSR and Next.js setup, and persistence changes.
+
## π Features
- **Class-Based**: Define state and logic using standard ES6 classes.
diff --git a/website/docs/UPGRADING_0_7.md b/website/docs/UPGRADING_0_7.md
new file mode 100644
index 0000000..920f9ad
--- /dev/null
+++ b/website/docs/UPGRADING_0_7.md
@@ -0,0 +1,105 @@
+# Upgrading to 0.7.0
+
+Version 0.7.0 focuses on core and React correctness, including controlled inputs, React Compiler, SSR, Next.js App Router, memory retention, and persistence. It includes React API and runtime changes that consumers upgrading from 0.6.x should review.
+
+## Remove React timing options
+
+React hooks now always subscribe synchronously. This keeps controlled inputs and their caret positions aligned with store updates. Remove `sync: true` and `sync: false` from both direct and bound hooks.
+
+Before, in 0.6.x:
+
+```tsx
+const state = useClassyStore(store, {sync: true});
+const count = useClassyStore(store, (state) => state.count, {sync: true});
+
+// useCounterStore was created with createStoreHook(store).
+const boundState = useCounterStore({sync: true});
+const boundCount = useCounterStore((state) => state.count, {sync: true});
+```
+
+After, in 0.7.0:
+
+```tsx
+const state = useClassyStore(store);
+const count = useClassyStore(store, (state) => state.count);
+
+const boundState = useCounterStore();
+const boundCount = useCounterStore((state) => state.count);
+```
+
+Auto-tracked hooks no longer accept an options argument, even an empty `{}`. Remove imports and annotations using the removed `UseClassyStoreOptions` type. Selector hooks still accept `UseClassyStoreSelectorOptions` with `isEqual`:
+
+```tsx
+// Before
+const selection = useClassyStore(store, selectSummary, {
+ sync: true,
+ isEqual: shallowEqual,
+});
+
+// After
+const selection = useClassyStore(store, selectSummary, {
+ isEqual: shallowEqual,
+});
+```
+
+The same change applies to selector options passed to a bound hook. Existing calls without React timing options continue to work.
+
+Ordinary `subscribe(store, listener)` callbacks still batch in a microtask, and core subscriptions retain their `{sync: true}` option. React selectors can run after each individual mutation, even when React batches the resulting renders into one commit. Keep selectors pure and able to read intermediate state during a multi-property update; this release does not introduce atomic transactions.
+
+## Check the auto-tracking runtime requirement
+
+Auto-tracked React hooks (`useClassyStore(store)` and bound hooks without a selector) now require native `WeakRef` support from ES2021. Weak references let mounted consumers release obsolete snapshots and collection values.
+
+Check that every browser or server runtime running auto-tracked hooks supports `WeakRef`. Transpiling JavaScript does not supply native weak-reference behavior. If your supported runtime lacks it, use explicit selectors or retain 0.6.x until you can raise that runtime requirement. Selector hooks do not require `WeakRef`.
+
+React 18 and 19 remain covered by the package compatibility tests. React Compiler works with both selector and auto-tracked hooks; no compiler opt-out is required.
+
+## Prepare server-rendered stores before live updates
+
+Client-only apps do not need hydration preparation. For server-rendered apps where persistence or another update can run before every Suspense boundary hydrates, call `prepareStoreForHydration(store)` on server and client stores initialized from matching data, before rendering or starting persistence.
+
+```ts
+import {createClassyStore} from '@codebelt/classy-store';
+import {prepareStoreForHydration} from '@codebelt/classy-store/react/server';
+
+const store = prepareStoreForHydration(
+ createClassyStore(new ProfileStore(initialData)),
+);
+```
+
+Create a separate store for each server request. The prepared snapshot is retained for that store's lifetime; use a new store for a new request or an intentional reset. See the [SSR guide](./REACT_SSR.md) and [typed context example](./REACT_SCOPING.md).
+
+The published `@codebelt/classy-store/react` entry now carries `use client`. In Next.js Server Components or other server-only modules, import preparation from `@codebelt/classy-store/react/server`. Hooks and the existing preparation export remain available from `/react` inside client modules and ordinary SSR. Pass serializable initial data across the Server Component boundary and construct the store inside a Client Provider; do not pass the store proxy across that boundary. See the [Next.js App Router guide](./REACT_NEXT.md).
+
+## Review persistence ordering and cleanup
+
+The stored JSON envelope has not changed, so upgrading the library alone does not require a persisted-data migration or a change to your application's persistence `version`.
+
+- Saves and removals requested through one persistence handle execute in request order. Each save captures the state when it is requested.
+- Rehydration waits for that handle's prior writes and ignores read results superseded by a newer read, save, clear, local edit, or accepted storage event.
+- `clear()` cancels pending auto-saves and removes storage after writes already requested. It does not reset the live store; a later edit can save again.
+- `unsubscribe()` stops listeners and timers, ignores pending reads, and skips queued operations that have not started. It cannot cancel I/O already issued to an adapter. If you need a final save, await `handle.save()` before unsubscribing while the application is still running.
+- Real browser tab synchronization ignores outdated storage events and avoids echo writes. Ordering is per handle; separate handles writing the same async storage key still need application or adapter coordination.
+
+See the [persistence guide](./PERSIST_TUTORIAL.md) for adapter, debounce, and cross-tab behavior.
+
+## Release notes
+
+### React integration
+
+- Synchronous React subscriptions fix controlled-input caret jumps without configuration; core subscriptions remain batched by default.
+- Selector or equality-function changes refresh selections without requiring a store mutation. Concurrent and abandoned renders keep their tracking isolated.
+- Auto-tracking preserves reads reused by React Compiler and memoized children. It can conservatively retain earlier reads until the relevant snapshot object's identity changes.
+- `prepareStoreForHydration` and the `/react/server` entry support delayed and streamed hydration, including persistence updates before a boundary hydrates.
+- Next.js App Router package boundaries, request isolation, navigation, refresh, and cleanup have production packed-package coverage with Compiler off and on.
+- A typed context example covers independent and nested stores, keyed resets, and effect-owned persistence cleanup.
+
+### Core, utilities, and performance
+
+- Undo/redo and persistence restore mutable nested state while preserving reactive collection methods and recorded snapshots.
+- Frozen snapshot backing copies are reused across auto-tracked consumers, reducing repeated work in large forms and lists.
+- Detached child proxies, obsolete snapshot clones, and retired React selections can be released, including after collection replacement and array truncation.
+- `Snapshot` preserves callable signatures for typed methods, overloads, and generic functions under strict TypeScript.
+- Persistence orders asynchronous operations and rejects stale reads and storage events, with regression coverage for overlapping operations and real two-tab React inputs.
+
+Validation includes core/React tests, three-browser input tests, packed React 18/19 consumers, Compiler off/on, streaming hydration, Next.js, render-count budgets, and heap-snapshot checks. Persistence, history, DevTools, and reactive collections also have combined happy-path coverage and public TypeScript checks.
diff --git a/website/docs/index.md b/website/docs/index.md
index e9f4b98..f3d13db 100644
--- a/website/docs/index.md
+++ b/website/docs/index.md
@@ -25,6 +25,8 @@ Class-based reactive state management for React, Vue, Svelte, Solid, and Angular
## Installation
+Upgrading from 0.6.x? Read the [0.7.0 upgrade guide and release notes](./UPGRADING_0_7.md) for the React API changes and runtime requirements.
+
```bash
bun add @codebelt/classy-store
```
diff --git a/website/sidebars.ts b/website/sidebars.ts
index a8b0d9c..aa60210 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -3,6 +3,7 @@ import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
const sidebars: SidebarsConfig = {
tutorialSidebar: [
'index',
+ 'UPGRADING_0_7',
{
type: 'category',
label: 'Tutorials',
@@ -10,6 +11,7 @@ const sidebars: SidebarsConfig = {
items: [
'TUTORIAL',
'REACT_SSR',
+ 'REACT_NEXT',
'REACT_SCOPING',
'PERSIST_TUTORIAL',
'DEVTOOLS_TUTORIAL',
diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt
index 5d53b08..f747e69 100644
--- a/website/static/llms-full.txt
+++ b/website/static/llms-full.txt
@@ -4,6 +4,116 @@ This file contains the full documentation for `@codebelt/classy-store` concatena
single file for LLM context windows. Generated from the source docs at:
https://codebelt.github.io/classy-store/docs/
+---
+SOURCE: https://codebelt.github.io/classy-store/docs/UPGRADING_0_7
+---
+
+# Upgrading to 0.7.0
+
+Version 0.7.0 focuses on core and React correctness, including controlled inputs, React Compiler, SSR, Next.js App Router, memory retention, and persistence. It includes React API and runtime changes that consumers upgrading from 0.6.x should review.
+
+## Remove React timing options
+
+React hooks now always subscribe synchronously. This keeps controlled inputs and their caret positions aligned with store updates. Remove `sync: true` and `sync: false` from both direct and bound hooks.
+
+Before, in 0.6.x:
+
+```tsx
+const state = useClassyStore(store, {sync: true});
+const count = useClassyStore(store, (state) => state.count, {sync: true});
+
+// useCounterStore was created with createStoreHook(store).
+const boundState = useCounterStore({sync: true});
+const boundCount = useCounterStore((state) => state.count, {sync: true});
+```
+
+After, in 0.7.0:
+
+```tsx
+const state = useClassyStore(store);
+const count = useClassyStore(store, (state) => state.count);
+
+const boundState = useCounterStore();
+const boundCount = useCounterStore((state) => state.count);
+```
+
+Auto-tracked hooks no longer accept an options argument, even an empty `{}`. Remove imports and annotations using the removed `UseClassyStoreOptions` type. Selector hooks still accept `UseClassyStoreSelectorOptions` with `isEqual`:
+
+```tsx
+// Before
+const selection = useClassyStore(store, selectSummary, {
+ sync: true,
+ isEqual: shallowEqual,
+});
+
+// After
+const selection = useClassyStore(store, selectSummary, {
+ isEqual: shallowEqual,
+});
+```
+
+The same change applies to selector options passed to a bound hook. Existing calls without React timing options continue to work.
+
+Ordinary `subscribe(store, listener)` callbacks still batch in a microtask, and core subscriptions retain their `{sync: true}` option. React selectors can run after each individual mutation, even when React batches the resulting renders into one commit. Keep selectors pure and able to read intermediate state during a multi-property update; this release does not introduce atomic transactions.
+
+## Check the auto-tracking runtime requirement
+
+Auto-tracked React hooks (`useClassyStore(store)` and bound hooks without a selector) now require native `WeakRef` support from ES2021. Weak references let mounted consumers release obsolete snapshots and collection values.
+
+Check that every browser or server runtime running auto-tracked hooks supports `WeakRef`. Transpiling JavaScript does not supply native weak-reference behavior. If your supported runtime lacks it, use explicit selectors or retain 0.6.x until you can raise that runtime requirement. Selector hooks do not require `WeakRef`.
+
+React 18 and 19 remain covered by the package compatibility tests. React Compiler works with both selector and auto-tracked hooks; no compiler opt-out is required.
+
+## Prepare server-rendered stores before live updates
+
+Client-only apps do not need hydration preparation. For server-rendered apps where persistence or another update can run before every Suspense boundary hydrates, call `prepareStoreForHydration(store)` on server and client stores initialized from matching data, before rendering or starting persistence.
+
+```ts
+import {createClassyStore} from '@codebelt/classy-store';
+import {prepareStoreForHydration} from '@codebelt/classy-store/react/server';
+
+const store = prepareStoreForHydration(
+ createClassyStore(new ProfileStore(initialData)),
+);
+```
+
+Create a separate store for each server request. The prepared snapshot is retained for that store's lifetime; use a new store for a new request or an intentional reset. See the [SSR guide](https://codebelt.github.io/classy-store/docs/REACT_SSR) and [typed context example](https://codebelt.github.io/classy-store/docs/REACT_SCOPING).
+
+The published `@codebelt/classy-store/react` entry now carries `use client`. In Next.js Server Components or other server-only modules, import preparation from `@codebelt/classy-store/react/server`. Hooks and the existing preparation export remain available from `/react` inside client modules and ordinary SSR. Pass serializable initial data across the Server Component boundary and construct the store inside a Client Provider; do not pass the store proxy across that boundary. See the [Next.js App Router guide](https://codebelt.github.io/classy-store/docs/REACT_NEXT).
+
+## Review persistence ordering and cleanup
+
+The stored JSON envelope has not changed, so upgrading the library alone does not require a persisted-data migration or a change to your application's persistence `version`.
+
+- Saves and removals requested through one persistence handle execute in request order. Each save captures the state when it is requested.
+- Rehydration waits for that handle's prior writes and ignores read results superseded by a newer read, save, clear, local edit, or accepted storage event.
+- `clear()` cancels pending auto-saves and removes storage after writes already requested. It does not reset the live store; a later edit can save again.
+- `unsubscribe()` stops listeners and timers, ignores pending reads, and skips queued operations that have not started. It cannot cancel I/O already issued to an adapter. If you need a final save, await `handle.save()` before unsubscribing while the application is still running.
+- Real browser tab synchronization ignores outdated storage events and avoids echo writes. Ordering is per handle; separate handles writing the same async storage key still need application or adapter coordination.
+
+See the [persistence guide](https://codebelt.github.io/classy-store/docs/PERSIST_TUTORIAL) for adapter, debounce, and cross-tab behavior.
+
+## Release notes
+
+### React integration
+
+- Synchronous React subscriptions fix controlled-input caret jumps without configuration; core subscriptions remain batched by default.
+- Selector or equality-function changes refresh selections without requiring a store mutation. Concurrent and abandoned renders keep their tracking isolated.
+- Auto-tracking preserves reads reused by React Compiler and memoized children. It can conservatively retain earlier reads until the relevant snapshot object's identity changes.
+- `prepareStoreForHydration` and the `/react/server` entry support delayed and streamed hydration, including persistence updates before a boundary hydrates.
+- Next.js App Router package boundaries, request isolation, navigation, refresh, and cleanup have production packed-package coverage with Compiler off and on.
+- A typed context example covers independent and nested stores, keyed resets, and effect-owned persistence cleanup.
+
+### Core, utilities, and performance
+
+- Undo/redo and persistence restore mutable nested state while preserving reactive collection methods and recorded snapshots.
+- Frozen snapshot backing copies are reused across auto-tracked consumers, reducing repeated work in large forms and lists.
+- Detached child proxies, obsolete snapshot clones, and retired React selections can be released, including after collection replacement and array truncation.
+- `Snapshot` preserves callable signatures for typed methods, overloads, and generic functions under strict TypeScript.
+- Persistence orders asynchronous operations and rejects stale reads and storage events, with regression coverage for overlapping operations and real two-tab React inputs.
+
+Validation includes core/React tests, three-browser input tests, packed React 18/19 consumers, Compiler off/on, streaming hydration, Next.js, render-count budgets, and heap-snapshot checks. Persistence, history, DevTools, and reactive collections also have combined happy-path coverage and public TypeScript checks.
+
---
SOURCE: https://codebelt.github.io/classy-store/docs/
---
diff --git a/website/static/llms.txt b/website/static/llms.txt
index de2fc4d..e6ba596 100644
--- a/website/static/llms.txt
+++ b/website/static/llms.txt
@@ -6,6 +6,7 @@
## Getting Started
+- [Upgrading to 0.7.0](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7): Removed React timing options, native WeakRef requirement, SSR and Next.js setup, persistence changes, and release notes
- [Introduction & API Reference](https://codebelt.github.io/classy-store/docs/): Full API
covering `createClassyStore`, `useClassyStore`, `createStoreHook`, `snapshot`, `subscribe`,
`shallowEqual`, synchronous React subscriptions, React Compiler compatibility, `reactiveMap`, `reactiveSet`