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
Setmodels 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
Setmodel, 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
setSelectedhassemantics and should close thelooseEqualgap.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
SetandArraymust 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.