Skip to content

Commit c7c3a6a

Browse files
committed
feat(runtime-core): emits validation and warnings
1 parent 24e9efc commit c7c3a6a

File tree

4 files changed

+142
-11
lines changed

4 files changed

+142
-11
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Note: emits and listener fallthrough is tested in
2+
// ./rendererAttrsFallthrough.spec.ts.
3+
4+
import { mockWarn } from '@vue/shared'
5+
import { render, defineComponent, h, nodeOps } from '@vue/runtime-test'
6+
import { isEmitListener } from '../src/componentEmits'
7+
8+
describe('emits option', () => {
9+
mockWarn()
10+
11+
test('trigger both raw event and capitalize handlers', () => {
12+
const Foo = defineComponent({
13+
render() {},
14+
created() {
15+
// the `emit` function is bound on component instances
16+
this.$emit('foo')
17+
this.$emit('bar')
18+
}
19+
})
20+
21+
const onfoo = jest.fn()
22+
const onBar = jest.fn()
23+
const Comp = () => h(Foo, { onfoo, onBar })
24+
render(h(Comp), nodeOps.createElement('div'))
25+
26+
expect(onfoo).toHaveBeenCalled()
27+
expect(onBar).toHaveBeenCalled()
28+
})
29+
30+
test('trigger hyphendated events for update:xxx events', () => {
31+
const Foo = defineComponent({
32+
render() {},
33+
created() {
34+
this.$emit('update:fooProp')
35+
this.$emit('update:barProp')
36+
}
37+
})
38+
39+
const fooSpy = jest.fn()
40+
const barSpy = jest.fn()
41+
const Comp = () =>
42+
h(Foo, {
43+
'onUpdate:fooProp': fooSpy,
44+
'onUpdate:bar-prop': barSpy
45+
})
46+
render(h(Comp), nodeOps.createElement('div'))
47+
48+
expect(fooSpy).toHaveBeenCalled()
49+
expect(barSpy).toHaveBeenCalled()
50+
})
51+
52+
test('warning for undeclared event (array)', () => {
53+
const Foo = defineComponent({
54+
emits: ['foo'],
55+
render() {},
56+
created() {
57+
// @ts-ignore
58+
this.$emit('bar')
59+
}
60+
})
61+
render(h(Foo), nodeOps.createElement('div'))
62+
expect(
63+
`Component emitted event "bar" but it is not declared`
64+
).toHaveBeenWarned()
65+
})
66+
67+
test('warning for undeclared event (object)', () => {
68+
const Foo = defineComponent({
69+
emits: {
70+
foo: null
71+
},
72+
render() {},
73+
created() {
74+
// @ts-ignore
75+
this.$emit('bar')
76+
}
77+
})
78+
render(h(Foo), nodeOps.createElement('div'))
79+
expect(
80+
`Component emitted event "bar" but it is not declared`
81+
).toHaveBeenWarned()
82+
})
83+
84+
test('validator warning', () => {
85+
const Foo = defineComponent({
86+
emits: {
87+
foo: (arg: number) => arg > 0
88+
},
89+
render() {},
90+
created() {
91+
this.$emit('foo', -1)
92+
}
93+
})
94+
render(h(Foo), nodeOps.createElement('div'))
95+
expect(`event validation failed for event "foo"`).toHaveBeenWarned()
96+
})
97+
98+
test('isEmitListener', () => {
99+
expect(isEmitListener(['click'], 'onClick')).toBe(true)
100+
expect(isEmitListener(['click'], 'onclick')).toBe(true)
101+
expect(isEmitListener({ click: null }, 'onClick')).toBe(true)
102+
expect(isEmitListener({ click: null }, 'onclick')).toBe(true)
103+
expect(isEmitListener(['click'], 'onBlick')).toBe(false)
104+
expect(isEmitListener({ click: null }, 'onBlick')).toBe(false)
105+
})
106+
})

packages/runtime-core/src/componentEmits.ts

+31-6
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import {
44
hasOwn,
55
EMPTY_OBJ,
66
capitalize,
7-
hyphenate
7+
hyphenate,
8+
isFunction
89
} from '@vue/shared'
910
import { ComponentInternalInstance } from './component'
1011
import { callWithAsyncErrorHandling, ErrorCodes } from './errorHandling'
12+
import { warn } from './warning'
1113

1214
export type ObjectEmitsOptions = Record<
1315
string,
@@ -40,6 +42,29 @@ export function emit(
4042
...args: any[]
4143
): any[] {
4244
const props = instance.vnode.props || EMPTY_OBJ
45+
46+
if (__DEV__) {
47+
const options = normalizeEmitsOptions(instance.type.emits)
48+
if (options) {
49+
if (!(event in options)) {
50+
warn(
51+
`Component emitted event "${event}" but it is not declared in the ` +
52+
`emits option.`
53+
)
54+
} else {
55+
const validator = options[event]
56+
if (isFunction(validator)) {
57+
const isValid = validator(...args)
58+
if (!isValid) {
59+
warn(
60+
`Invalid event arguments: event validation failed for event "${event}".`
61+
)
62+
}
63+
}
64+
}
65+
}
66+
}
67+
4368
let handler = props[`on${event}`] || props[`on${capitalize(event)}`]
4469
// for v-model update:xxx events, also trigger kebab-case equivalent
4570
// for props passed via kebab-case
@@ -81,13 +106,13 @@ export function normalizeEmitsOptions(
81106
// Check if an incoming prop key is a declared emit event listener.
82107
// e.g. With `emits: { click: null }`, props named `onClick` and `onclick` are
83108
// both considered matched listeners.
84-
export function isEmitListener(
85-
emits: ObjectEmitsOptions,
86-
key: string
87-
): boolean {
109+
export function isEmitListener(emits: EmitsOptions, key: string): boolean {
88110
return (
89111
isOn(key) &&
90-
(hasOwn(emits, key[2].toLowerCase() + key.slice(3)) ||
112+
(hasOwn(
113+
(emits = normalizeEmitsOptions(emits) as ObjectEmitsOptions),
114+
key[2].toLowerCase() + key.slice(3)
115+
) ||
91116
hasOwn(emits, key.slice(2)))
92117
)
93118
}

packages/runtime-core/src/componentOptions.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export type ComponentOptionsWithoutProps<
102102
D = {},
103103
C extends ComputedOptions = {},
104104
M extends MethodOptions = {},
105-
E extends EmitsOptions = Record<string, any>,
105+
E extends EmitsOptions = EmitsOptions,
106106
EE extends string = string
107107
> = ComponentOptionsBase<Props, RawBindings, D, C, M, E, EE> & {
108108
props?: undefined
@@ -116,7 +116,7 @@ export type ComponentOptionsWithArrayProps<
116116
D = {},
117117
C extends ComputedOptions = {},
118118
M extends MethodOptions = {},
119-
E extends EmitsOptions = Record<string, any>,
119+
E extends EmitsOptions = EmitsOptions,
120120
EE extends string = string,
121121
Props = Readonly<{ [key in PropNames]?: any }>
122122
> = ComponentOptionsBase<Props, RawBindings, D, C, M, E, EE> & {
@@ -129,7 +129,7 @@ export type ComponentOptionsWithObjectProps<
129129
D = {},
130130
C extends ComputedOptions = {},
131131
M extends MethodOptions = {},
132-
E extends EmitsOptions = Record<string, any>,
132+
E extends EmitsOptions = EmitsOptions,
133133
EE extends string = string,
134134
Props = Readonly<ExtractPropTypes<PropsOptions>>
135135
> = ComponentOptionsBase<Props, RawBindings, D, C, M, E, EE> & {

packages/runtime-core/src/componentProps.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
} from '@vue/shared'
1919
import { warn } from './warning'
2020
import { Data, ComponentInternalInstance } from './component'
21-
import { normalizeEmitsOptions, isEmitListener } from './componentEmits'
21+
import { isEmitListener } from './componentEmits'
2222

2323
export type ComponentPropsOptions<P = Data> =
2424
| ComponentObjectPropsOptions<P>
@@ -115,7 +115,7 @@ export function resolveProps(
115115
}
116116

117117
const { 0: options, 1: needCastKeys } = normalizePropsOptions(_options)!
118-
const emits = normalizeEmitsOptions(instance.type.emits)
118+
const emits = instance.type.emits
119119
const props: Data = {}
120120
let attrs: Data | undefined = undefined
121121

0 commit comments

Comments
 (0)