From bf473a64eacab21d734d556c66cc190aa4ff902d Mon Sep 17 00:00:00 2001 From: Evan You Date: Fri, 3 Apr 2020 12:05:52 -0400 Subject: [PATCH] feat(runtime-core): type and attr fallthrough support for emits option --- .../rendererAttrsFallthrough.spec.ts | 69 ++++++++++- .../runtime-core/src/apiDefineComponent.ts | 47 ++++++-- packages/runtime-core/src/apiOptions.ts | 34 ++++-- packages/runtime-core/src/component.ts | 38 ++++-- packages/runtime-core/src/componentProps.ts | 100 ++++++++++------ packages/runtime-core/src/componentProxy.ts | 8 +- packages/runtime-core/src/renderer.ts | 3 +- test-dts/defineComponent.test-d.tsx | 108 +++++++++++++----- test-dts/functionalComponent.test-d.tsx | 39 +++++++ 9 files changed, 350 insertions(+), 96 deletions(-) create mode 100644 test-dts/functionalComponent.test-d.tsx diff --git a/packages/runtime-core/__tests__/rendererAttrsFallthrough.spec.ts b/packages/runtime-core/__tests__/rendererAttrsFallthrough.spec.ts index d7eb0ac9..4719f15d 100644 --- a/packages/runtime-core/__tests__/rendererAttrsFallthrough.spec.ts +++ b/packages/runtime-core/__tests__/rendererAttrsFallthrough.spec.ts @@ -8,7 +8,8 @@ import { onUpdated, defineComponent, openBlock, - createBlock + createBlock, + FunctionalComponent } from '@vue/runtime-dom' import { mockWarn } from '@vue/shared' @@ -428,4 +429,70 @@ describe('attribute fallthrough', () => { await nextTick() expect(root.innerHTML).toBe(`
`) }) + + it('should not let listener fallthrough when declared in emits (stateful)', () => { + const Child = defineComponent({ + emits: ['click'], + render() { + return h( + 'button', + { + onClick: () => { + this.$emit('click', 'custom') + } + }, + 'hello' + ) + } + }) + + const onClick = jest.fn() + const App = { + render() { + return h(Child, { + onClick + }) + } + } + + const root = document.createElement('div') + document.body.appendChild(root) + render(h(App), root) + + const node = root.children[0] as HTMLElement + node.dispatchEvent(new CustomEvent('click')) + expect(onClick).toHaveBeenCalledTimes(1) + expect(onClick).toHaveBeenCalledWith('custom') + }) + + it('should not let listener fallthrough when declared in emits (functional)', () => { + const Child: FunctionalComponent<{}, { click: any }> = (_, { emit }) => { + // should not be in props + expect((_ as any).onClick).toBeUndefined() + return h('button', { + onClick: () => { + emit('click', 'custom') + } + }) + } + Child.emits = ['click'] + + const onClick = jest.fn() + const App = { + render() { + return h(Child, { + onClick + }) + } + } + + const root = document.createElement('div') + document.body.appendChild(root) + render(h(App), root) + + const node = root.children[0] as HTMLElement + node.dispatchEvent(new CustomEvent('click')) + expect(onClick).toHaveBeenCalledTimes(1) + expect(onClick).toHaveBeenCalledWith('custom') + }) }) diff --git a/packages/runtime-core/src/apiDefineComponent.ts b/packages/runtime-core/src/apiDefineComponent.ts index 9244a816..6d261f1b 100644 --- a/packages/runtime-core/src/apiDefineComponent.ts +++ b/packages/runtime-core/src/apiDefineComponent.ts @@ -3,7 +3,8 @@ import { MethodOptions, ComponentOptionsWithoutProps, ComponentOptionsWithArrayProps, - ComponentOptionsWithObjectProps + ComponentOptionsWithObjectProps, + EmitsOptions } from './apiOptions' import { SetupContext, RenderFunction } from './component' import { ComponentPublicInstance } from './componentProxy' @@ -39,13 +40,15 @@ export function defineComponent( // (uses user defined props interface) // return type is for Vetur and TSX support export function defineComponent< - Props, - RawBindings, - D, + Props = {}, + RawBindings = {}, + D = {}, C extends ComputedOptions = {}, - M extends MethodOptions = {} + M extends MethodOptions = {}, + E extends EmitsOptions = Record, + EE extends string = string >( - options: ComponentOptionsWithoutProps + options: ComponentOptionsWithoutProps ): { new (): ComponentPublicInstance< Props, @@ -53,6 +56,7 @@ export function defineComponent< D, C, M, + E, VNodeProps & Props > } @@ -65,12 +69,22 @@ export function defineComponent< RawBindings, D, C extends ComputedOptions = {}, - M extends MethodOptions = {} + M extends MethodOptions = {}, + E extends EmitsOptions = Record, + EE extends string = string >( - options: ComponentOptionsWithArrayProps + options: ComponentOptionsWithArrayProps< + PropNames, + RawBindings, + D, + C, + M, + E, + EE + > ): { // array props technically doesn't place any contraints on props in TSX - new (): ComponentPublicInstance + new (): ComponentPublicInstance } // overload 4: object format with object props declaration @@ -82,9 +96,19 @@ export function defineComponent< RawBindings, D, C extends ComputedOptions = {}, - M extends MethodOptions = {} + M extends MethodOptions = {}, + E extends EmitsOptions = Record, + EE extends string = string >( - options: ComponentOptionsWithObjectProps + options: ComponentOptionsWithObjectProps< + PropsOptions, + RawBindings, + D, + C, + M, + E, + EE + > ): { new (): ComponentPublicInstance< ExtractPropTypes, @@ -92,6 +116,7 @@ export function defineComponent< D, C, M, + E, VNodeProps & ExtractPropTypes > } diff --git a/packages/runtime-core/src/apiOptions.ts b/packages/runtime-core/src/apiOptions.ts index 0ed0e5c2..21df1f96 100644 --- a/packages/runtime-core/src/apiOptions.ts +++ b/packages/runtime-core/src/apiOptions.ts @@ -50,12 +50,14 @@ export interface ComponentOptionsBase< RawBindings, D, C extends ComputedOptions, - M extends MethodOptions -> extends LegacyOptions, SFCInternalOptions { + M extends MethodOptions, + E extends EmitsOptions, + EE extends string = string +> extends LegacyOptions, SFCInternalOptions { setup?: ( this: void, props: Props, - ctx: SetupContext + ctx: SetupContext ) => RawBindings | RenderFunction | void name?: string template?: string | object // can be a direct DOM node @@ -75,6 +77,7 @@ export interface ComponentOptionsBase< components?: Record directives?: Record inheritAttrs?: boolean + emits?: E | EE[] // Internal ------------------------------------------------------------------ @@ -97,10 +100,14 @@ export type ComponentOptionsWithoutProps< RawBindings = {}, D = {}, C extends ComputedOptions = {}, - M extends MethodOptions = {} -> = ComponentOptionsBase & { + M extends MethodOptions = {}, + E extends EmitsOptions = Record, + EE extends string = string +> = ComponentOptionsBase & { props?: undefined -} & ThisType>> +} & ThisType< + ComponentPublicInstance<{}, RawBindings, D, C, M, E, Readonly> + > export type ComponentOptionsWithArrayProps< PropNames extends string = string, @@ -108,10 +115,12 @@ export type ComponentOptionsWithArrayProps< D = {}, C extends ComputedOptions = {}, M extends MethodOptions = {}, + E extends EmitsOptions = Record, + EE extends string = string, Props = Readonly<{ [key in PropNames]?: any }> -> = ComponentOptionsBase & { +> = ComponentOptionsBase & { props: PropNames[] -} & ThisType> +} & ThisType> export type ComponentOptionsWithObjectProps< PropsOptions = ComponentObjectPropsOptions, @@ -119,10 +128,12 @@ export type ComponentOptionsWithObjectProps< D = {}, C extends ComputedOptions = {}, M extends MethodOptions = {}, + E extends EmitsOptions = Record, + EE extends string = string, Props = Readonly> -> = ComponentOptionsBase & { +> = ComponentOptionsBase & { props: PropsOptions -} & ThisType> +} & ThisType> export type ComponentOptions = | ComponentOptionsWithoutProps @@ -138,6 +149,8 @@ export interface MethodOptions { [key: string]: Function } +export type EmitsOptions = Record | string[] + export type ExtractComputedReturns = { [key in keyof T]: T[key] extends { get: Function } ? ReturnType @@ -162,7 +175,6 @@ type ComponentInjectOptions = export interface LegacyOptions< Props, - RawBindings, D, C extends ComputedOptions, M extends MethodOptions diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 130a7af7..de40cdbf 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -21,7 +21,7 @@ import { } from './errorHandling' import { AppContext, createAppContext, AppConfig } from './apiCreateApp' import { Directive, validateDirectiveName } from './directives' -import { applyOptions, ComponentOptions } from './apiOptions' +import { applyOptions, ComponentOptions, EmitsOptions } from './apiOptions' import { EMPTY_OBJ, isFunction, @@ -52,9 +52,13 @@ export interface SFCInternalOptions { __hmrUpdated?: boolean } -export interface FunctionalComponent

extends SFCInternalOptions { - (props: P, ctx: SetupContext): VNodeChild +export interface FunctionalComponent< + P = {}, + E extends EmitsOptions = Record +> extends SFCInternalOptions { + (props: P, ctx: SetupContext): any props?: ComponentPropsOptions

+ emits?: E | (keyof E)[] inheritAttrs?: boolean displayName?: string } @@ -92,12 +96,29 @@ export const enum LifecycleHooks { ERROR_CAPTURED = 'ec' } -export type Emit = (event: string, ...args: unknown[]) => any[] +type UnionToIntersection = (U extends any + ? (k: U) => void + : never) extends ((k: infer I) => void) + ? I + : never -export interface SetupContext { +export type Emit< + Options = Record, + Event extends keyof Options = keyof Options +> = Options extends any[] + ? (event: Options[0], ...args: any[]) => unknown[] + : UnionToIntersection< + { + [key in Event]: Options[key] extends ((...args: infer Args) => any) + ? (event: key, ...args: Args) => unknown[] + : (event: key, ...args: any[]) => unknown[] + }[Event] + > + +export interface SetupContext> { attrs: Data slots: Slots - emit: Emit + emit: Emit } export type RenderFunction = { @@ -248,7 +269,7 @@ export function createComponentInstance( rtc: null, ec: null, - emit: (event, ...args): any[] => { + emit: (event: string, ...args: any[]): any[] => { const props = instance.vnode.props || EMPTY_OBJ let handler = props[`on${event}`] || props[`on${capitalize(event)}`] if (!handler && event.indexOf('update:') === 0) { @@ -303,9 +324,8 @@ export function setupComponent( isSSR = false ) { isInSSRComponentSetup = isSSR - const propsOptions = instance.type.props const { props, children, shapeFlag } = instance.vnode - resolveProps(instance, props, propsOptions) + resolveProps(instance, props) resolveSlots(instance, children) // setup stateful logic diff --git a/packages/runtime-core/src/componentProps.ts b/packages/runtime-core/src/componentProps.ts index 61f968b7..1b51a9eb 100644 --- a/packages/runtime-core/src/componentProps.ts +++ b/packages/runtime-core/src/componentProps.ts @@ -13,10 +13,13 @@ import { PatchFlags, makeMap, isReservedProp, - EMPTY_ARR + EMPTY_ARR, + ShapeFlags, + isOn } from '@vue/shared' import { warn } from './warning' import { Data, ComponentInternalInstance } from './component' +import { EmitsOptions } from './apiOptions' export type ComponentPropsOptions

= | ComponentObjectPropsOptions

@@ -103,15 +106,17 @@ type NormalizedPropsOptions = [Record, string[]] export function resolveProps( instance: ComponentInternalInstance, - rawProps: Data | null, - _options: ComponentPropsOptions | void + rawProps: Data | null ) { + const _options = instance.type.props const hasDeclaredProps = !!_options if (!rawProps && !hasDeclaredProps) { + instance.props = instance.attrs = EMPTY_OBJ return } const { 0: options, 1: needCastKeys } = normalizePropsOptions(_options)! + const emits = normalizeEmitsOptions(instance.type.emits) const props: Data = {} let attrs: Data | undefined = undefined @@ -139,20 +144,18 @@ export function resolveProps( } // prop option names are camelized during normalization, so to support // kebab -> camel conversion here we need to camelize the key. - if (hasDeclaredProps) { - const camelKey = camelize(key) - if (hasOwn(options, camelKey)) { - setProp(camelKey, value) - } else { - // Any non-declared props are put into a separate `attrs` object - // for spreading. Make sure to preserve original key casing - ;(attrs || (attrs = {}))[key] = value - } - } else { - setProp(key, value) + let camelKey + if (hasDeclaredProps && hasOwn(options, (camelKey = camelize(key)))) { + setProp(camelKey, value) + } else if (!emits || !isListener(emits, key)) { + // Any non-declared (either as a prop or an emitted event) props are put + // into a separate `attrs` object for spreading. Make sure to preserve + // original key casing + ;(attrs || (attrs = {}))[key] = value } } } + if (hasDeclaredProps) { // set default values & cast booleans for (let i = 0; i < needCastKeys.length; i++) { @@ -186,15 +189,16 @@ export function resolveProps( validateProp(key, props[key], opt, !hasOwn(props, key)) } } - } else { - // if component has no declared props, $attrs === $props - attrs = props } // in case of dynamic props, check if we need to delete keys from // the props proxy const { patchFlag } = instance.vnode - if (propsProxy && (patchFlag === 0 || patchFlag & PatchFlags.FULL_PROPS)) { + if ( + hasDeclaredProps && + propsProxy && + (patchFlag === 0 || patchFlag & PatchFlags.FULL_PROPS) + ) { const rawInitialProps = toRaw(propsProxy) for (const key in rawInitialProps) { if (!hasOwn(props, key)) { @@ -206,15 +210,18 @@ export function resolveProps( // lock readonly lock() - instance.props = props + if ( + instance.vnode.shapeFlag & ShapeFlags.FUNCTIONAL_COMPONENT && + !hasDeclaredProps + ) { + // functional component with optional props: use attrs as props + instance.props = attrs || EMPTY_OBJ + } else { + instance.props = props + } instance.attrs = attrs || EMPTY_OBJ } -const normalizationMap = new WeakMap< - ComponentPropsOptions, - NormalizedPropsOptions ->() - function validatePropName(key: string) { if (key[0] !== '$') { return true @@ -230,10 +237,10 @@ export function normalizePropsOptions( if (!raw) { return EMPTY_ARR as any } - if (normalizationMap.has(raw)) { - return normalizationMap.get(raw)! + if ((raw as any)._n) { + return (raw as any)._n } - const options: NormalizedPropsOptions[0] = {} + const normalized: NormalizedPropsOptions[0] = {} const needCastKeys: NormalizedPropsOptions[1] = [] if (isArray(raw)) { for (let i = 0; i < raw.length; i++) { @@ -242,7 +249,7 @@ export function normalizePropsOptions( } const normalizedKey = camelize(raw[i]) if (validatePropName(normalizedKey)) { - options[normalizedKey] = EMPTY_OBJ + normalized[normalizedKey] = EMPTY_OBJ } } } else { @@ -253,7 +260,7 @@ export function normalizePropsOptions( const normalizedKey = camelize(key) if (validatePropName(normalizedKey)) { const opt = raw[key] - const prop: NormalizedProp = (options[normalizedKey] = + const prop: NormalizedProp = (normalized[normalizedKey] = isArray(opt) || isFunction(opt) ? { type: opt } : opt) if (prop) { const booleanIndex = getTypeIndex(Boolean, prop.type) @@ -269,9 +276,38 @@ export function normalizePropsOptions( } } } - const normalized: NormalizedPropsOptions = [options, needCastKeys] - normalizationMap.set(raw, normalized) - return normalized + const normalizedEntry: NormalizedPropsOptions = [normalized, needCastKeys] + Object.defineProperty(raw, '_n', { value: normalizedEntry }) + return normalizedEntry +} + +function normalizeEmitsOptions( + options: EmitsOptions | undefined +): Record | undefined { + if (!options) { + return + } else if (isArray(options)) { + if ((options as any)._n) { + return (options as any)._n + } + const normalized: Record = {} + options.forEach(key => (normalized[key] = null)) + Object.defineProperty(options, '_n', normalized) + return normalized + } else { + return options + } +} + +function isListener(emits: Record, key: string): boolean { + if (!isOn(key)) { + return false + } + const eventName = key.slice(2) + return ( + hasOwn(emits, eventName) || + hasOwn(emits, eventName[0].toLowerCase() + eventName.slice(1)) + ) } // use function string name to check type constructors diff --git a/packages/runtime-core/src/componentProxy.ts b/packages/runtime-core/src/componentProxy.ts index ce2261d7..5b0e0890 100644 --- a/packages/runtime-core/src/componentProxy.ts +++ b/packages/runtime-core/src/componentProxy.ts @@ -7,7 +7,8 @@ import { ComponentOptionsBase, ComputedOptions, MethodOptions, - resolveMergedOptions + resolveMergedOptions, + EmitsOptions } from './apiOptions' import { ReactiveEffect, UnwrapRef } from '@vue/reactivity' import { warn } from './warning' @@ -26,6 +27,7 @@ export type ComponentPublicInstance< D = {}, // return from data() C extends ComputedOptions = {}, M extends MethodOptions = {}, + E extends EmitsOptions = {}, PublicProps = P > = { $: ComponentInternalInstance @@ -36,9 +38,9 @@ export type ComponentPublicInstance< $slots: Slots $root: ComponentInternalInstance | null $parent: ComponentInternalInstance | null - $emit: Emit + $emit: Emit $el: any - $options: ComponentOptionsBase + $options: ComponentOptionsBase $forceUpdate: ReactiveEffect $nextTick: typeof nextTick $watch: typeof instanceWatch diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index f28bf250..d2ceca78 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -15,7 +15,6 @@ import { import { ComponentInternalInstance, createComponentInstance, - Component, Data, setupComponent } from './component' @@ -1249,7 +1248,7 @@ function baseCreateRenderer( nextVNode.component = instance instance.vnode = nextVNode instance.next = null - resolveProps(instance, nextVNode.props, (nextVNode.type as Component).props) + resolveProps(instance, nextVNode.props) resolveSlots(instance, nextVNode.children) } diff --git a/test-dts/defineComponent.test-d.tsx b/test-dts/defineComponent.test-d.tsx index 7df73044..05023e16 100644 --- a/test-dts/defineComponent.test-d.tsx +++ b/test-dts/defineComponent.test-d.tsx @@ -177,35 +177,35 @@ describe('with object props', () => { expectError() }) -describe('type inference w/ optional props declaration', () => { - const MyComponent = defineComponent({ - setup(_props: { msg: string }) { - return { - a: 1 - } - }, - render() { - expectType(this.$props.msg) - // props should be readonly - expectError((this.$props.msg = 'foo')) - // should not expose on `this` - expectError(this.msg) - expectType(this.a) - return null - } - }) +// describe('type inference w/ optional props declaration', () => { +// const MyComponent = defineComponent({ +// setup(_props: { msg: string }) { +// return { +// a: 1 +// } +// }, +// render() { +// expectType(this.$props.msg) +// // props should be readonly +// expectError((this.$props.msg = 'foo')) +// // should not expose on `this` +// expectError(this.msg) +// expectType(this.a) +// return null +// } +// }) - expectType() - expectError() - expectError() -}) +// expectType() +// expectError() +// expectError() +// }) -describe('type inference w/ direct setup function', () => { - const MyComponent = defineComponent((_props: { msg: string }) => {}) - expectType() - expectError() - expectError() -}) +// describe('type inference w/ direct setup function', () => { +// const MyComponent = defineComponent((_props: { msg: string }) => {}) +// expectType() +// expectError() +// expectError() +// }) describe('type inference w/ array props declaration', () => { defineComponent({ @@ -320,3 +320,57 @@ describe('defineComponent', () => { }) }) }) + +describe('emits', () => { + // Note: for TSX inference, ideally we want to map emits to onXXX props, + // but that requires type-level string constant concatenation as suggested in + // https://github.com/Microsoft/TypeScript/issues/12754 + + // The workaround for TSX users is instead of using emits, declare onXXX props + // and call them instead. Since `v-on:click` compiles to an `onClick` prop, + // this would also support other users consuming the component in templates + // with `v-on` listeners. + + // with object emits + defineComponent({ + emits: { + click: (n: number) => typeof n === 'number', + input: (b: string) => null + }, + setup(props, { emit }) { + emit('click', 1) + emit('input', 'foo') + expectError(emit('nope')) + expectError(emit('click')) + expectError(emit('click', 'foo')) + expectError(emit('input')) + expectError(emit('input', 1)) + }, + created() { + this.$emit('click', 1) + this.$emit('input', 'foo') + expectError(this.$emit('nope')) + expectError(this.$emit('click')) + expectError(this.$emit('click', 'foo')) + expectError(this.$emit('input')) + expectError(this.$emit('input', 1)) + } + }) + + // with array emits + defineComponent({ + emits: ['foo', 'bar'], + setup(props, { emit }) { + emit('foo') + emit('foo', 123) + emit('bar') + expectError(emit('nope')) + }, + created() { + this.$emit('foo') + this.$emit('foo', 123) + this.$emit('bar') + expectError(this.$emit('nope')) + } + }) +}) diff --git a/test-dts/functionalComponent.test-d.tsx b/test-dts/functionalComponent.test-d.tsx new file mode 100644 index 00000000..a5b4d899 --- /dev/null +++ b/test-dts/functionalComponent.test-d.tsx @@ -0,0 +1,39 @@ +import { expectError, expectType } from 'tsd' +import { FunctionalComponent } from './index' + +// simple function signature +const Foo = (props: { foo: number }) => props.foo + +// TSX +expectType() +expectError() +expectError() + +// Explicit signature with props + emits +const Bar: FunctionalComponent< + { foo: number }, + { update: (value: number) => void } +> = (props, { emit }) => { + expectType(props.foo) + + emit('update', 123) + expectError(emit('nope')) + expectError(emit('update')) + expectError(emit('update', 'nope')) +} + +// assigning runtime options +Bar.props = { + foo: Number +} +expectError((Bar.props = { foo: String })) + +Bar.emits = { + update: value => value > 1 +} +expectError((Bar.emits = { baz: () => void 0 })) + +// TSX +expectType() +expectError() +expectError()