fix(runtime-vapor): avoid re-registering fired once listeners
Project: vuejs/core | Language: TypeScript | Concepts: refactoring, testing-strategy | Difficulty: mid
Link: https://github.com/vuejs/core/pull/15386
The problem
One-time event handlers (using .once or similar) registered via dynamic v-on bindings could be re-registered and fired again after the component updated. This broke the guarantee that such handlers should only execute once, even if the component re-renders.
Why it's non-trivial
A naive fix might only prevent duplicate registration at initial render, but not account for component updates and reactivity. Because dynamic event bindings can change and effects can re-run, it's easy to accidentally re-register a .once handler after it has already fired, causing it to run more than once.
The approach
1. Regression Test for Once Handler in Dynamic v-on
A new test verifies that a v-on object with a .once handler only increments a counter once, even if the button is clicked multiple times and the component updates:
+ test('does not rebind a once handler in a v-on object after firing', async () => {
+ const hits = ref(0)
+ const onOnce = () => hits.value++
+ const Comp = defineVaporComponent({
+ setup() {
+ return { hits, onOnce }
+ },
+ render: compileToVaporRender(
+ `<button v-on="{ clickOnce: onOnce }">v-on object once listener</button><p>{{ hits }}</p>`,
+ {
+ bindingMetadata: {
+ hits: BindingTypes.SETUP_REF,
+ onOnce: BindingTypes.SETUP_CONST,
+ },
+ },
+ ),
+ })
+ const { host } = define(Comp).render()
+ const button = host.querySelector('button')!
+
+ button.click()
+ await nextTick()
+ button.click()
+
+ expect(hits.value).toBe(1)
+ })
This test ensures the one-time handler is not rebound after firing.
2. Prevent Re-binding of Fired Once Handlers
In the event binding logic, a flag is set on the element after a .once handler fires. If the effect re-runs and tries to bind the same handler, it is skipped:
+ if (options && options.once) {
+ // #15378 avoid re-registering invoked once listeners on effect re-runs
+ const firedKey = `$evtonce_${options.capture ? 1 : 0}_${event}`
+ if ((el as any)[firedKey]) return
+ const invoker = handler
+ handler = (...args: any[]) => {
+ ;(el as any)[firedKey] = true
+ return invoker(...args)
+ }
+ }
This code sets a property on the DOM element after the event fires, and checks it before binding again.
3. Always Parse Event Options for Dynamic Events
Dynamic events now always parse the event name for options (like .once) and pass them to the event binding logic:
- for (const name in events) {
- onBinding(el, name, events[name])
+ for (const name in events) {
+ const [event, options] = parseEventName(`on:${name}`)
+ onBinding(el, event, events[name], options)
}
This ensures that event modifiers are respected for all dynamic event bindings.
4. Remove Redundant setEvent Helper
The previous helper for setting events was removed in favor of a more direct and consistent approach, always routing through onBinding with parsed options:
-function setEvent(
- el: TargetElement,
- key: string,
- value: any,
- propCache?: Record<string, any>,
- prevProps?: Record<string, any>,
-): void {
- ...
-}
This simplifies the event binding flow and reduces sources of bugs.
From the review thread
No notable review discussion on this PR.
Takeaways
-
Event handler state should be tracked on the DOM element when necessary.
For features like.once, storing a flag on the element itself ensures correct behavior across re-renders. -
Always parse and respect event modifiers/options for dynamic event bindings.
Dynamic event names (like those inv-onobjects) must be parsed for modifiers (e.g.,.once,.capture) to avoid subtle bugs. -
Regression tests are critical for catching subtle reactivity and lifecycle bugs.
Adding a focused test that fails on the previous bug ensures this class of issue doesn't return.