diff --git a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts index d6a56ad10d74..a8d4146195a1 100644 --- a/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts +++ b/packages/angular/build/src/tools/esbuild/i18n-inliner-worker.ts @@ -13,7 +13,8 @@ import { MagicString } from 'magic-string'; import assert from 'node:assert'; import { deserialize } from 'node:v8'; import { workerData } from 'node:worker_threads'; -import { parseSync, visitorKeys } from 'oxc-parser'; +import { parseSync } from 'oxc-parser'; +import { traversePostOrder } from '../oxc/traversal'; import { loadLocaleData } from './i18n-locale-plugin'; import { createSharedTranslationProxy } from './i18n-translation-reader'; @@ -284,55 +285,6 @@ async function loadLocalizeTools(): Promise { return localizeToolsModule; } -/** - * Traverses ESTree AST nodes in post-order (bottom-up) without recursion. - * Bottom-up traversal ensures that nested `$localize` expressions are transformed and - * written to MagicString before outer containing templates are evaluated. - * - * @param root The root AST node to traverse. - * @param onExit Callback invoked on each AST node in post-order. - */ -function walkAstPostOrder(root: Node, onExit: (node: Node) => void): void { - const traverseStack: Node[] = [root]; - const postOrderNodes: Node[] = []; - - while (traverseStack.length > 0) { - const current = traverseStack.pop(); - if (!current) { - continue; - } - - postOrderNodes.push(current); - - const keys = visitorKeys[current.type]; - if (!keys) { - continue; - } - - for (let i = 0; i < keys.length; i++) { - const child = (current as unknown as Record)[keys[i]]; - if (!child) { - continue; - } - - if (Array.isArray(child)) { - for (const item of child) { - if (item) { - traverseStack.push(item); - } - } - } else { - traverseStack.push(child); - } - } - } - - // Process collected nodes in reverse order to achieve bottom-up (post-order) traversal - for (let i = postOrderNodes.length - 1; i >= 0; i--) { - onExit(postOrderNodes[i]); - } -} - /** * Metadata for a `$localize` tagged template expression extracted from the AST. */ @@ -372,7 +324,7 @@ function extractLocalizeMetadata(filename: string, code: string): FileLocalizeMe const localeInsertSites: { start: number; end: number }[] = []; let diagnostics: string[] | undefined; - walkAstPostOrder(program, (node) => { + traversePostOrder(program, (node) => { if (node.type === 'Literal') { if (typeof node.value === 'string' && node.value === '___NG_LOCALE_INSERT___') { localeInsertSites.push({ start: node.start, end: node.end }); diff --git a/packages/angular/build/src/tools/oxc/oxc-transform.ts b/packages/angular/build/src/tools/oxc/oxc-transform.ts index c0389a9f75a4..0ebbc04ec1e5 100644 --- a/packages/angular/build/src/tools/oxc/oxc-transform.ts +++ b/packages/angular/build/src/tools/oxc/oxc-transform.ts @@ -10,8 +10,9 @@ import type { DecodedSourceMap } from '@ampproject/remapping'; import { needsLinking } from '@angular/compiler-cli/linker'; import type { BindingIdentifier, Class, Node } from '@oxc-project/types'; import { MagicString } from 'magic-string'; -import { Visitor, parseSync } from 'oxc-parser'; +import { parseSync } from 'oxc-parser'; import { OxcLinker } from '../angular/linker/oxc-linker'; +import { traversePostOrder } from './traversal'; export interface OxcTransformOptions { sourcemap?: boolean; @@ -320,11 +321,6 @@ export function transform(filename: string, code: string, options: OxcTransformO return editedRanges.some((r) => start >= r.start && end <= r.end); } - // Track function nesting depth and closest function expression wrapper - let functionDepth = 0; - let classDepth = 0; - const functionStack: Node[] = []; - /** * Scans and rewrites TypeScript emitted enum declarations in the statement block. * Wraps enum statements inside a pure IIFE assignable directly to the enum variable. @@ -630,153 +626,115 @@ export function transform(filename: string, code: string, options: OxcTransformO } } - const visitor = new Visitor({ - ClassDeclaration(node) { - classDepth++; - }, - 'ClassDeclaration:exit'() { - classDepth--; - }, - ClassExpression(node) { - classDepth++; - }, - 'ClassExpression:exit'() { - classDepth--; - }, - FunctionDeclaration(node) { - functionDepth++; - functionStack.push(node); - }, - 'FunctionDeclaration:exit'() { - functionDepth--; - functionStack.pop(); - }, - FunctionExpression(node) { - functionDepth++; - functionStack.push(node); - }, - 'FunctionExpression:exit'() { - functionDepth--; - functionStack.pop(); - }, - ArrowFunctionExpression(node) { - functionDepth++; - functionStack.push(node); - }, - 'ArrowFunctionExpression:exit'() { - functionDepth--; - functionStack.pop(); - }, - 'Program:exit'(node) { - if (advancedOptimizations) { - adjustTypeScriptEnumsInStatements(node.body); - adjustStaticMembersInStatements(node.body); - } - }, - 'BlockStatement:exit'(node) { - if (advancedOptimizations) { - adjustTypeScriptEnumsInStatements(node.body); - adjustStaticMembersInStatements(node.body); - } - }, - CallExpression(node) { - if (isAlreadyEdited(node.start, node.end)) { - return; - } + traversePostOrder(program, (node, { functionDepth, classDepth, parentFunc }) => { + switch (node.type) { + case 'Program': + case 'BlockStatement': + if (advancedOptimizations) { + adjustTypeScriptEnumsInStatements(node.body); + adjustStaticMembersInStatements(node.body); + } + break; + + case 'CallExpression': { + if (isAlreadyEdited(node.start, node.end)) { + return; + } + + if (linker) { + const linkedCode = linker.linkCallExpression(node); + if (linkedCode !== undefined) { + source.overwrite(node.start, node.end, linkedCode); + markEdited(node.start, node.end); - if (linker) { - const linkedCode = linker.linkCallExpression(node); - if (linkedCode !== undefined) { - source.overwrite(node.start, node.end, linkedCode); - markEdited(node.start, node.end); + return; + } + } + if (!advancedOptimizations) { return; } - } - if (!advancedOptimizations) { - return; - } + // 1. Elide Angular Metadata check + let calleeName: string | undefined; + if (node.callee.type === 'Identifier') { + calleeName = node.callee.name; + } else if ( + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' + ) { + calleeName = node.callee.property.name; + } - // 1. Elide Angular Metadata check - let calleeName: string | undefined; - if (node.callee.type === 'Identifier') { - calleeName = node.callee.name; - } else if ( - node.callee.type === 'MemberExpression' && - node.callee.property.type === 'Identifier' - ) { - calleeName = node.callee.property.name; - } + if (calleeName && angularMetadataFunctions.has(calleeName)) { + if ( + parentFunc && + (parentFunc.type === 'FunctionExpression' || + parentFunc.type === 'ArrowFunctionExpression') + ) { + source.overwrite(node.start, node.end, 'void 0'); + markEdited(node.start, node.end); + + return; + } + } - if (calleeName && angularMetadataFunctions.has(calleeName)) { - const parentFunc = functionStack[functionStack.length - 1]; + // 2. Mark Top-Level Pure Functions check + if (!pureAnnotate || functionDepth > 0 || classDepth > 0 || topLevelSafeMode) { + return; + } + + const callee = unwrapParentheses(node.callee); if ( - parentFunc && - (parentFunc.type === 'FunctionExpression' || - parentFunc.type === 'ArrowFunctionExpression') + (callee.type === 'FunctionExpression' || callee.type === 'ArrowFunctionExpression') && + node.arguments.length !== 0 ) { - source.overwrite(node.start, node.end, 'void 0'); - markEdited(node.start, node.end); - return; } - } - // 2. Mark Top-Level Pure Functions check - if (!pureAnnotate || functionDepth > 0 || classDepth > 0 || topLevelSafeMode) { - return; - } + if ( + callee.type === 'Identifier' && + (isTslibHelperName(callee.name) || isBabelHelperName(callee.name)) + ) { + return; + } - const callee = unwrapParentheses(node.callee); - if ( - (callee.type === 'FunctionExpression' || callee.type === 'ArrowFunctionExpression') && - node.arguments.length !== 0 - ) { - return; + if (!hasPureComment(node.start)) { + source.appendLeft(node.start, '/*#__PURE__*/ '); + } + break; } - if ( - callee.type === 'Identifier' && - (isTslibHelperName(callee.name) || isBabelHelperName(callee.name)) - ) { - return; - } + case 'NewExpression': { + if ( + !advancedOptimizations || + !pureAnnotate || + functionDepth > 0 || + classDepth > 0 || + isAlreadyEdited(node.start, node.end) + ) { + return; + } - if (!hasPureComment(node.start)) { - source.appendLeft(node.start, '/*#__PURE__*/ '); - } - }, - NewExpression(node) { - if ( - !advancedOptimizations || - !pureAnnotate || - functionDepth > 0 || - classDepth > 0 || - isAlreadyEdited(node.start, node.end) - ) { - return; - } + if (!topLevelSafeMode) { + if (!hasPureComment(node.start)) { + source.appendLeft(node.start, '/*#__PURE__*/ '); + } - if (!topLevelSafeMode) { - if (!hasPureComment(node.start)) { - source.appendLeft(node.start, '/*#__PURE__*/ '); + return; } - return; - } - - const callee = node.callee; - if (callee.type === 'Identifier' && sideEffectFreeConstructors.has(callee.name)) { - if (!hasPureComment(node.start)) { - source.appendLeft(node.start, '/*#__PURE__*/ '); + const newCallee = node.callee; + if (newCallee.type === 'Identifier' && sideEffectFreeConstructors.has(newCallee.name)) { + if (!hasPureComment(node.start)) { + source.appendLeft(node.start, '/*#__PURE__*/ '); + } } + break; } - }, + } }); - visitor.visit(program); - let map: DecodedSourceMap | undefined; if (options.sourcemap) { const rawMap = source.generateDecodedMap({ hires: true, source: filename }); diff --git a/packages/angular/build/src/tools/oxc/traversal.ts b/packages/angular/build/src/tools/oxc/traversal.ts new file mode 100644 index 000000000000..f25f4bc78664 --- /dev/null +++ b/packages/angular/build/src/tools/oxc/traversal.ts @@ -0,0 +1,354 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Node } from '@oxc-project/types'; +import { visitorKeys } from 'oxc-parser'; + +/** + * Contextual scope information provided to AST visitor callbacks during traversal. + */ +export interface TraversalContext { + /** + * The current function nesting depth. Top-level code has depth 0. + */ + functionDepth: number; + + /** + * The current class nesting depth. Top-level code has depth 0. + */ + classDepth: number; + + /** + * The immediate enclosing function AST node, if currently inside a function. + */ + parentFunc?: Node; +} + +/** + * A set of TypeScript AST node types that contain executable code or runtime declarations. + */ +const executableTypeScriptNodes = new Set([ + 'TSEnumDeclaration', + 'TSEnumBody', + 'TSEnumMember', + 'TSModuleDeclaration', + 'TSModuleBlock', + 'TSParameterProperty', + 'TSImportEqualsDeclaration', + 'TSExternalModuleReference', +]); + +/** + * Determines whether the given AST node type represents a function declaration or expression. + * + * @param type The AST node type to check. + * @returns True if the type is a function; otherwise, false. + */ +function isFunction(type: Node['type']): boolean { + return ( + type === 'FunctionDeclaration' || + type === 'FunctionExpression' || + type === 'ArrowFunctionExpression' + ); +} + +/** + * Determines whether the given AST node type represents a class declaration or expression. + * + * @param type The AST node type to check. + * @returns True if the type is a class; otherwise, false. + */ +function isClass(type: Node['type']): boolean { + return type === 'ClassDeclaration' || type === 'ClassExpression'; +} + +/** + * Determines whether the given AST node type represents a non-executable TypeScript type-only node. + * Executable TypeScript nodes (e.g. enums, namespaces, parameter properties) return false. + * + * @param type The AST node type to check. + * @returns True if the type is a TypeScript type construct that can be safely pruned; otherwise, false. + */ +function isTypeOnlyTypeScriptNode(type: Node['type']): boolean { + return type.startsWith('TS') && !executableTypeScriptNodes.has(type); +} + +/** + * Pushes an array of AST nodes onto the traversal stack in reverse order. + * + * @param nodes The array of child AST nodes to push. + * @param stack The traversal stack. + */ +function pushNodesReverse( + nodes: readonly (Node | null | undefined)[] | undefined, + stack: (Node | null)[], +): void { + if (nodes) { + for (let i = nodes.length - 1; i >= 0; i--) { + const item = nodes[i]; + if (item) { + stack.push(item); + } + } + } +} + +/** + * Pushes child AST nodes of the specified node onto the traversal stack in reverse order + * so they are visited in source order. + * + * Explicit cases are provided for the most common AST node types (~95%+ of AST nodes) to: + * 1. Fast-path leaf nodes and frequent expressions/statements by avoiding dynamic `visitorKeys` + * dictionary lookups, string indexing, and dynamic array iteration. + * 2. Prune non-executable TypeScript type subtrees (e.g. `typeAnnotation`, `typeParameters`, + * `returnType`, `implements`) so traversal only processes executable JavaScript expressions. + * + * All other less frequent node types fall back to the `visitorKeys` lookup in `default:`. + * + * @param node The parent AST node whose children to push. + * @param stack The traversal stack. + */ +function pushChildNodes(node: Node, stack: (Node | null)[]): void { + switch (node.type) { + // High-frequency leaf nodes (~50% of AST) + case 'Identifier': + case 'PrivateIdentifier': + case 'Literal': + case 'ThisExpression': + break; + + case 'Program': + case 'BlockStatement': + case 'ClassBody': + case 'StaticBlock': + pushNodesReverse(node.body, stack); + break; + + case 'ExpressionStatement': + case 'ParenthesizedExpression': + case 'ChainExpression': + case 'TSNonNullExpression': + case 'TSAsExpression': + case 'TSTypeAssertion': + case 'TSSatisfiesExpression': + case 'TSInstantiationExpression': + case 'TSExportAssignment': + if (node.expression) { + stack.push(node.expression); + } + break; + + case 'CallExpression': + case 'NewExpression': + pushNodesReverse(node.arguments, stack); + if (node.callee) { + stack.push(node.callee); + } + break; + + case 'MemberExpression': + if (node.property) { + stack.push(node.property); + } + if (node.object) { + stack.push(node.object); + } + break; + + case 'BinaryExpression': + case 'LogicalExpression': + case 'AssignmentExpression': + if (node.right) { + stack.push(node.right); + } + if (node.left) { + stack.push(node.left); + } + break; + + case 'UnaryExpression': + case 'UpdateExpression': + case 'AwaitExpression': + case 'YieldExpression': + case 'ReturnStatement': + case 'ThrowStatement': + if (node.argument) { + stack.push(node.argument); + } + break; + + case 'IfStatement': + case 'ConditionalExpression': + if (node.alternate) { + stack.push(node.alternate); + } + if (node.consequent) { + stack.push(node.consequent); + } + if (node.test) { + stack.push(node.test); + } + break; + + case 'VariableDeclaration': + pushNodesReverse(node.declarations, stack); + break; + + case 'VariableDeclarator': + if (node.init) { + stack.push(node.init); + } + if (node.id) { + stack.push(node.id); + } + break; + + case 'FunctionDeclaration': + case 'FunctionExpression': + case 'ArrowFunctionExpression': + if (node.body) { + stack.push(node.body); + } + pushNodesReverse(node.params, stack); + if (node.id) { + stack.push(node.id); + } + break; + + case 'ClassDeclaration': + case 'ClassExpression': + if (node.body) { + stack.push(node.body); + } + if (node.superClass) { + stack.push(node.superClass); + } + if (node.id) { + stack.push(node.id); + } + pushNodesReverse(node.decorators, stack); + break; + + case 'Property': + case 'PropertyDefinition': + case 'MethodDefinition': + if (node.value) { + stack.push(node.value); + } + if (node.key) { + stack.push(node.key); + } + if (node.type !== 'Property') { + pushNodesReverse(node.decorators, stack); + } + break; + + case 'ObjectExpression': + pushNodesReverse(node.properties, stack); + break; + + case 'ArrayExpression': + pushNodesReverse(node.elements, stack); + break; + + case 'TemplateLiteral': + pushNodesReverse(node.expressions, stack); + break; + + case 'TaggedTemplateExpression': + if (node.quasi) { + stack.push(node.quasi); + } + if (node.tag) { + stack.push(node.tag); + } + break; + + default: { + // Prune non-executable TypeScript type-only AST nodes (e.g. TSTypeAnnotation, TSTypeReference, etc.) + if (isTypeOnlyTypeScriptNode(node.type)) { + break; + } + + const keys = visitorKeys[node.type]; + if (!keys) { + break; + } + + for (let i = keys.length - 1; i >= 0; i--) { + const child = ( + node as unknown as Record + )[keys[i]]; + if (!child) { + continue; + } + + if (Array.isArray(child)) { + pushNodesReverse(child, stack); + } else { + stack.push(child); + } + } + break; + } + } +} + +/** + * Traverses ESTree AST nodes in post-order (bottom-up) without recursion. + * + * @param root The root AST node to traverse. + * @param visit Callback invoked on each AST node in post-order. + */ +export function traversePostOrder( + root: Node, + visit: (node: Node, context: TraversalContext) => void, +): void { + const stack: (Node | null)[] = [root]; + let functionDepth = 0; + let classDepth = 0; + const functionStack: Node[] = []; + + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) { + break; + } + if (current === null) { + const node = stack.pop(); + if (!node) { + continue; + } + const type = node.type; + if (isFunction(type)) { + functionDepth--; + functionStack.pop(); + } else if (isClass(type)) { + classDepth--; + } + visit(node, { + functionDepth, + classDepth, + parentFunc: functionStack[functionStack.length - 1], + }); + continue; + } + + stack.push(current); + stack.push(null); + const type = current.type; + if (isFunction(type)) { + functionDepth++; + functionStack.push(current); + } else if (isClass(type)) { + classDepth++; + } + + pushChildNodes(current, stack); + } +}