diff --git a/src/component/common.ts b/src/component/common.ts new file mode 100644 index 00000000..014e0cd4 --- /dev/null +++ b/src/component/common.ts @@ -0,0 +1 @@ +export type Data = { [key: string]: unknown } diff --git a/src/component/component.ts b/src/component/component.ts deleted file mode 100644 index 8751a22b..00000000 --- a/src/component/component.ts +++ /dev/null @@ -1,161 +0,0 @@ -import Vue, { - VueConstructor, - VNode, - ComponentOptions as Vue2ComponentOptions, -} from 'vue' -import { ComponentPropsOptions, ExtractPropTypes } from './componentProps' -import { UnwrapRef } from '../reactivity' -import { HasDefined } from '../types/basic' - -export type Data = { [key: string]: unknown } - -export type ComponentInstance = InstanceType - -// public properties exposed on the proxy, which is used as the render context -// in templates (as `this` in the render option) -export type ComponentRenderProxy

= { - $data: S - $props: PublicProps - $attrs: Data - $refs: Data - $slots: Data - $root: ComponentInstance | null - $parent: ComponentInstance | null - $emit: (event: string, ...args: unknown[]) => void -} & P & - S - -// for Vetur and TSX support -type VueConstructorProxy = VueConstructor & { - new (...args: any[]): ComponentRenderProxy< - ExtractPropTypes, - UnwrapRef, - ExtractPropTypes - > -} - -type VueProxy = Vue2ComponentOptions< - Vue, - UnwrapRef, - never, - never, - PropsOptions, - ExtractPropTypes -> & - VueConstructorProxy - -export interface SetupContext { - readonly attrs: Record - readonly slots: { [key: string]: (...args: any[]) => VNode[] } - readonly parent: ComponentInstance | null - readonly root: ComponentInstance - readonly listeners: { [key: string]: Function } - - emit(event: string, ...args: any[]): void -} - -export type SetupFunction = ( - this: void, - props: Props, - ctx: SetupContext -) => RawBindings | (() => VNode | null) - -interface ComponentOptionsWithProps< - PropsOptions = ComponentPropsOptions, - RawBindings = Data, - Props = ExtractPropTypes -> { - props?: PropsOptions - setup?: SetupFunction -} - -interface ComponentOptionsWithArrayProps< - PropNames extends string = string, - RawBindings = Data, - Props = Readonly<{ [key in PropNames]?: any }> -> { - props?: PropNames[] - setup?: SetupFunction -} - -interface ComponentOptionsWithoutProps { - props?: undefined - setup?: SetupFunction -} - -// overload 1: object format with no props -export function defineComponent( - options: ComponentOptionsWithoutProps -): VueProxy -// overload 2: object format with array props declaration -// props inferred as { [key in PropNames]?: any } -// return type is for Vetur and TSX support -export function defineComponent< - PropNames extends string, - RawBindings = Data, - PropsOptions extends ComponentPropsOptions = ComponentPropsOptions ->( - // prettier-ignore - options: ( - ComponentOptionsWithArrayProps) & - Omit, keyof ComponentOptionsWithProps> -): VueProxy, RawBindings> -// overload 3: object format with object props declaration -// see `ExtractPropTypes` in ./componentProps.ts -export function defineComponent< - Props, - RawBindings = Data, - PropsOptions extends ComponentPropsOptions = ComponentPropsOptions ->( - // prettier-ignore - options: ( - // prefer the provided Props, otherwise infer it from PropsOptions - HasDefined extends true - ? ComponentOptionsWithProps - : ComponentOptionsWithProps) & - Omit, keyof ComponentOptionsWithProps> -): VueProxy -// implementation, close to no-op -export function defineComponent(options: any) { - return options as any -} - -// overload 1: object format with no props -export function createComponent( - options: ComponentOptionsWithoutProps -): VueProxy -// overload 2: object format with array props declaration -// props inferred as { [key in PropNames]?: any } -// return type is for Vetur and TSX support -export function createComponent< - PropNames extends string, - RawBindings = Data, - PropsOptions extends ComponentPropsOptions = ComponentPropsOptions ->( - // prettier-ignore - options: ( - ComponentOptionsWithArrayProps) & - Omit, keyof ComponentOptionsWithProps> -): VueProxy, RawBindings> -// overload 3: object format with object props declaration -// see `ExtractPropTypes` in ./componentProps.ts -export function createComponent< - Props, - RawBindings = Data, - PropsOptions extends ComponentPropsOptions = ComponentPropsOptions ->( - // prettier-ignore - options: ( - // prefer the provided Props, otherwise infer it from PropsOptions - HasDefined extends true - ? ComponentOptionsWithProps - : ComponentOptionsWithProps) & - Omit, keyof ComponentOptionsWithProps> -): VueProxy -// implementation, deferring to defineComponent, but logging a warning in dev mode -export function createComponent(options: any) { - if (__DEV__) { - Vue.util.warn('`createComponent` has been renamed to `defineComponent`.') - } - return defineComponent(options) -} diff --git a/src/component/componentOptions.ts b/src/component/componentOptions.ts new file mode 100644 index 00000000..5a2bce77 --- /dev/null +++ b/src/component/componentOptions.ts @@ -0,0 +1,100 @@ +import { Data } from './common' +import { ComponentPropsOptions, ExtractPropTypes } from './componentProps' +import { VNode } from 'vue' +import { ComponentInstance, ComponentRenderProxy } from './componentProxy' + +import { ComponentOptions as Vue2ComponentOptions } from 'vue' + +export interface SetupContext { + readonly attrs: Record + readonly slots: { [key: string]: (...args: any[]) => VNode[] } + readonly parent: ComponentInstance | null + readonly root: ComponentInstance + readonly listeners: { [key: string]: Function } + + emit(event: string, ...args: any[]): void +} + +export type ComputedGetter = (ctx?: any) => T +export type ComputedSetter = (v: T) => void + +export interface WritableComputedOptions { + get: ComputedGetter + set: ComputedSetter +} + +export type ComputedOptions = Record< + string, + ComputedGetter | WritableComputedOptions +> + +export interface MethodOptions { + [key: string]: Function +} + +export type SetupFunction = ( + this: void, + props: Props, + ctx: SetupContext +) => RawBindings | (() => VNode | null) + +interface ComponentOptionsBase< + Props, + D = Data, + C extends ComputedOptions = {}, + M extends MethodOptions = {} +> + extends Omit< + Vue2ComponentOptions, + 'data' | 'computed' | 'method' | 'setup' | 'props' + > { + data?: (this: Props, vm: Props) => D + computed?: C + methods?: M +} + +export type ExtractComputedReturns = { + [key in keyof T]: T[key] extends { get: (...args: any[]) => infer TReturn } + ? TReturn + : T[key] extends (...args: any[]) => infer TReturn + ? TReturn + : never +} + +export type ComponentOptionsWithProps< + PropsOptions = ComponentPropsOptions, + RawBindings = Data, + D = Data, + C extends ComputedOptions = {}, + M extends MethodOptions = {}, + Props = ExtractPropTypes +> = ComponentOptionsBase & { + props?: PropsOptions + setup?: SetupFunction +} & ThisType> + +export type ComponentOptionsWithArrayProps< + PropNames extends string = string, + RawBindings = Data, + D = Data, + C extends ComputedOptions = {}, + M extends MethodOptions = {}, + Props = Readonly<{ [key in PropNames]?: any }> +> = ComponentOptionsBase & { + props?: PropNames[] + setup?: SetupFunction +} & ThisType> + +export type ComponentOptionsWithoutProps< + Props = unknown, + RawBindings = Data, + D = Data, + C extends ComputedOptions = {}, + M extends MethodOptions = {} +> = ComponentOptionsBase & { + props?: undefined + setup?: SetupFunction +} & ThisType> + +export type WithLegacyAPI = T & + Omit, keyof T> diff --git a/src/component/componentProps.ts b/src/component/componentProps.ts index a6eeb5cd..83e0f932 100644 --- a/src/component/componentProps.ts +++ b/src/component/componentProps.ts @@ -1,4 +1,4 @@ -import { Data } from './component' +import { Data } from './common' export type ComponentPropsOptions

= | ComponentObjectPropsOptions

diff --git a/src/component/componentProxy.ts b/src/component/componentProxy.ts new file mode 100644 index 00000000..81c3dc9d --- /dev/null +++ b/src/component/componentProxy.ts @@ -0,0 +1,69 @@ +import { ExtractPropTypes } from './componentProps' +import { UnwrapRef } from '..' +import { Data } from './common' + +import Vue, { + VueConstructor, + ComponentOptions as Vue2ComponentOptions, +} from 'vue' +import { + ComputedOptions, + MethodOptions, + ExtractComputedReturns, +} from './componentOptions' + +export type ComponentInstance = InstanceType + +// public properties exposed on the proxy, which is used as the render context +// in templates (as `this` in the render option) +export type ComponentRenderProxy< + P = {}, // props type extracted from props option + B = {}, // raw bindings returned from setup() + D = {}, // return from data() + C extends ComputedOptions = {}, + M extends MethodOptions = {}, + PublicProps = P +> = { + $data: D + $props: Readonly

+ $attrs: Data + $refs: Data + $slots: Data + $root: ComponentInstance | null + $parent: ComponentInstance | null + $emit: (event: string, ...args: unknown[]) => void +} & Readonly

& + UnwrapRef & + D & + M & + ExtractComputedReturns & + Vue + +// for Vetur and TSX support +type VueConstructorProxy = VueConstructor & { + new (...args: any[]): ComponentRenderProxy< + ExtractPropTypes, + UnwrapRef, + ExtractPropTypes + > +} + +type DefaultData = object | ((this: V) => object) +type DefaultMethods = { [key: string]: (this: V, ...args: any[]) => any } +type DefaultComputed = { [key: string]: any } + +export type VueProxy< + PropsOptions, + RawBindings, + Data = DefaultData, + Computed = DefaultComputed, + Methods = DefaultMethods +> = Vue2ComponentOptions< + Vue, + UnwrapRef & Data, + Methods, + Computed, + PropsOptions, + ExtractPropTypes +> & + VueConstructorProxy diff --git a/src/component/defineComponent.ts b/src/component/defineComponent.ts new file mode 100644 index 00000000..81e24bb2 --- /dev/null +++ b/src/component/defineComponent.ts @@ -0,0 +1,62 @@ +import Vue from 'vue' +import { ComponentPropsOptions } from './componentProps' +import { + MethodOptions, + ComputedOptions, + ComponentOptionsWithoutProps, + ComponentOptionsWithArrayProps, + ComponentOptionsWithProps, +} from './componentOptions' +import { VueProxy } from './componentProxy' +import { Data } from './common' +import { HasDefined } from '../types/basic' + +// overload 1: object format with no props +export function defineComponent< + RawBindings, + D = Data, + C extends ComputedOptions = {}, + M extends MethodOptions = {} +>( + options: ComponentOptionsWithoutProps +): VueProxy + +// overload 2: object format with array props declaration +// props inferred as { [key in PropNames]?: any } +// return type is for Vetur and TSX support +export function defineComponent< + PropNames extends string, + RawBindings = Data, + D = Data, + C extends ComputedOptions = {}, + M extends MethodOptions = {}, + PropsOptions extends ComponentPropsOptions = ComponentPropsOptions +>( + options: ComponentOptionsWithArrayProps +): VueProxy, RawBindings, D, C, M> + +// overload 3: object format with object props declaration +// see `ExtractPropTypes` in ./componentProps.ts +export function defineComponent< + Props, + RawBindings = Data, + D = Data, + C extends ComputedOptions = {}, + M extends MethodOptions = {}, + PropsOptions extends ComponentPropsOptions = ComponentPropsOptions +>( + options: HasDefined extends true + ? ComponentOptionsWithProps + : ComponentOptionsWithProps +): VueProxy +// implementation, close to no-op +export function defineComponent(options: any) { + return options as any +} + +export const createComponent = ((options: any) => { + if (__DEV__) { + Vue.util.warn('`createComponent` has been renamed to `defineComponent`.') + } + return defineComponent(options) +}) as typeof defineComponent diff --git a/src/component/index.ts b/src/component/index.ts index a880a467..ecc2e4e9 100644 --- a/src/component/index.ts +++ b/src/component/index.ts @@ -1,10 +1,5 @@ -export { - Data, - createComponent, - defineComponent, - SetupFunction, - SetupContext, - ComponentInstance, - ComponentRenderProxy, -} from './component' +export { defineComponent, createComponent } from './defineComponent' +export { SetupFunction, SetupContext } from './componentOptions' +export { ComponentInstance, ComponentRenderProxy } from './componentProxy' +export { Data } from './common' export { PropType, PropOptions } from './componentProps' diff --git a/test-dts/defineComponent.test-d.ts b/test-dts/defineComponent.test-d.ts new file mode 100644 index 00000000..8a14f634 --- /dev/null +++ b/test-dts/defineComponent.test-d.ts @@ -0,0 +1,615 @@ +import { + ref, + reactive, + expectType, + expectError, + defineComponent, + PropType, +} from './index' + +describe('with object props', () => { + interface ExpectedProps { + a?: number | undefined + b: string + e?: Function + bb: string + cc?: string[] | undefined + dd: { n: 1 } + ee?: () => string + ff?: (a: number, b: string) => { a: boolean } + ccc?: string[] | undefined + ddd: string[] + eee: () => { a: string } + fff: (a: number, b: string) => { a: boolean } + hhh: boolean + } + + type GT = string & { __brand: unknown } + + defineComponent({ + props: { + a: Number, + // required should make property non-void + b: { + type: String, + required: true, + }, + e: Function, + // default value should infer type and make it non-void + bb: { + default: 'hello', + }, + // explicit type casting + cc: Array as PropType, + // required + type casting + dd: { + type: Object as PropType<{ n: 1 }>, + required: true, + }, + // return type + ee: Function as PropType<() => string>, + // arguments + object return + ff: Function as PropType<(a: number, b: string) => { a: boolean }>, + // explicit type casting with constructor + ccc: Array as () => string[], + // required + contructor type casting + ddd: { + type: Array as () => string[], + required: true, + }, + // required + object return + eee: { + type: Function as PropType<() => { a: string }>, + required: true, + }, + // required + arguments + object return + fff: { + type: Function as PropType<(a: number, b: string) => { a: boolean }>, + required: true, + }, + hhh: { + type: Boolean, + required: true, + }, + }, + setup(props) { + // type assertion. See https://github.com/SamVerschueren/tsd + expectType(props.a) + expectType(props.b) + expectType(props.e) + expectType(props.bb) + expectType(props.cc) + expectType(props.dd) + expectType(props.ee) + expectType(props.ff) + expectType(props.ccc) + expectType(props.ddd) + expectType(props.eee) + expectType(props.fff) + expectType(props.hhh) + + expectError((props.a = 1)) + + // setup context + return { + c: ref(1), + d: { + e: ref('hi'), + }, + f: reactive({ + g: ref('hello' as GT), + }), + } + }, + render(h) { + const props = this.$props + expectType(props.a) + expectType(props.b) + expectType(props.e) + expectType(props.bb) + expectType(props.cc) + expectType(props.dd) + expectType(props.ee) + expectType(props.ff) + expectType(props.ccc) + expectType(props.ddd) + expectType(props.eee) + expectType(props.fff) + expectType(props.hhh) + + // @ts-expect-error props should be readonly + expectError((props.a = 1)) + + // should also expose declared props on `this` + expectType(this.a) + expectType(this.b) + expectType(this.e) + expectType(this.bb) + expectType(this.cc) + expectType(this.dd) + expectType(this.ee) + expectType(this.ff) + expectType(this.ccc) + expectType(this.ddd) + expectType(this.eee) + expectType(this.fff) + expectType(this.hhh) + + // @ts-expect-error props on `this` should be readonly + expectError((this.a = 1)) + + // assert setup context unwrapping + expectType(this.c) + expectType(this.d.e) + expectType(this.f.g) + + // setup context properties should be mutable + this.c = 2 + + return h() + }, + }) +}) + +describe('type inference w/ array props declaration', () => { + defineComponent({ + props: ['a', 'b'], + setup(props) { + // @ts-expect-error props should be readonly + expectError((props.a = 1)) + expectType(props.a) + expectType(props.b) + return { + c: 1, + } + }, + render(h) { + expectType(this.$props.a) + expectType(this.$props.b) + // @ts-expect-error + expectError((this.$props.a = 1)) + expectType(this.a) + expectType(this.b) + expectType(this.c) + + return h() + }, + }) +}) + +describe('type inference w/ options API', () => { + defineComponent({ + props: { a: Number }, + setup() { + return { + b: 123, + } + }, + data() { + // Limitation: we cannot expose the return result of setup() on `this` + // here in data() - somehow that would mess up the inference + expectType(this.a) + return { + c: this.a || 123, + } + }, + computed: { + d(): number { + expectType(this.b) + return this.b + 1 + }, + }, + watch: { + a() { + expectType(this.b) + this.b + 1 + }, + }, + created() { + // props + expectType(this.a) + // returned from setup() + expectType(this.b) + // returned from data() + expectType(this.c) + // computed + expectType(this.d) + }, + methods: { + doSomething() { + // props + expectType(this.a) + // returned from setup() + expectType(this.b) + // returned from data() + expectType(this.c) + // computed + expectType(this.d) + }, + }, + render(h) { + // props + expectType(this.a) + // returned from setup() + expectType(this.b) + // returned from data() + expectType(this.c) + // computed + expectType(this.d) + + return h() + }, + }) +}) +describe('with mixins', () => { + /* + const MixinA = defineComponent({ + props: { + aP1: { + type: String, + default: 'aP1' + }, + aP2: Boolean + }, + data() { + return { + a: 1 + } + } + }) + const MixinB = defineComponent({ + props: ['bP1', 'bP2'], + data() { + return { + b: 2 + } + } + }) + const MixinC = defineComponent({ + data() { + return { + c: 3 + } + } + }) + const MixinD = defineComponent({ + mixins: [MixinA], + data() { + return { + d: 4 + } + }, + computed: { + dC1(): number { + return this.d + this.a + }, + dC2(): string { + return this.aP1 + 'dC2' + } + } + }) + const MyComponent = defineComponent({ + mixins: [MixinA, MixinB, MixinC, MixinD], + props: { + // required should make property non-void + z: { + type: String, + required: true + } + }, + render() { + const props = this.$props + // props + expectType(props.aP1) + expectType(props.aP2) + expectType(props.bP1) + expectType(props.bP2) + expectType(props.z) + + const data = this.$data + expectType(data.a) + expectType(data.b) + expectType(data.c) + expectType(data.d) + + // should also expose declared props on `this` + expectType(this.a) + expectType(this.aP1) + expectType(this.aP2) + expectType(this.b) + expectType(this.bP1) + expectType(this.c) + expectType(this.d) + expectType(this.dC1) + expectType(this.dC2) + + // props should be readonly + // @ts-expect-error + expectError((this.aP1 = 'new')) + // @ts-expect-error + expectError((this.z = 1)) + + // props on `this` should be readonly + // @ts-expect-error + expectError((this.bP1 = 1)) + + // string value can not assigned to number type value + // @ts-expect-error + expectError((this.c = '1')) + + // setup context properties should be mutable + this.d = 5 + + return null + } + }) + + // Test TSX + expectType( + + ) + + // missing required props + // @ts-expect-error + expectError() + + // wrong prop types + // @ts-expect-error + expectError() + // @ts-expect-error + expectError() + */ +}) + +describe('with extends', () => { + /* + const Base = defineComponent({ + props: { + aP1: Boolean, + aP2: { + type: Number, + default: 2 + } + }, + data() { + return { + a: 1 + } + }, + computed: { + c(): number { + return this.aP2 + this.a + } + } + }) + const MyComponent = defineComponent({ + extends: Base, + props: { + // required should make property non-void + z: { + type: String, + required: true + } + }, + render() { + const props = this.$props + // props + expectType(props.aP1) + expectType(props.aP2) + expectType(props.z) + + const data = this.$data + expectType(data.a) + + // should also expose declared props on `this` + expectType(this.a) + expectType(this.aP1) + expectType(this.aP2) + + // setup context properties should be mutable + this.a = 5 + + return null + } + }) + + // Test TSX + expectType() + + // missing required props + // @ts-expect-error + expectError() + + // wrong prop types + // @ts-expect-error + expectError() + // @ts-expect-error + expectError() + */ +}) +describe('extends with mixins', () => { + /* + const Mixin = defineComponent({ + props: { + mP1: { + type: String, + default: 'mP1' + }, + mP2: Boolean + }, + data() { + return { + a: 1 + } + } + }) + const Base = defineComponent({ + props: { + p1: Boolean, + p2: { + type: Number, + default: 2 + } + }, + data() { + return { + b: 2 + } + }, + computed: { + c(): number { + return this.p2 + this.b + } + } + }) + const MyComponent = defineComponent({ + extends: Base, + mixins: [Mixin], + props: { + // required should make property non-void + z: { + type: String, + required: true + } + }, + render() { + const props = this.$props + // props + expectType(props.p1) + expectType(props.p2) + expectType(props.z) + expectType(props.mP1) + expectType(props.mP2) + + const data = this.$data + expectType(data.a) + expectType(data.b) + + // should also expose declared props on `this` + expectType(this.a) + expectType(this.b) + expectType(this.p1) + expectType(this.p2) + expectType(this.mP1) + expectType(this.mP2) + + // setup context properties should be mutable + this.a = 5 + + return null + } + }) + + // Test TSX + expectType() + + // missing required props + // @ts-expect-error + expectError() + + // wrong prop types + // @ts-expect-error + expectError() + // @ts-expect-error + expectError() + */ +}) + +describe('compatibility w/ createApp', () => { + /* + const comp = defineComponent({}) + createApp(comp).mount('#hello') + + const comp2 = defineComponent({ + props: { foo: String } + }) + createApp(comp2).mount('#hello') + + const comp3 = defineComponent({ + setup() { + return { + a: 1 + } + } + }) + createApp(comp3).mount('#hello') + */ +}) + +describe('defineComponent', () => { + test('should accept components defined with defineComponent', () => { + const comp = defineComponent({}) + defineComponent({ + components: { comp }, + }) + }) +}) + +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') + // @ts-expect-error + expectError(emit('nope')) + // @ts-expect-error + expectError(emit('click')) + // @ts-expect-error + expectError(emit('click', 'foo')) + // @ts-expect-error + expectError(emit('input')) + // @ts-expect-error + expectError(emit('input', 1)) + }, + created() { + this.$emit('click', 1) + this.$emit('input', 'foo') + // @ts-expect-error + expectError(this.$emit('nope')) + // @ts-expect-error + expectError(this.$emit('click')) + // @ts-expect-error + expectError(this.$emit('click', 'foo')) + // @ts-expect-error + expectError(this.$emit('input')) + // @ts-expect-error + expectError(this.$emit('input', 1)) + } + }) + + // with array emits + defineComponent({ + emits: ['foo', 'bar'], + setup(props, { emit }) { + emit('foo') + emit('foo', 123) + emit('bar') + // @ts-expect-error + expectError(emit('nope')) + }, + created() { + this.$emit('foo') + this.$emit('foo', 123) + this.$emit('bar') + // @ts-expect-error + expectError(this.$emit('nope')) + } + }) + */ +}) diff --git a/test-dts/index.d.ts b/test-dts/index.d.ts index bd365356..551b2ba5 100644 --- a/test-dts/index.d.ts +++ b/test-dts/index.d.ts @@ -1,5 +1,7 @@ export * from '@vue/composition-api' +export function describe(_name: string, _fn: () => void): void + export function expectType(value: T): void export function expectError(value: T): void export function expectAssignable(value: T2): void