Skip to content

fix(runtime-vapor): keep prop reads on committed values inside dynamic branches - #15687

Closed
snmsoodan wants to merge 1 commit into
vuejs:minorfrom
snmsoodan:fix/vapor-sync-watcher-15673
Closed

snmsoodan wants to merge 1 commit into
vuejs:minorfrom
snmsoodan:fix/vapor-sync-watcher-15673

Conversation

@snmsoodan

Copy link
Copy Markdown

Fixes #15673. The reproduction now behaves like VDOM and like 3.5: the branch switches to x is gone and nothing is thrown.

Mechanism (measured, not inferred)

With the two tests below applied to the pristine base (08cad7648), the first one fails with:

TypeError: Cannot read properties of undefined (reading 'y')
 ❯ y                                    packages/runtime-vapor/__tests__/_utils.ts:221:10   (compiled SFC getter: () => data.x.y)
 ❯ ComputedRefImpl.fn                   packages/runtime-vapor/src/componentProps.ts:211:56  (prop source cache)
 ❯ ComputedRefImpl.update               packages/reactivity/src/computed.ts:168:29
 ❯ checkDirty                           packages/reactivity/src/system.ts:292:29
 ❯ RenderWatcherEffect.get dirty        packages/reactivity/src/effect.ts:161:11
 ❯ job                                  packages/runtime-core/src/apiWatch.ts:162:16       (the flush: 'sync' watcher)
 ❯ RenderWatcherEffect.notify           packages/runtime-core/src/apiWatch.ts:188:9
 ❯ flush                                packages/reactivity/src/system.ts:269:12
 ❯ endBatch                             packages/reactivity/src/system.ts:74:5
 ❯ trigger                              packages/reactivity/src/dep.ts:191:3

x.value = undefined triggers the ref, which notifies the child's flush: 'sync' watcher inline. Its dirty check refreshes the prop source cache (resolveFunctionSource wraps the compiled () => x.y in a computed), so x.y is evaluated after the v-if guard expression became invalid but before the guard's queued job disposed the branch that owns the child. The tail of the stack (trigger → endBatch) shows the error leaves the ref assignment itself: there is no callWithErrorHandling/handleError frame, and it is not routed to app.config.errorHandler. Render-only reads and pre/post watchers run later in the same flush, after the branch teardown, which is why only the synchronous-observation path can observe the stale source.

Fix

packages/runtime-vapor/src/component.ts — a child created inside a dynamic branch now commits its prop sources through a detached EffectScope that the branch teardown stops, reusing the existing isolatePropSources mechanism (packages/runtime-vapor/src/componentProps.ts) that KeepAlive already applies to cached components. The isolated props read a shallowReactive committed target written by an immediate renderEffect:

  • while the branch is alive, commits keep flowing (pinned by the second test, and by every existing prop test);
  • after disposal, reads keep returning the last committed value, and stopping the scope removes the commit effect's dependencies — so the invalidating mutation no longer notifies anything in the disposed child at all. That is precisely the difference between these two plain-reactivity measurements:
plain reactivity (@vue/reactivity, measured with a throwaway spec) result
const c = computed(() => x.value.y); c.value; then x.value = undefined, no watcher no throw (lazy, never refreshed)
watch(() => x.value.y, fn, { flush: 'sync' }) in a scope, scope.stop(), then x.value = undefined no throw (dependencies removed)
watch(() => x.value.y, fn, { flush: 'sync' }), then x.value = undefined throws Cannot read properties of undefined (reading 'y')
watch(computed(() => x.value.y), fn, { flush: 'sync' }) (the vapor prop shape), then x.value = undefined throws the same error

Also in this commit:

  • packages/runtime-vapor/src/fragment.ts marks the branch currently rendering (currentBranchFragment) around DynamicFragment.renderNodes(), saved/restored so nested branches and deferred (transition/KeepAlive) renders are correct.
  • unmountComponent and the VDOM-interop unmount stop inputScope unconditionally, instead of only when isKeepAliveEnabled. The scope previously existed only on the KeepAlive path, so the gate was a no-op there; without this the new scope would never be stopped in apps that do not use KeepAlive.

v-once children are skipped (once), matching the existing KeepAlive path, because their props are snapshotted at creation.

Negative control on the unmodified revision

git stash push -- packages/runtime-vapor/src, then the same tree with the same tests:

$ npx --yes pnpm@12.4.2 vitest run packages/runtime-vapor/__tests__/componentProps.spec.ts -t "sync watcher"
 × sync watcher does not re-read a source disposed by a v-if branch
     TypeError: Cannot read properties of undefined (reading 'y')
 × sync watcher of a v-if child still sees committed prop updates
     AssertionError: expected [ 'b' ] to deeply equal []
 Tests  2 failed | 58 skipped (60)

Full suite, same revision (08cad7648, sources pristine, tests added):

Test Files  1 failed | 68 passed (69)
     Tests  2 failed | 2479 passed | 2 expected fail | 16 todo (2499)

Full suite with the fix:

Test Files  69 passed (69)
     Tests  2481 passed | 2 expected fail | 16 todo (2499)

The only movement is the two new tests, failed → passed; nothing else changed on this base.

Tests added

packages/runtime-vapor/__tests__/componentProps.spec.ts (parity harness, so each case is asserted against VDOM in the same run):

  • sync watcher does not re-read a source disposed by a v-if branch — the issue's shape; asserts seen.vdom === [], seen.vapor === seen.vdom, vapor.text === 'x is gone' === vdom.text.
  • sync watcher of a v-if child still sees committed prop updates — the fix must not silence legitimate updates: asserts the watcher sees ['b'] in both modes after the flush, and that neither mode observes the value before the flush that commits it.

Timing: an existing divergence closed, not introduced

A throwaway probe (not committed) recorded a flush: 'sync' watcher on a v-if child while x.y changed:

before flush after flush
pristine vapor ['b'] ['b']
pristine vdom [] ['b']
vapor with this fix [] ['b']
vdom [] ['b']

So before this change Vapor delivered a synchronous prop read that VDOM never delivers — a divergence on its own. The fix makes Vapor match VDOM by publishing props at commit time, which is the semantics the KeepAlive path (#15228) already established. The second added test pins it, so this is a deliberate, asserted behavior change and not an accident: a flush: 'sync' consumer of a prop inside a dynamic branch now observes updates during the flush rather than inline with the mutation. Pre/post watchers and render reads are unaffected (they always ran in the flush).

Answers to the issue's claims

  1. "In Vapor the prop is the getter () => x.y" — confirmed; the throwing frame is the compiled SFC getter (_utils.ts:221), reached from the prop-source cache at componentProps.ts:211.
  2. "resolveFunctionSource caches it in a computed owned by the parent" — half right, and the distinction does not change the outcome. resolveFunctionSource evaluates the source in the parent's context but collects the cache computed in the active consumer scope (getCurrentScope()), i.e. the child's watcher/render scope, with onScopeDispose(() => (source._cache = undefined)). It is a computed with the child as its active consumer, which is why the child's sync watcher's dirty check is what refreshes it (stack frames 4–6).
  3. "assigning x notifies the child's sync watcher, whose dirty check refreshes that computed (running x.y) before the v-if branch has been disposed" — confirmed exactly; the guard's job is queued in the same flush and runs after the inline sync notification.
  4. "the dirty check runs outside the watcher's error handling, so the error escapes from the setter" — confirmed. The top of the stack is the user's own assignment (trigger → endBatch), there is no error-handling frame, app.config.errorHandler never sees it, and the test fails on the data.value.x = undefined line.
  5. The report also asks for VDOM parity at 3.6 and 3.5 — the added tests are parity tests, and the pre-fix run above is the VDOM half of the proof: VDOM passes both cases, Vapor threw.

Known limitations / unverified

  • The branch marker covers every DynamicFragment render (so v-if/v-else, dynamic components and slot-outlet fragments), not only createIf. Children inside v-for/ForBlock are deliberately not marked: I probed the analogous case on pristine and no stale read happens there, because a v-for item's prop source depends on the item, not on the collection that was invalidated (probe result on both revisions: { vdom: [], vapor: [] }, no throw), so there is nothing to fix.
  • Only rawProps is isolated for dynamic branches; rawSlots.$ keeps its closure semantics for that path. A slot whose closure invalidates a prop source after disposal was not reproduced and is not covered here.
  • The unguarded variant (no v-if) is out of scope: nothing is disposed there, the read is legitimate and the throw is inherent to the user's code — plain reactivity throws identically (table above), which is the shape reported in Reactivity (3.6): a throw while notifying one subscriber leaves the rest of the batch unnotified until the next unrelated write #15674; this PR does not touch packages/reactivity.
  • Not verified: any behavior difference for non-v-if dynamic branches is only covered by the existing suite (green), not by a dedicated new test.
  • Validation run in this worktree: ./node_modules/.bin/vp lint clean, ./node_modules/.bin/vp fmt --check clean, npx --yes pnpm@12.4.2 tsc --incremental --noEmit with no diagnostics (the pre-commit hook ran lint and tsc again on the staged diff and passed). packages/reactivity was not modified, so its suite was not run.

…c branches

A child created inside a dynamic branch is disposed by that branch's queued update job, which runs after synchronous consumers of its props have been notified. The dirty check of a `flush: 'sync'` watcher therefore refreshed the prop source computed while the branch was still alive, re-running `() => x.y` after the guard expression that produced it had already become invalid.

Props of a child created inside a dynamic branch now commit through a detached scope that the branch teardown stops, the same mechanism KeepAlive uses for cached components: reads stay on the last committed value, the branch disposal removes the commit effect's dependencies before the invalidating mutation can notify it, and no read happens after disposal.

fixes vuejs#15673

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7ee9c5ce-96b9-4d2b-a163-a6e5a4a4eb38
@coderabbitai

coderabbitai Bot commented Sep 28, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 158d1e2c-a0d8-4058-9591-317e8676dd96

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@snmsoodan

Copy link
Copy Markdown
Author

Overlap disclosure: @edison1105's #15696 and #15708 touch the same runtime-vapor prop-commit area for #15673.

This PR's scope is only the read side — a prop read inside a v-if branch must keep returning the committed value once the branch turns false — and it does not change how prop sources are committed or delivered, so it may well be redundant next to #15696/#15708. Both new specs here fail on main and pass on this branch.

If either of yours covers that case, close this one and I will not spend your review time on it.

@edison1105 edison1105 added the scope: vapor related to vapor mode label Sep 30, 2026
@edison1105

Copy link
Copy Markdown
Member

Thanks for the PR, closing in favor of #15708

@edison1105 edison1105 closed this Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: vapor related to vapor mode

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants