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
16 changes: 16 additions & 0 deletions .changeset/focused-vue-subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@codebelt/classy-store': patch
---

Update Vue snapshot refs synchronously so computed values are immediately current
and one `await nextTick()` waits for the resulting DOM update. Vue rendering and
ordinary core subscriptions still batch.

Require an active Vue effect scope for `useClassyStore()` and stop delivery when
that scope is disposed, including disposal during notification. Call the hook in
`setup()` / `<script setup>` or `effectScope().run()`; calls without an active
scope now throw instead of creating a subscription with no cleanup owner.

Correct the Vue peer dependency to 3.2 or later, matching the Effect Scope APIs
used by the adapter. Add regressions for inputs, writable computed models, class
getters, keyed lists, timing, and component cleanup.
2 changes: 1 addition & 1 deletion 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/vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"check:integration": "vue-tsc --project ../../packages/classy-store/tests/browser/vue/tsconfig.json --noEmit"
},
"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 && bun run --filter classy-store-svelte-example check:integration",
"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 && bun run --filter classy-store-vue-example check:integration",
"---------- Release (Changesets) ----------------------------------------": "",
"changeset": "changeset",
"changeset:add": "changeset add",
Expand Down
2 changes: 1 addition & 1 deletion packages/classy-store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@
},
"peerDependencies": {
"react": ">=18.0.0",
"vue": ">=3.0.0",
"vue": ">=3.2.0",
"svelte": ">=4.0.0",
"@angular/core": ">=17.0.0",
"solid-js": ">=1.0.0"
Expand Down
149 changes: 115 additions & 34 deletions packages/classy-store/src/frameworks/vue/vue.test.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,144 @@
import {describe, expect, it} from 'bun:test';
import {effectScope} from 'vue';
import {createClassyStore} from '../../core/core';
import {describe, expect, it, mock} from 'bun:test';
import {computed, effectScope, isShallow, nextTick, watch} from 'vue';
import {createClassyStore, getInternal, subscribe} from '../../core/core';
import {snapshot} from '../../snapshot/snapshot';
import {useClassyStore} from './vue';

const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));

describe('vue useClassyStore', () => {
it('throws when given a non-store object', () => {
const scope = effectScope();
try {
scope.run(() => {
expect(() => useClassyStore({count: 0})).toThrow(/not a store proxy/);
});
scope.run(() =>
expect(() => useClassyStore({count: 0})).toThrow(/not a store proxy/),
);
} finally {
scope.stop();
}
});

it('returns a ShallowRef holding the current snapshot', () => {
const s = createClassyStore({count: 5});
it('rejects calls without an active scope before subscribing', () => {
const store = createClassyStore({count: 0});
expect(() => useClassyStore(store)).toThrow(/active.*scope/i);
expect(getInternal(store).syncListeners.size).toBe(0);
expect(getInternal(store).batchedListeners.size).toBe(0);
});

it('rejects calls after the current scope has stopped', () => {
const store = createClassyStore({count: 0});
const scope = effectScope();
scope.run(() => {
scope.stop();
expect(() => useClassyStore(store)).toThrow(/active.*scope/i);
});
expect(getInternal(store).syncListeners.size).toBe(0);
expect(getInternal(store).batchedListeners.size).toBe(0);
});

it('returns a shallow ref containing the current immutable snapshot', () => {
const store = createClassyStore({count: 5, nested: {label: 'Initial'}});
const scope = effectScope();
try {
scope.run(() => {
const state = useClassyStore(s);
expect(state.value).toEqual({count: 5});
});
const state = scope.run(() => useClassyStore(store));
expect(isShallow(state)).toBe(true);
expect(state?.value).toBe(snapshot(store));
expect(Object.isFrozen(state?.value)).toBe(true);
expect(Object.isFrozen(state?.value.nested)).toBe(true);
} finally {
scope.stop();
}
});

it('updates the ref when the store mutates', async () => {
const s = createClassyStore({count: 0});
it('updates refs and computed values synchronously while Vue watchers and core subscriptions batch', async () => {
const store = createClassyStore({count: 0});
const scope = effectScope();
let stateRef!: ReturnType<typeof useClassyStore<typeof s>>;
scope.run(() => {
stateRef = useClassyStore(s);
const coreCallback = mock(() => {});
const stopCore = subscribe(store, coreCallback);
const watcher = mock(() => {});
const result = scope.run(() => {
const state = useClassyStore(store);
watch(state, watcher);
return {state, doubled: computed(() => state.value.count * 2)};
});

s.count = 42;
await flush();
expect(stateRef.value).toEqual({count: 42});
scope.stop();
if (!result) throw new Error('Missing scope result');
try {
store.count = 1;
expect(result.state.value.count).toBe(1);
expect(result.doubled.value).toBe(2);
store.count = 2;
expect(result.state.value.count).toBe(2);
expect(result.doubled.value).toBe(4);
expect(coreCallback).not.toHaveBeenCalled();
expect(watcher).not.toHaveBeenCalled();
await nextTick();
expect(coreCallback).toHaveBeenCalledTimes(1);
expect(watcher).toHaveBeenCalledTimes(1);
} finally {
scope.stop();
stopCore();
}
});

it('unsubscribes when the effect scope is stopped', async () => {
const s = createClassyStore({count: 0});
it('flushes Vue post effects after a single nextTick following a store mutation', async () => {
const store = createClassyStore({count: 0});
const scope = effectScope();
let stateRef!: ReturnType<typeof useClassyStore<typeof s>>;
const rendered: number[] = [];
scope.run(() => {
stateRef = useClassyStore(s);
const state = useClassyStore(store);
watch(state, (value) => rendered.push(value.count), {flush: 'post'});
});
try {
store.count = 1;
await nextTick();
expect(rendered).toEqual([1]);
} finally {
scope.stop();
}
});

scope.stop();
it('cleans up each scope independently and reads fresh state on remount', async () => {
const store = createClassyStore({count: 0});
const firstScope = effectScope();
const secondScope = effectScope();
const first = firstScope.run(() => useClassyStore(store));
const second = secondScope.run(() => useClassyStore(store));
if (!first || !second) throw new Error('Missing scope result');
try {
store.count = 1;
firstScope.stop();
firstScope.stop();
store.count = 2;
await nextTick();
expect(first.value.count).toBe(1);
expect(second.value.count).toBe(2);
} finally {
firstScope.stop();
secondScope.stop();
}
expect(getInternal(store).syncListeners.size).toBe(0);
expect(getInternal(store).batchedListeners.size).toBe(0);
const remountedScope = effectScope();
try {
const remounted = remountedScope.run(() => useClassyStore(store));
expect(remounted?.value.count).toBe(2);
} finally {
remountedScope.stop();
}
});

const before = stateRef.value;
s.count = 999;
await flush();
// After unmount, ref should not update further.
expect(stateRef.value).toBe(before);
it('does not update a ref whose scope is stopped during the same notification', async () => {
const store = createClassyStore({count: 0});
const scope = effectScope();
const stop = subscribe(store, () => scope.stop(), {sync: true});
const state = scope.run(() => useClassyStore(store));
try {
store.count = 1;
await nextTick();
expect(state?.value.count).toBe(0);
} finally {
scope.stop();
stop();
}
expect(getInternal(store).syncListeners.size).toBe(0);
expect(getInternal(store).batchedListeners.size).toBe(0);
});
});
36 changes: 28 additions & 8 deletions packages/classy-store/src/frameworks/vue/vue.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,45 @@
import {onScopeDispose, type ShallowRef, shallowRef} from 'vue';
import {
getCurrentScope,
onScopeDispose,
type ShallowRef,
shallowRef,
} from 'vue';
import {getInternal, subscribe} from '../../core/core';
import {snapshot} from '../../snapshot/snapshot';
import type {Snapshot} from '../../types';

/**
* Read immutable snapshots from a shallow ref inside setup() or an active
* effect scope. Ref updates are synchronous; Vue batches rendering normally.
* The scope owns subscription cleanup.
*/
export function useClassyStore<T extends object>(
proxyStore: T,
): ShallowRef<Snapshot<T>> {
// Validate up front so users get a clear error instead of an opaque trace.
getInternal(proxyStore);
if (!getCurrentScope()?.active) {
throw new Error(
'@codebelt/classy-store/vue: useClassyStore() requires an active Vue effect scope; call it in setup() or effectScope().run()',
);
}

const state = shallowRef(snapshot(proxyStore)) as unknown as ShallowRef<
Snapshot<T>
>;
let active = true;
const unsubscribe = subscribe(
proxyStore,
() => {
// A prior subscriber may stop this scope during the same notification.
if (active) state.value = snapshot(proxyStore);
},
{sync: true},
);

const unsubscribe = subscribe(proxyStore, () => {
state.value = snapshot(proxyStore);
onScopeDispose(() => {
active = false;
unsubscribe();
});

// `onScopeDispose` runs both on component unmount (setup() runs inside a
// scope) and on standalone `effectScope().stop()`.
onScopeDispose(unsubscribe);

return state;
}
13 changes: 12 additions & 1 deletion packages/classy-store/tests/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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 and Svelte bindings 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, Vue, 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,
Expand All @@ -24,6 +24,17 @@ Root `bun run typecheck` checks the fixture through the Svelte example's
the current `svelte-check` requires the JavaScript TypeScript compiler API;
the library's TypeScript 7 installation supplies the native compiler instead.

The `/vue` fixture compiles a Vue single-file component with `vue/compiler-sfc`.
Four cases cover text caret behavior, writable computed `v-model` inputs and a
checkbox, same-event computed reads and one-`nextTick()` DOM updates, keyed list
changes, and three mount/unmount cycles with pending renders. It also verifies
one render and one ordinary core notification for a multi-property action.
Before the adapter fix, the DOM read immediately after `await nextTick()` still
contained the old text and computed value. Vue warnings fail the fixture.
Run these cases with `bun run test:browser vue.spec.ts`; root `bun run typecheck`
checks its template through the Vue example's `check:integration` script using
that example's Vue tooling and TypeScript installation.

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
Expand Down
44 changes: 44 additions & 0 deletions packages/classy-store/tests/browser/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {fileURLToPath} from 'node:url';
import {compile} from 'svelte/compiler';
import {compileScript, parse} from 'vue/compiler-sfc';
import {compileConsumer} from '../compiler/compileConsumer.mjs';
import {
releaseStreamingBoundary,
Expand Down Expand Up @@ -93,12 +94,55 @@ if (!svelteBundle.success)
);
const svelteScript = await svelteBundle.outputs[0].text();

const vueBundle = await Bun.build({
entrypoints: [fileURLToPath(new URL('./vue/client.ts', import.meta.url))],
target: 'browser',
format: 'iife',
define: {
'process.env.NODE_ENV': '"development"',
__VUE_OPTIONS_API__: 'true',
__VUE_PROD_DEVTOOLS__: 'false',
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
},
plugins: [
{
name: 'vue-consumer',
setup(build) {
build.onLoad({filter: /\.vue$/}, async ({path}) => {
const {descriptor, errors} = parse(await Bun.file(path).text(), {
filename: path,
});
if (errors.length)
throw new AggregateError(errors, 'Could not parse Vue fixture');
const {content} = compileScript(descriptor, {
id: 'classy-vue-fixture',
inlineTemplate: true,
});
return {contents: content, loader: 'ts'};
});
},
},
],
});
if (!vueBundle.success)
throw new AggregateError(vueBundle.logs, 'Could not bundle Vue fixture');
const vueScript = await vueBundle.outputs[0].text();

Bun.serve({
hostname: '127.0.0.1',
port: 4179,
routes: {
'/': page,
'/persistence': persistencePage,
'/vue': () =>
new Response(
'<!doctype html><html><head><meta charset="utf-8"><title>Vue consumer</title></head><body><div id="vue-root"></div><script src="/vue/client.js"></script></body></html>',
{headers: {'Content-Type': 'text/html; charset=utf-8'}},
),
'/vue/client.js': () =>
new Response(vueScript, {
headers: {'Content-Type': 'text/javascript; charset=utf-8'},
}),
'/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>',
Expand Down
Loading
Loading