Skip to content

Commit ddc3ecf

Browse files
committed
perf(@angular/build): unify Oxc linking and optimization AST traversal passes
Combine partial declaration linking and advanced optimizations into a single AST traversal pass over one Oxc parseSync AST in oxc-transform. This eliminates duplicate AST parses and MagicString sourcemap remapping chains when transforming Angular packages.
1 parent 8d731ec commit ddc3ecf

5 files changed

Lines changed: 215 additions & 214 deletions

File tree

packages/angular/build/src/tools/angular/linker/oxc-linker.ts

Lines changed: 40 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,13 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import type { DecodedSourceMap } from '@ampproject/remapping';
109
import { ConsoleLogger, LogLevel } from '@angular/compiler-cli';
11-
import type { DeclarationScope } from '@angular/compiler-cli/linker';
12-
import { FileLinker, LinkerEnvironment, needsLinking } from '@angular/compiler-cli/linker';
10+
import { type DeclarationScope, FileLinker, LinkerEnvironment } from '@angular/compiler-cli/linker';
1311
import type {
1412
AbsoluteFsPath,
1513
ReadonlyFileSystem,
1614
} from '@angular/compiler-cli/src/ngtsc/file_system';
17-
import type { CallExpression, Node } from '@oxc-project/types';
18-
import MagicString from 'magic-string';
19-
import { parseSync, visitorKeys } from 'oxc-parser';
15+
import type { CallExpression } from '@oxc-project/types';
2016
import { OxcAstHost } from './oxc-ast-host';
2117
import { StringAstFactory } from './string-ast-factory';
2218

@@ -49,131 +45,52 @@ const noopFileSystem: ReadonlyFileSystem = {
4945
relative: (_from: string, to: string) => to,
5046
} as unknown as ReadonlyFileSystem;
5147

52-
const SHARED_LOGGER = new ConsoleLogger(LogLevel.info);
53-
54-
const SHARED_AST_HOST = new OxcAstHost();
55-
const SHARED_DECLARATION_SCOPE = new InlineDeclarationScope();
48+
let SHARED_LOGGER: ConsoleLogger;
49+
let SHARED_AST_HOST: OxcAstHost;
50+
let SHARED_DECLARATION_SCOPE: InlineDeclarationScope;
5651

5752
/**
58-
* Recursively traverses ESTree AST nodes with subtree pruning.
59-
* When `onCallExpression` returns `true` for a linked `CallExpression`,
60-
* child traversal into `callee` and `arguments` is skipped.
61-
*
62-
* Why subtree pruning is safe for the linker:
63-
* - Angular partial declarations (`ɵɵngDeclareComponent`, `ɵɵngDeclareDirective`,
64-
* etc.) are never nested inside each other.
65-
* - Once a declaration `CallExpression` is linked and replaced, there can never be
66-
* another partial declaration within its metadata argument object. Pruning its
67-
* subtree avoids traversing hundreds of unnecessary metadata argument nodes per
68-
* component.
53+
* Manages Angular partial declaration linking using Oxc AST nodes.
6954
*/
70-
function visitNode(
71-
node: Node | Node[] | null | undefined,
72-
onCallExpression: (node: CallExpression) => boolean,
73-
): void {
74-
if (node === null || node === undefined || typeof node !== 'object') {
75-
return;
76-
}
77-
78-
if (Array.isArray(node)) {
79-
for (let i = 0; i < node.length; i++) {
80-
visitNode(node[i], onCallExpression);
81-
}
82-
83-
return;
84-
}
85-
86-
const nodeType = node.type;
87-
if (!nodeType) {
88-
return;
55+
export class OxcLinker {
56+
readonly #fileLinker: FileLinker<unknown, string, unknown, string | undefined>;
57+
58+
constructor(filename: string, code: string, jit = false) {
59+
SHARED_LOGGER ??= new ConsoleLogger(LogLevel.info);
60+
SHARED_AST_HOST ??= new OxcAstHost();
61+
SHARED_DECLARATION_SCOPE ??= new InlineDeclarationScope();
62+
63+
const astFactory = new StringAstFactory(code);
64+
const linkerEnvironment = LinkerEnvironment.create(
65+
noopFileSystem,
66+
SHARED_LOGGER,
67+
SHARED_AST_HOST,
68+
astFactory,
69+
{ linkerJitMode: jit, sourceMapping: false },
70+
);
71+
72+
this.#fileLinker = new FileLinker(linkerEnvironment, filename as AbsoluteFsPath, code);
8973
}
9074

91-
if (nodeType === 'CallExpression') {
92-
if (onCallExpression(node)) {
93-
// Subtree pruning: partial declarations cannot be nested, so skip child traversal.
94-
return;
95-
}
96-
}
97-
98-
const keys = visitorKeys[nodeType];
99-
if (keys) {
100-
for (let i = 0; i < keys.length; i++) {
101-
const child = (node as unknown as Record<string, Node | Node[] | null | undefined>)[keys[i]];
102-
if (child !== undefined && child !== null) {
103-
visitNode(child, onCallExpression);
104-
}
105-
}
106-
}
107-
}
108-
109-
export interface OxcLinkerOptions {
110-
sourcemap?: boolean;
111-
jit?: boolean;
112-
skipCheck?: boolean;
113-
}
114-
115-
/**
116-
* Executes Angular partial declaration linking on the specified JavaScript file
117-
* using `oxc-parser` and `magic-string`.
118-
*
119-
* @param filename The full path to the file.
120-
* @param code The source code content.
121-
* @param options Linker options (sourcemap, jit, skipCheck).
122-
* @returns An object containing the transformed code and optional source map.
123-
*/
124-
export function linkWithOxc(filename: string, code: string, options: OxcLinkerOptions = {}) {
125-
if (!options.skipCheck && !needsLinking(filename, code)) {
126-
return { code, map: undefined };
127-
}
128-
129-
const astFactory = new StringAstFactory(code);
130-
131-
const linkerEnvironment = LinkerEnvironment.create(
132-
noopFileSystem,
133-
SHARED_LOGGER,
134-
SHARED_AST_HOST,
135-
astFactory,
136-
{ linkerJitMode: options.jit ?? false, sourceMapping: false },
137-
);
138-
139-
const fileLinker = new FileLinker(linkerEnvironment, filename as AbsoluteFsPath, code);
140-
const { program } = parseSync(filename, code, { range: true });
141-
142-
let s: MagicString | undefined;
143-
let hasLinked = false;
144-
145-
visitNode(program, (node) => {
75+
/**
76+
* Attempts to link an Angular partial declaration CallExpression.
77+
*
78+
* @param node The CallExpression AST node to check and link.
79+
* @returns The linked code string if the node is a partial declaration, or undefined otherwise.
80+
*/
81+
linkCallExpression(node: CallExpression): string | undefined {
14682
const calleeName = SHARED_AST_HOST.getSymbolName(node.callee);
147-
if (calleeName && fileLinker.isPartialDeclaration(calleeName)) {
148-
const args = SHARED_AST_HOST.parseArguments(node);
149-
const linkedCode = fileLinker.linkPartialDeclaration(
150-
calleeName,
151-
args,
152-
SHARED_DECLARATION_SCOPE,
153-
);
154-
155-
s ??= new MagicString(code);
156-
s.overwrite(node.start, node.end, linkedCode as string);
157-
hasLinked = true;
158-
159-
return true;
83+
if (!calleeName || !this.#fileLinker.isPartialDeclaration(calleeName)) {
84+
return undefined;
16085
}
16186

162-
return false;
163-
});
87+
const args = SHARED_AST_HOST.parseArguments(node);
88+
const linkedCode = this.#fileLinker.linkPartialDeclaration(
89+
calleeName,
90+
args,
91+
SHARED_DECLARATION_SCOPE,
92+
);
16493

165-
if (!hasLinked || !s) {
166-
return { code, map: undefined };
94+
return linkedCode as string;
16795
}
168-
169-
let map: DecodedSourceMap | undefined;
170-
if (options.sourcemap) {
171-
const rawMap = s.generateDecodedMap({ hires: true, source: filename });
172-
map = { ...rawMap, version: 3 };
173-
}
174-
175-
return {
176-
code: s.toString(),
177-
map,
178-
};
17996
}

packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { linkWithOxc } from './oxc-linker';
9+
import { transform } from '../../oxc/oxc-transform';
1010

11-
describe('linkWithOxc', () => {
11+
describe('oxc-linker', () => {
1212
it('should not modify code that does not need linking', () => {
1313
const input = 'const x = 1;';
14-
const result = linkWithOxc('test.js', input);
14+
const result = transform('test.js', input, { link: true, advancedOptimizations: false });
1515
expect(result.code).toBe(input);
1616
expect(result.map).toBeUndefined();
1717
});
@@ -29,7 +29,7 @@ describe('linkWithOxc', () => {
2929
});
3030
`;
3131

32-
const result = linkWithOxc('test.js', input);
32+
const result = transform('test.js', input, { link: true, advancedOptimizations: false });
3333
expect(result.code).toContain('i0.ɵɵdefineDirective');
3434
expect(result.code).not.toContain('i0.ɵɵngDeclareDirective');
3535
});
@@ -49,7 +49,7 @@ describe('linkWithOxc', () => {
4949
});
5050
`;
5151

52-
const result = linkWithOxc('test.js', input);
52+
const result = transform('test.js', input, { link: true, advancedOptimizations: false });
5353
expect(result.code).toContain('i0.ɵɵdefineComponent');
5454
expect(result.code).not.toContain('i0.ɵɵngDeclareComponent');
5555
});
@@ -67,7 +67,11 @@ describe('linkWithOxc', () => {
6767
});
6868
`;
6969

70-
const result = linkWithOxc('test.js', input, { sourcemap: true });
70+
const result = transform('test.js', input, {
71+
link: true,
72+
advancedOptimizations: false,
73+
sourcemap: true,
74+
});
7175
expect(result.map).toBeDefined();
7276
expect(result.map?.version).toBe(3);
7377
expect(result.map?.sources).toContain('test.js');

packages/angular/build/src/tools/esbuild/javascript-transformer-worker.ts

Lines changed: 36 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
loadInputSourceMapFromUrl,
2020
removeSourceMappingURL,
2121
} from '../../utils/source-map';
22-
import { linkWithOxc } from '../angular/linker/oxc-linker.js';
2322
import { transform as transformWithOxc } from '../oxc/oxc-transform.js';
2423
import type { JavaScriptTransformerOptions } from './javascript-transformer';
2524

@@ -170,61 +169,53 @@ async function transformJavaScriptImpl(
170169
coverageMap = result.map;
171170
}
172171

173-
if (shouldLink) {
174-
if (useBabelLinker) {
175-
const { createEs2015LinkerPlugin } = await import('@angular/compiler-cli/linker/babel');
176-
const { ConsoleLogger, LogLevel } = await import('@angular/compiler-cli');
172+
if (shouldLink && useBabelLinker) {
173+
const { createEs2015LinkerPlugin } = await import('@angular/compiler-cli/linker/babel');
174+
const { ConsoleLogger, LogLevel } = await import('@angular/compiler-cli');
177175

178-
const result = await transformAsync(code, {
179-
filename,
180-
inputSourceMap: false,
181-
sourceMaps: !!useInputSourcemap,
182-
compact: false,
183-
configFile: false,
184-
babelrc: false,
185-
browserslistConfigFile: false,
186-
plugins: [
187-
createEs2015LinkerPlugin({
188-
fileSystem: {
189-
exists: () => false,
190-
readFile: () => '',
191-
resolve: (...paths: string[]) => paths.join('/'),
192-
dirname: (path: string) => path.split('/').slice(0, -1).join('/'),
193-
relative: (_from: string, to: string) => to,
194-
} as never,
195-
logger: new ConsoleLogger(LogLevel.info),
196-
linkerJitMode: jit,
197-
// This is a workaround until https://github.com/angular/angular/issues/42769 is fixed.
198-
sourceMapping: false,
199-
}) as PluginItem,
200-
],
201-
});
176+
const result = await transformAsync(code, {
177+
filename,
178+
inputSourceMap: false,
179+
sourceMaps: !!useInputSourcemap,
180+
compact: false,
181+
configFile: false,
182+
babelrc: false,
183+
browserslistConfigFile: false,
184+
plugins: [
185+
createEs2015LinkerPlugin({
186+
fileSystem: {
187+
exists: () => false,
188+
readFile: () => '',
189+
resolve: (...paths: string[]) => paths.join('/'),
190+
dirname: (path: string) => path.split('/').slice(0, -1).join('/'),
191+
relative: (_from: string, to: string) => to,
192+
} as never,
193+
logger: new ConsoleLogger(LogLevel.info),
194+
linkerJitMode: jit,
195+
// This is a workaround until https://github.com/angular/angular/issues/42769 is fixed.
196+
sourceMapping: false,
197+
}) as PluginItem,
198+
],
199+
});
202200

203-
code = result?.code ?? code;
204-
if (result?.map) {
205-
maps.push(result.map as EncodedSourceMap);
206-
}
207-
} else {
208-
const result = linkWithOxc(filename, code, {
209-
sourcemap: useInputSourcemap,
210-
jit,
211-
skipCheck: true,
212-
});
213-
code = result.code;
214-
if (result.map) {
215-
maps.push(result.map);
216-
}
201+
code = result?.code ?? code;
202+
if (result?.map) {
203+
maps.push(result.map as EncodedSourceMap);
217204
}
218205
}
219206

220-
// Run advanced optimizations using our fast oxc-transform
221-
if (advancedOptimizations) {
207+
// Run Oxc linking and/or advanced optimizations in a single unified AST traversal pass
208+
const oxcLink = shouldLink && !useBabelLinker;
209+
if (oxcLink || advancedOptimizations) {
222210
const sideEffectFree = options.sideEffects === false;
223211
const safeAngularPackage =
224212
sideEffectFree && /[\\/]node_modules[\\/]@angular[\\/]/.test(filename);
225213
const topLevelSafeMode = !safeAngularPackage;
226214

227215
const result = transformWithOxc(filename, code, {
216+
link: oxcLink,
217+
jit,
218+
advancedOptimizations,
228219
sourcemap: useInputSourcemap,
229220
sideEffects: options.sideEffects,
230221
topLevelSafeMode,

0 commit comments

Comments
 (0)