diff --git a/packages/angular/build/src/tools/angular/linker/oxc-linker.ts b/packages/angular/build/src/tools/angular/linker/oxc-linker.ts index 7d085502978b..26133a6b5611 100644 --- a/packages/angular/build/src/tools/angular/linker/oxc-linker.ts +++ b/packages/angular/build/src/tools/angular/linker/oxc-linker.ts @@ -6,17 +6,13 @@ * found in the LICENSE file at https://angular.dev/license */ -import type { DecodedSourceMap } from '@ampproject/remapping'; import { ConsoleLogger, LogLevel } from '@angular/compiler-cli'; -import type { DeclarationScope } from '@angular/compiler-cli/linker'; -import { FileLinker, LinkerEnvironment, needsLinking } from '@angular/compiler-cli/linker'; +import { type DeclarationScope, FileLinker, LinkerEnvironment } from '@angular/compiler-cli/linker'; import type { AbsoluteFsPath, ReadonlyFileSystem, } from '@angular/compiler-cli/src/ngtsc/file_system'; -import type { CallExpression, Node } from '@oxc-project/types'; -import MagicString from 'magic-string'; -import { parseSync, visitorKeys } from 'oxc-parser'; +import type { CallExpression } from '@oxc-project/types'; import { OxcAstHost } from './oxc-ast-host'; import { StringAstFactory } from './string-ast-factory'; @@ -49,131 +45,52 @@ const noopFileSystem: ReadonlyFileSystem = { relative: (_from: string, to: string) => to, } as unknown as ReadonlyFileSystem; -const SHARED_LOGGER = new ConsoleLogger(LogLevel.info); - -const SHARED_AST_HOST = new OxcAstHost(); -const SHARED_DECLARATION_SCOPE = new InlineDeclarationScope(); +let SHARED_LOGGER: ConsoleLogger; +let SHARED_AST_HOST: OxcAstHost; +let SHARED_DECLARATION_SCOPE: InlineDeclarationScope; /** - * Recursively traverses ESTree AST nodes with subtree pruning. - * When `onCallExpression` returns `true` for a linked `CallExpression`, - * child traversal into `callee` and `arguments` is skipped. - * - * Why subtree pruning is safe for the linker: - * - Angular partial declarations (`ɵɵngDeclareComponent`, `ɵɵngDeclareDirective`, - * etc.) are never nested inside each other. - * - Once a declaration `CallExpression` is linked and replaced, there can never be - * another partial declaration within its metadata argument object. Pruning its - * subtree avoids traversing hundreds of unnecessary metadata argument nodes per - * component. + * Manages Angular partial declaration linking using Oxc AST nodes. */ -function visitNode( - node: Node | Node[] | null | undefined, - onCallExpression: (node: CallExpression) => boolean, -): void { - if (node === null || node === undefined || typeof node !== 'object') { - return; - } - - if (Array.isArray(node)) { - for (let i = 0; i < node.length; i++) { - visitNode(node[i], onCallExpression); - } - - return; - } - - const nodeType = node.type; - if (!nodeType) { - return; +export class OxcLinker { + readonly #fileLinker: FileLinker; + + constructor(filename: string, code: string, jit = false) { + SHARED_LOGGER ??= new ConsoleLogger(LogLevel.info); + SHARED_AST_HOST ??= new OxcAstHost(); + SHARED_DECLARATION_SCOPE ??= new InlineDeclarationScope(); + + const astFactory = new StringAstFactory(code); + const linkerEnvironment = LinkerEnvironment.create( + noopFileSystem, + SHARED_LOGGER, + SHARED_AST_HOST, + astFactory, + { linkerJitMode: jit, sourceMapping: false }, + ); + + this.#fileLinker = new FileLinker(linkerEnvironment, filename as AbsoluteFsPath, code); } - if (nodeType === 'CallExpression') { - if (onCallExpression(node)) { - // Subtree pruning: partial declarations cannot be nested, so skip child traversal. - return; - } - } - - const keys = visitorKeys[nodeType]; - if (keys) { - for (let i = 0; i < keys.length; i++) { - const child = (node as unknown as Record)[keys[i]]; - if (child !== undefined && child !== null) { - visitNode(child, onCallExpression); - } - } - } -} - -export interface OxcLinkerOptions { - sourcemap?: boolean; - jit?: boolean; - skipCheck?: boolean; -} - -/** - * Executes Angular partial declaration linking on the specified JavaScript file - * using `oxc-parser` and `magic-string`. - * - * @param filename The full path to the file. - * @param code The source code content. - * @param options Linker options (sourcemap, jit, skipCheck). - * @returns An object containing the transformed code and optional source map. - */ -export function linkWithOxc(filename: string, code: string, options: OxcLinkerOptions = {}) { - if (!options.skipCheck && !needsLinking(filename, code)) { - return { code, map: undefined }; - } - - const astFactory = new StringAstFactory(code); - - const linkerEnvironment = LinkerEnvironment.create( - noopFileSystem, - SHARED_LOGGER, - SHARED_AST_HOST, - astFactory, - { linkerJitMode: options.jit ?? false, sourceMapping: false }, - ); - - const fileLinker = new FileLinker(linkerEnvironment, filename as AbsoluteFsPath, code); - const { program } = parseSync(filename, code, { range: true }); - - let s: MagicString | undefined; - let hasLinked = false; - - visitNode(program, (node) => { + /** + * Attempts to link an Angular partial declaration CallExpression. + * + * @param node The CallExpression AST node to check and link. + * @returns The linked code string if the node is a partial declaration, or undefined otherwise. + */ + linkCallExpression(node: CallExpression): string | undefined { const calleeName = SHARED_AST_HOST.getSymbolName(node.callee); - if (calleeName && fileLinker.isPartialDeclaration(calleeName)) { - const args = SHARED_AST_HOST.parseArguments(node); - const linkedCode = fileLinker.linkPartialDeclaration( - calleeName, - args, - SHARED_DECLARATION_SCOPE, - ); - - s ??= new MagicString(code); - s.overwrite(node.start, node.end, linkedCode as string); - hasLinked = true; - - return true; + if (!calleeName || !this.#fileLinker.isPartialDeclaration(calleeName)) { + return undefined; } - return false; - }); + const args = SHARED_AST_HOST.parseArguments(node); + const linkedCode = this.#fileLinker.linkPartialDeclaration( + calleeName, + args, + SHARED_DECLARATION_SCOPE, + ); - if (!hasLinked || !s) { - return { code, map: undefined }; + return linkedCode as string; } - - let map: DecodedSourceMap | undefined; - if (options.sourcemap) { - const rawMap = s.generateDecodedMap({ hires: true, source: filename }); - map = { ...rawMap, version: 3 }; - } - - return { - code: s.toString(), - map, - }; } diff --git a/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts index 5f5cf6d84fe9..fa62c1c5ece1 100644 --- a/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts +++ b/packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts @@ -6,12 +6,12 @@ * found in the LICENSE file at https://angular.dev/license */ -import { linkWithOxc } from './oxc-linker'; +import { transform } from '../../oxc/oxc-transform'; -describe('linkWithOxc', () => { +describe('oxc-linker', () => { it('should not modify code that does not need linking', () => { const input = 'const x = 1;'; - const result = linkWithOxc('test.js', input); + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); expect(result.code).toBe(input); expect(result.map).toBeUndefined(); }); @@ -29,7 +29,7 @@ describe('linkWithOxc', () => { }); `; - const result = linkWithOxc('test.js', input); + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); expect(result.code).toContain('i0.ɵɵdefineDirective'); expect(result.code).not.toContain('i0.ɵɵngDeclareDirective'); }); @@ -49,7 +49,7 @@ describe('linkWithOxc', () => { }); `; - const result = linkWithOxc('test.js', input); + const result = transform('test.js', input, { link: true, advancedOptimizations: false }); expect(result.code).toContain('i0.ɵɵdefineComponent'); expect(result.code).not.toContain('i0.ɵɵngDeclareComponent'); }); @@ -67,7 +67,11 @@ describe('linkWithOxc', () => { }); `; - const result = linkWithOxc('test.js', input, { sourcemap: true }); + const result = transform('test.js', input, { + link: true, + advancedOptimizations: false, + sourcemap: true, + }); expect(result.map).toBeDefined(); expect(result.map?.version).toBe(3); expect(result.map?.sources).toContain('test.js'); diff --git a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts index 4d951ec8e963..f2ec7eccce6d 100644 --- a/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts +++ b/packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts @@ -19,7 +19,6 @@ import { loadInputSourceMapFromUrl, removeSourceMappingURL, } from '../../utils/source-map'; -import { linkWithOxc } from '../angular/linker/oxc-linker.js'; import { transform as transformWithOxc } from '../oxc/oxc-transform.js'; import type { JavaScriptTransformerOptions } from './javascript-transformer'; @@ -170,61 +169,53 @@ async function transformJavaScriptImpl( coverageMap = result.map; } - if (shouldLink) { - if (useBabelLinker) { - const { createEs2015LinkerPlugin } = await import('@angular/compiler-cli/linker/babel'); - const { ConsoleLogger, LogLevel } = await import('@angular/compiler-cli'); + if (shouldLink && useBabelLinker) { + const { createEs2015LinkerPlugin } = await import('@angular/compiler-cli/linker/babel'); + const { ConsoleLogger, LogLevel } = await import('@angular/compiler-cli'); - const result = await transformAsync(code, { - filename, - inputSourceMap: false, - sourceMaps: !!useInputSourcemap, - compact: false, - configFile: false, - babelrc: false, - browserslistConfigFile: false, - plugins: [ - createEs2015LinkerPlugin({ - fileSystem: { - exists: () => false, - readFile: () => '', - resolve: (...paths: string[]) => paths.join('/'), - dirname: (path: string) => path.split('/').slice(0, -1).join('/'), - relative: (_from: string, to: string) => to, - } as never, - logger: new ConsoleLogger(LogLevel.info), - linkerJitMode: jit, - // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed. - sourceMapping: false, - }) as PluginItem, - ], - }); + const result = await transformAsync(code, { + filename, + inputSourceMap: false, + sourceMaps: !!useInputSourcemap, + compact: false, + configFile: false, + babelrc: false, + browserslistConfigFile: false, + plugins: [ + createEs2015LinkerPlugin({ + fileSystem: { + exists: () => false, + readFile: () => '', + resolve: (...paths: string[]) => paths.join('/'), + dirname: (path: string) => path.split('/').slice(0, -1).join('/'), + relative: (_from: string, to: string) => to, + } as never, + logger: new ConsoleLogger(LogLevel.info), + linkerJitMode: jit, + // This is a workaround until https://github.com/angular/angular/issues/42769 is fixed. + sourceMapping: false, + }) as PluginItem, + ], + }); - code = result?.code ?? code; - if (result?.map) { - maps.push(result.map as EncodedSourceMap); - } - } else { - const result = linkWithOxc(filename, code, { - sourcemap: useInputSourcemap, - jit, - skipCheck: true, - }); - code = result.code; - if (result.map) { - maps.push(result.map); - } + code = result?.code ?? code; + if (result?.map) { + maps.push(result.map as EncodedSourceMap); } } - // Run advanced optimizations using our fast oxc-transform - if (advancedOptimizations) { + // Run Oxc linking and/or advanced optimizations in a single unified AST traversal pass + const oxcLink = shouldLink && !useBabelLinker; + if (oxcLink || advancedOptimizations) { const sideEffectFree = options.sideEffects === false; const safeAngularPackage = sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename); const topLevelSafeMode = !safeAngularPackage; const result = transformWithOxc(filename, code, { + link: oxcLink, + jit, + advancedOptimizations, sourcemap: useInputSourcemap, sideEffects: options.sideEffects, topLevelSafeMode, diff --git a/packages/angular/build/src/tools/oxc/oxc-transform.ts b/packages/angular/build/src/tools/oxc/oxc-transform.ts index bec14cd40385..c0389a9f75a4 100644 --- a/packages/angular/build/src/tools/oxc/oxc-transform.ts +++ b/packages/angular/build/src/tools/oxc/oxc-transform.ts @@ -7,15 +7,20 @@ */ 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 { OxcLinker } from '../angular/linker/oxc-linker'; export interface OxcTransformOptions { sourcemap?: boolean; sideEffects?: boolean; topLevelSafeMode?: boolean; pureAnnotate?: boolean; + link?: boolean; + jit?: boolean; + advancedOptimizations?: boolean; } /** @@ -256,8 +261,12 @@ function analyzeClassStaticProperties(classNode: Node, code: string): boolean { // eslint-disable-next-line max-lines-per-function export function transform(filename: string, code: string, options: OxcTransformOptions) { const { program } = parseSync(filename, code, { range: true }); - const s = new MagicString(code); + const source = new MagicString(code); + const shouldLink = options.link && needsLinking(filename, code); + const linker = shouldLink ? new OxcLinker(filename, code, options.jit) : undefined; + + const advancedOptimizations = options.advancedOptimizations ?? true; const sideEffectFree = options.sideEffects === false; const topLevelSafeMode = options.topLevelSafeMode ?? false; const wrapDecorators = sideEffectFree; @@ -412,12 +421,12 @@ export function transform(filename: string, code: string, options: OxcTransformO } // 1. Remove leading/trailing characters/parentheses of the expression statement - s.remove(nextStatement.start, nextExpr.start); - s.remove(nextExpr.end, nextStatement.end); + source.remove(nextStatement.start, nextExpr.start); + source.remove(nextExpr.end, nextStatement.end); markEdited(nextStatement.start, nextStatement.end); // 2. Add return statement inside IIFE body - s.appendRight(callee.body.end - 1, `; return ${paramName};`); + source.appendRight(callee.body.end - 1, `; return ${paramName};`); // 3. Remove `Name = ` assignment in arguments if it's a simple identifier if (rightCallArgument.left.type === 'Identifier') { @@ -428,13 +437,13 @@ export function transform(filename: string, code: string, options: OxcTransformO if (unwrapParentheses(rightCallArgument.right).type === 'AssignmentExpression') { replacement = `(${replacement})`; } - s.overwrite(arg.right.start, arg.right.end, replacement); + source.overwrite(arg.right.start, arg.right.end, replacement); markEdited(arg.right.start, arg.right.end); } // 4. Move IIFE to the var initializer - s.move(nextExpr.start, nextExpr.end, decl.id.end); - s.appendLeft(decl.id.end, ' = /*#__PURE__*/ '); + source.move(nextExpr.start, nextExpr.end, decl.id.end); + source.appendLeft(decl.id.end, ' = /*#__PURE__*/ '); } } @@ -562,7 +571,7 @@ export function transform(filename: string, code: string, options: OxcTransformO // Perform elisions immediately for (const item of wrapStatementPaths) { if (item.type === 'elide') { - s.remove(item.statement.start, item.statement.end); + source.remove(item.statement.start, item.statement.end); markEdited(item.statement.start, item.statement.end); } } @@ -582,27 +591,30 @@ export function transform(filename: string, code: string, options: OxcTransformO if (isExportDefault) { // 1. Remove `export default ` - s.overwrite(statement.start, classNode.start, ''); + source.overwrite(statement.start, classNode.start, ''); // 2. Wrap in IIFE - s.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); - s.appendLeft( + source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); + source.appendLeft( lastStatement.end, `\nreturn ${classIdName};\n})();\nexport { ${classIdName} as default };`, ); } else if (isExportNamed) { // 1. Export is kept, turn `class` into `let ClassName = IIFE` - s.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); - s.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`); + source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); + source.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`); } else if (isVariableClass) { // Wrap class inside init: `/*#__PURE__*/ (() => { let ClassName = class ClassName {}; return ClassName; })()` - s.appendRight(classNode.start, `/*#__PURE__*/ (() => {\nlet ${classIdName} = `); + source.appendRight(classNode.start, `/*#__PURE__*/ (() => {\nlet ${classIdName} = `); const terminator = activeWrapPaths.length === 0 ? ';' : ''; const iifeClosing = activeWrapPaths.length === 0 ? '})()' : '})();'; - s.appendLeft(lastStatement.end, `${terminator}\nreturn ${classIdName};\n${iifeClosing}`); + source.appendLeft( + lastStatement.end, + `${terminator}\nreturn ${classIdName};\n${iifeClosing}`, + ); } else { // Standard ClassDeclaration - s.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); - s.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`); + source.appendRight(classNode.start, `let ${classIdName} = /*#__PURE__*/ (() => {\n`); + source.appendLeft(lastStatement.end, `\nreturn ${classIdName};\n})();`); } markEdited(statement.start, lastStatement.end); @@ -611,8 +623,8 @@ export function transform(filename: string, code: string, options: OxcTransformO i += wrapStatementPaths.length; } else if (isExportDefault && !hasPotentialSideEffects) { // Splitting default export even when not wrapped - s.overwrite(statement.start, classNode.start, ''); - s.appendLeft(classNode.end, `\nexport { ${classIdName} as default };`); + source.overwrite(statement.start, classNode.start, ''); + source.appendLeft(classNode.end, `\nexport { ${classIdName} as default };`); markEdited(statement.start, classNode.end); } } @@ -655,19 +667,37 @@ export function transform(filename: string, code: string, options: OxcTransformO functionDepth--; functionStack.pop(); }, - Program(node) { - adjustTypeScriptEnumsInStatements(node.body); - adjustStaticMembersInStatements(node.body); + 'Program:exit'(node) { + if (advancedOptimizations) { + adjustTypeScriptEnumsInStatements(node.body); + adjustStaticMembersInStatements(node.body); + } }, - BlockStatement(node) { - 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; } + 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; + } + // 1. Elide Angular Metadata check let calleeName: string | undefined; if (node.callee.type === 'Identifier') { @@ -686,7 +716,7 @@ export function transform(filename: string, code: string, options: OxcTransformO (parentFunc.type === 'FunctionExpression' || parentFunc.type === 'ArrowFunctionExpression') ) { - s.overwrite(node.start, node.end, 'void 0'); + source.overwrite(node.start, node.end, 'void 0'); markEdited(node.start, node.end); return; @@ -714,11 +744,12 @@ export function transform(filename: string, code: string, options: OxcTransformO } if (!hasPureComment(node.start)) { - s.appendLeft(node.start, '/*#__PURE__*/ '); + source.appendLeft(node.start, '/*#__PURE__*/ '); } }, NewExpression(node) { if ( + !advancedOptimizations || !pureAnnotate || functionDepth > 0 || classDepth > 0 || @@ -729,7 +760,7 @@ export function transform(filename: string, code: string, options: OxcTransformO if (!topLevelSafeMode) { if (!hasPureComment(node.start)) { - s.appendLeft(node.start, '/*#__PURE__*/ '); + source.appendLeft(node.start, '/*#__PURE__*/ '); } return; @@ -738,7 +769,7 @@ export function transform(filename: string, code: string, options: OxcTransformO const callee = node.callee; if (callee.type === 'Identifier' && sideEffectFreeConstructors.has(callee.name)) { if (!hasPureComment(node.start)) { - s.appendLeft(node.start, '/*#__PURE__*/ '); + source.appendLeft(node.start, '/*#__PURE__*/ '); } } }, @@ -748,12 +779,12 @@ export function transform(filename: string, code: string, options: OxcTransformO let map: DecodedSourceMap | undefined; if (options.sourcemap) { - const rawMap = s.generateDecodedMap({ hires: true, source: filename }); + const rawMap = source.generateDecodedMap({ hires: true, source: filename }); map = { ...rawMap, version: 3 }; } return { - code: s.toString(), + code: source.toString(), map, }; } diff --git a/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts index daa4cf554634..5cf2221744c1 100644 --- a/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts +++ b/packages/angular/build/src/tools/oxc/oxc-transform_spec.ts @@ -8,14 +8,72 @@ import { transform } from './oxc-transform'; -describe('oxc-transform sourcemaps', () => { - it('should generate a decoded sourcemap when sourcemap option is enabled', () => { - const input = 'var result = new SomeClass();'; - const result = transform('test.js', input, { sourcemap: true }); - - expect(result.map).toBeDefined(); - expect(result.map?.version).toBe(3); - expect(result.map?.sources).toContain('test.js'); - expect(result.map?.mappings.length).toBeGreaterThan(0); +describe('oxc-transform', () => { + describe('sourcemaps', () => { + it('should generate a decoded sourcemap when sourcemap option is enabled', () => { + const input = 'var result = new SomeClass();'; + const result = transform('test.js', input, { sourcemap: true }); + + expect(result.map).toBeDefined(); + expect(result.map?.version).toBe(3); + expect(result.map?.sources).toContain('test.js'); + expect(result.map?.mappings.length).toBeGreaterThan(0); + }); + }); + + describe('linking and unified passes', () => { + const componentInput = ` + import * as i0 from "@angular/core"; + export class MyComponent {} + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + isStandalone: true, + selector: "my-cmp", + template: "Hello" + }); + `; + + it('should link partial component declarations when link option is enabled', () => { + const result = transform('test.js', componentInput, { link: true }); + expect(result.code).toContain('i0.ɵɵdefineComponent'); + expect(result.code).not.toContain('i0.ɵɵngDeclareComponent'); + }); + + it('should not link partial component declarations when link option is disabled', () => { + const result = transform('test.js', componentInput, { link: false }); + expect(result.code).not.toContain('i0.ɵɵdefineComponent'); + expect(result.code).toContain('i0.ɵɵngDeclareComponent'); + }); + + it('should perform linking and advanced optimizations simultaneously in a single pass', () => { + const input = ` + import * as i0 from "@angular/core"; + export class MyComponent { + static create() { + return new MyComponent(); + } + } + MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ + minVersion: "12.0.0", + version: "14.0.0", + ngImport: i0, + type: MyComponent, + isStandalone: true, + selector: "my-cmp", + template: "Hello" + }); + `; + + const result = transform('test.js', input, { + link: true, + advancedOptimizations: true, + topLevelSafeMode: true, + }); + expect(result.code).toContain('i0.ɵɵdefineComponent'); + expect(result.code).not.toContain('i0.ɵɵngDeclareComponent'); + }); }); });