fix(defineModel): force local update when setter results in same emitted value

fix #10279
fix #10301
This commit is contained in:
Evan You 2024-07-11 16:59:55 +08:00
parent 0ac0f2e338
commit de174e1aa7
No known key found for this signature in database
GPG Key ID: 00E9AB7A6704CE0A
3 changed files with 120 additions and 13 deletions

View File

@ -1,6 +1,7 @@
import {
Fragment,
type Ref,
type TestElement,
createApp,
createBlock,
createElementBlock,
@ -526,4 +527,89 @@ describe('useModel', () => {
await nextTick()
expect(msg.value).toBe('UGHH')
})
// #10279
test('force local update when setter formats value to the same value', async () => {
let childMsg: Ref<string>
let childModifiers: Record<string, true | undefined>
const compRender = vi.fn()
const parentRender = vi.fn()
const Comp = defineComponent({
props: ['msg', 'msgModifiers'],
emits: ['update:msg'],
setup(props) {
;[childMsg, childModifiers] = useModel(props, 'msg', {
set(val) {
if (childModifiers.number) {
return val.replace(/\D+/g, '')
}
},
})
return () => {
compRender()
return h('input', {
// simulate how v-model works
onVnodeBeforeMount(vnode) {
;(vnode.el as TestElement).props.value = childMsg.value
},
onVnodeBeforeUpdate(vnode) {
;(vnode.el as TestElement).props.value = childMsg.value
},
onInput(value: any) {
childMsg.value = value
},
})
}
},
})
const msg = ref(1)
const Parent = defineComponent({
setup() {
return () => {
parentRender()
return h(Comp, {
msg: msg.value,
msgModifiers: { number: true },
'onUpdate:msg': val => {
msg.value = val
},
})
}
},
})
const root = nodeOps.createElement('div')
render(h(Parent), root)
expect(parentRender).toHaveBeenCalledTimes(1)
expect(compRender).toHaveBeenCalledTimes(1)
expect(serializeInner(root)).toBe('<input value=1></input>')
const input = root.children[0] as TestElement
// simulate v-model update
input.props.onInput((input.props.value = '2'))
await nextTick()
expect(msg.value).toBe(2)
expect(parentRender).toHaveBeenCalledTimes(2)
expect(compRender).toHaveBeenCalledTimes(2)
expect(serializeInner(root)).toBe('<input value=2></input>')
input.props.onInput((input.props.value = '2a'))
await nextTick()
expect(msg.value).toBe(2)
expect(parentRender).toHaveBeenCalledTimes(2)
// should force local update
expect(compRender).toHaveBeenCalledTimes(3)
expect(serializeInner(root)).toBe('<input value=2></input>')
input.props.onInput((input.props.value = '2a'))
await nextTick()
expect(parentRender).toHaveBeenCalledTimes(2)
// should not force local update if set to the same value
expect(compRender).toHaveBeenCalledTimes(3)
})
})

View File

@ -28,6 +28,7 @@ import {
compatModelEmit,
compatModelEventPrefix,
} from './compat/componentVModel'
import { getModelModifiers } from './helpers/useModel'
export type ObjectEmitsOptions = Record<
string,
@ -125,16 +126,12 @@ export function emit(
const isModelListener = event.startsWith('update:')
// for v-model update:xxx events, apply modifiers on args
const modelArg = isModelListener && event.slice(7)
if (modelArg && modelArg in props) {
const modifiersKey = `${
modelArg === 'modelValue' ? 'model' : modelArg
}Modifiers`
const { number, trim } = props[modifiersKey] || EMPTY_OBJ
if (trim) {
const modifiers = isModelListener && getModelModifiers(props, event.slice(7))
if (modifiers) {
if (modifiers.trim) {
args = rawArgs.map(a => (isString(a) ? a.trim() : a))
}
if (number) {
if (modifiers.number) {
args = rawArgs.map(looseToNumber)
}
}

View File

@ -29,9 +29,13 @@ export function useModel(
const camelizedName = camelize(name)
const hyphenatedName = hyphenate(name)
const modifiers = getModelModifiers(props, name)
const res = customRef((track, trigger) => {
let localValue: any
let prevSetValue: any
let prevEmittedValue: any
watchSyncEffect(() => {
const propValue = props[name]
if (hasChanged(localValue, propValue)) {
@ -39,11 +43,13 @@ export function useModel(
trigger()
}
})
return {
get() {
track()
return options.get ? options.get(localValue) : localValue
},
set(value) {
const rawProps = i.vnode!.props
if (
@ -59,24 +65,36 @@ export function useModel(
) &&
hasChanged(value, localValue)
) {
// no v-model, local update
localValue = value
trigger()
}
i.emit(`update:${name}`, options.set ? options.set(value) : value)
const emittedValue = options.set ? options.set(value) : value
i.emit(`update:${name}`, emittedValue)
// #10279: if the local value is converted via a setter but the value
// emitted to parent was the same, the parent will not trigger any
// updates and there will be no prop sync. However the local input state
// may be out of sync, so we need to force an update here.
if (
value !== emittedValue &&
value !== prevSetValue &&
emittedValue === prevEmittedValue
) {
trigger()
}
prevSetValue = value
prevEmittedValue = emittedValue
},
}
})
const modifierKey =
name === 'modelValue' ? 'modelModifiers' : `${name}Modifiers`
// @ts-expect-error
res[Symbol.iterator] = () => {
let i = 0
return {
next() {
if (i < 2) {
return { value: i++ ? props[modifierKey] || {} : res, done: false }
return { value: i++ ? modifiers || EMPTY_OBJ : res, done: false }
} else {
return { done: true }
}
@ -86,3 +104,9 @@ export function useModel(
return res
}
export const getModelModifiers = (
props: Record<string, any>,
modelName: string,
): Record<string, boolean> | undefined =>
props[`${modelName === 'modelValue' ? 'model' : modelName}Modifiers`]