From e971bf712d61ffc389f35c093371275aa404b1b6 Mon Sep 17 00:00:00 2001 From: codeBelt Date: Thu, 22 Oct 2026 23:55:54 -0500 Subject: [PATCH] fix(svelte): deliver synchronous updates and clean up subscriptions --- .changeset/focused-svelte-subscriptions.md | 9 ++ bun.lock | 1 + examples/svelte/package.json | 3 +- package.json | 2 +- packages/classy-store/package.json | 1 + .../src/frameworks/svelte/svelte.test.ts | 140 ++++++++++++++---- .../src/frameworks/svelte/svelte.ts | 31 +++- packages/classy-store/tests/browser/README.md | 17 ++- packages/classy-store/tests/browser/server.ts | 42 ++++++ .../classy-store/tests/browser/svelte.spec.ts | 104 +++++++++++++ .../tests/browser/svelte/SvelteForm.svelte | 31 ++++ .../tests/browser/svelte/client.ts | 30 ++++ .../tests/browser/svelte/svelte.store.ts | 83 +++++++++++ .../tests/browser/svelte/tsconfig.json | 4 + website/docs/ARCHITECTURE.md | 8 +- website/docs/TUTORIAL.md | 2 +- website/docs/index.md | 16 +- website/static/llms-full.txt | 26 +++- 18 files changed, 502 insertions(+), 48 deletions(-) create mode 100644 .changeset/focused-svelte-subscriptions.md create mode 100644 packages/classy-store/tests/browser/svelte.spec.ts create mode 100644 packages/classy-store/tests/browser/svelte/SvelteForm.svelte create mode 100644 packages/classy-store/tests/browser/svelte/client.ts create mode 100644 packages/classy-store/tests/browser/svelte/svelte.store.ts create mode 100644 packages/classy-store/tests/browser/svelte/tsconfig.json diff --git a/.changeset/focused-svelte-subscriptions.md b/.changeset/focused-svelte-subscriptions.md new file mode 100644 index 0000000..e7a3696 --- /dev/null +++ b/.changeset/focused-svelte-subscriptions.md @@ -0,0 +1,9 @@ +--- +'@codebelt/classy-store': patch +--- + +Fix Svelte readable-store subscriptions to deliver mutations synchronously, +including mutations made by the initial callback. Prevent callbacks after +unsubscription and clean up if the initial callback throws. Ordinary core +subscriptions remain batched. Add regressions for Svelte 5 input editing, +computed values, list updates, and component cleanup. diff --git a/bun.lock b/bun.lock index 628b1fc..d60e098 100644 --- a/bun.lock +++ b/bun.lock @@ -132,6 +132,7 @@ "react-compiler-runtime": "1.0.0", "react-dom": "19.3.1", "solid-js": "1.9.17", + "svelte": "5.55.5", "tsdown": "0.23.1", "typescript": "7.0.2", "vue": "3.5.44", diff --git a/examples/svelte/package.json b/examples/svelte/package.json index 6781046..877ae91 100644 --- a/examples/svelte/package.json +++ b/examples/svelte/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.json" + "check": "svelte-check --tsconfig ./tsconfig.json", + "check:integration": "svelte-check --tsconfig ../../packages/classy-store/tests/browser/svelte/tsconfig.json" }, "dependencies": { "@codebelt/classy-store": "workspace:*", diff --git a/package.json b/package.json index 1b70f6d..c680b07 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "---------- Lint --------------------------------------------------------": "", "lint": "biome check", "lint:fix": "biome check --write", - "typecheck": "bun run --filter @codebelt/classy-store typecheck && bun run --filter @codebelt/classy-store typecheck:scoping", + "typecheck": "bun run --filter @codebelt/classy-store typecheck && bun run --filter @codebelt/classy-store typecheck:scoping && bun run --filter classy-store-svelte-example check:integration", "---------- Release (Changesets) ----------------------------------------": "", "changeset": "changeset", "changeset:add": "changeset add", diff --git a/packages/classy-store/package.json b/packages/classy-store/package.json index ad68028..15bdc02 100644 --- a/packages/classy-store/package.json +++ b/packages/classy-store/package.json @@ -165,6 +165,7 @@ "react-compiler-runtime": "1.0.0", "react-dom": "19.3.1", "solid-js": "1.9.17", + "svelte": "5.55.5", "tsdown": "0.23.1", "typescript": "7.0.2", "vue": "3.5.44" diff --git a/packages/classy-store/src/frameworks/svelte/svelte.test.ts b/packages/classy-store/src/frameworks/svelte/svelte.test.ts index 2e03ae8..4eb6486 100644 --- a/packages/classy-store/src/frameworks/svelte/svelte.test.ts +++ b/packages/classy-store/src/frameworks/svelte/svelte.test.ts @@ -1,42 +1,128 @@ import {describe, expect, it, mock} from 'bun:test'; -import {createClassyStore} from '../../core/core'; +import {createClassyStore, getInternal, subscribe} from '../../core/core'; import {toSvelteStore} from './svelte'; -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - describe('toSvelteStore', () => { it('throws when given a non-store object', () => { expect(() => toSvelteStore({count: 0})).toThrow(/not a store proxy/); }); - it('calls the run callback immediately with the current snapshot', () => { - const s = createClassyStore({count: 7}); - const store = toSvelteStore(s); - const run = mock((_v: unknown) => {}); - store.subscribe(run); + it('immediately delivers the current immutable snapshot to each subscriber', () => { + const store = createClassyStore({count: 7}); + const readable = toSvelteStore(store); + const run = mock((value: {readonly count: number}) => { + expect(Object.isFrozen(value)).toBe(true); + }); + const unsubscribe = readable.subscribe(run); expect(run).toHaveBeenCalledTimes(1); - expect(run.mock.calls[0][0]).toEqual({count: 7}); - }); + expect(run).toHaveBeenLastCalledWith({count: 7}); + unsubscribe(); - it('calls run again with a new snapshot when the store mutates', async () => { - const s = createClassyStore({count: 0}); - const store = toSvelteStore(s); - const run = mock((_v: unknown) => {}); - store.subscribe(run); - s.count = 1; - await flush(); + store.count = 8; + const unsubscribeAgain = readable.subscribe(run); expect(run).toHaveBeenCalledTimes(2); - expect(run.mock.calls[1][0]).toEqual({count: 1}); + expect(run).toHaveBeenLastCalledWith({count: 8}); + unsubscribeAgain(); + }); + + it('delivers each mutation synchronously while ordinary subscriptions stay batched', async () => { + const store = createClassyStore({count: 0}); + const values: number[] = []; + const batched = mock(() => {}); + const unsubscribeCore = subscribe(store, batched); + const unsubscribe = toSvelteStore(store).subscribe((value) => { + values.push(value.count); + }); + + try { + store.count = 1; + expect(values).toEqual([0, 1]); + store.count = 2; + expect(values).toEqual([0, 1, 2]); + expect(batched).not.toHaveBeenCalled(); + await Promise.resolve(); + expect(batched).toHaveBeenCalledTimes(1); + expect(values).toEqual([0, 1, 2]); + } finally { + unsubscribe(); + unsubscribeCore(); + } }); - it('returns an unsubscribe function that stops further calls', async () => { - const s = createClassyStore({count: 0}); - const store = toSvelteStore(s); - const run = mock((_v: unknown) => {}); - const unsub = store.subscribe(run); - unsub(); - s.count = 99; - await flush(); - expect(run).toHaveBeenCalledTimes(1); // only the initial sync call + it('observes a mutation made by the initial subscription callback', () => { + const store = createClassyStore({count: 0}); + const values: number[] = []; + const unsubscribe = toSvelteStore(store).subscribe((value) => { + values.push(value.count); + if (value.count === 0) store.count = 1; + }); + + try { + expect(values).toEqual([0, 1]); + } finally { + unsubscribe(); + } + }); + + it('removes the listener if the initial callback throws', () => { + const store = createClassyStore({count: 0}); + const internal = getInternal(store); + const run = mock(() => { + throw new Error('Initial subscriber failed'); + }); + + expect(() => toSvelteStore(store).subscribe(run)).toThrow( + 'Initial subscriber failed', + ); + expect(internal.syncListeners.size).toBe(0); + expect(internal.batchedListeners.size).toBe(0); + store.count = 1; + expect(run).toHaveBeenCalledTimes(1); + }); + + it('cleans up independently and tolerates repeated unsubscription', async () => { + const store = createClassyStore({count: 0}); + const readable = toSvelteStore(store); + const firstValues: number[] = []; + const secondValues: number[] = []; + const unsubscribeFirst = readable.subscribe((value) => { + firstValues.push(value.count); + }); + const unsubscribeSecond = readable.subscribe((value) => { + secondValues.push(value.count); + }); + + store.count = 1; + unsubscribeFirst(); + unsubscribeFirst(); + store.count = 2; + unsubscribeSecond(); + store.count = 3; + await Promise.resolve(); + + expect(firstValues).toEqual([0, 1]); + expect(secondValues).toEqual([0, 1, 2]); + expect(getInternal(store).syncListeners.size).toBe(0); + expect(getInternal(store).batchedListeners.size).toBe(0); + }); + + it('does not call a subscriber removed during the same notification', async () => { + const store = createClassyStore({count: 0}); + const readable = toSvelteStore(store); + let unsubscribeSecond = () => {}; + const unsubscribeFirst = readable.subscribe((value) => { + if (value.count > 0) unsubscribeSecond(); + }); + const second = mock(() => {}); + unsubscribeSecond = readable.subscribe(second); + + try { + store.count = 1; + await Promise.resolve(); + expect(second).toHaveBeenCalledTimes(1); + } finally { + unsubscribeFirst(); + unsubscribeSecond(); + } }); }); diff --git a/packages/classy-store/src/frameworks/svelte/svelte.ts b/packages/classy-store/src/frameworks/svelte/svelte.ts index cc33140..8d14a8c 100644 --- a/packages/classy-store/src/frameworks/svelte/svelte.ts +++ b/packages/classy-store/src/frameworks/svelte/svelte.ts @@ -6,6 +6,12 @@ export interface ClassyReadable { subscribe(run: (value: T) => void): () => void; } +/** + * Expose immutable snapshots as a Svelte readable store. Each subscription + * receives the current snapshot immediately and subsequent mutations synchronously. + * Svelte's `$store` syntax handles unsubscription on component destruction; + * manual subscribers must call the returned unsubscribe function. + */ export function toSvelteStore( proxyStore: T, ): ClassyReadable> { @@ -14,12 +20,27 @@ export function toSvelteStore( return { subscribe(run: (value: Snapshot) => void): () => void { - // Svelte contract: call immediately with current value - run(snapshot(proxyStore)); + let active = true; + const notify = () => { + // Another subscriber can unsubscribe this one during notification. + if (active) run(snapshot(proxyStore)); + }; + // Subscribe before the initial callback so its mutations are observed too. + // Svelte requires synchronous delivery; ordinary core subscribers still batch. + const unsubscribeCore = coreSubscribe(proxyStore, notify, {sync: true}); + const unsubscribe = () => { + active = false; + unsubscribeCore(); + }; - return coreSubscribe(proxyStore, () => { - run(snapshot(proxyStore)); - }); + try { + notify(); + } catch (error) { + unsubscribe(); + throw error; + } + + return unsubscribe; }, }; } diff --git a/packages/classy-store/tests/browser/README.md b/packages/classy-store/tests/browser/README.md index 409b2d7..cf71d7d 100644 --- a/packages/classy-store/tests/browser/README.md +++ b/packages/classy-store/tests/browser/README.md @@ -1,4 +1,4 @@ -# React browser regressions +# Browser regressions From the repository root: @@ -9,7 +9,20 @@ bunx playwright install chromium firefox webkit bun run test:browser ``` -CI also installs the Linux system dependencies with `playwright install --with-deps`. The suite starts an isolated local Bun server and tests the source React binding in Chromium, Firefox, and WebKit. Package exports and React version compatibility are tested separately in `tests/compatibility`. +CI also installs the Linux system dependencies with `playwright install --with-deps`. The suite starts an isolated local Bun server and tests the source React and Svelte bindings in Chromium, Firefox, and WebKit. Package exports and React version compatibility are tested separately in `tests/compatibility`. + +The `/svelte` fixture compiles a Svelte 5 component using real `$store` +subscriptions and `$derived` values. Four cases cover input caret behavior, +same-event snapshot reads and class getters, keyed list changes, and automatic +subscription cleanup across three mount/unmount cycles. The timing case also +checks that ordinary core subscriptions still batch. Run just these cases with +`bun run test:browser svelte.spec.ts`. Before the adapter fix, the event handler +read the old snapshot after updating two properties. + +Root `bun run typecheck` checks the fixture through the Svelte example's +`check:integration` script. This reuses its TypeScript 5 installation because +the current `svelte-check` requires the JavaScript TypeScript compiler API; +the library's TypeScript 7 installation supplies the native compiler instead. 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. diff --git a/packages/classy-store/tests/browser/server.ts b/packages/classy-store/tests/browser/server.ts index 2cec8b7..6c08b36 100644 --- a/packages/classy-store/tests/browser/server.ts +++ b/packages/classy-store/tests/browser/server.ts @@ -1,4 +1,5 @@ import {fileURLToPath} from 'node:url'; +import {compile} from 'svelte/compiler'; import {compileConsumer} from '../compiler/compileConsumer.mjs'; import { releaseStreamingBoundary, @@ -60,12 +61,53 @@ if (!compilerBundle.success) ); const compilerScript = await compilerBundle.outputs[0].text(); +const svelteBundle = await Bun.build({ + entrypoints: [fileURLToPath(new URL('./svelte/client.ts', import.meta.url))], + target: 'browser', + format: 'iife', + conditions: ['development'], + plugins: [ + { + name: 'svelte-consumer', + setup(build) { + build.onLoad({filter: /\.svelte$/}, async ({path}) => { + const {js, warnings} = compile(await Bun.file(path).text(), { + filename: path, + generate: 'client', + dev: true, + }); + if (warnings.length) + throw new Error( + warnings.map((warning) => warning.message).join('\n'), + ); + return {contents: js.code, loader: 'js'}; + }); + }, + }, + ], +}); +if (!svelteBundle.success) + throw new AggregateError( + svelteBundle.logs, + 'Could not bundle Svelte fixture', + ); +const svelteScript = await svelteBundle.outputs[0].text(); + Bun.serve({ hostname: '127.0.0.1', port: 4179, routes: { '/': page, '/persistence': persistencePage, + '/svelte': () => + new Response( + 'Svelte consumer
', + {headers: {'Content-Type': 'text/html; charset=utf-8'}}, + ), + '/svelte/client.js': () => + new Response(svelteScript, { + headers: {'Content-Type': 'text/javascript; charset=utf-8'}, + }), '/compiler': () => new Response( 'Compiled consumer
', diff --git a/packages/classy-store/tests/browser/svelte.spec.ts b/packages/classy-store/tests/browser/svelte.spec.ts new file mode 100644 index 0000000..e4acbec --- /dev/null +++ b/packages/classy-store/tests/browser/svelte.spec.ts @@ -0,0 +1,104 @@ +import {expect} from '@playwright/test'; +import {test} from './browser.test'; + +test.beforeEach(async ({page}) => { + await page.goto('/svelte'); + await expect(page.getByLabel('Text', {exact: true})).toHaveValue('abcdef'); +}); + +test('Svelte inputs preserve the caret and update computed values', async ({ + page, +}) => { + const input = page.getByLabel('Text', {exact: true}); + await input.focus(); + await input.evaluate((element: HTMLInputElement) => + element.setSelectionRange(2, 2), + ); + await input.pressSequentially('XY'); + await expect(input).toHaveValue('abXYcdef'); + expect( + await input.evaluate((element: HTMLInputElement) => element.selectionStart), + ).toBe(4); + await expect(page.getByTestId('text')).toHaveText('abXYcdef'); + await expect(page.getByTestId('uppercase')).toHaveText('ABXYCDEF'); + + await input.evaluate((element: HTMLInputElement) => + element.setSelectionRange(2, 4), + ); + await input.pressSequentially('Z'); + await expect(input).toHaveValue('abZcdef'); + expect( + await input.evaluate((element: HTMLInputElement) => element.selectionStart), + ).toBe(3); + await input.press('Backspace'); + await expect(input).toHaveValue('abcdef'); + await expect(page.getByTestId('uppercase')).toHaveText('ABCDEF'); + + await page.evaluate(() => window.svelteProbe.setText('external')); + await expect(input).toHaveValue('external'); + await expect(page.getByTestId('uppercase')).toHaveText('EXTERNAL'); +}); + +test('Svelte sees multi-property updates during the event while core subscriptions batch', async ({ + page, +}) => { + await expect(page.getByTestId('doubled')).toHaveText('2'); + await page.getByRole('button', {name: 'Update together'}).click(); + await expect(page.getByLabel('Text', {exact: true})).toHaveValue('updated'); + await expect(page.getByTestId('uppercase')).toHaveText('UPDATED'); + await expect(page.getByTestId('doubled')).toHaveText('6'); + const state = await page.evaluate(() => window.svelteProbe.getState()); + expect(state.snapshotDuringEvent).toBe('updated:6:UPDATED'); + expect(state.coreDuringEvent).toBe(0); + expect(state.coreNotifications).toBe(1); +}); + +test('Svelte keyed lists handle insertions, nested edits, removals, and replacement', async ({ + page, +}) => { + const items = page.getByRole('listitem'); + await expect(items).toHaveText(['One', 'Two']); + await page.getByRole('button', {name: 'Add item'}).click(); + await expect(items).toHaveText(['One', 'Two', 'Three']); + await expect(page.getByTestId('item-count')).toHaveText('3'); + await page.getByRole('button', {name: 'Rename first'}).click(); + await expect(items).toHaveText(['Renamed', 'Two', 'Three']); + await page.getByRole('button', {name: 'Remove first'}).click(); + await expect(items).toHaveText(['Two', 'Three']); + await expect(page.getByTestId('item-count')).toHaveText('2'); + await page.getByRole('button', {name: 'Replace items'}).click(); + await expect(items).toHaveText(['Four']); + await expect(page.getByTestId('item-count')).toHaveText('1'); +}); + +test('Svelte automatically unsubscribes on unmount and resubscribes with current state', async ({ + page, +}) => { + for (let cycle = 0; cycle < 3; cycle += 1) { + expect( + await page.evaluate(() => window.svelteProbe.getState()), + ).toMatchObject({ + activeSubscriptions: 1, + syncListeners: 1, + }); + await page.evaluate(() => window.svelteProbe.unmount()); + await expect(page.getByLabel('Text', {exact: true})).toHaveCount(0); + const unmounted = await page.evaluate(() => window.svelteProbe.getState()); + expect(unmounted).toMatchObject({activeSubscriptions: 0, syncListeners: 0}); + + await page.evaluate( + (value) => window.svelteProbe.setText(value), + `unmounted-${cycle}`, + ); + expect( + await page.evaluate(() => window.svelteProbe.getState().deliveries), + ).toBe(unmounted.deliveries); + await page.evaluate(() => window.svelteProbe.mount()); + await expect(page.getByLabel('Text', {exact: true})).toHaveValue( + `unmounted-${cycle}`, + ); + await expect(page.getByTestId('uppercase')).toHaveText( + `UNMOUNTED-${cycle}`, + ); + } +}); diff --git a/packages/classy-store/tests/browser/svelte/SvelteForm.svelte b/packages/classy-store/tests/browser/svelte/SvelteForm.svelte new file mode 100644 index 0000000..9cf47cf --- /dev/null +++ b/packages/classy-store/tests/browser/svelte/SvelteForm.svelte @@ -0,0 +1,31 @@ + + + +{$snap.text} +{uppercase} +{$snap.doubled} +{itemCount} + + + + + +
    + {#each $snap.items as item (item.id)} +
  • {item.label}
  • + {/each} +
diff --git a/packages/classy-store/tests/browser/svelte/client.ts b/packages/classy-store/tests/browser/svelte/client.ts new file mode 100644 index 0000000..e882e55 --- /dev/null +++ b/packages/classy-store/tests/browser/svelte/client.ts @@ -0,0 +1,30 @@ +import {mount, unmount} from 'svelte'; +import SvelteForm from './SvelteForm.svelte'; +import {formStore, getProbe} from './svelte.store'; + +const target = document.getElementById('svelte-root'); +if (!target) throw new Error('Missing Svelte root'); +let component: ReturnType | undefined; + +const svelteProbe = { + getState: getProbe, + setText: (text: string) => formStore.setText(text), + mount() { + if (component) throw new Error('Svelte fixture is already mounted'); + component = mount(SvelteForm, {target}); + }, + async unmount() { + if (!component) throw new Error('Svelte fixture is not mounted'); + await unmount(component); + component = undefined; + }, +}; + +declare global { + interface Window { + svelteProbe: typeof svelteProbe; + } +} + +window.svelteProbe = svelteProbe; +svelteProbe.mount(); diff --git a/packages/classy-store/tests/browser/svelte/svelte.store.ts b/packages/classy-store/tests/browser/svelte/svelte.store.ts new file mode 100644 index 0000000..8cf0a76 --- /dev/null +++ b/packages/classy-store/tests/browser/svelte/svelte.store.ts @@ -0,0 +1,83 @@ +import { + createClassyStore, + getInternal, + subscribe, +} from '../../../src/core/core'; +import { + type ClassyReadable, + toSvelteStore, +} from '../../../src/frameworks/svelte/svelte'; +import type {Snapshot} from '../../../src/types'; + +class FormStore { + text = 'abcdef'; + count = 1; + items = [ + {id: 1, label: 'One'}, + {id: 2, label: 'Two'}, + ]; + + get doubled() { + return this.count * 2; + } + + setText(text: string) { + this.text = text; + } + + updateTogether() { + this.text = 'updated'; + this.count = 3; + } + + addItem() { + this.items.push({id: 3, label: 'Three'}); + } + + renameFirst() { + this.items[0].label = 'Renamed'; + } + + removeFirst() { + this.items.splice(0, 1); + } + + replaceItems() { + this.items = [{id: 4, label: 'Four'}]; + } +} + +export const formStore = createClassyStore(new FormStore()); +export const probe = { + activeSubscriptions: 0, + deliveries: 0, + coreNotifications: 0, + coreDuringEvent: -1, + snapshotDuringEvent: '', +}; + +subscribe(formStore, () => { + probe.coreNotifications += 1; +}); + +const readable = toSvelteStore(formStore); +export const snap: ClassyReadable> = { + subscribe(run) { + probe.activeSubscriptions += 1; + const unsubscribe = readable.subscribe((value) => { + probe.deliveries += 1; + run(value); + }); + return () => { + probe.activeSubscriptions -= 1; + unsubscribe(); + }; + }, +}; + +export function getProbe() { + return { + ...probe, + syncListeners: getInternal(formStore).syncListeners.size, + }; +} diff --git a/packages/classy-store/tests/browser/svelte/tsconfig.json b/packages/classy-store/tests/browser/svelte/tsconfig.json new file mode 100644 index 0000000..30a8a0a --- /dev/null +++ b/packages/classy-store/tests/browser/svelte/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*.ts", "**/*.svelte"] +} diff --git a/website/docs/ARCHITECTURE.md b/website/docs/ARCHITECTURE.md index 1c7b616..0ff7d03 100644 --- a/website/docs/ARCHITECTURE.md +++ b/website/docs/ARCHITECTURE.md @@ -138,7 +138,7 @@ root. This guide and the utility architecture guides live in `website/docs`. ### Overview -The `createClassyStore()` function wraps a class instance in an ES6 Proxy. Assignments and deletions through that proxy, including array operations and supported nested state, are intercepted. Raw-object writes and descriptor changes bypass these traps. Subscribers are batched by default into one microtask notification, while React uses the synchronous path. +The `createClassyStore()` function wraps a class instance in an ES6 Proxy. Assignments and deletions through that proxy, including array operations and supported nested state, are intercepted. Raw-object writes and descriptor changes bypass these traps. Subscribers are batched by default into one microtask notification, while React and Svelte use the synchronous path. ### Data Flow: Mutation → Notification @@ -215,6 +215,12 @@ store.items.push('a') Subscribers opt into immediate timing with `{sync: true}`. Sync subscribers run after version propagation and before the batched microtask. They are intentionally not deduped: two writes mean two sync notifications. Every React hook uses this timing so `useSyncExternalStore` observes the new value during the input event, preserving controlled-input caret position. Selectors can run after each write; React batches rendering within event handlers. +The Svelte adapter also uses synchronous notifications to satisfy the readable +store contract. It subscribes before delivering the initial snapshot so mutations +inside that first callback are observed, and removes the subscription if the +initial callback throws. Normal unsubscription stops delivery even if another +subscriber removes it during an in-progress notification. + ### Version Propagation When a nested property mutates, versions bump from the mutated node up to the root: diff --git a/website/docs/TUTORIAL.md b/website/docs/TUTORIAL.md index a29e0ea..137e46a 100644 --- a/website/docs/TUTORIAL.md +++ b/website/docs/TUTORIAL.md @@ -1,6 +1,6 @@ # Classy Store Tutorial -`Classy Store` is a reactive state library for React, Vue, Svelte, Solid, and Angular built on ES proxies. Define state as classes, wrap them with `createClassyStore()`, and read them with the binding for your framework. Shared client stores need no Provider, observer, or reducer; context providers can scope independent instances. The library batches ordinary subscriptions while notifying React synchronously, and uses structural sharing for change detection. +`Classy Store` is a reactive state library for React, Vue, Svelte, Solid, and Angular built on ES proxies. Define state as classes, wrap them with `createClassyStore()`, and read them with the binding for your framework. Shared client stores need no Provider, observer, or reducer; context providers can scope independent instances. The library batches ordinary subscriptions while notifying React and Svelte synchronously, and uses structural sharing for change detection. ## Quick Start diff --git a/website/docs/index.md b/website/docs/index.md index 76e50ce..56e3f61 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -154,17 +154,25 @@ Import from the entry that owns the API. Core, collections, and utilities do not factory functions. The React entry carries `use client`. Import hydration preparation from `/react/server` in both server code and client modules. -The other adapters publish whole snapshots using ordinary batched subscriptions: -Vue returns a `ShallowRef` and uses the current effect scope for cleanup; Svelte -returns a readable-store interface; Solid returns a signal accessor and uses its +The other adapters publish whole snapshots. Vue returns a `ShallowRef` and uses +the current effect scope for cleanup; Svelte returns a readable-store interface; +Solid returns a signal accessor and uses its owner's cleanup; Angular returns a readonly signal and requires an injection context. React's selector and auto-tracking modes are specific to `/react`. +Svelte's `toSvelteStore(store)` delivers the initial snapshot immediately and +notifies subscribers synchronously after each mutation. `$store` subscriptions +are cleaned up automatically when the component is destroyed; manual subscribers +must call the returned unsubscribe function. Snapshots are readonly: update the +original store or call its actions from input handlers instead of using +`bind:value` on snapshot fields. Vue, Solid, Angular, and ordinary core +subscriptions use microtask batching. + ## API Reference ### `createClassyStore(instance)` -Wraps a class instance in a reactive Proxy. Mutations notify React immediately; ordinary subscribers are batched via `queueMicrotask`. +Wraps a class instance in a reactive Proxy. Mutations notify React and Svelte immediately; ordinary subscribers are batched via `queueMicrotask`. ```typescript const myStore = createClassyStore(new MyClass()); diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt index 9f13487..a71211f 100644 --- a/website/static/llms-full.txt +++ b/website/static/llms-full.txt @@ -163,17 +163,25 @@ Import from the entry that owns the API. Core, collections, and utilities do not factory functions. The React entry carries `use client`. Import hydration preparation from `/react/server` in both server code and client modules. -The other adapters publish whole snapshots using ordinary batched subscriptions: -Vue returns a `ShallowRef` and uses the current effect scope for cleanup; Svelte -returns a readable-store interface; Solid returns a signal accessor and uses its +The other adapters publish whole snapshots. Vue returns a `ShallowRef` and uses +the current effect scope for cleanup; Svelte returns a readable-store interface; +Solid returns a signal accessor and uses its owner's cleanup; Angular returns a readonly signal and requires an injection context. React's selector and auto-tracking modes are specific to `/react`. +Svelte's `toSvelteStore(store)` delivers the initial snapshot immediately and +notifies subscribers synchronously after each mutation. `$store` subscriptions +are cleaned up automatically when the component is destroyed; manual subscribers +must call the returned unsubscribe function. Snapshots are readonly: update the +original store or call its actions from input handlers instead of using +`bind:value` on snapshot fields. Vue, Solid, Angular, and ordinary core +subscriptions use microtask batching. + ## API Reference ### `createClassyStore(instance)` -Wraps a class instance in a reactive Proxy. Mutations notify React immediately; ordinary subscribers are batched via `queueMicrotask`. +Wraps a class instance in a reactive Proxy. Mutations notify React and Svelte immediately; ordinary subscribers are batched via `queueMicrotask`. ```typescript const myStore = createClassyStore(new MyClass()); @@ -977,7 +985,7 @@ SOURCE: https://codebelt.github.io/classy-store/docs/TUTORIAL # Classy Store Tutorial -`Classy Store` is a reactive state library for React, Vue, Svelte, Solid, and Angular built on ES proxies. Define state as classes, wrap them with `createClassyStore()`, and read them with the binding for your framework. Shared client stores need no Provider, observer, or reducer; context providers can scope independent instances. The library batches ordinary subscriptions while notifying React synchronously, and uses structural sharing for change detection. +`Classy Store` is a reactive state library for React, Vue, Svelte, Solid, and Angular built on ES proxies. Define state as classes, wrap them with `createClassyStore()`, and read them with the binding for your framework. Shared client stores need no Provider, observer, or reducer; context providers can scope independent instances. The library batches ordinary subscriptions while notifying React and Svelte synchronously, and uses structural sharing for change detection. ## Quick Start @@ -1744,7 +1752,7 @@ root. This guide and the utility architecture guides live in `website/docs`. ### Overview -The `createClassyStore()` function wraps a class instance in an ES6 Proxy. Assignments and deletions through that proxy, including array operations and supported nested state, are intercepted. Raw-object writes and descriptor changes bypass these traps. Subscribers are batched by default into one microtask notification, while React uses the synchronous path. +The `createClassyStore()` function wraps a class instance in an ES6 Proxy. Assignments and deletions through that proxy, including array operations and supported nested state, are intercepted. Raw-object writes and descriptor changes bypass these traps. Subscribers are batched by default into one microtask notification, while React and Svelte use the synchronous path. ### Data Flow: Mutation → Notification @@ -1821,6 +1829,12 @@ store.items.push('a') Subscribers opt into immediate timing with `{sync: true}`. Sync subscribers run after version propagation and before the batched microtask. They are intentionally not deduped: two writes mean two sync notifications. Every React hook uses this timing so `useSyncExternalStore` observes the new value during the input event, preserving controlled-input caret position. Selectors can run after each write; React batches rendering within event handlers. +The Svelte adapter also uses synchronous notifications to satisfy the readable +store contract. It subscribes before delivering the initial snapshot so mutations +inside that first callback are observed, and removes the subscription if the +initial callback throws. Normal unsubscription stops delivery even if another +subscriber removes it during an in-progress notification. + ### Version Propagation When a nested property mutates, versions bump from the mutated node up to the root: