]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(defineModel): force local update when setter results in same emitted value
authorEvan You <evan@vuejs.org>
Thu, 11 Jul 2024 08:59:55 +0000 (16:59 +0800)
committerEvan You <evan@vuejs.org>
Thu, 11 Jul 2024 09:00:39 +0000 (17:00 +0800)
fix #10279
fix #10301

packages/runtime-core/__tests__/helpers/useModel.spec.ts
packages/runtime-core/src/componentEmits.ts
packages/runtime-core/src/helpers/useModel.ts

index c02af337b8746b03735ca75fa05f0eb96866df40..f5b2a0108b003c63e8e8cf392d87a0d50e71a51c 100644 (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)
+  })
 })
index 4551235bc5a00ada196bd5f0972e952deeda4b4d..b6589b922277b87b0125e5b018487cbb66c48f7b 100644 (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)
     }
   }
index f6fbca554a7c5cceb6124e4f7c6bf52c1f939b89..38f004bb53528dd4eb8c33ef5bd3038660c6a239 100644 (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`]