fix(v-model): handle dynamic assigners and array assigners

close #923
This commit is contained in:
Evan You 2020-04-04 20:51:42 -04:00
parent c1d5928f3b
commit f42d11e8e1
5 changed files with 122 additions and 40 deletions

View File

@ -10,13 +10,18 @@ import {
import { VNode, cloneVNode, isVNode, VNodeProps } from '../vnode' import { VNode, cloneVNode, isVNode, VNodeProps } from '../vnode'
import { warn } from '../warning' import { warn } from '../warning'
import { onBeforeUnmount, injectHook, onUnmounted } from '../apiLifecycle' 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 { watch } from '../apiWatch'
import { SuspenseBoundary } from './Suspense' import { SuspenseBoundary } from './Suspense'
import { import {
RendererInternals, RendererInternals,
queuePostRenderEffect, queuePostRenderEffect,
invokeHooks,
MoveType, MoveType,
RendererElement, RendererElement,
RendererNode RendererNode
@ -106,7 +111,7 @@ const KeepAliveImpl = {
queuePostRenderEffect(() => { queuePostRenderEffect(() => {
child.isDeactivated = false child.isDeactivated = false
if (child.a) { if (child.a) {
invokeHooks(child.a) invokeArrayFns(child.a)
} }
}, parentSuspense) }, parentSuspense)
} }
@ -116,7 +121,7 @@ const KeepAliveImpl = {
queuePostRenderEffect(() => { queuePostRenderEffect(() => {
const component = vnode.component! const component = vnode.component!
if (component.da) { if (component.da) {
invokeHooks(component.da) invokeArrayFns(component.da)
} }
component.isDeactivated = true component.isDeactivated = true
}, parentSuspense) }, parentSuspense)

View File

@ -32,7 +32,8 @@ import {
PatchFlags, PatchFlags,
ShapeFlags, ShapeFlags,
NOOP, NOOP,
hasOwn hasOwn,
invokeArrayFns
} from '@vue/shared' } from '@vue/shared'
import { import {
queueJob, queueJob,
@ -40,13 +41,7 @@ import {
flushPostFlushCbs, flushPostFlushCbs,
invalidateJob invalidateJob
} from './scheduler' } from './scheduler'
import { import { effect, stop, ReactiveEffectOptions, isRef } from '@vue/reactivity'
effect,
stop,
ReactiveEffectOptions,
isRef,
DebuggerEvent
} from '@vue/reactivity'
import { resolveProps } from './componentProps' import { resolveProps } from './componentProps'
import { resolveSlots } from './componentSlots' import { resolveSlots } from './componentSlots'
import { pushWarningContext, popWarningContext, warn } from './warning' import { pushWarningContext, popWarningContext, warn } from './warning'
@ -265,14 +260,8 @@ function createDevEffectOptions(
): ReactiveEffectOptions { ): ReactiveEffectOptions {
return { return {
scheduler: queueJob, scheduler: queueJob,
onTrack: instance.rtc ? e => invokeHooks(instance.rtc!, e) : void 0, onTrack: instance.rtc ? e => invokeArrayFns(instance.rtc!, e) : void 0,
onTrigger: instance.rtg ? e => invokeHooks(instance.rtg!, e) : void 0 onTrigger: instance.rtg ? e => invokeArrayFns(instance.rtg!, e) : void 0
}
}
export function invokeHooks(hooks: Function[], arg?: DebuggerEvent) {
for (let i = 0; i < hooks.length; i++) {
hooks[i](arg)
} }
} }
@ -1106,7 +1095,7 @@ function baseCreateRenderer(
} }
// beforeMount hook // beforeMount hook
if (bm) { if (bm) {
invokeHooks(bm) invokeArrayFns(bm)
} }
// onVnodeBeforeMount // onVnodeBeforeMount
if ((vnodeHook = props && props.onVnodeBeforeMount)) { if ((vnodeHook = props && props.onVnodeBeforeMount)) {
@ -1189,7 +1178,7 @@ function baseCreateRenderer(
next.el = vnode.el next.el = vnode.el
// beforeUpdate hook // beforeUpdate hook
if (bu) { if (bu) {
invokeHooks(bu) invokeArrayFns(bu)
} }
// onVnodeBeforeUpdate // onVnodeBeforeUpdate
if ((vnodeHook = next.props && next.props.onVnodeBeforeUpdate)) { if ((vnodeHook = next.props && next.props.onVnodeBeforeUpdate)) {
@ -1812,7 +1801,7 @@ function baseCreateRenderer(
const { bum, effects, update, subTree, um, da, isDeactivated } = instance const { bum, effects, update, subTree, um, da, isDeactivated } = instance
// beforeUnmount hook // beforeUnmount hook
if (bum) { if (bum) {
invokeHooks(bum) invokeArrayFns(bum)
} }
if (effects) { if (effects) {
for (let i = 0; i < effects.length; i++) { for (let i = 0; i < effects.length; i++) {

View File

@ -5,7 +5,8 @@ import {
defineComponent, defineComponent,
vModelDynamic, vModelDynamic,
withDirectives, withDirectives,
VNode VNode,
ref
} from '@vue/runtime-dom' } from '@vue/runtime-dom'
const triggerEvent = (type: string, el: Element) => { const triggerEvent = (type: string, el: Element) => {
@ -58,6 +59,72 @@ describe('vModel', () => {
expect(input.value).toEqual('bar') 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 () => { it('should work with textarea', async () => {
const component = defineComponent({ const component = defineComponent({
data() { data() {

View File

@ -6,10 +6,14 @@ import {
warn warn
} from '@vue/runtime-core' } from '@vue/runtime-core'
import { addEventListener } from '../modules/events' 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) => type AssignerFn = (value: any) => void
vnode.props!['onUpdate:modelValue']
const getModelAssigner = (vnode: VNode): AssignerFn => {
const fn = vnode.props!['onUpdate:modelValue']
return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
}
function onCompositionStart(e: Event) { function onCompositionStart(e: Event) {
;(e.target as any).composing = true ;(e.target as any).composing = true
@ -34,14 +38,16 @@ function toNumber(val: string): number | string {
return isNaN(n) ? val : n 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 // 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. // be tree-shaken in case v-model is never used.
export const vModelText: ObjectDirective< export const vModelText: ModelDirective<
HTMLInputElement | HTMLTextAreaElement HTMLInputElement | HTMLTextAreaElement
> = { > = {
beforeMount(el, { value, modifiers: { lazy, trim, number } }, vnode) { beforeMount(el, { value, modifiers: { lazy, trim, number } }, vnode) {
el.value = value el.value = value
const assign = getModelAssigner(vnode) el._assign = getModelAssigner(vnode)
const castToNumber = number || el.type === 'number' const castToNumber = number || el.type === 'number'
addEventListener(el, lazy ? 'change' : 'input', () => { addEventListener(el, lazy ? 'change' : 'input', () => {
let domValue: string | number = el.value let domValue: string | number = el.value
@ -50,7 +56,7 @@ export const vModelText: ObjectDirective<
} else if (castToNumber) { } else if (castToNumber) {
domValue = toNumber(domValue) domValue = toNumber(domValue)
} }
assign(domValue) el._assign(domValue)
}) })
if (trim) { if (trim) {
addEventListener(el, 'change', () => { addEventListener(el, 'change', () => {
@ -67,7 +73,8 @@ export const vModelText: ObjectDirective<
addEventListener(el, 'change', onCompositionEnd) 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) { if (value === oldValue) {
return return
} }
@ -83,14 +90,15 @@ export const vModelText: ObjectDirective<
} }
} }
export const vModelCheckbox: ObjectDirective<HTMLInputElement> = { export const vModelCheckbox: ModelDirective<HTMLInputElement> = {
beforeMount(el, binding, vnode) { beforeMount(el, binding, vnode) {
setChecked(el, binding, vnode) setChecked(el, binding, vnode)
const assign = getModelAssigner(vnode) el._assign = getModelAssigner(vnode)
addEventListener(el, 'change', () => { addEventListener(el, 'change', () => {
const modelValue = (el as any)._modelValue const modelValue = (el as any)._modelValue
const elementValue = getValue(el) const elementValue = getValue(el)
const checked = el.checked const checked = el.checked
const assign = el._assign
if (isArray(modelValue)) { if (isArray(modelValue)) {
const index = looseIndexOf(modelValue, elementValue) const index = looseIndexOf(modelValue, elementValue)
const found = index !== -1 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( function setChecked(
@ -124,33 +135,37 @@ function setChecked(
} }
} }
export const vModelRadio: ObjectDirective<HTMLInputElement> = { export const vModelRadio: ModelDirective<HTMLInputElement> = {
beforeMount(el, { value }, vnode) { beforeMount(el, { value }, vnode) {
el.checked = looseEqual(value, vnode.props!.value) el.checked = looseEqual(value, vnode.props!.value)
const assign = getModelAssigner(vnode) el._assign = getModelAssigner(vnode)
addEventListener(el, 'change', () => { addEventListener(el, 'change', () => {
assign(getValue(el)) el._assign(getValue(el))
}) })
}, },
beforeUpdate(el, { value, oldValue }, vnode) { beforeUpdate(el, { value, oldValue }, vnode) {
el._assign = getModelAssigner(vnode)
if (value !== oldValue) { if (value !== oldValue) {
el.checked = looseEqual(value, vnode.props!.value) 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. // use mounted & updated because <select> relies on its children <option>s.
mounted(el, { value }, vnode) { mounted(el, { value }, vnode) {
setSelected(el, value) setSelected(el, value)
const assign = getModelAssigner(vnode) el._assign = getModelAssigner(vnode)
addEventListener(el, 'change', () => { addEventListener(el, 'change', () => {
const selectedVal = Array.prototype.filter const selectedVal = Array.prototype.filter
.call(el.options, (o: HTMLOptionElement) => o.selected) .call(el.options, (o: HTMLOptionElement) => o.selected)
.map(getValue) .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 }) { updated(el, { value }) {
setSelected(el, value) setSelected(el, value)
} }

View File

@ -119,3 +119,9 @@ export const toDisplayString = (val: unknown): string => {
? JSON.stringify(val, null, 2) ? JSON.stringify(val, null, 2)
: String(val) : String(val)
} }
export function invokeArrayFns(fns: Function[], arg?: any) {
for (let i = 0; i < fns.length; i++) {
fns[i](arg)
}
}