]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
fix(v-model): handle dynamic assigners and array assigners
authorEvan You <yyx990803@gmail.com>
Sun, 5 Apr 2020 00:51:42 +0000 (20:51 -0400)
committerEvan You <yyx990803@gmail.com>
Sun, 5 Apr 2020 00:51:42 +0000 (20:51 -0400)
close #923

packages/runtime-core/src/components/KeepAlive.ts
packages/runtime-core/src/renderer.ts
packages/runtime-dom/__tests__/directives/vModel.spec.ts
packages/runtime-dom/src/directives/vModel.ts
packages/shared/src/index.ts

index 1e62746ebd9989efb20a666de9f1efd53a9b4a77..ff53876c3165cd5048903c805c286f58f9c12952 100644 (file)
@@ -10,13 +10,18 @@ import {
 import { VNode, cloneVNode, isVNode, VNodeProps } from '../vnode'
 import { warn } from '../warning'
 import { onBeforeUnmount, injectHook, onUnmounted } from '../apiLifecycle'
-import { isString, isArray, ShapeFlags, remove } from '@vue/shared'
+import {
+  isString,
+  isArray,
+  ShapeFlags,
+  remove,
+  invokeArrayFns
+} from '@vue/shared'
 import { watch } from '../apiWatch'
 import { SuspenseBoundary } from './Suspense'
 import {
   RendererInternals,
   queuePostRenderEffect,
-  invokeHooks,
   MoveType,
   RendererElement,
   RendererNode
@@ -106,7 +111,7 @@ const KeepAliveImpl = {
       queuePostRenderEffect(() => {
         child.isDeactivated = false
         if (child.a) {
-          invokeHooks(child.a)
+          invokeArrayFns(child.a)
         }
       }, parentSuspense)
     }
@@ -116,7 +121,7 @@ const KeepAliveImpl = {
       queuePostRenderEffect(() => {
         const component = vnode.component!
         if (component.da) {
-          invokeHooks(component.da)
+          invokeArrayFns(component.da)
         }
         component.isDeactivated = true
       }, parentSuspense)
index d2ceca78f33dd908df23316546476ecacbcc94ae..11e2c76be2543749a706882fadfb1dfac78d7d37 100644 (file)
@@ -32,7 +32,8 @@ import {
   PatchFlags,
   ShapeFlags,
   NOOP,
-  hasOwn
+  hasOwn,
+  invokeArrayFns
 } from '@vue/shared'
 import {
   queueJob,
@@ -40,13 +41,7 @@ import {
   flushPostFlushCbs,
   invalidateJob
 } from './scheduler'
-import {
-  effect,
-  stop,
-  ReactiveEffectOptions,
-  isRef,
-  DebuggerEvent
-} from '@vue/reactivity'
+import { effect, stop, ReactiveEffectOptions, isRef } from '@vue/reactivity'
 import { resolveProps } from './componentProps'
 import { resolveSlots } from './componentSlots'
 import { pushWarningContext, popWarningContext, warn } from './warning'
@@ -265,14 +260,8 @@ function createDevEffectOptions(
 ): ReactiveEffectOptions {
   return {
     scheduler: queueJob,
-    onTrack: instance.rtc ? e => invokeHooks(instance.rtc!, e) : void 0,
-    onTrigger: instance.rtg ? e => invokeHooks(instance.rtg!, e) : void 0
-  }
-}
-
-export function invokeHooks(hooks: Function[], arg?: DebuggerEvent) {
-  for (let i = 0; i < hooks.length; i++) {
-    hooks[i](arg)
+    onTrack: instance.rtc ? e => invokeArrayFns(instance.rtc!, e) : void 0,
+    onTrigger: instance.rtg ? e => invokeArrayFns(instance.rtg!, e) : void 0
   }
 }
 
@@ -1106,7 +1095,7 @@ function baseCreateRenderer(
         }
         // beforeMount hook
         if (bm) {
-          invokeHooks(bm)
+          invokeArrayFns(bm)
         }
         // onVnodeBeforeMount
         if ((vnodeHook = props && props.onVnodeBeforeMount)) {
@@ -1189,7 +1178,7 @@ function baseCreateRenderer(
         next.el = vnode.el
         // beforeUpdate hook
         if (bu) {
-          invokeHooks(bu)
+          invokeArrayFns(bu)
         }
         // onVnodeBeforeUpdate
         if ((vnodeHook = next.props && next.props.onVnodeBeforeUpdate)) {
@@ -1812,7 +1801,7 @@ function baseCreateRenderer(
     const { bum, effects, update, subTree, um, da, isDeactivated } = instance
     // beforeUnmount hook
     if (bum) {
-      invokeHooks(bum)
+      invokeArrayFns(bum)
     }
     if (effects) {
       for (let i = 0; i < effects.length; i++) {
index aac67c00d2db56ab0fa291e65c547c3cfe47774b..771a1ef3a70c36b5eb2f0ff876424d84aaf9761c 100644 (file)
@@ -5,7 +5,8 @@ import {
   defineComponent,
   vModelDynamic,
   withDirectives,
-  VNode
+  VNode,
+  ref
 } from '@vue/runtime-dom'
 
 const triggerEvent = (type: string, el: Element) => {
@@ -58,6 +59,72 @@ describe('vModel', () => {
     expect(input.value).toEqual('bar')
   })
 
+  it('should work with multiple listeners', async () => {
+    const spy = jest.fn()
+    const component = defineComponent({
+      data() {
+        return { value: null }
+      },
+      render() {
+        return [
+          withVModel(
+            h('input', {
+              'onUpdate:modelValue': [setValue.bind(this), spy]
+            }),
+            this.value
+          )
+        ]
+      }
+    })
+    render(h(component), root)
+
+    const input = root.querySelector('input')!
+    const data = root._vnode.component.data
+
+    input.value = 'foo'
+    triggerEvent('input', input)
+    await nextTick()
+    expect(data.value).toEqual('foo')
+    expect(spy).toHaveBeenCalledWith('foo')
+  })
+
+  it('should work with updated listeners', async () => {
+    const spy1 = jest.fn()
+    const spy2 = jest.fn()
+    const toggle = ref(true)
+
+    const component = defineComponent({
+      render() {
+        return [
+          withVModel(
+            h('input', {
+              'onUpdate:modelValue': toggle.value ? spy1 : spy2
+            }),
+            'foo'
+          )
+        ]
+      }
+    })
+    render(h(component), root)
+
+    const input = root.querySelector('input')!
+
+    input.value = 'foo'
+    triggerEvent('input', input)
+    await nextTick()
+    expect(spy1).toHaveBeenCalledWith('foo')
+
+    // udpate listener
+    toggle.value = false
+    await nextTick()
+
+    input.value = 'bar'
+    triggerEvent('input', input)
+    await nextTick()
+    expect(spy1).not.toHaveBeenCalledWith('bar')
+    expect(spy2).toHaveBeenCalledWith('bar')
+  })
+
   it('should work with textarea', async () => {
     const component = defineComponent({
       data() {
index 9cc4a5d24bf428b2d1a330423b4efcbcaf89eadb..104c4218a5691e738eac54e4c4eb6ae08dd62c85 100644 (file)
@@ -6,10 +6,14 @@ import {
   warn
 } from '@vue/runtime-core'
 import { addEventListener } from '../modules/events'
-import { isArray, looseEqual, looseIndexOf } from '@vue/shared'
+import { isArray, looseEqual, looseIndexOf, invokeArrayFns } from '@vue/shared'
 
-const getModelAssigner = (vnode: VNode): ((value: any) => void) =>
-  vnode.props!['onUpdate:modelValue']
+type AssignerFn = (value: any) => void
+
+const getModelAssigner = (vnode: VNode): AssignerFn => {
+  const fn = vnode.props!['onUpdate:modelValue']
+  return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
+}
 
 function onCompositionStart(e: Event) {
   ;(e.target as any).composing = true
@@ -34,14 +38,16 @@ function toNumber(val: string): number | string {
   return isNaN(n) ? val : n
 }
 
+type ModelDirective<T> = ObjectDirective<T & { _assign: AssignerFn }>
+
 // We are exporting the v-model runtime directly as vnode hooks so that it can
 // be tree-shaken in case v-model is never used.
-export const vModelText: ObjectDirective<
+export const vModelText: ModelDirective<
   HTMLInputElement | HTMLTextAreaElement
 > = {
   beforeMount(el, { value, modifiers: { lazy, trim, number } }, vnode) {
     el.value = value
-    const assign = getModelAssigner(vnode)
+    el._assign = getModelAssigner(vnode)
     const castToNumber = number || el.type === 'number'
     addEventListener(el, lazy ? 'change' : 'input', () => {
       let domValue: string | number = el.value
@@ -50,7 +56,7 @@ export const vModelText: ObjectDirective<
       } else if (castToNumber) {
         domValue = toNumber(domValue)
       }
-      assign(domValue)
+      el._assign(domValue)
     })
     if (trim) {
       addEventListener(el, 'change', () => {
@@ -67,7 +73,8 @@ export const vModelText: ObjectDirective<
       addEventListener(el, 'change', onCompositionEnd)
     }
   },
-  beforeUpdate(el, { value, oldValue, modifiers: { trim, number } }) {
+  beforeUpdate(el, { value, oldValue, modifiers: { trim, number } }, vnode) {
+    el._assign = getModelAssigner(vnode)
     if (value === oldValue) {
       return
     }
@@ -83,14 +90,15 @@ export const vModelText: ObjectDirective<
   }
 }
 
-export const vModelCheckbox: ObjectDirective<HTMLInputElement> = {
+export const vModelCheckbox: ModelDirective<HTMLInputElement> = {
   beforeMount(el, binding, vnode) {
     setChecked(el, binding, vnode)
-    const assign = getModelAssigner(vnode)
+    el._assign = getModelAssigner(vnode)
     addEventListener(el, 'change', () => {
       const modelValue = (el as any)._modelValue
       const elementValue = getValue(el)
       const checked = el.checked
+      const assign = el._assign
       if (isArray(modelValue)) {
         const index = looseIndexOf(modelValue, elementValue)
         const found = index !== -1
@@ -106,7 +114,10 @@ export const vModelCheckbox: ObjectDirective<HTMLInputElement> = {
       }
     })
   },
-  beforeUpdate: setChecked
+  beforeUpdate(el, binding, vnode) {
+    setChecked(el, binding, vnode)
+    el._assign = getModelAssigner(vnode)
+  }
 }
 
 function setChecked(
@@ -124,33 +135,37 @@ function setChecked(
   }
 }
 
-export const vModelRadio: ObjectDirective<HTMLInputElement> = {
+export const vModelRadio: ModelDirective<HTMLInputElement> = {
   beforeMount(el, { value }, vnode) {
     el.checked = looseEqual(value, vnode.props!.value)
-    const assign = getModelAssigner(vnode)
+    el._assign = getModelAssigner(vnode)
     addEventListener(el, 'change', () => {
-      assign(getValue(el))
+      el._assign(getValue(el))
     })
   },
   beforeUpdate(el, { value, oldValue }, vnode) {
+    el._assign = getModelAssigner(vnode)
     if (value !== oldValue) {
       el.checked = looseEqual(value, vnode.props!.value)
     }
   }
 }
 
-export const vModelSelect: ObjectDirective<HTMLSelectElement> = {
+export const vModelSelect: ModelDirective<HTMLSelectElement> = {
   // use mounted & updated because <select> relies on its children <option>s.
   mounted(el, { value }, vnode) {
     setSelected(el, value)
-    const assign = getModelAssigner(vnode)
+    el._assign = getModelAssigner(vnode)
     addEventListener(el, 'change', () => {
       const selectedVal = Array.prototype.filter
         .call(el.options, (o: HTMLOptionElement) => o.selected)
         .map(getValue)
-      assign(el.multiple ? selectedVal : selectedVal[0])
+      el._assign(el.multiple ? selectedVal : selectedVal[0])
     })
   },
+  beforeUpdate(el, _binding, vnode) {
+    el._assign = getModelAssigner(vnode)
+  },
   updated(el, { value }) {
     setSelected(el, value)
   }
index d5c356140f25eb1d04c1cc3467f0fb7934dbac62..75c6f0fbd6fb1019d0c25fc70286b404ced15a76 100644 (file)
@@ -119,3 +119,9 @@ export const toDisplayString = (val: unknown): string => {
       ? JSON.stringify(val, null, 2)
       : String(val)
 }
+
+export function invokeArrayFns(fns: Function[], arg?: any) {
+  for (let i = 0; i < fns.length; i++) {
+    fns[i](arg)
+  }
+}