Repository navigation
Conversation
Add @dunky.dev/state-machine-vue, mirroring the React package's API one-for-one (useMachine, useSelector, normalize, mergeProps, ComponentEffect/ComponentEffects) implemented with Vue reactivity: - useMachine builds machine+connector once in setup(), drives a shallowRef off the connector snapshot (exposed as a ComputedRef), pushes props via watch->setProps, runs start/stop on the mount lifecycle, and runs each ComponentEffect as its own dep-keyed watch. - useSelector returns a value-deduped readonly ref. - normalize translates agnostic bindings to Vue DOM/ARIA props (onPress->onClick, onValueChange->onInput, focusable->tabindex, ...). - mergeProps merges consumer + component props with Vue's class/style conventions. Wire the package into tsdown, tsconfig paths, the docs sidebar, and a new libs/vue.mdx; add a changeset. Add sandbox/vue (SFCs) driving the shared cmdk core through the new bindings. Co-authored-by: Claude <noreply@anthropic.com>
Rewrite the useMachine / useSelector / normalize / mergeProps examples in the Vue README and docs page to lead with SFC <script setup> + <template> (v-bind) syntax instead of h() render functions — matching how the sandbox is written and what Vue developers expect. Keep a short note that the same api/normalize output works in JSX or a manual h() render function. Co-authored-by: Claude <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
# Conflicts: # tsconfig.json
- Rename @dunky.dev/state-machine-vue -> @dunky.dev/vue-state-machine, matching the <substrate>-state-machine naming every other target uses. - Version 0.3.2, in step with the fixed @dunky.dev/* group. - Ship src alongside dist (parity with #53). - Drop the ComponentEffects alias — react removed it; every target exports the ComponentEffect tuple alone. - Kebab-case the sandbox SFC filenames and format, fixing the lint and format CI failures. - Add vue to the substrate enumerations in AGENTS/ARCHITECTURE. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Member
Author
|
Brought the branch up to date with main and fixed the red checks:
Verified locally: 360/360 tests, typecheck, build + publint all green. The dunky-ui Vue substrate ( |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same code — the mechanism essays in useMachine/useSelector/normalize/ mergeProps compressed to the load-bearing constraints (props-proxy identity, array-of-getters watch sources, Vue's event-name casing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tables show the common subset; the source is the exhaustive list — same pointer the react docs carry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Semantic resolution beyond the textual conflict: normalize's maps are now vocabulary-typed (HandlerTargets/AttrTargets + key-cast reads), the shared conformance fixture covers the vue ledgers, workspace deps pin exact per the versioning policy, and the never-published package starts at 0.0.0 instead of the lockstep-era 0.3.2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… shared DOM package Brings the branch up to main: TypeScript 7.0.2, per-package tsconfigs with the root tsconfig.build.json references (#63), @dunky.dev/state-machine-dom (#32), the Solid target, the core perf refactors, and the website redesign. Conflicts were target enumerations only — each list now names react, solid, vue, native, opentui; the lockfile is regenerated from main's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows #63: packages/vue extends the root tsconfig and joins the tsconfig.build.json references, so `pnpm typecheck` covers it. Dev dependencies move to the current releases the bridge is verified against: vue ^3.5.43 and @vue/test-utils ^2.5.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useMachine ran each ComponentEffect as an immediate watcher during setup(): before mount, before service.start(), and on the server too — Vue runs immediate watchers in SSR setup, so a document listener crashed renderToString, and DOM refs were still empty on the client. Effects now follow the React target exactly: start, then the effects, after mount; a dep change re-runs one after the DOM patch (flush post), cleanup first; unmount stops the machine, then cleans up. A <KeepAlive> deactivation pauses the machine and its effects and reactivation resumes them with state intact, as React's <Activity> does. Nothing runs on the server — Vue never registers lifecycle hooks there. Also: the props watch is no longer deep (setProps is a shallow dedup, so the traversal was wasted work over every nested prop value), the connector subscription disposes with the scope, and the api computed re-reads the memoized snapshot lazily, so connect() runs once per read after a change instead of once per machine notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Vue normalize carried its own copy of DOM_ATTR_MAP, the payload adapters, and AnyEvent — and the copy still handed out a detached `preventDefault: e.preventDefault`, so calling it on an adapted payload threw "Illegal invocation" in real browsers (main fixed it once, in the shared package). Vue now reuses the shared DOM half like react and solid and owns only what is its own: its listener names and focusable -> tabindex. The listener names are derived from DOM_HANDLER_MAP rather than restated. Vue hyphenates a listener prop's camel tail to find the DOM event (runtime-dom parseName), so `onPointerEnter` would listen to `pointer-enter`; the shared names are the camelCase of the very same events, so lowercasing their tail is lossless and a new shared handler reaches Vue with no edit here. The tests pin every name by hand and fire each mapped handler on a real element through Vue's renderer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The effects were started with `effects.map`, so one that threw on its first run lost the stop handles of every effect started before it: their listeners outlived the component. Handles are now recorded as each effect starts, and a re-run clears the previous cleanup before calling it, so a re-run that throws can't make unmount call that cleanup a second time. The snapshot staleness signal is now `triggerRef` on a shallow ref of the connector instead of an incremented counter: the computed reads `source.value.snapshot`, with no bare tracking read for a minifier to second-guess and no read inside the notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dunky.dev/state-machine-dom and -utils reach the Vue sandbox only through its Vite aliases, so knip reported them as unused; the React and Solid sandboxes don't list -dom either. knip is back to main's baseline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Vue runs a child's mounted hook before its parent's, so a compound component's part that sends from onMounted reaches the machine before useMachine starts it. The core transitions a stopped machine, and the parent must render that state; this pins it, since the dialog bindings built on this adapter depend on it. Also words the docs page's Vapor claim as what it is: verified on the 3.6 release candidate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Code review found useSelector stale in the documented pattern `useSelector(props.machine, () => ...props.value)`: it re-ran the selector only when the machine notified, so a changed prop kept the old result until an unrelated machine change. React re-runs the selector on every render; Vue now watches the selector itself, so a reactive value it reads re-selects too. Both paths dedupe against the ref — the core Selection's own `prev` would go stale once the Vue path moved on. The machine may now be a ref or a getter (the parameter widens to MaybeRefOrGetter; a plain machine still works): the subscription lives in an immediate watcher, so swapping the machine re-subscribes, and on the server, where Vue 3.5+ stops an immediate watcher right after its first run, a render leaves no listener on a machine that outlives it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Code review found two gaps against the React target. A ComponentEffect that threw on its first run aborted the loop, so the effects after it never started on a component that stayed mounted. Each effect now runs in its own immediate watcher, where Vue reports the error and moves on; the machine is an implicit first dep, as in React's dep array, which also keeps the source non-empty for Vue releases before 3.5.x. And teardown ran in onBeforeUnmount, with the DOM still attached, while React runs passive-effect cleanups after the nodes are gone. Teardown now runs in onUnmounted — stop(), then the cleanups — with the cleanups held by the bridge rather than onCleanup, since the instance scope stops its watchers before the DOM is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mergeProps detected listeners with its own `key.startsWith('on')`, which
disagreed with the base's rule (an uppercase third character) and ran a
second pass building closures. A consumer's handler array is now folded
into one function before the base merge, copied on write, so the base's
single listener rule decides whether it composes; nothing allocates
unless an array is present.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs' dialog effect gated on `!props.closeOnEscape` while the machine defaults the option to true, so with the recommended `default: undefined` Escape never closed it; the Vue examples now gate on `=== false`. The README, docs page, and changeset describe useSelector's reactive re-selection and machine refs/getters, the server cleanup (and its 3.3–3.4 caveat), effect isolation, and cleanup after the DOM is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng on the server The second review pass found the selector's reactive reads tracked only by the Vue-path watcher's own runs: machine-triggered re-selections ran untracked, so a selector that short-circuited on its first run never subscribed to the props it skipped. One sync watcher now evaluates every selection — machine notifications reach it through `triggerRef`, its own reactive reads directly — so each run re-collects what it read. The server path no longer leans on Vue 3.5's stop-after-first-run for immediate watchers, which 3.3 and 3.4 don't do: a component rendering on the server (detected by the injected ssrContextKey every server renderer provides) returns the seeded ref without subscribing at all. The subscription disposes through onScopeDispose again, which warns when useSelector runs outside any scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With no app errorHandler, Vue rethrows a watcher's error in dev, so an effect that threw inside its immediate watcher escaped watch(): the effects after it never started and its own watcher was never recorded for teardown. Each effect now gets a non-immediate watcher and its disposer first, then its first run; the pass always finishes, and the first failure is rethrown afterwards — the core's own teardown rule. pause() uses the same rule, so a throwing cleanup can't strand the rest. Watchers that are never immediate need no padding for the empty-source bug of Vue before 3.5.36. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…example useSelector now subscribes nothing on the server on every supported version, so the README states that plainly. The boolean-props example declares closeOnEscape with `default: undefined` too, which the Escape example's `=== false` gate relies on; effect isolation is worded as implemented — the others still run, then the first error is reported. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Solid sandbox's missing line belongs to a cross-target follow-up, not to the Vue PR — this branch only adds Vue's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A third review pass reproduced two bugs in the eager sync watcher on Vue 3.5.43: it ran between individual prop writes, so a selector reading two props saw one updated and one stale (and could throw), and when the selector threw, Vue handed the callback `undefined`, which became the selection. useSelector is now a computed fed by triggerRef on machine notifications: evaluated where it is read, after patching, re-collecting its reactive reads each run, fresh right after send(), never evaluated before its first read; a throw surfaces at the reader and the last good value stands. It returns a ComputedRef, like useMachine's api. An equal selection returns the previous value's identity so readers skip re-rendering — which needs the computed of Vue 3.4's reactivity; 3.3 notifies readers eagerly. Verified: 3.4.0, 3.4.38, 3.5.0, 3.5.43, and 3.6.0-rc.10 pass the whole suite; 3.3.0 fails exactly the three re-render tests. The peer range is now `^3.4.0`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unmounting now stops the machine in onBeforeUnmount, before the parts unmount — React's passive cleanups run top-down too — so a part sending from its own teardown reaches a stopped machine and fires no prop callback (pinned by a test where a child sends from onUnmounted). The effect cleanups still wait for onUnmounted, once the DOM is gone. Effects are back in one immediate watcher each, where Vue reports every failing effect individually and starts the rest; the constant first source only pads the array for the empty-source bug Vue fixed in 3.5.36. A core effect throwing in start() no longer leaves the component's effects unstarted, and cleanup failures are all reported — a lone one as-is, several as an AggregateError. A test also pins that connect() never runs mid-transition: the computed api reads the snapshot only after an entry action has run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The base merge lets the library win, so `class: undefined` on the library side erased a consumer's class and the overlay never restored it. class and style always apply from both sides; a nullish library value now leaves the consumer's in place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Documents the final contract: useSelector returns a computed ref; the peer floor is ^3.4.0 and why; on unmount the machine stops parent-first and the effects clean up after the DOM is gone. And a consumer rule the adapter cannot enforce — Vue has no public way to run code untracked: send() runs prop callbacks synchronously, so send from event handlers, `watch` callbacks, or hooks, never from watchEffect, a computed, or a render function, where those callbacks' reads become dependencies. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fourth review pass found that `active` was assigned only after every effect's watcher was created: when Vue rethrows a failing effect (dev without an errorHandler, or throwUnhandledErrorInProduction), the effects already started had no disposer recorded, and a later onActivated would start them twice. Disposers are now recorded as each effect starts. Deactivation used try/finally, so a throwing effect cleanup replaced a stop() failure. Deactivation and unmount now share one settle step that runs everything and reports every failure — a lone one as-is, several as an AggregateError. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useSelector is the O(readers) path — one call per row — and every call created a swap watcher, even for a plain machine that can never change. The watcher now exists only for a ref or getter, and both subscriptions share one wake closure. The last selection stays in a closure rather than the computed getter's `previous` argument, which Vue 3.5.0 never passed (verified: that variant fails the isEqual test there). A test name now says what it asserts: a selector failure rethrows at the read and the next change recovers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…await Vue runs a kept-alive tree's parts' onDeactivated hooks before the root's, so a part shouldn't send from one; and useSelector, like useMachine, goes before the first `await` in a hand-written async setup(), where nothing restores the instance after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… deps A red-team pass at 58ef992 found effects handed the live props proxy, so a cleanup read the next run's props: `registry.add(p.id)` / `registry.delete(p.id)` with deps ['id'] leaked 'a' after id a→b, where React's render props keep it balanced. Each run now gets its own plain copy. The effect runner adopts the review's prototype: a non-immediate watcher per effect, its disposer recorded before a direct first run, every failure collected and rethrown once the pass is done. With no app errorHandler, Vue rethrows, and an immediate watcher's job used to escape the loop — later effects never started and the failing one's watcher was never recorded (pinned in its own test file, since an escaped error leaves Vue's post-flush queue stuck for the module). A cleanup that throws no longer blocks the next setup, and the constant-source padding for pre-3.5.36 Vue is gone with the immediate watchers. Sends from tracked code: reaction callbacks (onOpenChange…) now run untracked through a throwaway ReactiveEffect, and the bridge wakes its computed through a customRef trigger, which reads nothing (triggerRef reads in Vue 3.4 dev builds). A watchEffect that sends no longer picks up a callback's reads and re-sends on an unrelated change. And the connector is destroyed with the scope, so a machine kept past unmount and restarted can't call back into the gone component. The two overlapping lifecycle assertions are dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… read On a Vue 3.5+ server a computed re-evaluates only when something reactive changed, and useSelector subscribes nothing there — so a selection read during setup stayed pinned through a later send. A server render now returns a getter ref (`toRef(selector)`) that runs the selector on every read. The return type is honest for both paths: `Readonly<Ref<T>>`, which a ComputedRef satisfies. On the client the machine wakes the computed through the same read-free trigger as useMachine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ass/style honestly Three gaps from the red-team pass: - A consumer handler array was folded into one function, losing Vue's invoker semantics — falsy entries skipped, stopImmediatePropagation between entries, each entry's error reported on its own. The array is now kept, the library handler appended last behind the shared veto. - A consumer listening with an option modifier (onClickCapture, onClickOnce) never met the library's onClick, so its preventDefault vetoed nothing. The library's plain listener now honors that veto; composing the two would move one of them to the other's phase. (The test dispatches after the clock moves: Vue ignores a listener attached in the same millisecond the event is stamped with.) - The return type claimed the consumer's class/style types while the runtime may hand back arrays: `Omit<Props, 'class' | 'style'>` plus Vue's class and style shapes, spelled out locally because Vue exports ClassValue only from late 3.5 releases. The real-element helper moves to tests/fixtures, now that two suites share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The red-team pass found the Vue samples didn't type-check. The docs page used `setup.infer()`, which infers Event as `never` against connect's Event; typed its parts `object`, which normalize won't take; and listed an `onEscapeKey` it never defined. The README left connectTooltip implicitly `any`, used TooltipMachine and TooltipMachineProps it never declared, and sent an `escape` event its machine lacks. The changeset defaulted a `modal` prop its DialogProps didn't have. Every sample is now a complete, typed module or component: `setup.as<Context, Event>()`, `Record<string, unknown>` parts, an exported machine type, effects in their own module, full SFCs instead of "as above". All 21 compile under vue-tsc against the packed tarball (strict, skipLibCheck off). The contract text follows the code. Each effect run gets its own props snapshot. Cleanups run children first (React: parent first), and under <Transition> while the leaving element is still in the document. Never watch api or a selection with `flush: 'sync'`. useSelector returns a readonly ref, read fresh on the server. Handler arrays and modifier listeners keep the veto. "Replace, don't mutate" joins the Boolean-props rule. The two core README anchors are fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comments only. The Vue target lowercases each shared name after its leading capital, so a new entry must stay `on` + the camelCased DOM event; the tabindex and native-event notes now name Vue alongside Solid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by the Vue dialog work: a component mounted into a <KeepAlive> view that was already deactivated — a part added while its cached view is hidden — started its machine and ran its effects, because Vue fires onMounted there too. useMachine now holds still until the view activates; Vue fires the part's onActivated then (injected into the view's root), and resume starts the machine and the effects. The check is the ancestor walk Vue's own activation hooks make, since only a view's root carries `isDeactivated` — every primitive built on the adapter gets it, so the binding's own `inDeactivatedView` workaround can go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ming States the deactivated-view hold precisely. Also notes a timing consequence of effects re-running after the render: a machine change an effect makes (a controlled option echoed into the machine) renders in the pass after, so a `post` watcher queued by the same prop change reads the previous DOM, while onUpdated runs after both renders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… async parts after mount
From the delta review of the fix pass:
- untracked() built its throwaway ReactiveEffect in the active scope, and
before 3.6 Vue keeps a stopped effect there: 1000 sends inside
effectScope().run() grew the scope by 1000 effects, sends from
onUpdated grew the component scope likewise. The effect now lives in
a detached scope of its own, stopped with it (regression test counts
the scope's effects).
- `try { teardown() } finally { setup }` dropped the cleanup's error when
the setup threw too; both now reach Vue's error handling, as an
AggregateError.
- A cached view's activation can reach an async descendant under
<Suspense> before it mounts, which started the machine and ran the
effects against no DOM, never to run again. Activation now starts
only a mounted component; onMounted starts the rest.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fier listeners
- `Omit<Props, 'class' | 'style'>` erased the named members of a
consumer type with an index signature (`{ id: string; [k: string]:
unknown }` lost `id: string`); the keys are now remapped instead.
- A modifier listener bound twice (`@click.capture` plus a v-bind'ed
onClickCapture) reaches attrs as an array and skipped the modifier
check; the check now applies to arrays too.
- Passive is no longer treated as a vetoing modifier: a passive
listener can't preventDefault.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… hold The veto works for a capture listener and for a `.once` bound from the first render; it can't for a passive listener (the browser ignores its preventDefault) or a `.once` added after mount (registered after the library's). And a Vapor component has no getCurrentInstance(), so one mounted into an already-deactivated KeepAlive view starts right away. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
@dunky.dev/vue-state-machine, the Vue target. Same exports as React and Solid (useMachine,useSelector,normalize,mergeProps,ComponentEffect), built on Vue primitives with React's start order. Peer:vue^3.4.0. Synced withmain.The bridge
useMachinebuilds once insetup()and returns{ api: ComputedRef<Api>, machine }.connect()never runs in the middle of a transition.watch.ComponentEffect.onUnmounted.<KeepAlive>pauses like React's<Activity>. A component mounted into an already-deactivated view holds still until the view activates, and an async component inside a cached view starts only once it has mounted.useSelectorreturns a readonly ref holding the selected value as-is.normalizereuses@dunky.dev/state-machine-dom, with Vue's listener names derived from it (onPointerenter,onKeydown). Every name is pinned and fired on a real element.mergePropsmergeProps.onClickCapture) veto the plain one; a passive one can't.classandstyleof any shape merge as[consumer, library]and are typed as such.Consumer rules (documented)
default: undefined.useMachineanduseSelectorbefore the firstawait.watchcallbacks or hooks.apior a selection withflush: 'sync'.onUpdated.Verification
test:ci(449 tests),typecheck,lint,format:check,build,website:prodandsandbox/vue buildpass.knipmatches main's baseline.vue-tscagainst the packed tarball.vue-tsc, the client build and the SSR build; it renders on the server with no effect run and hydrates without mismatch.🤖 Generated with Claude Code