diff --git a/packages-private/dts-test/setupHelpers.test-d.ts b/packages-private/dts-test/setupHelpers.test-d.ts index 4074176ff98..3bcb5218713 100644 --- a/packages-private/dts-test/setupHelpers.test-d.ts +++ b/packages-private/dts-test/setupHelpers.test-d.ts @@ -207,6 +207,99 @@ describe('defineProps w/ generic type declaration + withDefaults', (res.bool) }) +describe('defineProps w/union type', () => { + type PP = + | { + type: 'text' + mm: string + } + | { + type: 'number' + mm: number | null + } + + const res = defineProps() + expectType(res.mm) + + if (res.type === 'text') { + expectType(res.mm) + } + + if (res.type === 'number') { + expectType(res.mm) + } +}) + +describe('defineProps w/distributive union type', () => { + type PP = + | { + type1: 'text' + mm1: string + } + | { + type2: 'number' + mm2: number | null + } + + const res = defineProps() + + if ('type1' in res) { + expectType(res.mm1) + } + + if ('type2' in res) { + expectType(res.mm2) + } +}) + +describe('withDefaults w/ union type', () => { + type PP = + | { + type?: 'text' + mm: string + } + | { + type?: 'number' + mm: number | null + } + + const res = withDefaults(defineProps(), { + type: 'text', + }) + + if (res.type && res.type === 'text') { + expectType(res.mm) + } + + if (res.type === 'number') { + expectType(res.mm) + } +}) + +describe('withDefaults w/ generic union type', () => { + type PP = + | { + tt?: 'a' + mm: T + } + | { + tt?: 'b' + mm: T[] + } + + const res = withDefaults(defineProps(), { + tt: 'a', + }) + + if (res.tt === 'a') { + expectType(res.mm) + } else { + expectType(res.mm) + } +}) + describe('withDefaults w/ boolean type', () => { const res1 = withDefaults( defineProps<{ diff --git a/packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineProps.spec.ts.snap b/packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineProps.spec.ts.snap index d59807b8547..70149942dd6 100644 --- a/packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineProps.spec.ts.snap +++ b/packages/compiler-sfc/__tests__/compileScript/__snapshots__/defineProps.spec.ts.snap @@ -93,6 +93,68 @@ return { foo } })" `; +exports[`defineProps > discriminated union 1`] = ` +"import { defineComponent as _defineComponent } from 'vue' + +export default /*@__PURE__*/_defineComponent({ + props: { + tag: { type: [String, Number], required: true, union: [['d1'], ['d2']] }, + d1: { type: Number, required: true, union: ['tag'] }, + d2: { type: String, required: true, union: ['tag'] } + }, + setup(__props: any, { expose: __expose }) { + __expose(); + + const props = __props; + +return { props } +} + +})" +`; + +exports[`defineProps > distributive union w/ conditional keys 1`] = ` +"import { defineComponent as _defineComponent } from 'vue' + +export default /*@__PURE__*/_defineComponent({ + props: { + a: { type: String, required: false, union: ['b'] }, + b: { type: Number, required: true, union: ['a'] }, + c: { type: Number, required: true, union: ['d'] }, + d: { type: String, required: false, union: ['c'] } + }, + setup(__props: any, { expose: __expose }) { + __expose(); + + const props = __props; + +return { props } +} + +})" +`; + +exports[`defineProps > distributive union with boolean keys 1`] = ` +"import { defineComponent as _defineComponent } from 'vue' + +export default /*@__PURE__*/_defineComponent({ + props: { + a: { type: String, required: true, union: ['b'] }, + b: { type: Number, required: true, union: ['a'] }, + c: { type: Boolean, required: true, union: ['d'] }, + d: { type: String, required: true, union: ['c'] } + }, + setup(__props: any, { expose: __expose }) { + __expose(); + + const props = __props; + +return { props } +} + +})" +`; + exports[`defineProps > should escape names w/ special symbols 1`] = ` "import { defineComponent as _defineComponent } from 'vue' diff --git a/packages/compiler-sfc/__tests__/compileScript/defineProps.spec.ts b/packages/compiler-sfc/__tests__/compileScript/defineProps.spec.ts index c9ef103c416..0392ac6054d 100644 --- a/packages/compiler-sfc/__tests__/compileScript/defineProps.spec.ts +++ b/packages/compiler-sfc/__tests__/compileScript/defineProps.spec.ts @@ -399,6 +399,54 @@ const props = defineProps({ foo: String }) assertCode(content) }) + test('distributive union w/ conditional keys', () => { + const { content } = compile(` + + `) + assertCode(content) + }) + + test('discriminated union', () => { + const { content } = compile(` + + `) + assertCode(content) + }) + + test('distributive union with boolean keys', () => { + const { content } = compile(` + + `) + assertCode(content) + }) + // #7111 test('withDefaults (static) w/ production mode', () => { const { content } = compile( diff --git a/packages/compiler-sfc/src/script/defineProps.ts b/packages/compiler-sfc/src/script/defineProps.ts index 9a4880a1a54..d7fa02d7d47 100644 --- a/packages/compiler-sfc/src/script/defineProps.ts +++ b/packages/compiler-sfc/src/script/defineProps.ts @@ -25,6 +25,7 @@ import { import { genModelProps } from './defineModel' import { getObjectOrArrayExpressionKeys } from './analyzeScriptBindings' import { processPropsDestructure } from './definePropsDestructure' +import { isArray } from '@vue/shared' export const DEFINE_PROPS = 'defineProps' export const WITH_DEFAULTS = 'withDefaults' @@ -34,8 +35,11 @@ export interface PropTypeData { type: string[] required: boolean skipCheck: boolean + union?: UnionDefinition } +export type UnionDefinition = string[] | string[][] + export type PropsDestructureBindings = Record< string, // public prop key { @@ -231,6 +235,7 @@ function resolveRuntimePropsFromType( key, required: !e.optional, type: type || [`null`], + union: e.union, skipCheck, }) } @@ -239,7 +244,7 @@ function resolveRuntimePropsFromType( function genRuntimePropFromType( ctx: TypeResolveContext, - { key, required, type, skipCheck }: PropTypeData, + { key, required, type, skipCheck, union }: PropTypeData, hasStaticDefaults: boolean, ): string { let defaultString: string | undefined @@ -272,6 +277,7 @@ function genRuntimePropFromType( return `${finalKey}: { ${concatStrings([ `type: ${toRuntimeTypeString(type)}`, `required: ${required}`, + union && `union: ${genUnionArrayString(union)}`, skipCheck && 'skipCheck: true', defaultString, ])} }` @@ -306,6 +312,18 @@ function genRuntimePropFromType( } } +function genUnionArrayString(union: UnionDefinition): string { + const entries = union.map(key => { + if (isArray(key)) { + return genUnionArrayString(key) + } else { + return `'${key}'` + } + }) + + return `[${entries.join(', ')}]` +} + /** * check defaults. If the default object is an object literal with only * static properties, we can directly generate more optimized default diff --git a/packages/compiler-sfc/src/script/resolveType.ts b/packages/compiler-sfc/src/script/resolveType.ts index 6bb647f11ff..fe983768d35 100644 --- a/packages/compiler-sfc/src/script/resolveType.ts +++ b/packages/compiler-sfc/src/script/resolveType.ts @@ -42,6 +42,7 @@ import type TS from 'typescript' import { dirname, extname, join } from 'path' import { minimatch as isMatch } from 'minimatch' import * as process from 'process' +import type { UnionDefinition } from './defineProps' export type SimpleTypeResolveOptions = Partial< Pick< @@ -121,18 +122,20 @@ export class TypeScope { exportedDeclares: Record = Object.create(null) } -export interface MaybeWithScope { +interface MaybeWithScope { _ownerScope?: TypeScope } +interface MaybeWithUnion { + union?: UnionDefinition +} + +type ResolvedElementProp = (TSPropertySignature | TSMethodSignature) & + WithScope & + MaybeWithUnion + interface ResolvedElements { - props: Record< - string, - (TSPropertySignature | TSMethodSignature) & { - // resolved props always has ownerScope attached - _ownerScope: TypeScope - } - > + props: Record calls?: (TSCallSignatureDeclaration | TSFunctionType)[] } @@ -368,6 +371,9 @@ function mergeElements( for (const key in props) { if (!hasOwn(baseProps, key)) { baseProps[key] = props[key] + + const keys = Object.keys(props).filter(k => k !== key) + baseProps[key].union = keys } else { baseProps[key] = createProperty( baseProps[key].key, @@ -378,6 +384,10 @@ function mergeElements( }, baseProps[key]._ownerScope, baseProps[key].optional || props[key].optional, + [ + baseProps[key].union || [], + Object.keys(props).filter(k => k !== key), + ], ) } } @@ -393,7 +403,8 @@ function createProperty( typeAnnotation: TSType, scope: TypeScope, optional: boolean, -): TSPropertySignature & WithScope { + union?: UnionDefinition, +): TSPropertySignature & WithScope & MaybeWithUnion { return { type: 'TSPropertySignature', key, @@ -404,6 +415,7 @@ function createProperty( typeAnnotation, }, _ownerScope: scope, + union, } } diff --git a/packages/runtime-core/__tests__/componentProps.spec.ts b/packages/runtime-core/__tests__/componentProps.spec.ts index 8c9c38b3c1f..fc2693a7f4c 100644 --- a/packages/runtime-core/__tests__/componentProps.spec.ts +++ b/packages/runtime-core/__tests__/componentProps.spec.ts @@ -166,6 +166,123 @@ describe('component props', () => { expect('type check failed for prop "qux"').toHaveBeenWarned() }) + test('distributive union props', () => { + /** + * type Props = { + * a?: string + * b: number + * bb?: number + * } | { + * c: boolean + * d?: string + * } + */ + const props = { + a: { + type: String, + required: false, + union: ['b', 'bb'], + }, + b: { + type: Number, + required: true, + union: ['a', 'bb'], + }, + bb: { + type: Number, + required: false, + union: ['a', 'b'], + }, + c: { + type: Boolean, + required: true, + union: ['d'], + }, + d: { + type: String, + required: false, + union: ['c'], + }, + } + + let proxy: any + const Comp = { + props, + render() { + proxy = this + }, + } + + render( + h(Comp, { + a: 'foo', + // b: absent required - should warn + // bb: absent not required - should not warn + // c: not in active union path - undefined boolean should not default to false - should not warn + // d: not in active union path - should not warn + }), + nodeOps.createElement('div'), + ) + + expect(proxy.a).toBe('foo') + expect('Missing required prop: "b"').toHaveBeenWarned() + + expect('Missing required prop: "c"').not.toHaveBeenWarned() + expect('Missing required prop: "d"').not.toHaveBeenWarned() + }) + + test('discriminated union props', () => { + /** + * type Props = { + * a: string + * b: number + * } | { + * a: string + * c: string + * } + */ + const props = { + a: { + type: [String, Number], + required: true, + union: [['b'], ['c']], + }, + b: { + type: Number, + required: true, + union: ['a'], + }, + c: { + type: Boolean, + required: true, + union: ['a'], + }, + } + + let proxy: any + const Comp = { + props, + render() { + proxy = this + }, + } + + render( + h(Comp, { + b: 2, + // a: absent required - should warn + // c: not in active union path - should not warn + }), + nodeOps.createElement('div'), + ) + + expect(proxy.b).toBe(2) + expect('Missing required prop: "a"').toHaveBeenWarned() + + expect('Missing required prop: "b"').not.toHaveBeenWarned() + expect('Missing required prop: "c"').not.toHaveBeenWarned() + }) + test('default value', () => { let proxy: any const defaultFn = vi.fn(() => ({ a: 1 })) diff --git a/packages/runtime-core/src/apiSetupHelpers.ts b/packages/runtime-core/src/apiSetupHelpers.ts index 33817818a93..e54c69728e4 100644 --- a/packages/runtime-core/src/apiSetupHelpers.ts +++ b/packages/runtime-core/src/apiSetupHelpers.ts @@ -313,9 +313,6 @@ export function defineModel(): any { } type NotUndefined = T extends undefined ? never : T -type MappedOmit = { - [P in keyof T as P extends K ? never : P]: T[P] -} type InferDefaults = { [K in keyof T]?: InferDefault @@ -331,7 +328,7 @@ type PropsWithDefaults< T, Defaults extends InferDefaults, BKeys extends keyof T, -> = Readonly> & { +> = Readonly & { readonly [K in keyof Defaults as K extends keyof T ? K : never]-?: K extends keyof T diff --git a/packages/runtime-core/src/componentProps.ts b/packages/runtime-core/src/componentProps.ts index e07fb476788..c4ccb9e7a97 100644 --- a/packages/runtime-core/src/componentProps.ts +++ b/packages/runtime-core/src/componentProps.ts @@ -52,11 +52,17 @@ export type Prop = PropOptions | PropType type DefaultFactory = (props: Data) => T | null | undefined +type UnionDefinition = string[] | string[][] + export interface PropOptions { type?: PropType | true | null required?: boolean default?: D | DefaultFactory | null | undefined | object validator?(value: unknown, props: Data): boolean + /** + * @internal + */ + union?: UnionDefinition /** * @internal */ @@ -488,7 +494,10 @@ function resolvePropValue( } } // boolean casting - if (opt[BooleanFlags.shouldCast]) { + if ( + opt[BooleanFlags.shouldCast] && + (!opt.union || isInActiveUnion(key, opt, props)) + ) { if (isAbsent && !hasDefault) { value = false } else if ( @@ -502,6 +511,28 @@ function resolvePropValue( return value } +function isInActiveUnion( + key: string, + prop: NormalizedProp, + props: Data, +): boolean { + const union = prop.union + if (!union) { + return false + } + return union.some(u => { + return ( + Object.entries(props) + .filter(([k]) => k !== key) // skip current key + // check if any key in union is in props + .some( + ([k, v]) => + (u === k || (Array.isArray(u) && u.includes(k))) && v !== undefined, + ) + ) + }) +} + const mixinPropsCache = new WeakMap() export function normalizePropsOptions( @@ -678,6 +709,11 @@ function validateProp( isAbsent: boolean, ) { const { type, required, validator, skipCheck } = prop + + if (prop.union && !isInActiveUnion(name, prop, props)) { + return + } + // required! if (required && isAbsent) { warn('Missing required prop: "' + name + '"')