From c7c3a6a3bef6275be8f9f8873358421017bb5386 Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 3 Apr 2020 20:40:34 -0400 Subject: [PATCH] feat(runtime-core): emits validation and warnings --- .../__tests__/componentEmits.spec.ts | 106 ++++++++++++++++++ packages/runtime-core/src/componentEmits.ts | 37 +++++- packages/runtime-core/src/componentOptions.ts | 6 +- packages/runtime-core/src/componentProps.ts | 4 +- 4 files changed, 142 insertions(+), 11 deletions(-) create mode 100644 packages/runtime-core/__tests__/componentEmits.spec.ts diff --git a/packages/runtime-core/__tests__/componentEmits.spec.ts b/packages/runtime-core/__tests__/componentEmits.spec.ts new file mode 100644 index 00000000000..331b814cd3c --- /dev/null +++ b/packages/runtime-core/__tests__/componentEmits.spec.ts @@ -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) + }) +}) diff --git a/packages/runtime-core/src/componentEmits.ts b/packages/runtime-core/src/componentEmits.ts index b2720653c82..78a78ef51d5 100644 --- a/packages/runtime-core/src/componentEmits.ts +++ b/packages/runtime-core/src/componentEmits.ts @@ -4,10 +4,12 @@ import { hasOwn, EMPTY_OBJ, capitalize, - hyphenate + hyphenate, + isFunction } from '@vue/shared' import { ComponentInternalInstance } from './component' import { callWithAsyncErrorHandling, ErrorCodes } from './errorHandling' +import { warn } from './warning' export type ObjectEmitsOptions = Record< string, @@ -40,6 +42,29 @@ export function emit( ...args: any[] ): any[] { 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)}`] // for v-model update:xxx events, also trigger kebab-case equivalent // 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. // e.g. With `emits: { click: null }`, props named `onClick` and `onclick` are // both considered matched listeners. -export function isEmitListener( - emits: ObjectEmitsOptions, - key: string -): boolean { +export function isEmitListener(emits: EmitsOptions, key: string): boolean { return ( 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))) ) } diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index d9397316724..842e9a0cebc 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -102,7 +102,7 @@ export type ComponentOptionsWithoutProps< D = {}, C extends ComputedOptions = {}, M extends MethodOptions = {}, - E extends EmitsOptions = Record, + E extends EmitsOptions = EmitsOptions, EE extends string = string > = ComponentOptionsBase & { props?: undefined @@ -116,7 +116,7 @@ export type ComponentOptionsWithArrayProps< D = {}, C extends ComputedOptions = {}, M extends MethodOptions = {}, - E extends EmitsOptions = Record, + E extends EmitsOptions = EmitsOptions, EE extends string = string, Props = Readonly<{ [key in PropNames]?: any }> > = ComponentOptionsBase & { @@ -129,7 +129,7 @@ export type ComponentOptionsWithObjectProps< D = {}, C extends ComputedOptions = {}, M extends MethodOptions = {}, - E extends EmitsOptions = Record, + E extends EmitsOptions = EmitsOptions, EE extends string = string, Props = Readonly> > = ComponentOptionsBase & { diff --git a/packages/runtime-core/src/componentProps.ts b/packages/runtime-core/src/componentProps.ts index eb746b367a0..f34bfafd41a 100644 --- a/packages/runtime-core/src/componentProps.ts +++ b/packages/runtime-core/src/componentProps.ts @@ -18,7 +18,7 @@ import { } from '@vue/shared' import { warn } from './warning' import { Data, ComponentInternalInstance } from './component' -import { normalizeEmitsOptions, isEmitListener } from './componentEmits' +import { isEmitListener } from './componentEmits' export type ComponentPropsOptions

= | ComponentObjectPropsOptions

@@ -115,7 +115,7 @@ export function resolveProps( } const { 0: options, 1: needCastKeys } = normalizePropsOptions(_options)! - const emits = normalizeEmitsOptions(instance.type.emits) + const emits = instance.type.emits const props: Data = {} let attrs: Data | undefined = undefined