Reactivity (3.6): a throw while notifying one subscriber leaves the rest of the batch unnotified until the next unrelated write
Vue version
3.6.0-rc.9 (also vue built from minor at 08cad76, via pkg.pr.new). 3.5.43 is not affected.
Link to minimal reproduction
Steps to reproduce
- Open the reproduction (Vue 3.6.0-rc.9, no Vapor).
- Click set x to undefined. The
computedoverx.value.ythrows while the sync watcher checks it, and the error is shown. - Click change another ref.
<script setup>
import { ref, computed, watch } from 'vue'
const x = ref({ y: 'a' })
const y = computed(() => x.value.y)
watch(y, () => {}, { flush: 'sync' })
const other = ref(0)
</script>
<template>
<button @click="x = undefined">set x to undefined</button>
<button @click="other++">change another ref</button>
<p>x is {{ x === undefined ? 'undefined' : 'set' }}</p>
<p>other is {{ other }}</p>
</template>What is expected?
After step 2 the page says "x is undefined", as it does on 3.5.43: the error in one subscriber is reported, and the other subscribers of x, here the component's render, still hear the change.
What is actually happening?
After step 2 the page keeps saying "x is set". It changes to "x is undefined" only at step 3, when other is written, which has nothing to do with x.
flush() in packages/reactivity/src/system.ts has no try/finally. When effect.notify() throws, the loop stops with notifyIndex and notifyBufferLength left as they were, so the rest of notifyBuffer is neither notified nor cleared, and the next endBatch() that finds notifyBufferLength non-zero delivers it:
export function flush(): void {
while (notifyIndex < notifyBufferLength) {
const effect = notifyBuffer[notifyIndex]!
notifyBuffer[notifyIndex++] = undefined
effect.notify()
}
notifyIndex = 0
notifyBufferLength = 0
}System Info
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.9Any additional comments?
- The same happens with plain reactivity and no component, whatever throws first: a
computedread by a syncwatchor byeffect(), aneffect()whose function throws, or a syncwatchwhose getter or callback throws (outside an app, where those rethrow). Every subscriber of the same ref that sits after it in the batch, such as a later syncwatchor a latereffect(), runs only at the next unrelated write on 3.6.0-rc.9 andminor. On 3.5.43 they all run at once, and the first error is rethrown afterwards. - Inside an app, an error from a watcher's getter or callback goes to
app.config.errorHandlerand stalls nothing. Acomputedthat throws while a sync watcher checks whether it is dirty does not go there: that check runs innotify(), outside the watcher'scall(...), so the error leavesflush()and comes out of the assignment. The escape itself also happens on 3.5.43; the stall does not. - alien-signals had the same bug and fixed it in stackblitz/alien-signals#97 (bfd4be520, "ensure effect queue is properly cleaned up after flush error") by putting its
flush()in atry/finallythat re-arms the effects it did not reach and resets the counters. #14813 syncedsystem.tsto alien-signals v3.2.0, which includes that fix, but Vue's ownflush()was left as it was. Porting that fix would stop the late delivery at an unrelated write; notifying the rest of the batch and then rethrowing, as 3.5 does, is what would also keep the page above correct. - Found through #15673, where a Vapor prop getter throws in that dirty check.
Source: vuejs/core