Skip to content

Commit cc5e7af

Browse files
committed
refactor(@angular/build): extract shared breadth-first initial file analysis
Previously, both `BundlerContext` and `optimizeChunks` maintained separate implementations for discovering transitive initial files and calculating their import depths from the esbuild metafile. Both implementations used a depth-first stack traversal via `Array.prototype.pop()`, which visited deeper branches in reverse import order and failed to propagate updated minimum depths to the transitive dependencies of already-visited chunks in diamond dependency graphs. This commit extracts a shared `findTransitiveInitialFiles` helper that uses a breadth-first queue traversal over direct static imports. Processing chunks in breadth-first order guarantees that each file is discovered at its true shortest import depth on first visit, preserves the natural declaration order of imports at each depth level, and eliminates the duplicated metafile traversal logic across initial bundling and chunk optimization.
1 parent 0fd4404 commit cc5e7af

4 files changed

Lines changed: 355 additions & 84 deletions

File tree

‎packages/angular/build/src/builders/application/chunk-optimizer.ts‎

Lines changed: 6 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ import { BundleMergedContextResult } from '../../tools/esbuild/bundler-context';
2424
import {
2525
type BuildOutputFile,
2626
BuildOutputFileType,
27-
type InitialFileRecord,
2827
createOutputFile,
28+
findTransitiveInitialFiles,
2929
} from '../../tools/esbuild/bundler-files';
3030
import { useRolldownChunks } from '../../utils/environment-options';
3131
import { assertIsError } from '../../utils/error';
@@ -166,21 +166,6 @@ function bundleOutputToEsbuildMetafile(
166166
return newMetafile;
167167
}
168168

169-
/**
170-
* Creates an InitialFileRecord object with a specified depth.
171-
* @param depth The depth of the file in the dependency graph.
172-
* @returns An InitialFileRecord object.
173-
*/
174-
function createInitialFileRecord(depth: number): InitialFileRecord {
175-
return {
176-
type: 'script',
177-
entrypoint: false,
178-
external: false,
179-
serverFile: false,
180-
depth,
181-
};
182-
}
183-
184169
/**
185170
* Creates an esbuild message object for a chunk optimization failure.
186171
* @param message The error message detailing the cause of the failure.
@@ -369,14 +354,11 @@ export async function optimizeChunks(
369354
);
370355

371356
// Add new optimized chunks
372-
const importsPerFile: Record<string, string[]> = {};
373357
for (const optimizedFile of optimizedOutput) {
374358
if (optimizedFile.type !== 'chunk') {
375359
continue;
376360
}
377361

378-
importsPerFile[optimizedFile.fileName] = optimizedFile.imports;
379-
380362
original.outputFiles.push(
381363
createOutputFile(optimizedFile.fileName, optimizedFile.code, BuildOutputFileType.Browser),
382364
);
@@ -392,41 +374,17 @@ export async function optimizeChunks(
392374
}
393375

394376
// Update initial files to reflect optimized chunks
395-
const entriesToAnalyze: [string, InitialFileRecord][] = [];
396377
for (const usedFile of usedChunks) {
397378
// Leave the main file since its information did not change
398-
if (usedFile === mainFile) {
399-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
400-
entriesToAnalyze.push([mainFile, original.initialFiles.get(mainFile)!]);
401-
continue;
379+
if (usedFile !== mainFile) {
380+
original.initialFiles.delete(usedFile);
402381
}
403-
404-
// Remove all other used chunks
405-
original.initialFiles.delete(usedFile);
406382
}
407383

408384
// Analyze for transitive initial files
409-
let currentEntry;
410-
while ((currentEntry = entriesToAnalyze.pop())) {
411-
const [entryPath, entryRecord] = currentEntry;
412-
413-
for (const importPath of importsPerFile[entryPath]) {
414-
const existingRecord = original.initialFiles.get(importPath);
415-
if (existingRecord) {
416-
// Store the smallest value depth
417-
if (existingRecord.depth > entryRecord.depth + 1) {
418-
existingRecord.depth = entryRecord.depth + 1;
419-
}
420-
421-
continue;
422-
}
423-
424-
const record = createInitialFileRecord(entryRecord.depth + 1);
425-
426-
original.initialFiles.set(importPath, record);
427-
entriesToAnalyze.push([importPath, record]);
428-
}
429-
}
385+
const mainRecord = original.initialFiles.get(mainFile);
386+
assert(mainRecord, `Main file record should always be present in initialFiles [${mainFile}].`);
387+
findTransitiveInitialFiles(newMetafile, original.initialFiles, [[mainFile, mainRecord]]);
430388

431389
return original;
432390
}

‎packages/angular/build/src/tools/esbuild/bundler-context.ts‎

Lines changed: 2 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
BuildOutputFileType,
2525
type InitialFileRecord,
2626
convertOutputFile,
27+
findTransitiveInitialFiles,
2728
} from './bundler-files';
2829
import { LoadResultCache, MemoryLoadResultCache } from './load-result-cache';
2930

@@ -363,41 +364,7 @@ export class BundlerContext {
363364
}
364365

365366
// Analyze for transitive initial files
366-
const entriesToAnalyze = [...initialFiles];
367-
let currentEntry;
368-
while ((currentEntry = entriesToAnalyze.pop())) {
369-
const [entryPath, entryRecord] = currentEntry;
370-
371-
for (const initialImport of result.metafile.outputs[entryPath].imports) {
372-
const existingRecord = initialFiles.get(initialImport.path);
373-
if (existingRecord) {
374-
// Store the smallest value depth
375-
if (existingRecord.depth > entryRecord.depth + 1) {
376-
existingRecord.depth = entryRecord.depth + 1;
377-
}
378-
379-
continue;
380-
}
381-
382-
if (initialImport.kind === 'import-statement' || initialImport.kind === 'import-rule') {
383-
const record: InitialFileRecord = {
384-
type: initialImport.kind === 'import-rule' ? 'style' : 'script',
385-
entrypoint: false,
386-
external: initialImport.external,
387-
serverFile: isPlatformServer,
388-
depth: entryRecord.depth + 1,
389-
};
390-
391-
if (!this.initialFilter || this.initialFilter(record)) {
392-
initialFiles.set(initialImport.path, record);
393-
}
394-
395-
if (!initialImport.external) {
396-
entriesToAnalyze.push([initialImport.path, record]);
397-
}
398-
}
399-
}
400-
}
367+
findTransitiveInitialFiles(result.metafile, initialFiles, undefined, this.initialFilter);
401368

402369
// Collect all external package names
403370
const externalImports = new Set<string>();

‎packages/angular/build/src/tools/esbuild/bundler-files.ts‎

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

9-
import type { OutputFile } from 'esbuild';
9+
import type { Metafile, OutputFile } from 'esbuild';
1010
import { calculateHash } from '../../utils/hash';
1111

1212
export interface InitialFileRecord {
@@ -18,6 +18,54 @@ export interface InitialFileRecord {
1818
depth: number;
1919
}
2020

21+
/**
22+
* Analyzes a metafile to find and record all transitive initial files starting from the given
23+
* initial entries using breadth-first traversal so each file is recorded at its minimum depth.
24+
*
25+
* @param metafile The esbuild metafile containing output import relationships.
26+
* @param initialFiles The map of initial files to populate.
27+
* @param entriesToAnalyze Optional starting entries to traverse; defaults to all current `initialFiles`.
28+
* @param initialFilter Optional filter predicate to determine whether a discovered record should be added.
29+
*/
30+
export function findTransitiveInitialFiles(
31+
metafile: Metafile,
32+
initialFiles: Map<string, InitialFileRecord>,
33+
entriesToAnalyze: Iterable<[string, InitialFileRecord]> = initialFiles,
34+
initialFilter?: (initial: Readonly<InitialFileRecord>) => boolean,
35+
): void {
36+
const queue = [...entriesToAnalyze];
37+
for (const [entryPath, entryRecord] of queue) {
38+
const entryOutput = metafile.outputs[entryPath];
39+
if (!entryOutput) {
40+
continue;
41+
}
42+
43+
for (const initialImport of entryOutput.imports) {
44+
if (initialFiles.has(initialImport.path)) {
45+
continue;
46+
}
47+
48+
if (initialImport.kind === 'import-statement' || initialImport.kind === 'import-rule') {
49+
const record: InitialFileRecord = {
50+
type: initialImport.kind === 'import-rule' ? 'style' : 'script',
51+
entrypoint: false,
52+
external: initialImport.external,
53+
serverFile: entryRecord.serverFile,
54+
depth: entryRecord.depth + 1,
55+
};
56+
57+
if (!initialFilter || initialFilter(record)) {
58+
initialFiles.set(initialImport.path, record);
59+
60+
if (!initialImport.external) {
61+
queue.push([initialImport.path, record]);
62+
}
63+
}
64+
}
65+
}
66+
}
67+
}
68+
2169
export enum BuildOutputFileType {
2270
Browser,
2371
Media,

0 commit comments

Comments
 (0)