Skip to content

Commit f42d11e

Browse files
committed
fix(v-model): handle dynamic assigners and array assigners
close #923
1 parent c1d5928 commit f42d11e

File tree

5 files changed

+122
-40
lines changed

5 files changed

+122
-40
lines changed

packages/runtime-core/src/components/KeepAlive.ts

+9-4
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@ import {
1010
import { VNode, cloneVNode, isVNode, VNodeProps } from '../vnode'
1111
import { warn } from '../warning'
1212
import { onBeforeUnmount, injectHook, onUnmounted } from '../apiLifecycle'
13-
import { isString, isArray, ShapeFlags, remove } from '@vue/shared'
13+
import {
14+
isString,
15+
isArray,
16+
ShapeFlags,
17+
remove,
18+
invokeArrayFns
19+
} from '@vue/shared'
1420
import { watch } from '../apiWatch'
1521
import { SuspenseBoundary } from './Suspense'
1622
import {
1723
RendererInternals,
1824
queuePostRenderEffect,
19-
invokeHooks,
2025
MoveType,
2126
RendererElement,
2227
RendererNode
@@ -106,7 +111,7 @@ const KeepAliveImpl = {
106111
queuePostRenderEffect(() => {
107112
child.isDeactivated = false
108113
if (child.a) {
109-
invokeHooks(child.a)
114+
invokeArrayFns(child.a)
110115
}
111116
}, parentSuspense)
112117
}
@@ -116,7 +121,7 @@ const KeepAliveImpl = {
116121
queuePostRenderEffect(() => {
117122
const component = vnode.component!
118123
if (component.da) {
119-
invokeHooks(component.da)
124+
invokeArrayFns(component.da)
120125
}
121126
component.isDeactivated = true
122127
}, parentSuspense)

packages/runtime-core/src/renderer.ts

+8-19
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,16 @@ import {
3232
PatchFlags,
3333
ShapeFlags,
3434
NOOP,
35-
hasOwn
35+
hasOwn,
36+
invokeArrayFns
3637
} from '@vue/shared'
3738
import {
3839
queueJob,
3940
queuePostFlushCb,
4041
flushPostFlushCbs,
4142
invalidateJob
4243
} from './scheduler'
43-
import {
44-
effect,
45-
stop,
46-
ReactiveEffectOptions,
47-
isRef,
48-
DebuggerEvent
49-
} from '@vue/reactivity'
44+
import { effect, stop, ReactiveEffectOptions, isRef } from '@vue/reactivity'
5045
import { resolveProps } from './componentProps'
5146
import { resolveSlots } from './componentSlots'
5247
import { pushWarningContext, popWarningContext, warn } from './warning'
@@ -265,14 +260,8 @@ function createDevEffectOptions(
265260
): ReactiveEffectOptions {
266261
return {
267262
scheduler: queueJob,
268-
onTrack: instance.rtc ? e => invokeHooks(instance.rtc!, e) : void 0,
269-
onTrigger: instance.rtg ? e => invokeHooks(instance.rtg!, e) : void 0
270-
}
271-
}
272-
273-
export function invokeHooks(hooks: Function[], arg?: DebuggerEvent) {
274-
for (let i = 0; i < hooks.length; i++) {
275-
hooks[i](arg)
263+
onTrack: instance.rtc ? e => invokeArrayFns(instance.rtc!, e) : void 0,
264+
onTrigger: instance.rtg ? e => invokeArrayFns(instance.rtg!, e) : void 0
276265
}
277266
}
278267

@@ -1106,7 +1095,7 @@ function baseCreateRenderer(
11061095
}
11071096
// beforeMount hook
11081097
if (bm) {
1109-
invokeHooks(bm)
1098+
invokeArrayFns(bm)
11101099
}
11111100
// onVnodeBeforeMount
11121101
if ((vnodeHook = props && props.onVnodeBeforeMount)) {
@@ -1189,7 +1178,7 @@ function baseCreateRenderer(
11891178
next.el = vnode.el
11901179
// beforeUpdate hook
11911180
if (bu) {
1192-
invokeHooks(bu)
1181+
invokeArrayFns(bu)
11931182
}
11941183
// onVnodeBeforeUpdate
11951184
if ((vnodeHook = next.props && next.props.onVnodeBeforeUpdate)) {
@@ -1812,7 +1801,7 @@ function baseCreateRenderer(
18121801
const { bum, effects, update, subTree, um, da, isDeactivated } = instance
18131802
// beforeUnmount hook
18141803
if (bum) {
1815-
invokeHooks(bum)
1804+
invokeArrayFns(bum)
18161805
}
18171806
if (effects) {
18181807
for (let i = 0; i < effects.length; i++) {

packages/runtime-dom/__tests__/directives/vModel.spec.ts

+68-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import {
55
defineComponent,
66
vModelDynamic,
77
withDirectives,
8-
VNode
8+
VNode,
9+
ref
910
} from '@vue/runtime-dom'
1011

1112
const triggerEvent = (type: string, el: Element) => {
@@ -58,6 +59,72 @@ describe('vModel', () => {
5859
expect(input.value).toEqual('bar')
5960
})
6061

62+
it('should work with multiple listeners', async () => {
63+
const spy = jest.fn()
64+
const component = defineComponent({
65+
data() {
66+
return { value: null }
67+
},
68+
render() {
69+
return [
70+
withVModel(
71+
h('input', {
72+
'onUpdate:modelValue': [setValue.bind(this), spy]
73+
}),
74+
this.value
75+
)
76+
]
77+
}
78+
})
79+
render(h(component), root)
80+
81+
const input = root.querySelector('input')!
82+
const data = root._vnode.component.data
83+
84+
input.value = 'foo'
85+
triggerEvent('input', input)
86+
await nextTick()
87+
expect(data.value).toEqual('foo')
88+
expect(spy).toHaveBeenCalledWith('foo')
89+
})
90+
91+
it('should work with updated listeners', async () => {
92+
const spy1 = jest.fn()
93+
const spy2 = jest.fn()
94+
const toggle = ref(true)
95+
96+
const component = defineComponent({
97+
render() {
98+
return [
99+
withVModel(
100+
h('input', {
101+
'onUpdate:modelValue': toggle.value ? spy1 : spy2
102+
}),
103+
'foo'
104+
)
105+
]
106+
}
107+
})
108+
render(h(component), root)
109+
110+
const input = root.querySelector('input')!
111+
112+
input.value = 'foo'
113+
triggerEvent('input', input)
114+
await nextTick()
115+
expect(spy1).toHaveBeenCalledWith('foo')
116+
117+
// udpate listener
118+
toggle.value = false
119+
await nextTick()
120+
121+
input.value = 'bar'
122+
triggerEvent('input', input)
123+
await nextTick()
124+
expect(spy1).not.toHaveBeenCalledWith('bar')
125+
expect(spy2).toHaveBeenCalledWith('bar')
126+
})
127+
61128
it('should work with textarea', async () => {
62129
const component = defineComponent({
63130
data() {

packages/runtime-dom/src/directives/vModel.ts

+31-16
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@ import {
66
warn
77
} from '@vue/runtime-core'
88
import { addEventListener } from '../modules/events'
9-
import { isArray, looseEqual, looseIndexOf } from '@vue/shared'
9+
import { isArray, looseEqual, looseIndexOf, invokeArrayFns } from '@vue/shared'
1010

11-
const getModelAssigner = (vnode: VNode): ((value: any) => void) =>
12-
vnode.props!['onUpdate:modelValue']
11+
type AssignerFn = (value: any) => void
12+
13+
const getModelAssigner = (vnode: VNode): AssignerFn => {
14+
const fn = vnode.props!['onUpdate:modelValue']
15+
return isArray(fn) ? value => invokeArrayFns(fn, value) : fn
16+
}
1317

1418
function onCompositionStart(e: Event) {
1519
;(e.target as any).composing = true
@@ -34,14 +38,16 @@ function toNumber(val: string): number | string {
3438
return isNaN(n) ? val : n
3539
}
3640

41+
type ModelDirective<T> = ObjectDirective<T & { _assign: AssignerFn }>
42+
3743
// We are exporting the v-model runtime directly as vnode hooks so that it can
3844
// be tree-shaken in case v-model is never used.
39-
export const vModelText: ObjectDirective<
45+
export const vModelText: ModelDirective<
4046
HTMLInputElement | HTMLTextAreaElement
4147
> = {
4248
beforeMount(el, { value, modifiers: { lazy, trim, number } }, vnode) {
4349
el.value = value
44-
const assign = getModelAssigner(vnode)
50+
el._assign = getModelAssigner(vnode)
4551
const castToNumber = number || el.type === 'number'
4652
addEventListener(el, lazy ? 'change' : 'input', () => {
4753
let domValue: string | number = el.value
@@ -50,7 +56,7 @@ export const vModelText: ObjectDirective<
5056
} else if (castToNumber) {
5157
domValue = toNumber(domValue)
5258
}
53-
assign(domValue)
59+
el._assign(domValue)
5460
})
5561
if (trim) {
5662
addEventListener(el, 'change', () => {
@@ -67,7 +73,8 @@ export const vModelText: ObjectDirective<
6773
addEventListener(el, 'change', onCompositionEnd)
6874
}
6975
},
70-
beforeUpdate(el, { value, oldValue, modifiers: { trim, number } }) {
76+
beforeUpdate(el, { value, oldValue, modifiers: { trim, number } }, vnode) {
77+
el._assign = getModelAssigner(vnode)
7178
if (value === oldValue) {
7279
return
7380
}
@@ -83,14 +90,15 @@ export const vModelText: ObjectDirective<
8390
}
8491
}
8592

86-
export const vModelCheckbox: ObjectDirective<HTMLInputElement> = {
93+
export const vModelCheckbox: ModelDirective<HTMLInputElement> = {
8794
beforeMount(el, binding, vnode) {
8895
setChecked(el, binding, vnode)
89-
const assign = getModelAssigner(vnode)
96+
el._assign = getModelAssigner(vnode)
9097
addEventListener(el, 'change', () => {
9198
const modelValue = (el as any)._modelValue
9299
const elementValue = getValue(el)
93100
const checked = el.checked
101+
const assign = el._assign
94102
if (isArray(modelValue)) {
95103
const index = looseIndexOf(modelValue, elementValue)
96104
const found = index !== -1
@@ -106,7 +114,10 @@ export const vModelCheckbox: ObjectDirective<HTMLInputElement> = {
106114
}
107115
})
108116
},
109-
beforeUpdate: setChecked
117+
beforeUpdate(el, binding, vnode) {
118+
setChecked(el, binding, vnode)
119+
el._assign = getModelAssigner(vnode)
120+
}
110121
}
111122

112123
function setChecked(
@@ -124,33 +135,37 @@ function setChecked(
124135
}
125136
}
126137

127-
export const vModelRadio: ObjectDirective<HTMLInputElement> = {
138+
export const vModelRadio: ModelDirective<HTMLInputElement> = {
128139
beforeMount(el, { value }, vnode) {
129140
el.checked = looseEqual(value, vnode.props!.value)
130-
const assign = getModelAssigner(vnode)
141+
el._assign = getModelAssigner(vnode)
131142
addEventListener(el, 'change', () => {
132-
assign(getValue(el))
143+
el._assign(getValue(el))
133144
})
134145
},
135146
beforeUpdate(el, { value, oldValue }, vnode) {
147+
el._assign = getModelAssigner(vnode)
136148
if (value !== oldValue) {
137149
el.checked = looseEqual(value, vnode.props!.value)
138150
}
139151
}
140152
}
141153

142-
export const vModelSelect: ObjectDirective<HTMLSelectElement> = {
154+
export const vModelSelect: ModelDirective<HTMLSelectElement> = {
143155
// use mounted & updated because <select> relies on its children <option>s.
144156
mounted(el, { value }, vnode) {
145157
setSelected(el, value)
146-
const assign = getModelAssigner(vnode)
158+
el._assign = getModelAssigner(vnode)
147159
addEventListener(el, 'change', () => {
148160
const selectedVal = Array.prototype.filter
149161
.call(el.options, (o: HTMLOptionElement) => o.selected)
150162
.map(getValue)
151-
assign(el.multiple ? selectedVal : selectedVal[0])
163+
el._assign(el.multiple ? selectedVal : selectedVal[0])
152164
})
153165
},
166+
beforeUpdate(el, _binding, vnode) {
167+
el._assign = getModelAssigner(vnode)
168+
},
154169
updated(el, { value }) {
155170
setSelected(el, value)
156171
}

packages/shared/src/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -119,3 +119,9 @@ export const toDisplayString = (val: unknown): string => {
119119
? JSON.stringify(val, null, 2)
120120
: String(val)
121121
}
122+
123+
export function invokeArrayFns(fns: Function[], arg?: any) {
124+
for (let i = 0; i < fns.length; i++) {
125+
fns[i](arg)
126+
}
127+
}

0 commit comments

Comments
 (0)