Forráskód Böngészése

fix(runtime-core): force model update when reverted before sync (#14897)

close #13524
Liu Bo 5 napja
szülő
commit
7f76378b0d

+ 70 - 0
packages/runtime-core/__tests__/helpers/useModel.spec.ts

@@ -613,6 +613,76 @@ describe('useModel', () => {
     expect(compRender).toHaveBeenCalledTimes(3)
   })
 
+  // #13524
+  test('force local update when an intermediate model value is reverted before parent update', async () => {
+    let childMsg: Ref<string>
+
+    const compRender = vi.fn()
+    const parentRender = vi.fn()
+
+    const Comp = defineComponent({
+      props: ['msg'],
+      emits: ['update:msg'],
+      setup(props) {
+        childMsg = useModel(props, 'msg')
+        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
+            },
+            onModelInput(value: any) {
+              childMsg.value = value
+            },
+            onInput() {
+              childMsg.value = 'a'
+            },
+          })
+        }
+      },
+    })
+
+    const msg = ref('a')
+    const Parent = defineComponent({
+      setup() {
+        return () => {
+          parentRender()
+          return h(Comp, {
+            msg: msg.value,
+            '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="a"></input>')
+
+    const input = root.children[0] as TestElement
+
+    // simulate a browser that does not flush microtasks between event listeners
+    ;['ab', 'ac', 'ad'].forEach(value => {
+      input.props.onModelInput((input.props.value = value))
+      input.props.onInput()
+    })
+    await nextTick()
+
+    expect(msg.value).toBe('a')
+    expect(parentRender).toHaveBeenCalledTimes(2)
+    expect(compRender).toHaveBeenCalledTimes(2)
+    expect(serializeInner(root)).toBe('<input value="a"></input>')
+  })
+
   test('set no change value', async () => {
     let changeChildMsg!: (val: string) => void
 

+ 22 - 14
packages/runtime-core/src/helpers/useModel.ts

@@ -65,18 +65,17 @@ export function useModel(
           return
         }
         const rawProps = i.vnode!.props
-        if (
-          !(
-            rawProps &&
-            // check if parent has passed v-model
-            (name in rawProps ||
-              camelizedName in rawProps ||
-              hyphenatedName in rawProps) &&
-            (`onUpdate:${name}` in rawProps ||
-              `onUpdate:${camelizedName}` in rawProps ||
-              `onUpdate:${hyphenatedName}` in rawProps)
-          )
-        ) {
+        const hasVModel = !!(
+          rawProps &&
+          // check if parent has passed v-model
+          (name in rawProps ||
+            camelizedName in rawProps ||
+            hyphenatedName in rawProps) &&
+          (`onUpdate:${name}` in rawProps ||
+            `onUpdate:${camelizedName}` in rawProps ||
+            `onUpdate:${hyphenatedName}` in rawProps)
+        )
+        if (!hasVModel) {
           // no v-model, local update
           localValue = value
           trigger()
@@ -88,9 +87,18 @@ export function useModel(
         // 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 (
-          hasChanged(value, emittedValue) &&
           hasChanged(value, prevSetValue) &&
-          !hasChanged(emittedValue, prevEmittedValue)
+          ((hasChanged(value, emittedValue) &&
+            !hasChanged(emittedValue, prevEmittedValue)) ||
+            // #13524: browsers differ in when they flush microtasks between
+            // event listeners. If a v-model listener emits an intermediate value
+            // and a following listener restores the model to its previous prop
+            // value before parent updates are flushed, the parent render can be
+            // deduped as having no prop change. Force a local update so DOM state
+            // such as an input's value is synchronized back to the current model.
+            (hasVModel &&
+              prevSetValue !== EMPTY_OBJ &&
+              !hasChanged(emittedValue, localValue)))
         ) {
           trigger()
         }