From 019282d2db76964f74a337e363b2376d0d8a819b Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 30 Mar 2023 10:30:43 +0800 Subject: [PATCH 1/6] chore: reanme test file --- ...opsTransform.spec.ts => compileScriptPropsDestructure.spec.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/compiler-sfc/__tests__/{compileScriptPropsTransform.spec.ts => compileScriptPropsDestructure.spec.ts} (100%) diff --git a/packages/compiler-sfc/__tests__/compileScriptPropsTransform.spec.ts b/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts similarity index 100% rename from packages/compiler-sfc/__tests__/compileScriptPropsTransform.spec.ts rename to packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts From f361b5b27a68fa52b164b97c940ef475d367f9de Mon Sep 17 00:00:00 2001 From: Evan You Date: Mon, 27 Mar 2023 20:47:25 +0800 Subject: [PATCH 2/6] feat(sfc): reactive props destructure - Reactive props destructure is now stable - Split props destructure logic from reactivity transform package and move back into compiler-sfc --- ...compileScriptPropsDestructure.spec.ts.snap | 188 ++++++++++++++ .../compileScriptPropsDestructure.spec.ts | 31 +-- packages/compiler-sfc/src/compileScript.ts | 57 +++-- .../src/compileScriptPropsDestructure.ts | 231 ++++++++++++++++++ 4 files changed, 463 insertions(+), 44 deletions(-) create mode 100644 packages/compiler-sfc/__tests__/__snapshots__/compileScriptPropsDestructure.spec.ts.snap create mode 100644 packages/compiler-sfc/src/compileScriptPropsDestructure.ts 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 00000000000..bd4b61835ba --- /dev/null +++ b/packages/compiler-sfc/__tests__/__snapshots__/compileScriptPropsDestructure.spec.ts.snap @@ -0,0 +1,188 @@ +// 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/ runtime declaration 1`] = ` +"import { mergeDefaults as _mergeDefaults } from 'vue' + +export default { + props: _mergeDefaults(['foo', 'bar'], { + foo: 1, + bar: () => ({}) +}), + 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: () => ({}) } + }, + 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__/compileScriptPropsDestructure.spec.ts b/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts index 9fe711fbc3d..fed5ebc59d3 100644 --- a/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.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`) }) }) }) diff --git a/packages/compiler-sfc/src/compileScript.ts b/packages/compiler-sfc/src/compileScript.ts index d9c59ab665e..8dfeeb7d1a2 100644 --- a/packages/compiler-sfc/src/compileScript.ts +++ b/packages/compiler-sfc/src/compileScript.ts @@ -59,6 +59,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 +133,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 +160,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 +318,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) @@ -452,10 +454,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 +480,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( @@ -1220,6 +1219,7 @@ export function compileScript( } // apply reactivity transform + // TODO remove in 3.4 if (enableReactivityTransform && shouldTransform(script.content)) { const { rootRefs, importedHelpers } = transformAST( scriptAst, @@ -1416,19 +1416,28 @@ export function compileScript( } } - // 3. Apply reactivity transform + // 3.1 props destructure transform + if (propsDestructureDecl) { + transformDestructuredProps( + scriptSetupAst, + s, + startOffset, + propsDestructuredBindings + ) + } + + // 3.2 Apply reactivity transform + // TODO remove in 3.4 if ( - (enableReactivityTransform && - // normal ` + ) + ).toThrow(`foo is a destructured prop and cannot be directly watched.`) + + expect(() => + compile( + `` + ) + ).toThrow(`foo is a destructured prop and cannot be directly watched.`) + }) }) }) diff --git a/packages/compiler-sfc/src/compileScript.ts b/packages/compiler-sfc/src/compileScript.ts index e9c286201cb..32446853bf9 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 { @@ -1423,7 +1424,9 @@ export function compileScript( scriptSetupAst, s, startOffset, - propsDestructuredBindings + propsDestructuredBindings, + error, + vueImportAliases.watch ) } @@ -2285,21 +2288,6 @@ function genRuntimeEmits(emits: Set) { : `` } -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)) - ) -} - function canNeverBeRef(node: Node, userReactiveImport?: string): boolean { if (isCallOf(node, userReactiveImport)) { return true diff --git a/packages/compiler-sfc/src/compileScriptPropsDestructure.ts b/packages/compiler-sfc/src/compileScriptPropsDestructure.ts index e804c75da35..a46d666d016 100644 --- a/packages/compiler-sfc/src/compileScriptPropsDestructure.ts +++ b/packages/compiler-sfc/src/compileScriptPropsDestructure.ts @@ -13,7 +13,9 @@ import { isInDestructureAssignment, isReferencedIdentifier, isStaticProperty, - walkFunctionParams + walkFunctionParams, + isCallOf, + unwrapTSNode } from '@vue/compiler-core' import { hasOwn, genPropsAccessExp } from '@vue/shared' import { PropsDestructureBindings } from './compileScript' @@ -28,7 +30,9 @@ export function transformDestructuredProps( ast: Program, s: MagicString, offset = 0, - knownProps: PropsDestructureBindings + knownProps: PropsDestructureBindings, + error: (msg: string, node: Node, end?: number) => never, + watchMethodName = 'watch' ) { const rootScope: Scope = {} const scopeStack: Scope[] = [rootScope] @@ -43,12 +47,6 @@ export function transformDestructuredProps( propsLocalToPublicMap[local] = key } - function error(msg: string, node: Node): never { - const e = new Error(msg) - ;(e as any).node = node - throw e - } - function registerLocalBinding(id: Identifier) { excludedIds.add(id) if (currentScope) { @@ -96,12 +94,8 @@ export function transformDestructuredProps( return } for (const decl of stmt.declarations) { - const isCall = - decl.init && - decl.init.type === 'CallExpression' && - decl.init.callee.type === 'Identifier' const isDefineProps = - isRoot && isCall && (decl as any).init.callee.name === 'defineProps' + isRoot && decl.init && isCallOf(unwrapTSNode(decl.init), 'defineProps') for (const id of extractIdentifiers(decl.id)) { if (isDefineProps) { // for defineProps destructure, only exclude them since they @@ -164,6 +158,28 @@ export function transformDestructuredProps( enter(node: Node, parent?: Node) { parent && parentStack.push(parent) + // skip type nodes + if ( + parent && + parent.type.startsWith('TS') && + parent.type !== 'TSAsExpression' && + parent.type !== 'TSNonNullExpression' && + parent.type !== 'TSTypeAssertion' + ) { + return this.skip() + } + + if (isCallOf(node, watchMethodName)) { + const arg = unwrapTSNode(node.arguments[0]) + if (arg.type === 'Identifier') { + error( + `${arg.name} is a destructured prop and cannot be directly watched. ` + + `Use a getter () => ${arg.name} instead.`, + arg + ) + } + } + // function scopes if (isFunctionType(node)) { scopeStack.push((currentScope = {})) @@ -191,17 +207,6 @@ export function transformDestructuredProps( return } - // skip type nodes - if ( - parent && - parent.type.startsWith('TS') && - parent.type !== 'TSAsExpression' && - parent.type !== 'TSNonNullExpression' && - parent.type !== 'TSTypeAssertion' - ) { - return this.skip() - } - if (node.type === 'Identifier') { if ( isReferencedIdentifier(node, parent!, parentStack) && From cf9ebaf174bbef73299c85146e42a6a682091b61 Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 30 Mar 2023 11:55:39 +0800 Subject: [PATCH 6/6] feat: catch common cases of default value / type declaratiom mismatch --- .../compileScriptPropsDestructure.spec.ts | 15 ++++++- packages/compiler-sfc/src/compileScript.ts | 39 +++++++++++++++++++ .../src/compileScriptPropsDestructure.ts | 2 +- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts b/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts index 7387b90ddb1..346f95a5c7a 100644 --- a/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts +++ b/packages/compiler-sfc/__tests__/compileScriptPropsDestructure.spec.ts @@ -303,7 +303,7 @@ describe('sfc props transform', () => { watch(foo, () => {}) ` ) - ).toThrow(`foo is a destructured prop and cannot be directly watched.`) + ).toThrow(`"foo" is a destructured prop and cannot be directly watched.`) expect(() => compile( @@ -313,7 +313,18 @@ describe('sfc props transform', () => { w(foo, () => {}) ` ) - ).toThrow(`foo is a destructured prop and cannot be directly watched.`) + ).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 32446853bf9..96ea7df18bc 100644 --- a/packages/compiler-sfc/src/compileScript.ts +++ b/packages/compiler-sfc/src/compileScript.ts @@ -944,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 @@ -952,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 @@ -2232,6 +2250,27 @@ function inferEnumType(node: TSEnumDeclaration): string[] { return types.size ? [...types] : ['Number'] } +// non-comprehensive, best-effort type infernece for a runtime value +// this is used to catch default value / type declaration mismatches +// when using props destructure. +function inferValueType(node: Node): string | undefined { + switch (node.type) { + case 'StringLiteral': + return 'String' + case 'NumericLiteral': + return 'Number' + case 'BooleanLiteral': + return 'Boolean' + case 'ObjectExpression': + return 'Object' + case 'ArrayExpression': + return 'Array' + case 'FunctionExpression': + case 'ArrowFunctionExpression': + return 'Function' + } +} + function extractRuntimeEmits( node: TSFunctionType | TSTypeLiteral | TSInterfaceBody, emits: Set diff --git a/packages/compiler-sfc/src/compileScriptPropsDestructure.ts b/packages/compiler-sfc/src/compileScriptPropsDestructure.ts index a46d666d016..bc38912653e 100644 --- a/packages/compiler-sfc/src/compileScriptPropsDestructure.ts +++ b/packages/compiler-sfc/src/compileScriptPropsDestructure.ts @@ -173,7 +173,7 @@ export function transformDestructuredProps( const arg = unwrapTSNode(node.arguments[0]) if (arg.type === 'Identifier') { error( - `${arg.name} is a destructured prop and cannot be directly watched. ` + + `"${arg.name}" is a destructured prop and cannot be directly watched. ` + `Use a getter () => ${arg.name} instead.`, arg )