From ba9c2ae247fcc3960b238a04cb635158daa82004 Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 30 Mar 2023 11:58:16 +0800 Subject: [PATCH] feat(compiler-sfc): enable reactive props destructure by default and deprecate withDefaults() (#7986) --- packages/compiler-core/src/babelUtils.ts | 18 +- ...compileScriptPropsDestructure.spec.ts.snap | 210 ++++++++++++++++ ... => compileScriptPropsDestructure.spec.ts} | 64 +++-- packages/compiler-sfc/src/compileScript.ts | 131 ++++++---- .../src/compileScriptPropsDestructure.ts | 236 ++++++++++++++++++ packages/runtime-core/src/apiSetupHelpers.ts | 2 + 6 files changed, 593 insertions(+), 68 deletions(-) create mode 100644 packages/compiler-sfc/__tests__/__snapshots__/compileScriptPropsDestructure.spec.ts.snap rename packages/compiler-sfc/__tests__/{compileScriptPropsTransform.spec.ts => compileScriptPropsDestructure.spec.ts} (86%) create mode 100644 packages/compiler-sfc/src/compileScriptPropsDestructure.ts diff --git a/packages/compiler-core/src/babelUtils.ts b/packages/compiler-core/src/babelUtils.ts index 52175ef48..b58d9a064 100644 --- a/packages/compiler-core/src/babelUtils.ts +++ b/packages/compiler-core/src/babelUtils.ts @@ -9,7 +9,8 @@ import type { Program, ImportDefaultSpecifier, ImportNamespaceSpecifier, - ImportSpecifier + ImportSpecifier, + CallExpression } from '@babel/types' import { walk } from 'estree-walker' @@ -449,3 +450,18 @@ export function unwrapTSNode(node: Node): Node { return node } } + +export function isCallOf( + node: Node | null | undefined, + test: string | ((id: string) => boolean) | null | undefined +): node is CallExpression { + return !!( + node && + test && + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + (typeof test === 'string' + ? node.callee.name === test + : test(node.callee.name)) + ) +} diff --git a/packages/compiler-sfc/__tests__/__snapshots__/compileScriptPropsDestructure.spec.ts.snap b/packages/compiler-sfc/__tests__/__snapshots__/compileScriptPropsDestructure.spec.ts.snap new file mode 100644 index 000000000..525f4b6ed --- /dev/null +++ b/packages/compiler-sfc/__tests__/__snapshots__/compileScriptPropsDestructure.spec.ts.snap @@ -0,0 +1,210 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`sfc props transform > aliasing 1`] = ` +"import { toDisplayString as _toDisplayString } from \\"vue\\" + + +export default { + props: ['foo'], + setup(__props) { + + + let x = foo + let y = __props.foo + +return (_ctx, _cache) => { + return _toDisplayString(__props.foo + __props.foo) +} +} + +}" +`; + +exports[`sfc props transform > basic usage 1`] = ` +"import { toDisplayString as _toDisplayString } from \\"vue\\" + + +export default { + props: ['foo'], + setup(__props) { + + + console.log(__props.foo) + +return (_ctx, _cache) => { + return _toDisplayString(__props.foo) +} +} + +}" +`; + +exports[`sfc props transform > computed static key 1`] = ` +"import { toDisplayString as _toDisplayString } from \\"vue\\" + + +export default { + props: ['foo'], + setup(__props) { + + + console.log(__props.foo) + +return (_ctx, _cache) => { + return _toDisplayString(__props.foo) +} +} + +}" +`; + +exports[`sfc props transform > default values w/ array runtime declaration 1`] = ` +"import { mergeDefaults as _mergeDefaults } from 'vue' + +export default { + props: _mergeDefaults(['foo', 'bar', 'baz'], { + foo: 1, + bar: () => ({}), + func: () => {}, __skip_func: true +}), + setup(__props) { + + + +return () => {} +} + +}" +`; + +exports[`sfc props transform > default values w/ object runtime declaration 1`] = ` +"import { mergeDefaults as _mergeDefaults } from 'vue' + +export default { + props: _mergeDefaults({ foo: Number, bar: Object, func: Function, ext: null }, { + foo: 1, + bar: () => ({}), + func: () => {}, __skip_func: true, + ext: x, __skip_ext: true +}), + setup(__props) { + + + +return () => {} +} + +}" +`; + +exports[`sfc props transform > default values w/ type declaration 1`] = ` +"import { defineComponent as _defineComponent } from 'vue' + +export default /*#__PURE__*/_defineComponent({ + props: { + foo: { type: Number, required: false, default: 1 }, + bar: { type: Object, required: false, default: () => ({}) }, + func: { type: Function, required: false, default: () => {} } + }, + setup(__props: any) { + + + +return () => {} +} + +})" +`; + +exports[`sfc props transform > default values w/ type declaration, prod mode 1`] = ` +"import { defineComponent as _defineComponent } from 'vue' + +export default /*#__PURE__*/_defineComponent({ + props: { + foo: { default: 1 }, + bar: { default: () => ({}) }, + baz: null, + boola: { type: Boolean }, + boolb: { type: [Boolean, Number] }, + func: { type: Function, default: () => {} } + }, + setup(__props: any) { + + + +return () => {} +} + +})" +`; + +exports[`sfc props transform > multiple variable declarations 1`] = ` +"import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from \\"vue\\" + + +export default { + props: ['foo'], + setup(__props) { + + const bar = 'fish', hello = 'world' + +return (_ctx, _cache) => { + return (_openBlock(), _createElementBlock(\\"div\\", null, _toDisplayString(__props.foo) + \\" \\" + _toDisplayString(hello) + \\" \\" + _toDisplayString(bar), 1 /* TEXT */)) +} +} + +}" +`; + +exports[`sfc props transform > nested scope 1`] = ` +"export default { + props: ['foo', 'bar'], + setup(__props) { + + + function test(foo) { + console.log(foo) + console.log(__props.bar) + } + +return () => {} +} + +}" +`; + +exports[`sfc props transform > non-identifier prop names 1`] = ` +"import { toDisplayString as _toDisplayString } from \\"vue\\" + + +export default { + props: { 'foo.bar': Function }, + setup(__props) { + + + let x = __props[\\"foo.bar\\"] + +return (_ctx, _cache) => { + return _toDisplayString(__props[\\"foo.bar\\"]) +} +} + +}" +`; + +exports[`sfc props transform > rest spread 1`] = ` +"import { createPropsRestProxy as _createPropsRestProxy } from 'vue' + +export default { + props: ['foo', 'bar', 'baz'], + setup(__props) { + +const rest = _createPropsRestProxy(__props, [\\"foo\\",\\"bar\\"]); + + + +return () => {} +} + +}" +`; diff --git a/packages/compiler-sfc/__tests__/compileScriptPropsTransform.spec.ts b/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts similarity index 86% rename from packages/compiler-sfc/__tests__/compileScriptPropsTransform.spec.ts rename to packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts index 9fe711fbc..346f95a5c 100644 --- a/packages/compiler-sfc/__tests__/compileScriptPropsTransform.spec.ts +++ b/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts @@ -6,7 +6,6 @@ describe('sfc props transform', () => { function compile(src: string, options?: Partial) { return compileSFCScript(src, { inlineTemplate: true, - reactivityTransform: true, ...options }) } @@ -211,23 +210,6 @@ describe('sfc props transform', () => { }) }) - test('$$() escape', () => { - const { content } = compile(` - - `) - expect(content).toMatch(`const __props_foo = _toRef(__props, 'foo')`) - expect(content).toMatch(`const __props_bar = _toRef(__props, 'bar')`) - expect(content).toMatch(`console.log((__props_foo))`) - expect(content).toMatch(`console.log((__props_bar))`) - expect(content).toMatch(`({ foo: __props_foo, baz: __props_bar })`) - assertCode(content) - }) - // #6960 test('computed static key', () => { const { content, bindings } = compile(` @@ -292,7 +274,7 @@ describe('sfc props transform', () => { ).toThrow(`cannot reference locally declared variables`) }) - test('should error if assignment to constant variable', () => { + test('should error if assignment to destructured prop binding', () => { expect(() => compile( `` ) - ).toThrow(`Assignment to constant variable.`) + ).toThrow(`Cannot assign to destructured props`) + + expect(() => + compile( + `` + ) + ).toThrow(`Cannot assign to destructured props`) + }) + + test('should error when watching destructured prop', () => { + expect(() => + compile( + `` + ) + ).toThrow(`"foo" is a destructured prop and cannot be directly watched.`) + + expect(() => + compile( + `` + ) + ).toThrow(`"foo" is a destructured prop and cannot be directly watched.`) + }) + + // not comprehensive, but should help for most common cases + test('should error if default value type does not match declared type', () => { + expect(() => + compile( + `` + ) + ).toThrow(`Default value of prop "foo" does not match declared type.`) }) }) }) diff --git a/packages/compiler-sfc/src/compileScript.ts b/packages/compiler-sfc/src/compileScript.ts index d9c59ab66..96ea7df18 100644 --- a/packages/compiler-sfc/src/compileScript.ts +++ b/packages/compiler-sfc/src/compileScript.ts @@ -11,7 +11,8 @@ import { isFunctionType, walkIdentifiers, getImportedName, - unwrapTSNode + unwrapTSNode, + isCallOf } from '@vue/compiler-dom' import { DEFAULT_FILENAME, SFCDescriptor, SFCScriptBlock } from './parse' import { @@ -59,6 +60,7 @@ import { warnOnce } from './warn' import { rewriteDefaultAST } from './rewriteDefault' import { createCache } from './cache' import { shouldTransform, transformAST } from '@vue/reactivity-transform' +import { transformDestructuredProps } from './compileScriptPropsDestructure' // Special compiler macros const DEFINE_PROPS = 'defineProps' @@ -132,6 +134,14 @@ export interface ImportBinding { isUsedInTemplate: boolean } +export type PropsDestructureBindings = Record< + string, // public prop key + { + local: string // local identifier, may be different + default?: Expression + } +> + type FromNormalScript = T & { __fromNormalScript?: boolean | null } type PropsDeclType = FromNormalScript type EmitsDeclType = FromNormalScript< @@ -151,7 +161,6 @@ export function compileScript( // feature flags // TODO remove support for deprecated options when out of experimental const enableReactivityTransform = !!options.reactivityTransform - const enablePropsTransform = !!options.reactivityTransform const isProd = !!options.isProd const genSourceMap = options.sourceMap !== false const hoistStatic = options.hoistStatic !== false && !script @@ -310,14 +319,8 @@ export function compileScript( // record declared types for runtime props type generation const declaredTypes: Record = {} // props destructure data - const propsDestructuredBindings: Record< - string, // public prop key - { - local: string // local identifier, may be different - default?: Expression - isConst: boolean - } - > = Object.create(null) + const propsDestructuredBindings: PropsDestructureBindings = + Object.create(null) // magic-string state const s = new MagicString(source) @@ -410,11 +413,7 @@ export function compileScript( } } - function processDefineProps( - node: Node, - declId?: LVal, - declKind?: VariableDeclaration['kind'] - ): boolean { + function processDefineProps(node: Node, declId?: LVal): boolean { if (!isCallOf(node, DEFINE_PROPS)) { return false } @@ -452,10 +451,9 @@ export function compileScript( } if (declId) { - const isConst = declKind === 'const' - if (enablePropsTransform && declId.type === 'ObjectPattern') { + // handle props destructure + if (declId.type === 'ObjectPattern') { propsDestructureDecl = declId - // props destructure - handle compilation sugar for (const prop of declId.properties) { if (prop.type === 'ObjectProperty') { const propKey = resolveObjectKey(prop.key, prop.computed) @@ -479,14 +477,12 @@ export function compileScript( // store default value propsDestructuredBindings[propKey] = { local: left.name, - default: right, - isConst + default: right } } else if (prop.value.type === 'Identifier') { // simple destructure propsDestructuredBindings[propKey] = { - local: prop.value.name, - isConst + local: prop.value.name } } else { error( @@ -515,7 +511,12 @@ export function compileScript( if (!isCallOf(node, WITH_DEFAULTS)) { return false } - if (processDefineProps(node.arguments[0], declId, declKind)) { + warnOnce( + `withDefaults() has been deprecated. ` + + `Props destructure is now reactive by default - ` + + `use destructure with default values instead.` + ) + if (processDefineProps(node.arguments[0], declId)) { if (propsRuntimeDecl) { error( `${WITH_DEFAULTS} can only be used with type-based ` + @@ -943,7 +944,23 @@ export function compileScript( defaultVal.start!, defaultVal.end! ) + const unwrapped = unwrapTSNode(defaultVal) + + if ( + inferredType && + inferredType.length && + !inferredType.includes(UNKNOWN_TYPE) + ) { + const valueType = inferValueType(unwrapped) + if (valueType && !inferredType.includes(valueType)) { + error( + `Default value of prop "${key}" does not match declared type.`, + unwrapped + ) + } + } + // If the default value is a function or is an identifier referencing // external value, skip factory wrap. This is needed when using // destructure w/ runtime declaration since we cannot safely infer @@ -951,10 +968,12 @@ export function compileScript( const needSkipFactory = !inferredType && (isFunctionType(unwrapped) || unwrapped.type === 'Identifier') + const needFactoryWrap = !needSkipFactory && !isLiteralNode(unwrapped) && !inferredType?.includes('Function') + return { valueString: needFactoryWrap ? `() => (${value})` : value, needSkipFactory @@ -1220,6 +1239,7 @@ export function compileScript( } // apply reactivity transform + // TODO remove in 3.4 if (enableReactivityTransform && shouldTransform(script.content)) { const { rootRefs, importedHelpers } = transformAST( scriptAst, @@ -1300,7 +1320,7 @@ export function compileScript( // defineProps / defineEmits const isDefineProps = - processDefineProps(init, decl.id, node.kind) || + processDefineProps(init, decl.id) || processWithDefaults(init, decl.id, node.kind) const isDefineEmits = processDefineEmits(init, decl.id) if (isDefineProps || isDefineEmits) { @@ -1416,19 +1436,30 @@ export function compileScript( } } - // 3. Apply reactivity transform + // 3.1 props destructure transform + if (propsDestructureDecl) { + transformDestructuredProps( + scriptSetupAst, + s, + startOffset, + propsDestructuredBindings, + error, + vueImportAliases.watch + ) + } + + // 3.2 Apply reactivity transform + // TODO remove in 3.4 if ( - (enableReactivityTransform && - // normal