From 3ee62bae0e5712cb4302cd0402de5acb685dd9df Mon Sep 17 00:00:00 2001 From: daiwei Date: Sat, 12 Oct 2024 11:42:30 +0800 Subject: [PATCH 1/4] fix(vModel): avoid updates caused by side effects of the click --- packages/runtime-dom/src/directives/vModel.ts | 17 +++- packages/vue/__tests__/e2e/vModel.spec.ts | 93 ++++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/packages/runtime-dom/src/directives/vModel.ts b/packages/runtime-dom/src/directives/vModel.ts index 5057e16d472..7d93b705d7f 100644 --- a/packages/runtime-dom/src/directives/vModel.ts +++ b/packages/runtime-dom/src/directives/vModel.ts @@ -122,6 +122,9 @@ export const vModelCheckbox: ModelDirective = { deep: true, created(el, _, vnode) { el[assignKey] = getModelAssigner(vnode) + addEventListener(el, 'mousedown', () => { + ;(el as any)._willChange = true + }) addEventListener(el, 'change', () => { const modelValue = (el as any)._modelValue const elementValue = getValue(el) @@ -153,6 +156,10 @@ export const vModelCheckbox: ModelDirective = { // set initial checked on mount to wait for true-value/false-value mounted: setChecked, beforeUpdate(el, binding, vnode) { + if ((el as any)._willChange) { + ;(el as any)._willChange = false + return + } el[assignKey] = getModelAssigner(vnode) setChecked(el, binding, vnode) }, @@ -160,7 +167,7 @@ export const vModelCheckbox: ModelDirective = { function setChecked( el: HTMLInputElement, - { value, oldValue }: DirectiveBinding, + { value }: DirectiveBinding, vnode: VNode, ) { // store the v-model value on the element so it can be accessed by the @@ -173,7 +180,6 @@ function setChecked( } else if (isSet(value)) { checked = value.has(vnode.props!.value) } else { - if (value === oldValue) return checked = looseEqual(value, getCheckboxValue(el, true)) } @@ -204,6 +210,9 @@ export const vModelSelect: ModelDirective = { deep: true, created(el, { value, modifiers: { number } }, vnode) { const isSetModel = isSet(value) + addEventListener(el, 'mousedown', () => { + ;(el as any)._willChange = true + }) addEventListener(el, 'change', () => { const selectedVal = Array.prototype.filter .call(el.options, (o: HTMLOptionElement) => o.selected) @@ -234,6 +243,10 @@ export const vModelSelect: ModelDirective = { }, updated(el, { value }) { if (!el._assigning) { + if ((el as any)._willChange) { + ;(el as any)._willChange = false + return + } setSelected(el, value) } }, diff --git a/packages/vue/__tests__/e2e/vModel.spec.ts b/packages/vue/__tests__/e2e/vModel.spec.ts index e1a06bda532..598379d7143 100644 --- a/packages/vue/__tests__/e2e/vModel.spec.ts +++ b/packages/vue/__tests__/e2e/vModel.spec.ts @@ -1,7 +1,7 @@ import path from 'node:path' import { setupPuppeteer } from './e2eUtils' -const { page, click, isChecked } = setupPuppeteer() +const { page, click, isChecked, html, value } = setupPuppeteer() import { nextTick } from 'vue' beforeEach(async () => { @@ -55,3 +55,94 @@ test('checkbox click with v-model', async () => { expect(await isChecked('#first')).toBe(false) expect(await isChecked('#second')).toBe(true) }) + +// #8638 +test('checkbox click with v-model array value', async () => { + await page().evaluate(() => { + const { createApp, ref } = (window as any).Vue + createApp({ + template: ` + {{cls}} + + `, + setup() { + const inputModel = ref([]) + const count = ref(0) + const change = () => { + count.value++ + } + return { + inputModel, + change, + cls: count, + } + }, + }).mount('#app') + }) + + expect(await isChecked('#checkEl')).toBe(false) + expect(await html('#app')).toMatchInlineSnapshot( + `"0 "`, + ) + + await click('#checkEl') + await nextTick() + expect(await isChecked('#checkEl')).toBe(true) + expect(await html('#app')).toMatchInlineSnapshot( + `"1 "`, + ) + + await click('#checkEl') + await nextTick() + expect(await isChecked('#checkEl')).toBe(false) + expect(await html('#app')).toMatchInlineSnapshot( + `"2 "`, + ) +}) + +// #8579 +test('select click with v-model', async () => { + await page().evaluate(() => { + const { createApp } = (window as any).Vue + createApp({ + template: ` +

+ Changed: {{changed}} +

+

+ Chosen: {{chosen}} +

+
+ +
+ `, + data() { + return { + choices: ['A', 'B'], + chosen: 'A', + changed: false, + } + }, + }).mount('#app') + }) + + expect(await value('#selectEl')).toBe('A') + expect(await html('#app')).toMatchInlineSnapshot( + `"

Changed: false

Chosen: A

"`, + ) + + await page().select('#selectEl', 'B') + await nextTick() + expect(await value('#selectEl')).toBe('B') + expect(await html('#app')).toMatchInlineSnapshot( + `"

Changed: true

Chosen: B

"`, + ) +}) From 0472d008f48aba4874176781407858e769439f3c Mon Sep 17 00:00:00 2001 From: daiwei Date: Sat, 12 Oct 2024 12:13:54 +0800 Subject: [PATCH 2/4] chore: update --- packages/runtime-dom/src/directives/vModel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime-dom/src/directives/vModel.ts b/packages/runtime-dom/src/directives/vModel.ts index 7d93b705d7f..3c5e9fa933f 100644 --- a/packages/runtime-dom/src/directives/vModel.ts +++ b/packages/runtime-dom/src/directives/vModel.ts @@ -122,7 +122,7 @@ export const vModelCheckbox: ModelDirective = { deep: true, created(el, _, vnode) { el[assignKey] = getModelAssigner(vnode) - addEventListener(el, 'mousedown', () => { + addEventListener(el, 'click', () => { ;(el as any)._willChange = true }) addEventListener(el, 'change', () => { @@ -210,7 +210,7 @@ export const vModelSelect: ModelDirective = { deep: true, created(el, { value, modifiers: { number } }, vnode) { const isSetModel = isSet(value) - addEventListener(el, 'mousedown', () => { + addEventListener(el, 'click', () => { ;(el as any)._willChange = true }) addEventListener(el, 'change', () => { From 89d5cf312c5e05d026719f1ddf0b6653a72ce571 Mon Sep 17 00:00:00 2001 From: daiwei Date: Sat, 12 Oct 2024 14:56:36 +0800 Subject: [PATCH 3/4] chore: update --- packages/runtime-dom/src/directives/vModel.ts | 46 ++++++++++--------- packages/shared/src/looseEqual.ts | 20 +++++++- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/packages/runtime-dom/src/directives/vModel.ts b/packages/runtime-dom/src/directives/vModel.ts index 3c5e9fa933f..a3a68b78f7f 100644 --- a/packages/runtime-dom/src/directives/vModel.ts +++ b/packages/runtime-dom/src/directives/vModel.ts @@ -122,9 +122,6 @@ export const vModelCheckbox: ModelDirective = { deep: true, created(el, _, vnode) { el[assignKey] = getModelAssigner(vnode) - addEventListener(el, 'click', () => { - ;(el as any)._willChange = true - }) addEventListener(el, 'change', () => { const modelValue = (el as any)._modelValue const elementValue = getValue(el) @@ -156,10 +153,6 @@ export const vModelCheckbox: ModelDirective = { // set initial checked on mount to wait for true-value/false-value mounted: setChecked, beforeUpdate(el, binding, vnode) { - if ((el as any)._willChange) { - ;(el as any)._willChange = false - return - } el[assignKey] = getModelAssigner(vnode) setChecked(el, binding, vnode) }, @@ -170,9 +163,17 @@ function setChecked( { value }: DirectiveBinding, vnode: VNode, ) { + if (looseEqual(value, (el as any)._cachedValue)) { + return + } // store the v-model value on the element so it can be accessed by the // change listener. ;(el as any)._modelValue = value + ;(el as any)._cachedValue = isArray(value) + ? [...value] + : isSet(value) + ? new Set(value) + : value let checked: boolean if (isArray(value)) { @@ -210,22 +211,20 @@ export const vModelSelect: ModelDirective = { deep: true, created(el, { value, modifiers: { number } }, vnode) { const isSetModel = isSet(value) - addEventListener(el, 'click', () => { - ;(el as any)._willChange = true - }) addEventListener(el, 'change', () => { const selectedVal = Array.prototype.filter .call(el.options, (o: HTMLOptionElement) => o.selected) .map((o: HTMLOptionElement) => number ? looseToNumber(getValue(o)) : getValue(o), ) - el[assignKey]( - el.multiple - ? isSetModel - ? new Set(selectedVal) - : selectedVal - : selectedVal[0], - ) + const modelValue = el.multiple + ? isSetModel + ? new Set(selectedVal) + : selectedVal + : selectedVal[0] + el[assignKey](modelValue) + ;(el as any)._cachedValue = isArray(value) ? [...value] : value + el._assigning = true nextTick(() => { el._assigning = false @@ -243,16 +242,21 @@ export const vModelSelect: ModelDirective = { }, updated(el, { value }) { if (!el._assigning) { - if ((el as any)._willChange) { - ;(el as any)._willChange = false - return - } setSelected(el, value) } }, } function setSelected(el: HTMLSelectElement, value: any) { + if (looseEqual(value, (el as any)._cachedValue)) { + return + } + ;(el as any)._cachedValue = isArray(value) + ? [...value] + : isSet(value) + ? new Set(value) + : value + const isMultiple = el.multiple const isArrayValue = isArray(value) if (isMultiple && !isArrayValue && !isSet(value)) { diff --git a/packages/shared/src/looseEqual.ts b/packages/shared/src/looseEqual.ts index 9e71767219c..54f222dc044 100644 --- a/packages/shared/src/looseEqual.ts +++ b/packages/shared/src/looseEqual.ts @@ -1,4 +1,4 @@ -import { isArray, isDate, isObject, isSymbol } from './general' +import { isArray, isDate, isObject, isSet, isSymbol } from './general' function looseCompareArrays(a: any[], b: any[]) { if (a.length !== b.length) return false @@ -9,6 +9,17 @@ function looseCompareArrays(a: any[], b: any[]) { return equal } +function looseCompareSets(a: Set, b: Set) { + if (a.size !== b.size) return false + let equal = true + a.forEach((v: any) => { + if (!b.has(v)) { + equal = false + } + }) + return equal +} + export function looseEqual(a: any, b: any): boolean { if (a === b) return true let aValidType = isDate(a) @@ -26,6 +37,13 @@ export function looseEqual(a: any, b: any): boolean { if (aValidType || bValidType) { return aValidType && bValidType ? looseCompareArrays(a, b) : false } + + aValidType = isSet(a) + bValidType = isSet(b) + if (aValidType || bValidType) { + return aValidType && bValidType ? looseCompareSets(a, b) : false + } + aValidType = isObject(a) bValidType = isObject(b) if (aValidType || bValidType) { From 5c8bd9a9718efb3da9520d16262ca60624a5a99f Mon Sep 17 00:00:00 2001 From: daiwei Date: Sat, 12 Oct 2024 15:40:09 +0800 Subject: [PATCH 4/4] chore: fix test utils test failing --- packages/runtime-dom/src/directives/vModel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime-dom/src/directives/vModel.ts b/packages/runtime-dom/src/directives/vModel.ts index a3a68b78f7f..64d589ac56c 100644 --- a/packages/runtime-dom/src/directives/vModel.ts +++ b/packages/runtime-dom/src/directives/vModel.ts @@ -248,7 +248,7 @@ export const vModelSelect: ModelDirective = { } function setSelected(el: HTMLSelectElement, value: any) { - if (looseEqual(value, (el as any)._cachedValue)) { + if ((el as any)._assigning && looseEqual(value, (el as any)._cachedValue)) { return } ;(el as any)._cachedValue = isArray(value)