unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
vuejs/core
mid7/10
error-handlingtesting-strategyrefactoring

fix(runtime-vapor): avoid rebinding fired once handlers in dynamic props

Project: vuejs/core | Language: TypeScript | Concepts: error-handling, testing-strategy, refactoring | Difficulty: mid
Link: https://github.com/vuejs/core/pull/15383

The problem

When using dynamic props to bind event handlers with the .once modifier (e.g., onClickOnce), the handler could be rebound and fired multiple times if the dynamic props were updated after the event fired. This broke the expected "fire once" behavior for dynamically bound .once handlers.

Why it's non-trivial

A naive fix might just prevent rebinding if the handler is already present, but this wouldn't account for the fact that the handler should only be prevented from rebinding after it has fired. Additionally, dynamic props can change frequently, so the solution must track whether the .once handler has already been triggered, even as the props object changes.

The approach

1. Add a regression test to ensure .once handlers on dynamic props only fire once:

+  test('does not rebind a once handler in dynamic props after firing', async () => {
+    const hits = ref(0)
+    const onOnce = () => hits.value++
+    const Comp = defineVaporComponent({
+      setup() {
+        return { hits, onOnce }
+      },
+      render: compileToVaporRender(
+        `<button v-bind="{ onClickOnce: onOnce }" /><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 sets up a component with a dynamic .once click handler, clicks the button twice, and asserts that the handler only runs once.

2. Route dynamic event prop setting through a new setEvent helper:

-    // Events and objects can have stable identity with mutable internals, so
-    // only skip unchanged primitive values.
+    if (isOn(key)) {
+      setEvent(el, key, value, nextProps, prevProps)
+      continue
+    }

Now, whenever a dynamic prop key is an event (like onClickOnce), it goes through setEvent, which will handle the .once logic.

3. Implement setEvent to track fired .once handlers and prevent rebinding:

+function setEvent(
+  el: TargetElement,
+  key: string,
+  value: any,
+  propCache?: Record<string, any>,
+  prevProps?: Record<string, any>,
+): void {
+  if (shouldSkipFallthroughKey(el, key)) {
+    return
+  }
+  const [event, options] = parseEventName(key)
+  const eventOptions = options as AddEventListenerOptions | undefined
+  if (eventOptions && eventOptions.once && propCache) {
+    propCache[key] = !!prevProps && prevProps[key] === true
+    // #15378 avoid binding the same event twice when the once binding is already fired
+    if (!propCache[key] && value) {
+      onBinding(
+        el,
+        event,
+        // mark the once binding as fired
+        () => (propCache[key] = true),
+        eventOptions,
+      )
+      onBinding(el, event, value, eventOptions)
+    }
+  } else {
+    onBinding(el, event, value, eventOptions)
+  }
+}

This function checks if the event is a .once event. If so, it uses a cache to track if the handler has already fired. If it hasn't, it binds both a marker function (to set the fired flag) and the actual handler. If the handler has already fired, it does not rebind.

From the review thread

No notable review discussion on this PR.

Takeaways

  • Stateful event binding requires explicit tracking: When dealing with dynamic event bindings, especially for "fire once" semantics, you need to track handler state across prop updates to avoid duplicate registration.
  • Regression tests should cover edge cases: Adding a test that simulates the problematic sequence (multiple clicks, dynamic prop updates) is essential to prevent future regressions.
  • Centralize event logic for consistency: Routing all event prop handling through a single helper (setEvent) makes it easier to enforce correct behavior and maintain the code.