Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 40 additions & 123 deletions packages/angular/build/src/tools/angular/linker/oxc-linker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<unknown, string, unknown, string | undefined>;

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<string, Node | Node[] | null | undefined>)[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,
};
}
16 changes: 10 additions & 6 deletions packages/angular/build/src/tools/angular/linker/oxc-linker_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand All @@ -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');
});
Expand All @@ -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');
});
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Comment thread
alan-agius4 marked this conversation as resolved.
advancedOptimizations,
sourcemap: useInputSourcemap,
sideEffects: options.sideEffects,
topLevelSafeMode,
Expand Down
Loading
Loading