Skip to content

feat(vue): Vue 3 integration - #33

Open
ivanbanov wants to merge 49 commits into
mainfrom
vue
Open

ivanbanov wants to merge 49 commits into
mainfrom
vue

Conversation

@ivanbanov

@ivanbanov ivanbanov commented Jun 23, 2026 •

Copy link
Copy Markdown
Member

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 with main.

The bridge

  • useMachine builds once in setup() and returns { api: ComputedRef<Api>, machine }.
    • The computed reads the connector's memoized snapshot lazily, through a trigger that reads nothing, so connect() never runs in the middle of a transition.
    • Props reach the connector as a plain copy through a shallow watch.
    • Reaction callbacks run untracked.
    • The connector is destroyed with the scope.
  • Effects. After mount the bridge starts the machine, then each ComponentEffect.
    • Each run gets its own props snapshot, so a cleanup undoes exactly what its run set up.
    • A re-run happens only when a named dep changes, after the DOM patch.
    • If one effect throws, the others still run and every error is reported; a throwing cleanup doesn't block the next setup.
  • Unmount. The machine stops before the children unmount (so a part's teardown send fires no callback). The effect cleanups follow in 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.
  • SSR starts nothing and runs no effect.
  • useSelector returns a readonly ref holding the selected value as-is.
    • Re-selects lazily on machine changes and on the selector's own reactive reads.
    • Follows a machine passed as a ref or getter.
    • On the server it subscribes nothing and reads fresh.
  • normalize reuses @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.
  • mergeProps
    • Veto-aware handler chaining, unlike Vue's own mergeProps.
    • A consumer handler array is kept, with the library handler appended.
    • Capture listeners (onClickCapture) veto the plain one; a passive one can't.
    • class and style of any shape merge as [consumer, library] and are typed as such.

Consumer rules (documented)

  • Declare Boolean props with default: undefined.
  • Replace prop values, don't mutate them in place.
  • Call useMachine and useSelector before the first await.
  • Send from handlers, watch callbacks or hooks.
  • Don't watch api or a selection with flush: 'sync'.
  • To act on what an effect's machine change rendered, use onUpdated.

Verification

  • test:ci (449 tests), typecheck, lint, format:check, build, website:prod and sandbox/vue build pass.
  • knip matches main's baseline.
  • Changeset: minor.
  • Every docs sample type-checks under vue-tsc against the packed tarball.
  • Version matrix (scratch installs): all 68 Vue tests pass on 3.4.0, 3.4.38, 3.5.0, 3.5.43 and 3.6.0-rc.10; Vapor SFCs work, server rendering included (known limit: a Vapor component mounted into an already-deactivated KeepAlive view starts at once — Vapor exposes no current instance).
  • Consumer check: the packed tarball in a Vite + Vue 3.5.43 app passes 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

ivanbanov and others added 2 commits June 23, 2026 21:53
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>
@ivanbanov ivanbanov added the 🚧 WIP This will not be worked on label Jun 23, 2026
@vercel

vercel Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
dunky-state-machine Ready Ready Preview Oct 8, 2026 11:38am UTC
state-machine-website Ready Ready Preview Oct 8, 2026 11:38am UTC

ivanbanov and others added 2 commits August 14, 2026 11:52
- 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>
@ivanbanov

Copy link
Copy Markdown
Member Author

Brought the branch up to date with main and fixed the red checks:

  • Merged main (0.3.2 releases, substrate-prefix naming, opentui target, src shipping).
  • Renamed the package to @dunky.dev/vue-state-machine, matching the <substrate>-state-machine convention that landed on main after this PR was opened. Version aligned to the fixed group (0.3.2), src now ships alongside dist (parity with chore: ship the src folder in every published package #53).
  • Dropped the ComponentEffects alias — react removed it on main; every target now exports the ComponentEffect tuple alone.
  • Fixed the lint + format failures: the sandbox SFCs are kebab-cased (app.vue, command-palette.vue) per the repo filename rule, and everything is oxfmt-clean.

Verified locally: 360/360 tests, typecheck, build + publint all green.

The dunky-ui Vue substrate (@dunky.dev/vue-dialog) is being built against this adapter — publishing this is its prerequisite.

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>
@ivanbanov ivanbanov changed the title feat(vue): Vue 3 bindings feat(vue): Vue 3 integration Aug 18, 2026
ivanbanov and others added 4 commits October 4, 2026 20:51
… 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>
ivanbanov and others added 28 commits October 5, 2026 15:11
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>
@ivanbanov ivanbanov removed the 🚧 WIP This will not be worked on label Oct 8, 2026

This branch was successfully deployed

1 active deployment
Preview — 0f2b479b Deployed Oct 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant