8000 Migrate `packages/compiler` and `packages/elements` by devversion · Pull Request #61566 · angular/angular · GitHub
[go: up one dir, main page]

Skip to content

Migrate packages/compiler and packages/elements #61566

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 11 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
build: rework symbol extractor to support lazy/common chunks
Since we are going to replace our `app_bundle` rule (custom ESBuild +
Terser pipeline) with the real Angular CLI where shared/lazy/common
chunks may exist, we need to update the symbol extractor to support
multiple files.

We could have just merged all symbols, but this commit tries to do
better by detecting what symbols are loaded eagerly vs. lazily. This
will be very useful for e.g. defer tests or other lazy features we are
introducing in the feature.
  • Loading branch information
devversion committed May 27, 2025
commit 612cd5fcb8ab5dc4ff5d1cf41a824c7fbd6fac6a
2 changes: 1 addition & 1 deletion tools/symbol-extractor/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ ts_project(
),
tsconfig = "//tools:tsconfig_build",
deps = [
"//:node_modules/@bazel/runfiles",
"//:node_modules/@types/jasmine",
"//:node_modules/tinyglobby",
"//:node_modules/typescript",
],
)
Expand Down
102 changes: 83 additions & 19 deletions tools/symbol-extractor/cli.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,23 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {runfiles} from '@bazel/runfiles';
import * as fs from 'fs';
import * as path from 'path';
import {globSync} from 'tinyglobby';

import {SymbolExtractor} from './symbol_extractor.mjs';
import assert from 'assert';

const args = process.argv.slice(2) as [string, string];
process.exitCode = main(args) ? 0 : 1;

interface GoldenFile {
chunks: {
main: string[];
lazy: string[];
};
}

/**
* CLI main method.
*
Expand All @@ -23,29 +31,85 @@ process.exitCode = main(args) ? 0 : 1;
* ```
*/
function main(argv: [string, string, string] | [string, string]): boolean {
const javascriptFilePath = runfiles.resolveWorkspaceRelative(argv[0]);
const goldenFilePath = runfiles.resolveWorkspaceRelative(argv[1]);
const bundlesDir = path.resolve(argv[0]);
const goldenFilePath = path.resolve(argv[1]);
const doUpdate = argv[2] === '--accept';
const bundles = globSync('**/*.js', {cwd: bundlesDir});
const goldenContent = fs.readFileSync(goldenFilePath).toString();
const golden = JSON.parse(goldenContent) as GoldenFile;

console.info('Input javascript file:', javascriptFilePath);
console.info('Input bundles directory:', bundlesDir);

const javascriptContent = fs.readFileSync(javascriptFilePath).toString();
const goldenContent = fs.readFileSync(goldenFilePath).toString();
const symbolExtractor = new SymbolExtractor(javascriptFilePath, javascriptContent);
const importEdges = new Map<string, string[]>();
const bundleSymbols = new Map<string, SymbolExtractor>();

for (const bundleFile of bundles) {
console.info('Processing bundle file:', bundleFile);

const javascriptContent = fs.readFileSync(path.join(bundlesDir, bundleFile)).toString();
const symbolExtractor = new SymbolExtractor(javascriptContent);

// Keep track of import edges, so we can determine what is loaded lazily vs eagerly.
importEdges.set(bundleFile, symbolExtractor.eagerlyLoadedRelativeSpecifiers);

bundleSymbols.set(bundleFile, symbolExtractor);
}

// Find all bundles that are eagerly loaded by the `main.js` bundle.
const eagerlyLoadedBundles = new Set<string>();
const queue: string[] = ['main.js'];
while (queue.length !== 0) {
const entry = queue.pop()!;

if (eagerlyLoadedBundles.has(entry)) {
continue;
}
eagerlyLoadedBundles.add(entry);

for (const edge of importEdges.get(entry) ?? []) {
queue.push(edge);
}
}

const eagerlyLoadedSymbols: string[] = [];
const lazySymbols: string[] = [];

for (const bundleFile of bundles) {
const extractor = bundleSymbols.get(bundleFile);
assert(extractor, `Expected symbol extractor to exist for bundle: ${bundleFile}`);

(eagerlyLoadedBundles.has(bundleFile) ? eagerlyLoadedSymbols : lazySymbols).push(
...extractor.actual,
);
}

let passed = false;
if (doUpdate) {
const newGolden: GoldenFile = {
chunks: {
main: eagerlyLoadedSymbols,
lazy: lazySymbols,
},
};

const goldenOutFilePath = path.join(process.env['BUILD_WORKING_DIRECTORY']!, argv[1]);
fs.writeFileSync(goldenOutFilePath, JSON.stringify(symbolExtractor.actual, undefined, 2));
console.error('Updated gold file:', goldenOutFilePath);
passed = true;
} else {
passed = symbolExtractor.compareAndPrintError(goldenContent);
if (!passed) {
console.error(`TEST FAILED!`);
console.error(` To update the golden file run: `);
console.error(` yarn bazel run ${process.env['TEST_TARGET']}.accept`);
}
fs.writeFileSync(goldenOutFilePath, JSON.stringify(newGolden, undefined, 2));
console.error('Updated golden file:', goldenOutFilePath);
return true;
}

const success =
SymbolExtractor.compareAndPrintError(goldenFilePath, golden?.chunks?.lazy ?? [], lazySymbols) &&
SymbolExtractor.compareAndPrintError(
goldenFilePath,
golden?.chunks?.main ?? [],
eagerlyLoadedSymbols,
);

if (!success) {
console.error(`TEST FAILED!`);
console.error(` To update the golden file run: `);
console.error(` yarn bazel run ${process.env['TEST_TARGET']}.accept`);
}
return passed;

return success;
}
8 changes: 4 additions & 4 deletions tools/symbol-extractor/index.bzl
8000
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_test")
This test verifies that a set of top level symbols from a javascript file match a gold file.
"""

def js_expected_symbol_test(name, src, golden, data = [], **kwargs):
def js_expected_symbol_test(name, bundles_dir, golden, data = [], **kwargs):
"""This test verifies that a set of top level symbols from a javascript file match a gold file.
"""
all_data = data + [
Label("//tools/symbol-extractor:lib_rjs"),
src,
bundles_dir,
golden,
]
entry_point = "//tools/symbol-extractor:cli.mjs"
Expand All @@ -24,7 +24,7 @@ def js_expected_symbol_test(name, src, golden, data = [], **kwargs):
data = all_data,
entry_point = entry_point,
tags = kwargs.pop("tags", []) + ["symbol_extractor"],
fixed_args = ["$(rootpath %s)" % src, "$(rootpath %s)" % golden],
fixed_args = ["$(rootpath %s)" % bundles_dir, "$(rootpath %s)" % golden],
**kwargs
)

Expand All @@ -33,6 +33,6 @@ def js_expected_symbol_test(name, src, golden, data = [], **kwargs):
testonly = True,
data = all_data,
entry_point = entry_point,
fixed_args = ["$(rootpath %s)" % src, "$(rootpath %s)" % golden, "--accept"],
fixed_args = ["$(rootpath %s)" % bundles_dir, "$(rootpath %s)" % golden, "--accept"],
**kwargs
)
80 changes: 37 additions & 43 deletions tools/symbol-extractor/symbol_extractor.mts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,18 @@ import ts from 'typescript';

export class SymbolExtractor {
public actual: string[];
public eagerlyLoadedRelativeSpecifiers: string[];

static parse(contents: string): {symbols: string[]; eagerlyLoadedRelativeSpecifiers: string[]} {
const eagerlyLoadedRelativeSpecifiers: string[] = [];

static parse(path: string, contents: string): string[] {
const symbols: string[] = [];
const source: ts.SourceFile = ts.createSourceFile(path, contents, ts.ScriptTarget.Latest, true);
const source: ts.SourceFile = ts.createSourceFile(
'_test_file.js',
contents,
ts.ScriptTarget.Latest,
true,
);
let fnRecurseDepth = 0;
function visitor(child: ts.Node) {
// Left for easier debugging.
Expand Down Expand Up @@ -45,11 +53,7 @@ export class SymbolExtractor {
// Terser optimizes variable declarations with `undefined` as initializer
// by omitting the initializer completely. We capture such declarations as well.
// https://github.com/terser/terser/blob/86ea74d5c12ae51b64468/CHANGELOG.md#v540.
if (fnRecurseDepth !== 0) {
if (!isEsmInitFunction(varDecl)) {
symbols.push(stripSuffix(varDecl.name.getText()));
}
}
symbols.push(stripSuffix(varDecl.name.getText()));
break;
case ts.SyntaxKind.FunctionDeclaration:
const funcDecl = child as ts.FunctionDeclaration;
Expand All @@ -59,20 +63,29 @@ export class SymbolExtractor {
const classDecl = child as ts.ClassDeclaration;
classDecl.name && symbols.push(stripSuffix(classDecl.name.getText()));
break;
case ts.SyntaxKind.ImportDeclaration:
// Keep track of eagerly loaded modules, so that can determine which
// chunks of a set of bundles is eagerly loaded vs. lazily.
const {moduleSpecifier} = child as ts.ImportDeclaration;
if (ts.isStringLiteral(moduleSpecifier) && moduleSpecifier.text.startsWith('.')) {
eagerlyLoadedRelativeSpecifiers.push(moduleSpecifier.text);
}

default:
// Left for easier debugging.
// console.log('###', ts.SyntaxKind[child.kind], child.getText());
}
}
visitor(source);
symbols.sort();
return symbols;

return {
symbols,
eagerlyLoadedRelativeSpecifiers,
};
}

static diff(actual: string[], expected: string | string[]): {[name: string]: number} {
if (typeof expected == 'string') {
expected = JSON.parse(expected) as string[];
}
static diff(actual: string[], expected: string[]): {[name: string]: number} {
const diff: {[name: string]: number} = {};

// All symbols in the golden file start out with a count corresponding to the number of symbols
Expand All @@ -92,23 +105,27 @@ export class SymbolExtractor {
return diff;
}

constructor(
private path: string,
private contents: string,
) {
this.actual = SymbolExtractor.parse(path, contents);
constructor(contents: string) {
const res = SymbolExtractor.parse(contents);

this.actual = res.symbols;
this.eagerlyLoadedRelativeSpecifiers = res.eagerlyLoadedRelativeSpecifiers;
}

expect(expectedSymbols: string[]) {
expect(SymbolExtractor.diff(this.actual, expectedSymbols)).toEqual({});
}

compareAndPrintError(expected: string | string[]): boolean {
static compareAndPrintError(
goldenFilename: string,
expected: string[],
actual: string[],
): boolean {
let passed = true;
const diff = SymbolExtractor.diff(this.actual, expected);
const diff = SymbolExtractor.diff(actual, expected);
Object.keys(diff).forEach((key) => {
if (passed) {
console.error(`Expected symbols in '${this.path}' did not match gold file.`);
console.error(`Expected symbols in '${goldenFilename}' did not match gold file.`);
passed = false;
}
const missingOrExtra = diff[key] > 0 ? 'extra' : 'missing';
Expand All @@ -124,26 +141,3 @@ function stripSuffix(text: string): string {
const index = text.lastIndexOf('$');
return index > -1 ? text.substring(0, index) : text;
}

/**
* This function detects a specific pattern that represents ESM modules
* in the generated code. Those symbols are not really needed for the purposes
* of symbol checking, since they only represent a module graph and all
* nested symbols are being captured by the logic already. The pattern that
* this function detects looks like this:
* ```
* var init_testability = __esm({
* "packages/core/src/testability/testability.mjs"() {
* // ...
* }
* });
* ```
*/
function isEsmInitFunction(varDecl: ts.VariableDeclaration) {
return (
varDecl.name.getText().startsWith('init_') &&
varDecl.initializer &&
ts.isCallExpression(varDecl.initializer) &&
(varDecl.initializer.expression as ts.Identifier).escapedText === '___esm'
);
}
16 changes: 11 additions & 5 deletions tools/symbol-extractor/symbol_extractor_spec.mts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {SymbolExtractor} from './symbol_extractor.mjs';

describe('scenarios', () => {
const symbolExtractorSpecDir = path.dirname(
runfiles.resolve('angular/tools/symbol-extractor/symbol_extractor_spec/empty.json'),
runfiles.resolve('angular/tools/symbol-extractor/symbol_extractor_spec/simple.json'),
);
const scenarioFiles = fs.readdirSync(symbolExtractorSpecDir);
for (let i = 0; i < scenarioFiles.length; i++) {
Expand All @@ -37,8 +37,8 @@ describe('scenarios', () => {
it(testName, () => {
const jsFileContent = fs.readFileSync(path.join(symbolExtractorSpecDir, filePath)).toString();
const jsonFileContent = fs.readFileSync(goldenFilePath).toString();
const symbols = SymbolExtractor.parse(testName, jsFileContent);
const diff = SymbolExtractor.diff(symbols, jsonFileContent);
const {symbols} = SymbolExtractor.parse(jsFileContent);
const diff = SymbolExtractor.diff(symbols, JSON.parse(jsonFileContent));
expect(diff).toEqual({});
});
}
Expand All @@ -56,8 +56,14 @@ describe('scenarios', () => {
const jsonFileContent = fs
.readFileSync(path.join(symbolExtractorSpecDir, 'es2015_class_output.json'))
.toString();
const symbols = SymbolExtractor.parse('es2015_class_output', jsFileContent);
const diff = SymbolExtractor.diff(symbols, jsonFileContent);
const {symbols} = SymbolExtractor.parse(jsFileContent);
const diff = SymbolExtractor.diff(symbols, JSON.parse(jsonFileContent) as string[]);
expect(diff).toEqual({});
});

it('should properly detect eaglery loaded files', () => {
const jsFileContent = `import {} from './other_chunk';`;
const {eagerlyLoadedRelativeSpecifiers} = SymbolExtractor.parse(jsFileContent);
expect(eagerlyLoadedRelativeSpecifiers).toEqual(['./other_chunk']);
});
});
9 changes: 0 additions & 9 deletions tools/symbol-extractor/symbol_extractor_spec/empty.js

This file was deleted.

1 change: 0 additions & 1 deletion tools/symbol-extractor/symbol_extractor_spec/empty.json

This file was deleted.

0