unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
vuejs/core
mid8/10
error-handlingrefactoringtesting-strategydata-structures

fix(v-model): re-sync select when model is overridden in change handler

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

The problem

When a <select v-model> element has a change handler (or watcher) that immediately overrides the model value (for example, resetting it to a different selection or clearing it), the DOM's selected option(s) could become out of sync with the model. This was especially problematic for multiple <select>s bound to a Set, because Vue's equality check (looseEqual) didn't handle Set values correctly. As a result, the user's selection could appear to "stick" in the UI even though the model had changed.

Why it's non-trivial

A naive fix would just always update the DOM selection after a model change. But this can cause unnecessary DOM writes and flickering, and it doesn't address the subtlety that Set and Array equality can't be checked with a shallow or loose comparison. The code must:

  • Correctly snapshot the user's intended value before any user code (change handler) runs, since user code can mutate arrays/sets in-place.
  • Correctly compare the "pending" user assignment with the actual model after the handler runs, using semantics that match how options are matched in the DOM.
  • Avoid unnecessary DOM updates if the model and the user's intended value are already in sync.

The approach

1. Track the pending assignment

Instead of a boolean _assigning, the code now records the pending value and selection mode (single/multiple) before invoking the model assigner. This snapshot is used later to decide whether the DOM needs to be re-synced.

-    _assigning?: boolean
+    _pendingValue?: [multiple: boolean, value: any]

This replaces the old _assigning flag with a tuple that tracks both the selection mode and the value being assigned.

2. Snapshot the assigned value before user code runs

When the user triggers a change, the code computes the value that should be assigned, then stores a snapshot of it on the element before calling the model assigner. For array models, it copies the array to avoid mutation issues.

+      const multiple = el.multiple
+      const assignedValue = multiple
+        ? isSet((el as any)._modelValue)
+          ? new Set(selectedVal)
+          : selectedVal
+        : selectedVal[0]
+      // Array models receive selectedVal directly, so snapshot it before user
+      // code can mutate it. Set models already receive a fresh Set.
+      const pending = (el._pendingValue = [
+        multiple,
+        multiple
+          ? isArray(assignedValue)
+            ? selectedVal.slice()
+            : selectedVal
+          : assignedValue,
+      ])
+      try {
+        el[assignKey](assignedValue)
+      } finally {
+        nextTick(() => {
+          if (el._pendingValue === pending) {
+            el._pendingValue = undefined
+          }
+        })
+      }
  • This ensures that even if the handler mutates the array/set, the original user intent is preserved for comparison in the next update cycle.

3. Only skip DOM sync if the model matches the assigned value

In the updated hook, the code checks if the current model value is equivalent to the pending assigned value (using logic that handles Set, Array, and primitives correctly). If not, it re-syncs the DOM selection.

-    if (!el._assigning) {
-      setSelected(el, value)
-    }
+    const pending = el._pendingValue
+    el._pendingValue = undefined
+    if (
+      !pending ||
+      pending[0] !== el.multiple ||
+      !isSameSelectValue(value, pending[1], pending[0])
+    ) {
+      setSelected(el, value)
+    }
  • This prevents unnecessary DOM writes, but ensures that if the model was changed by the handler, the DOM is corrected.

4. Implement robust value comparison for sets, arrays, and primitives

A helper function compares the model value and the assigned value, matching the semantics used by the DOM option matching logic:

+function isSameSelectValue(
+  value: any,
+  assignedValue: any,
+  multiple: boolean,
+): boolean {
+  if (!multiple) return looseEqual(value, assignedValue)
+  if (isArray(value)) return looseEqual(value, assignedValue)
+  if (isSet(value)) {
+    if (value.size !== assignedValue.length) return false
+    for (const item of assignedValue) {
+      if (!value.has(item)) return false
+    }
+    return true
+  }
+  return false
+}
  • This ensures that Set models are compared by contents, not by reference, and that array models are compared by value.

5. Add regression tests

A suite of tests was added to ensure all edge cases are covered, including:

  • Multiple select with Set model, handler resets to a different value.
  • Single select with handler that clears the model.
  • Handling of duplicate option values.
  • Changing from multiple to single select during a pending update.
  • Ensuring no redundant DOM writes for object or primitive models.
  • Handling assignment errors gracefully.

Example test for the Set override case:

+  it('should sync a multiple select when the Set model is overridden', async () => {
+    const { input, options, data } = mountSelectModel(
+      new Set<string>(),
+      () => new Set(['foo']),
+      true,
+    )
+    const foo = options[0]
+    const bar = options[1]
+
+    bar.selected = true
+    triggerEvent('change', input)
+    await nextTick()
+
+    expect(Array.from(data.value)).toEqual(['foo'])
+    expect(foo.selected).toBe(true)
+    expect(bar.selected).toBe(false)
+  })

From the review thread

zzlw: Thanks for continuing this. The Set comparison matches setSelected has semantics and should close the looseEqual gap.

One gap worth fixing: the original #10505 repro is a single select whose change handler resets the value to null, and #14960 had a regression test for exactly that case. Could you carry that test over here too? Otherwise the original issue scenario is not directly locked by a test.

Takeaways

  • When synchronizing UI state with a model, always snapshot user intent before running user code that might mutate the data.
  • Equality checks for data structures like Set and Array must be implemented explicitly; shallow or loose equality is not enough for non-primitives.
  • Regression tests should cover all edge cases, including those involving mutation, mode changes, and error handling, to prevent subtle bugs from resurfacing.