Baike.dev
All toolsOnline toolsAI codingTrendingOpen sourceNewsSubmit
Log in
Back to tool/Back to issues
#15673·Vue.js

[Vapor] a child's `flush: 'sync'` watcher re-reads a prop after the `v-if` guarding it turns false

Author: daopkCreated Sep 27, 2026Updated Sep 27, 2026

Vue version

3.6.0-rc.9 (also vue built from minor at 08cad76, via pkg.pr.new)

Link to minimal reproduction

https://play.vuejs.org/#eNqFUtGK2zAQ/JWpKNiGRC4UCjW2uaM/UOiroecq60TUkYQkOzHC/15kJekVeu2bVjOzmt1RYM/G8HkiVrHaCSuNhyM/Gcy90bbtlDwbbT0CLA1YMVh9RjZPlD2gLyc5Hm4AL7eKJ0KnhFbO44omyvOApULWZ1iLO+RPVl/UDc+yIoqGSQkvtYIYqbd5gdApwNslHYArn/txIjSY1IEGqegQgRWi9+KEnIo7M7V/0F/+oowculR4H4ifybn+SOvL1q5Ta6fqMq2ljc5qT2cz9p7aSKh/TN5rhScxSvGz6djmt2Otoziz178fqcvETbq0sXkvh6ZjV7xrXg/CUC3xmi8dQ5kEBvOeRkftFdLhqBXVpblBbQj3La5ruq7LVz7Zjj0y+X/Kl22Df+Z8z8pYbRwaJKdfY5Ui/eatVMct1U5tHfK8QNMmBV92SGVYdwgYxsmdKmRuUSJ9hX/t2LQiuq8QApY3J/w+k3VSK1axj/wT/7C3gn9m6y8MoPlQ

Steps to reproduce

  1. Open the reproduction (Vue 3.6.0-rc.9, both files <script setup vapor>).
  2. Click set x to undefined.

For comparison, the same two files without vapor: https://play.vuejs.org/#eNp9UtGK2zAQ/JWpKNiGRC4UCjW2uaM/UOiroOcq60TUkYQkOzHC/15sJekVevem1cysZncU2bO1fBqJVaz20ikb4CmMthVana1xARGOeizonTkjm0bKHtC3kxoON4CXW8UTQWhptA+4olnlecRcIesyLMUdCidnLvqGZ1mxivpRy6CMhhyoc3mBKDQQ3JwOwJVP3TASGoz6QL3SdFiBBbIL8oScijsztX/QX/6jXDl0qfAxEj+T992RlpetndCL0HWZFtKuzupAZzt0gdqVUP8aQzAaT3JQ8ncj2OZXsNbTOnMwfx+py8RNurSxaa/6RrArPjSvB2Go5vWaz4KhTAKLaU+Dp/YK5XE0murS3qA2xvsWlyVd1+Urn2zHHpm8l+9l292/Cd9Tss5YjwbJ4/e1SmH+CE7p45an0FuHPC/QtEnB5x1SGZcdIvph9KcKmZ+1TJ/gve3aVq6+K8SI+c3Zfk7kvDKaVewz/8I/7Z3kX9nyB4uV9MA=

App.vue:

xml
<script setup vapor>
import { ref } from 'vue'
import Child from './Child.vue'

const x = ref({ y: 'a' })
const thrown = ref('')

function clear() {
  try {
    x.value = undefined
  } catch (e) {
    thrown.value = `x.value = undefined threw: ${e.message}`
  }
}
</script>

<template>
  <button @click="clear">set x to undefined</button>
  <Child v-if="x !== undefined" :y="x.y" />
  <p v-else>x is gone</p>
  <p>{{ thrown }}</p>
</template>

Child.vue:

xml
<script setup vapor>
import { watch } from 'vue'

const props = defineProps({ y: String })

watch(() => props.y, () => {}, { flush: 'sync' })
</script>

<template>
  <p>child: {{ y }}</p>
</template>

What is expected?

Only "x is gone". The child sits behind v-if="x !== undefined", so x.y is never evaluated once x is gone, and assigning the ref does not throw. That is what the VDOM build of the same two files does (3.6.0-rc.9 and 3.5.43).

What is actually happening?

The branch does switch to "x is gone", but the assignment x.value = undefined throws TypeError: Cannot read properties of undefined (reading 'y') (the reproduction catches it and prints it). It is thrown to whatever assigned the ref, and never reaches app.config.errorHandler.

In Vapor the prop is the getter () => x.y, and resolveFunctionSource in runtime-vapor caches it in a computed owned by the parent. Assigning x notifies the child's sync watcher, and its dirty check refreshes that computed, running x.y before the v-if has disposed the branch. The dirty check happens outside the watcher's call(...) error handling, so the error comes out of the setter.

System Info

bash
System:
  OS: macOS 27.0
  CPU: (12) arm64 Apple M2 Pro
  Shell: 5.9 - /bin/zsh
Binaries:
  Node: 24.21.0
  npm: 11.19.0
  pnpm: 12.6.0
Browsers:
  Chrome: 154.0.8037.57
  Safari: 27.0
npmPackages:
  vue: 3.6.0-rc.9

Any additional comments?

  • It throws the same way with watchSyncEffect, with a computed over the prop that a sync watcher reads, when the prop is handed on to a grandchild that watches it with flush: 'sync', and when the v-if is on an element wrapping the child.
  • It does not throw when the child only renders the prop, reads it in a computed for its render, or watches it with the default flush: those run after the v-if's own effect, which disposes the branch first. :y="x?.y" also avoids it.
  • The throw leaves the rest of that batch unnotified until the next, unrelated reactive write: a {{ x === undefined ? 'unset' : 'set' }} placed after the v-if keeps showing set, and watchers on x registered after mount run only when some other ref changes. That part is not Vapor's own: with plain reactivity, a computed that throws while a sync watcher checks it does the same on 3.6.0-rc.9 and minor, where 3.5.43 still notifies the rest of the batch before rethrowing. It may deserve an issue of its own.
  • #15228 (fixed by #15251) is the same mechanism for a child kept by <KeepAlive>, whose prop sources are now committed through an effect owned by the branch scope. A plain v-if has no such boundary, and a synchronous reader does not wait for the branch to be disposed.
  • Found through a UI library that watches a popover's isOpen prop with flush: 'sync' for its enter/exit transition: a popover under a v-if, whose is-open read through the guarded value, threw every time that value went away.

Source: vuejs/core

View original on GitHubView discussion on GitHub