diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts index 1e62746ebd9..ff53876c316 100644 --- a/packages/runtime-core/src/components/KeepAlive.ts +++ b/packages/runtime-core/src/components/KeepAlive.ts @@ -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) diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index d2ceca78f33..11e2c76be25 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -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++) { diff --git a/packages/runtime-dom/__tests__/directives/vModel.spec.ts b/packages/runtime-dom/__tests__/directives/vModel.spec.ts index aac67c00d2d..771a1ef3a70 100644 --- a/packages/runtime-dom/__tests__/directives/vModel.spec.ts +++ b/packages/runtime-dom/__tests__/directives/vModel.spec.ts @@ -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() { diff --git a/packages/runtime-dom/src/directives/vModel.ts b/packages/runtime-dom/src/directives/vModel.ts index 9cc4a5d24bf..104c4218a56 100644 --- a/packages/runtime-dom/src/directives/vModel.ts +++ b/packages/runtime-dom/src/directives/vModel.ts @@ -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 = ObjectDirective + // 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 = { +export const vModelCheckbox: ModelDirective = { 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 = { } }) }, - 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 = { +export const vModelRadio: ModelDirective = { 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 = { +export const vModelSelect: ModelDirective = { // use mounted & updated because