diff --git a/.changeset/focused-vue-subscriptions.md b/.changeset/focused-vue-subscriptions.md new file mode 100644 index 0000000..955f1ea --- /dev/null +++ b/.changeset/focused-vue-subscriptions.md @@ -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()` / `', + {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( 'Svelte consumer
', diff --git a/packages/classy-store/tests/browser/vue.spec.ts b/packages/classy-store/tests/browser/vue.spec.ts new file mode 100644 index 0000000..30de149 --- /dev/null +++ b/packages/classy-store/tests/browser/vue.spec.ts @@ -0,0 +1,110 @@ +import {expect} from '@playwright/test'; +import {test} from './browser.test'; + +test.beforeEach(async ({page}) => { + await page.goto('/vue'); + await expect(page.getByLabel('Text', {exact: true})).toHaveValue('abcdef'); +}); + +test('Vue inputs preserve the caret and support writable computed models', 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('uppercase')).toHaveText('ABXYCDEF'); + await expect(page.getByLabel('Text model')).toHaveValue('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 page.getByLabel('Text model').fill('model edit'); + await expect(input).toHaveValue('model edit'); + await expect(page.getByTestId('uppercase')).toHaveText('MODEL EDIT'); + await page.getByLabel('Enabled').check(); + await expect(page.getByTestId('enabled')).toHaveText('true'); + await page.getByLabel('Enabled').uncheck(); + await expect(page.getByTestId('enabled')).toHaveText('false'); + await page.evaluate(() => window.vueProbe.setText('external')); + await expect(input).toHaveValue('external'); + await expect(page.getByLabel('Text model')).toHaveValue('external'); +}); + +test('Vue updates refs immediately and the DOM after one nextTick while core subscriptions batch', async ({ + page, +}) => { + await expect(page.getByTestId('summary')).toHaveText('abcdef:2:ABCDEF'); + await page.getByRole('button', {name: 'Update together'}).click(); + await expect(page.getByTestId('summary')).toHaveText('updated:6:UPDATED'); + const state = await page.evaluate(() => window.vueProbe.getState()); + expect(state.summaryAfterNextTick).toBe('updated:6:UPDATED'); + expect(state.snapshotDuringEvent).toBe('updated:6:UPDATED'); + expect(state.coreDuringEvent).toBe(0); + expect(state.coreNotifications).toBe(1); + expect(state.commits).toEqual(['abcdef:2:ABCDEF', 'updated:6:UPDATED']); +}); + +test('Vue keyed lists handle insertion, nested edits, removal, 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('Vue unmount cancels pending renders and releases subscriptions across remounts', async ({ + page, +}) => { + for (let cycle = 0; cycle < 3; cycle += 1) { + expect(await page.evaluate(() => window.vueProbe.getState())).toMatchObject( + {syncListeners: 1, batchedListeners: 1}, + ); + const commits = await page.evaluate(() => { + const count = window.vueProbe.getState().commits.length; + window.vueProbe.setText('pending'); + window.vueProbe.unmount(); + return count; + }); + await expect(page.getByLabel('Text', {exact: true})).toHaveCount(0); + expect(await page.evaluate(() => window.vueProbe.getState())).toMatchObject( + {syncListeners: 0, batchedListeners: 1}, + ); + await page.evaluate( + (value) => window.vueProbe.setText(value), + `unmounted-${cycle}`, + ); + expect( + await page.evaluate(() => window.vueProbe.getState().commits.length), + ).toBe(commits); + await page.evaluate(() => window.vueProbe.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/vue/VueForm.vue b/packages/classy-store/tests/browser/vue/VueForm.vue new file mode 100644 index 0000000..99824db --- /dev/null +++ b/packages/classy-store/tests/browser/vue/VueForm.vue @@ -0,0 +1,51 @@ + + + diff --git a/packages/classy-store/tests/browser/vue/client.ts b/packages/classy-store/tests/browser/vue/client.ts new file mode 100644 index 0000000..860148f --- /dev/null +++ b/packages/classy-store/tests/browser/vue/client.ts @@ -0,0 +1,31 @@ +import {createApp} from 'vue'; +import VueForm from './VueForm.vue'; +import {formStore, getProbe} from './vue.store'; + +const target = document.getElementById('vue-root'); +if (!target) throw new Error('Missing Vue root'); +let app: ReturnType | undefined; +const vueProbe = { + getState: getProbe, + setText: (text: string) => formStore.setText(text), + mount() { + if (app) throw new Error('Vue fixture is already mounted'); + app = createApp(VueForm); + app.config.warnHandler = (message) => { + throw new Error(`Vue warning: ${message}`); + }; + app.mount(target); + }, + unmount() { + if (!app) throw new Error('Vue fixture is not mounted'); + app.unmount(); + app = undefined; + }, +}; +declare global { + interface Window { + vueProbe: typeof vueProbe; + } +} +window.vueProbe = vueProbe; +vueProbe.mount(); diff --git a/packages/classy-store/tests/browser/vue/env.d.ts b/packages/classy-store/tests/browser/vue/env.d.ts new file mode 100644 index 0000000..f3ac333 --- /dev/null +++ b/packages/classy-store/tests/browser/vue/env.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type {DefineComponent} from 'vue'; + const component: DefineComponent; + export default component; +} diff --git a/packages/classy-store/tests/browser/vue/tsconfig.json b/packages/classy-store/tests/browser/vue/tsconfig.json new file mode 100644 index 0000000..738f142 --- /dev/null +++ b/packages/classy-store/tests/browser/vue/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": {"jsx": "preserve"}, + "include": ["**/*.ts", "**/*.vue"] +} diff --git a/packages/classy-store/tests/browser/vue/vue.store.ts b/packages/classy-store/tests/browser/vue/vue.store.ts new file mode 100644 index 0000000..d3410a8 --- /dev/null +++ b/packages/classy-store/tests/browser/vue/vue.store.ts @@ -0,0 +1,61 @@ +import { + createClassyStore, + getInternal, + subscribe, +} from '../../../src/core/core'; + +class FormStore { + text = 'abcdef'; + count = 1; + enabled = false; + items = [ + {id: 1, label: 'One'}, + {id: 2, label: 'Two'}, + ]; + + get doubled() { + return this.count * 2; + } + setText(text: string) { + this.text = text; + } + setEnabled(enabled: boolean) { + this.enabled = enabled; + } + 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 = { + coreNotifications: 0, + coreDuringEvent: -1, + snapshotDuringEvent: '', + summaryAfterNextTick: '', + commits: [] as string[], +}; +subscribe(formStore, () => { + probe.coreNotifications += 1; +}); + +export function getProbe() { + return { + ...probe, + syncListeners: getInternal(formStore).syncListeners.size, + batchedListeners: getInternal(formStore).batchedListeners.size, + }; +} diff --git a/website/docs/ARCHITECTURE.md b/website/docs/ARCHITECTURE.md index 0ff7d03..d6c9439 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 and Svelte use 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, Vue, and Svelte use the synchronous path. ### Data Flow: Mutation → Notification @@ -221,6 +221,11 @@ 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. +The Vue adapter updates its shallow ref synchronously too. Vue handles render +batching, and `nextTick()` then waits for the queued DOM update. The hook requires +an active component or effect scope to own cleanup, and ignores notifications if +that scope has been disposed 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 137e46a..2d3daac 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 and Svelte 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, Vue, 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 56e3f61..8522be5 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -36,7 +36,7 @@ All framework peer dependencies are optional — install only what your project | Framework | Peer Dependency | Import | |-----------|----------------|--------| | React | `react >= 18.0.0` | `@codebelt/classy-store/react` | -| Vue | `vue >= 3.0.0` | `@codebelt/classy-store/vue` | +| Vue | `vue >= 3.2.0` | `@codebelt/classy-store/vue` | | Svelte | `svelte >= 4.0.0` | `@codebelt/classy-store/svelte` | | Solid | `solid-js >= 1.0.0` | `@codebelt/classy-store/solid` | | Angular | `@angular/core >= 17.0.0` | `@codebelt/classy-store/angular` | @@ -165,14 +165,24 @@ 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. +`bind:value` on snapshot fields. Solid, Angular, and ordinary core subscriptions +use microtask batching. + +Vue's `useClassyStore(store)` updates its `ShallowRef` synchronously, so computed +values are current immediately after an action and `await nextTick()` waits for +Vue's DOM update. Vue still batches rendering. Call the hook synchronously in +`setup()` / `