From de174e1aa756508c7542605a448e55a373afb1ed Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 11 Jul 2024 16:59:55 +0800 Subject: [PATCH] fix(defineModel): force local update when setter results in same emitted value fix #10279 fix #10301 --- .../__tests__/helpers/useModel.spec.ts | 86 +++++++++++++++++++ packages/runtime-core/src/componentEmits.ts | 13 ++- packages/runtime-core/src/helpers/useModel.ts | 34 ++++++-- 3 files changed, 120 insertions(+), 13 deletions(-) diff --git a/packages/runtime-core/__tests__/helpers/useModel.spec.ts b/packages/runtime-core/__tests__/helpers/useModel.spec.ts index c02af337b87..f5b2a0108b0 100644 --- a/packages/runtime-core/__tests__/helpers/useModel.spec.ts +++ b/packages/runtime-core/__tests__/helpers/useModel.spec.ts @@ -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 + let childModifiers: Record + + 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('') + + 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.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.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) + }) }) diff --git a/packages/runtime-core/src/componentEmits.ts b/packages/runtime-core/src/componentEmits.ts index 4551235bc5a..b6589b92227 100644 --- a/packages/runtime-core/src/componentEmits.ts +++ b/packages/runtime-core/src/componentEmits.ts @@ -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) } } diff --git a/packages/runtime-core/src/helpers/useModel.ts b/packages/runtime-core/src/helpers/useModel.ts index f6fbca554a7..38f004bb535 100644 --- a/packages/runtime-core/src/helpers/useModel.ts +++ b/packages/runtime-core/src/helpers/useModel.ts @@ -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, + modelName: string, +): Record | undefined => + props[`${modelName === 'modelValue' ? 'model' : modelName}Modifiers`]