Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(vModel): avoid updates caused by side effects of the click #12155

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions packages/runtime-dom/src/directives/vModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,20 +160,27 @@ export const vModelCheckbox: ModelDirective<HTMLInputElement> = {

function setChecked(
el: HTMLInputElement,
{ value, oldValue }: DirectiveBinding,
{ 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)) {
checked = looseIndexOf(value, vnode.props!.value) > -1
} else if (isSet(value)) {
checked = value.has(vnode.props!.value)
} else {
if (value === oldValue) return
checked = looseEqual(value, getCheckboxValue(el, true))
}

Expand Down Expand Up @@ -210,13 +217,14 @@ export const vModelSelect: ModelDirective<HTMLSelectElement, 'number'> = {
.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
Expand All @@ -240,6 +248,15 @@ export const vModelSelect: ModelDirective<HTMLSelectElement, 'number'> = {
}

function setSelected(el: HTMLSelectElement, value: any) {
if ((el as any)._assigning && 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)) {
Expand Down
20 changes: 19 additions & 1 deletion packages/shared/src/looseEqual.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,6 +9,17 @@ function looseCompareArrays(a: any[], b: any[]) {
return equal
}

function looseCompareSets(a: Set<any>, b: Set<any>) {
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)
Expand All @@ -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) {
Expand Down
93 changes: 92 additions & 1 deletion packages/vue/__tests__/e2e/vModel.spec.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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}}
<input
id="checkEl"
type="checkbox"
@click="change"
v-model="inputModel"
value="a"
>
`,
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 <input id="checkEl" type="checkbox" value="a">"`,
)

await click('#checkEl')
await nextTick()
expect(await isChecked('#checkEl')).toBe(true)
expect(await html('#app')).toMatchInlineSnapshot(
`"1 <input id="checkEl" type="checkbox" value="a">"`,
)

await click('#checkEl')
await nextTick()
expect(await isChecked('#checkEl')).toBe(false)
expect(await html('#app')).toMatchInlineSnapshot(
`"2 <input id="checkEl" type="checkbox" value="a">"`,
)
})

// #8579
test('select click with v-model', async () => {
await page().evaluate(() => {
const { createApp } = (window as any).Vue
createApp({
template: `
<p>
Changed: {{changed}}
</p>
<p>
Chosen: {{chosen}}
</p>
<form @input="changed = true">
<select id="selectEl" v-model="chosen">
<option v-for="choice of choices" :key="choice" :value="choice">{{ choice }}</option>
</select>
</form>
`,
data() {
return {
choices: ['A', 'B'],
chosen: 'A',
changed: false,
}
},
}).mount('#app')
})

expect(await value('#selectEl')).toBe('A')
expect(await html('#app')).toMatchInlineSnapshot(
`"<p> Changed: false</p><p> Chosen: A</p><form><select id="selectEl"><option value="A">A</option><option value="B">B</option></select></form>"`,
)

await page().select('#selectEl', 'B')
await nextTick()
expect(await value('#selectEl')).toBe('B')
expect(await html('#app')).toMatchInlineSnapshot(
`"<p> Changed: true</p><p> Chosen: B</p><form><select id="selectEl"><option value="A">A</option><option value="B">B</option></select></form>"`,
)
})