feat(runtime-core): emits validation and warnings
This commit is contained in:
parent
24e9efcc21
commit
c7c3a6a3be
106
packages/runtime-core/__tests__/componentEmits.spec.ts
Normal file
106
packages/runtime-core/__tests__/componentEmits.spec.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
// Note: emits and listener fallthrough is tested in
|
||||||
|
// ./rendererAttrsFallthrough.spec.ts.
|
||||||
|
|
||||||
|
import { mockWarn } from '@vue/shared'
|
||||||
|
import { render, defineComponent, h, nodeOps } from '@vue/runtime-test'
|
||||||
|
import { isEmitListener } from '../src/componentEmits'
|
||||||
|
|
||||||
|
describe('emits option', () => {
|
||||||
|
mockWarn()
|
||||||
|
|
||||||
|
test('trigger both raw event and capitalize handlers', () => {
|
||||||
|
const Foo = defineComponent({
|
||||||
|
render() {},
|
||||||
|
created() {
|
||||||
|
// the `emit` function is bound on component instances
|
||||||
|
this.$emit('foo')
|
||||||
|
this.$emit('bar')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onfoo = jest.fn()
|
||||||
|
const onBar = jest.fn()
|
||||||
|
const Comp = () => h(Foo, { onfoo, onBar })
|
||||||
|
render(h(Comp), nodeOps.createElement('div'))
|
||||||
|
|
||||||
|
expect(onfoo).toHaveBeenCalled()
|
||||||
|
expect(onBar).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('trigger hyphendated events for update:xxx events', () => {
|
||||||
|
const Foo = defineComponent({
|
||||||
|
render() {},
|
||||||
|
created() {
|
||||||
|
this.$emit('update:fooProp')
|
||||||
|
this.$emit('update:barProp')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fooSpy = jest.fn()
|
||||||
|
const barSpy = jest.fn()
|
||||||
|
const Comp = () =>
|
||||||
|
h(Foo, {
|
||||||
|
'onUpdate:fooProp': fooSpy,
|
||||||
|
'onUpdate:bar-prop': barSpy
|
||||||
|
})
|
||||||
|
render(h(Comp), nodeOps.createElement('div'))
|
||||||
|
|
||||||
|
expect(fooSpy).toHaveBeenCalled()
|
||||||
|
expect(barSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('warning for undeclared event (array)', () => {
|
||||||
|
const Foo = defineComponent({
|
||||||
|
emits: ['foo'],
|
||||||
|
render() {},
|
||||||
|
created() {
|
||||||
|
// @ts-ignore
|
||||||
|
this.$emit('bar')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
render(h(Foo), nodeOps.createElement('div'))
|
||||||
|
expect(
|
||||||
|
`Component emitted event "bar" but it is not declared`
|
||||||
|
).toHaveBeenWarned()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('warning for undeclared event (object)', () => {
|
||||||
|
const Foo = defineComponent({
|
||||||
|
emits: {
|
||||||
|
foo: null
|
||||||
|
},
|
||||||
|
render() {},
|
||||||
|
created() {
|
||||||
|
// @ts-ignore
|
||||||
|
this.$emit('bar')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
render(h(Foo), nodeOps.createElement('div'))
|
||||||
|
expect(
|
||||||
|
`Component emitted event "bar" but it is not declared`
|
||||||
|
).toHaveBeenWarned()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('validator warning', () => {
|
||||||
|
const Foo = defineComponent({
|
||||||
|
emits: {
|
||||||
|
foo: (arg: number) => arg > 0
|
||||||
|
},
|
||||||
|
render() {},
|
||||||
|
created() {
|
||||||
|
this.$emit('foo', -1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
render(h(Foo), nodeOps.createElement('div'))
|
||||||
|
expect(`event validation failed for event "foo"`).toHaveBeenWarned()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('isEmitListener', () => {
|
||||||
|
expect(isEmitListener(['click'], 'onClick')).toBe(true)
|
||||||
|
expect(isEmitListener(['click'], 'onclick')).toBe(true)
|
||||||
|
expect(isEmitListener({ click: null }, 'onClick')).toBe(true)
|
||||||
|
expect(isEmitListener({ click: null }, 'onclick')).toBe(true)
|
||||||
|
expect(isEmitListener(['click'], 'onBlick')).toBe(false)
|
||||||
|
expect(isEmitListener({ click: null }, 'onBlick')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
@ -4,10 +4,12 @@ import {
|
|||||||
hasOwn,
|
hasOwn,
|
||||||
EMPTY_OBJ,
|
EMPTY_OBJ,
|
||||||
capitalize,
|
capitalize,
|
||||||
hyphenate
|
hyphenate,
|
||||||
|
isFunction
|
||||||
} from '@vue/shared'
|
} from '@vue/shared'
|
||||||
import { ComponentInternalInstance } from './component'
|
import { ComponentInternalInstance } from './component'
|
||||||
import { callWithAsyncErrorHandling, ErrorCodes } from './errorHandling'
|
import { callWithAsyncErrorHandling, ErrorCodes } from './errorHandling'
|
||||||
|
import { warn } from './warning'
|
||||||
|
|
||||||
export type ObjectEmitsOptions = Record<
|
export type ObjectEmitsOptions = Record<
|
||||||
string,
|
string,
|
||||||
@ -40,6 +42,29 @@ export function emit(
|
|||||||
...args: any[]
|
...args: any[]
|
||||||
): any[] {
|
): any[] {
|
||||||
const props = instance.vnode.props || EMPTY_OBJ
|
const props = instance.vnode.props || EMPTY_OBJ
|
||||||
|
|
||||||
|
if (__DEV__) {
|
||||||
|
const options = normalizeEmitsOptions(instance.type.emits)
|
||||||
|
if (options) {
|
||||||
|
if (!(event in options)) {
|
||||||
|
warn(
|
||||||
|
`Component emitted event "${event}" but it is not declared in the ` +
|
||||||
|
`emits option.`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const validator = options[event]
|
||||||
|
if (isFunction(validator)) {
|
||||||
|
const isValid = validator(...args)
|
||||||
|
if (!isValid) {
|
||||||
|
warn(
|
||||||
|
`Invalid event arguments: event validation failed for event "${event}".`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let handler = props[`on${event}`] || props[`on${capitalize(event)}`]
|
let handler = props[`on${event}`] || props[`on${capitalize(event)}`]
|
||||||
// for v-model update:xxx events, also trigger kebab-case equivalent
|
// for v-model update:xxx events, also trigger kebab-case equivalent
|
||||||
// for props passed via kebab-case
|
// for props passed via kebab-case
|
||||||
@ -81,13 +106,13 @@ export function normalizeEmitsOptions(
|
|||||||
// Check if an incoming prop key is a declared emit event listener.
|
// Check if an incoming prop key is a declared emit event listener.
|
||||||
// e.g. With `emits: { click: null }`, props named `onClick` and `onclick` are
|
// e.g. With `emits: { click: null }`, props named `onClick` and `onclick` are
|
||||||
// both considered matched listeners.
|
// both considered matched listeners.
|
||||||
export function isEmitListener(
|
export function isEmitListener(emits: EmitsOptions, key: string): boolean {
|
||||||
emits: ObjectEmitsOptions,
|
|
||||||
key: string
|
|
||||||
): boolean {
|
|
||||||
return (
|
return (
|
||||||
isOn(key) &&
|
isOn(key) &&
|
||||||
(hasOwn(emits, key[2].toLowerCase() + key.slice(3)) ||
|
(hasOwn(
|
||||||
|
(emits = normalizeEmitsOptions(emits) as ObjectEmitsOptions),
|
||||||
|
key[2].toLowerCase() + key.slice(3)
|
||||||
|
) ||
|
||||||
hasOwn(emits, key.slice(2)))
|
hasOwn(emits, key.slice(2)))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -102,7 +102,7 @@ export type ComponentOptionsWithoutProps<
|
|||||||
D = {},
|
D = {},
|
||||||
C extends ComputedOptions = {},
|
C extends ComputedOptions = {},
|
||||||
M extends MethodOptions = {},
|
M extends MethodOptions = {},
|
||||||
E extends EmitsOptions = Record<string, any>,
|
E extends EmitsOptions = EmitsOptions,
|
||||||
EE extends string = string
|
EE extends string = string
|
||||||
> = ComponentOptionsBase<Props, RawBindings, D, C, M, E, EE> & {
|
> = ComponentOptionsBase<Props, RawBindings, D, C, M, E, EE> & {
|
||||||
props?: undefined
|
props?: undefined
|
||||||
@ -116,7 +116,7 @@ export type ComponentOptionsWithArrayProps<
|
|||||||
D = {},
|
D = {},
|
||||||
C extends ComputedOptions = {},
|
C extends ComputedOptions = {},
|
||||||
M extends MethodOptions = {},
|
M extends MethodOptions = {},
|
||||||
E extends EmitsOptions = Record<string, any>,
|
E extends EmitsOptions = EmitsOptions,
|
||||||
EE extends string = string,
|
EE extends string = string,
|
||||||
Props = Readonly<{ [key in PropNames]?: any }>
|
Props = Readonly<{ [key in PropNames]?: any }>
|
||||||
> = ComponentOptionsBase<Props, RawBindings, D, C, M, E, EE> & {
|
> = ComponentOptionsBase<Props, RawBindings, D, C, M, E, EE> & {
|
||||||
@ -129,7 +129,7 @@ export type ComponentOptionsWithObjectProps<
|
|||||||
D = {},
|
D = {},
|
||||||
C extends ComputedOptions = {},
|
C extends ComputedOptions = {},
|
||||||
M extends MethodOptions = {},
|
M extends MethodOptions = {},
|
||||||
E extends EmitsOptions = Record<string, any>,
|
E extends EmitsOptions = EmitsOptions,
|
||||||
EE extends string = string,
|
EE extends string = string,
|
||||||
Props = Readonly<ExtractPropTypes<PropsOptions>>
|
Props = Readonly<ExtractPropTypes<PropsOptions>>
|
||||||
> = ComponentOptionsBase<Props, RawBindings, D, C, M, E, EE> & {
|
> = ComponentOptionsBase<Props, RawBindings, D, C, M, E, EE> & {
|
||||||
|
@ -18,7 +18,7 @@ import {
|
|||||||
} from '@vue/shared'
|
} from '@vue/shared'
|
||||||
import { warn } from './warning'
|
import { warn } from './warning'
|
||||||
import { Data, ComponentInternalInstance } from './component'
|
import { Data, ComponentInternalInstance } from './component'
|
||||||
import { normalizeEmitsOptions, isEmitListener } from './componentEmits'
|
import { isEmitListener } from './componentEmits'
|
||||||
|
|
||||||
export type ComponentPropsOptions<P = Data> =
|
export type ComponentPropsOptions<P = Data> =
|
||||||
| ComponentObjectPropsOptions<P>
|
| ComponentObjectPropsOptions<P>
|
||||||
@ -115,7 +115,7 @@ export function resolveProps(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { 0: options, 1: needCastKeys } = normalizePropsOptions(_options)!
|
const { 0: options, 1: needCastKeys } = normalizePropsOptions(_options)!
|
||||||
const emits = normalizeEmitsOptions(instance.type.emits)
|
const emits = instance.type.emits
|
||||||
const props: Data = {}
|
const props: Data = {}
|
||||||
let attrs: Data | undefined = undefined
|
let attrs: Data | undefined = undefined
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user