Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/focused-svelte-subscriptions.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion examples/svelte/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/classy-store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
140 changes: 113 additions & 27 deletions packages/classy-store/src/frameworks/svelte/svelte.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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();
}
});
});
31 changes: 26 additions & 5 deletions packages/classy-store/src/frameworks/svelte/svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export interface ClassyReadable<T> {
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<T extends object>(
proxyStore: T,
): ClassyReadable<Snapshot<T>> {
Expand All @@ -14,12 +20,27 @@ export function toSvelteStore<T extends object>(

return {
subscribe(run: (value: Snapshot<T>) => 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;
},
};
}
17 changes: 15 additions & 2 deletions packages/classy-store/tests/browser/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# React browser regressions
# Browser regressions

From the repository root:

Expand All @@ -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.

Expand Down
42 changes: 42 additions & 0 deletions packages/classy-store/tests/browser/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {fileURLToPath} from 'node:url';
import {compile} from 'svelte/compiler';
import {compileConsumer} from '../compiler/compileConsumer.mjs';
import {
releaseStreamingBoundary,
Expand Down Expand Up @@ -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(
'<!doctype html><html><head><meta charset="utf-8"><title>Svelte consumer</title></head><body><div id="svelte-root"></div><script src="/svelte/client.js"></script></body></html>',
{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(
'<!doctype html><html><head><meta charset="utf-8"><title>Compiled consumer</title></head><body><div id="compiler-root"></div><script src="/compiler/client.js"></script></body></html>',
Expand Down
Loading
Loading