From 04e02986cd24281d662ebe43e2cd2be0af3f7e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20=C4=8Cern=C3=BD?= <112722215+cernymatej@users.noreply.github.com> Date: Thu, 9 Jan 2025 16:11:44 +0100 Subject: [PATCH] fix(nuxt): don't hoist identifiers declared locally in `definePageMeta` when extracting page metadata (#30490) --- packages/nuxt/src/core/utils/parse.ts | 82 ++++++-- packages/nuxt/src/pages/plugins/page-meta.ts | 20 +- packages/nuxt/test/page-metadata.test.ts | 196 ++++++++++++++++++- packages/nuxt/test/parse.test.ts | 85 +++++++- 4 files changed, 360 insertions(+), 23 deletions(-) diff --git a/packages/nuxt/src/core/utils/parse.ts b/packages/nuxt/src/core/utils/parse.ts index c063dbcdfa..0f6f6d8b5e 100644 --- a/packages/nuxt/src/core/utils/parse.ts +++ b/packages/nuxt/src/core/utils/parse.ts @@ -53,12 +53,30 @@ export function withLocations (node: T): WithLocations { return node as WithLocations } +/** + * A function to check whether scope A is a child of scope B. + * @example + * ```ts + * isChildScope('0-1-2', '0-1') // true + * isChildScope('0-1', '0-1') // false + * ``` + * + * @param a the child scope + * @param b the parent scope + * @returns true if scope A is a child of scope B, false otherwise (also when they are the same) + */ +function isChildScope (a: string, b: string) { + return a.startsWith(b) && a.length > b.length +} + abstract class BaseNode { abstract type: string + readonly scope: string node: WithLocations - constructor (node: WithLocations) { + constructor (node: WithLocations, scope: string) { this.node = node + this.scope = scope } /** @@ -72,6 +90,14 @@ abstract class BaseNode { * For instance, for a function parameter, this would be the end of the function declaration. */ abstract get end (): number + + /** + * Check if the node is defined under a specific scope. + * @param scope + */ + isUnderScope (scope: string) { + return isChildScope(this.scope, scope) + } } class IdentifierNode extends BaseNode { @@ -90,8 +116,8 @@ class FunctionParamNode extends BaseNode { type = 'FunctionParam' as const fnNode: WithLocations - constructor (node: WithLocations, fnNode: WithLocations) { - super(node) + constructor (node: WithLocations, scope: string, fnNode: WithLocations) { + super(node, scope) this.fnNode = fnNode } @@ -120,8 +146,8 @@ class VariableNode extends BaseNode { type = 'Variable' as const variableNode: WithLocations - constructor (node: WithLocations, variableNode: WithLocations) { - super(node) + constructor (node: WithLocations, scope: string, variableNode: WithLocations) { + super(node, scope) this.variableNode = variableNode } @@ -138,8 +164,8 @@ class ImportNode extends BaseNode - constructor (node: WithLocations, importNode: WithLocations) { - super(node) + constructor (node: WithLocations, scope: string, importNode: WithLocations) { + super(node, scope) this.importNode = importNode } @@ -156,8 +182,8 @@ class CatchParamNode extends BaseNode { type = 'CatchParam' as const catchNode: WithLocations - constructor (node: WithLocations, catchNode: WithLocations) { - super(node) + constructor (node: WithLocations, scope: string, catchNode: WithLocations) { + super(node, scope) this.catchNode = catchNode } @@ -264,7 +290,7 @@ export class ScopeTracker { const identifiers = getPatternIdentifiers(param) for (const identifier of identifiers) { - this.declareIdentifier(identifier.name, new FunctionParamNode(identifier, fn)) + this.declareIdentifier(identifier.name, new FunctionParamNode(identifier, this.scopeIndexKey, fn)) } } @@ -276,10 +302,10 @@ export class ScopeTracker { this.declareIdentifier( identifier.name, parent.type === 'VariableDeclaration' - ? new VariableNode(identifier, parent) + ? new VariableNode(identifier, this.scopeIndexKey, parent) : parent.type === 'CatchClause' - ? new CatchParamNode(identifier, parent) - : new FunctionParamNode(identifier, parent), + ? new CatchParamNode(identifier, this.scopeIndexKey, parent) + : new FunctionParamNode(identifier, this.scopeIndexKey, parent), ) } } @@ -295,7 +321,7 @@ export class ScopeTracker { case 'FunctionDeclaration': // declare function name for named functions, skip for `export default` if (node.id?.name) { - this.declareIdentifier(node.id.name, new FunctionNode(node)) + this.declareIdentifier(node.id.name, new FunctionNode(node, this.scopeIndexKey)) } this.pushScope() for (const param of node.params) { @@ -309,7 +335,7 @@ export class ScopeTracker { this.pushScope() // can be undefined, for example in class method definitions if (node.id?.name) { - this.declareIdentifier(node.id.name, new FunctionNode(node)) + this.declareIdentifier(node.id.name, new FunctionNode(node, this.scopeIndexKey)) } this.pushScope() @@ -333,7 +359,7 @@ export class ScopeTracker { case 'ClassDeclaration': // declare class name for named classes, skip for `export default` if (node.id?.name) { - this.declareIdentifier(node.id.name, new IdentifierNode(withLocations(node.id))) + this.declareIdentifier(node.id.name, new IdentifierNode(withLocations(node.id), this.scopeIndexKey)) } break @@ -342,13 +368,13 @@ export class ScopeTracker { // e.g. const MyClass = class InternalClassName { // InternalClassName is only available within the class body this.pushScope() if (node.id?.name) { - this.declareIdentifier(node.id.name, new IdentifierNode(withLocations(node.id))) + this.declareIdentifier(node.id.name, new IdentifierNode(withLocations(node.id), this.scopeIndexKey)) } break case 'ImportDeclaration': for (const specifier of node.specifiers) { - this.declareIdentifier(specifier.local.name, new ImportNode(withLocations(specifier), node)) + this.declareIdentifier(specifier.local.name, new ImportNode(withLocations(specifier), this.scopeIndexKey, node)) } break @@ -429,6 +455,26 @@ export class ScopeTracker { return null } + getCurrentScope () { + return this.scopeIndexKey + } + + /** + * Check if the current scope is a child of a specific scope. + * @example + * ```ts + * // current scope is 0-1 + * isCurrentScopeUnder('0') // true + * isCurrentScopeUnder('0-1') // false + * ``` + * + * @param scope the parent scope + * @returns `true` if the current scope is a child of the specified scope, `false` otherwise (also when they are the same) + */ + isCurrentScopeUnder (scope: string) { + return isChildScope(this.scopeIndexKey, scope) + } + /** * Freezes the scope tracker, preventing further declarations. * It also resets the scope index stack to its initial state, so that the scope tracker can be reused. diff --git a/packages/nuxt/src/pages/plugins/page-meta.ts b/packages/nuxt/src/pages/plugins/page-meta.ts index a5972f4e44..bde07df6a3 100644 --- a/packages/nuxt/src/pages/plugins/page-meta.ts +++ b/packages/nuxt/src/pages/plugins/page-meta.ts @@ -228,6 +228,8 @@ export const PageMetaPlugin = (options: PageMetaPluginOptions = {}) => createUnp if (!meta) { return } + const definePageMetaScope = scopeTracker.getCurrentScope() + walk(meta, { scopeTracker, enter (node, parent) { @@ -236,10 +238,24 @@ export const PageMetaPlugin = (options: PageMetaPluginOptions = {}) => createUnp || node.type !== 'Identifier' // checking for `node.type` to narrow down the type ) { return } + const declaration = scopeTracker.getDeclaration(node.name) + if (declaration) { + // check if the declaration was made inside `definePageMeta` and if so, do not process it + // (ensures that we don't hoist local variables in inline middleware, for example) + if ( + declaration.isUnderScope(definePageMetaScope) + // ensures that we compare the correct declaration to the reference + // (when in the same scope, the declaration must come before the reference, otherwise it must be in a parent scope) + && (scopeTracker.isCurrentScopeUnder(declaration.scope) || declaration.start < node.start) + ) { + return + } + } + if (isStaticIdentifier(node.name)) { addImport(node.name) - } else { - processDeclaration(scopeTracker.getDeclaration(node.name)) + } else if (declaration) { + processDeclaration(declaration) } }, }) diff --git a/packages/nuxt/test/page-metadata.test.ts b/packages/nuxt/test/page-metadata.test.ts index 60247fd4e1..ab1c44b46a 100644 --- a/packages/nuxt/test/page-metadata.test.ts +++ b/packages/nuxt/test/page-metadata.test.ts @@ -393,6 +393,188 @@ definePageMeta({ `) }) + it('should not import static identifiers when shadowed in the same scope', () => { + const sfc = ` + + ` + const res = compileScript(parse(sfc).descriptor, { id: 'component.vue' }) + expect(transformPlugin.transform.call({ + parse: (code: string, opts: any = {}) => Parser.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + ...opts, + }), + }, res.content, 'component.vue?macro=true')?.code).toMatchInlineSnapshot(` + "const __nuxt_page_meta = { + middleware: () => { + const useState = (key) => ({ value: { isLoggedIn: false } }) + const auth = useState('auth') + if (!auth.value.isLoggedIn) { + return navigateTo('/login') + } + }, + } + export default __nuxt_page_meta" + `) + }) + + it('should not import static identifiers when shadowed in parent scope', () => { + const sfc = ` + + ` + const res = compileScript(parse(sfc).descriptor, { id: 'component.vue' }) + expect(transformPlugin.transform.call({ + parse: (code: string, opts: any = {}) => Parser.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + ...opts, + }), + }, res.content, 'component.vue?macro=true')?.code).toMatchInlineSnapshot(` + "const __nuxt_page_meta = { + middleware: () => { + function isLoggedIn() { + const auth = useState('auth') + return auth.value.isLoggedIn + } + + const useState = (key) => ({ value: { isLoggedIn: false } }) + if (!isLoggedIn()) { + return navigateTo('/login') + } + }, + } + export default __nuxt_page_meta" + `) + }) + + it('should import static identifiers when a shadowed and a non-shadowed one is used', () => { + const sfc = ` + + ` + const res = compileScript(parse(sfc).descriptor, { id: 'component.vue' }) + expect(transformPlugin.transform.call({ + parse: (code: string, opts: any = {}) => Parser.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + ...opts, + }), + }, res.content, 'component.vue?macro=true')?.code).toMatchInlineSnapshot(` + "import { useState } from '#app/composables/state' + + const __nuxt_page_meta = { + middleware: [ + () => { + const useState = (key) => ({ value: { isLoggedIn: false } }) + const auth = useState('auth') + if (!auth.value.isLoggedIn) { + return navigateTo('/login') + } + }, + () => { + const auth = useState('auth') + if (!auth.value.isLoggedIn) { + return navigateTo('/login') + } + } + ] + } + export default __nuxt_page_meta" + `) + }) + + it('should import static identifiers when a shadowed and a non-shadowed one is used in the same scope', () => { + const sfc = ` + + ` + const res = compileScript(parse(sfc).descriptor, { id: 'component.vue' }) + expect(transformPlugin.transform.call({ + parse: (code: string, opts: any = {}) => Parser.parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + ...opts, + }), + }, res.content, 'component.vue?macro=true')?.code).toMatchInlineSnapshot(` + "import { useState } from '#app/composables/state' + + const __nuxt_page_meta = { + middleware: () => { + const auth1 = useState('auth') + const useState = (key) => ({ value: { isLoggedIn: false } }) + const auth2 = useState('auth') + if (!auth1.value.isLoggedIn || !auth2.value.isLoggedIn) { + return navigateTo('/login') + } + }, + } + export default __nuxt_page_meta" + `) + }) + it('should work with esbuild.keepNames = true', async () => { const sfc = `