Skip to content

Commit ad232b6

Browse files
committed
perf(@angular/build): optimize incremental watch rebuilds in angular compilation
1 parent d09f98e commit ad232b6

2 files changed

Lines changed: 219 additions & 67 deletions

File tree

‎packages/angular/build/src/tools/angular/compilation/aot-compilation.ts‎

Lines changed: 199 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class AngularCompilationState {
4141
constructor(
4242
public readonly angularProgram: ng.NgtscProgram,
4343
public readonly compilerHost: ng.CompilerHost,
44-
public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram,
44+
public typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram,
4545
public readonly affectedFiles: ReadonlySet<ts.SourceFile>,
4646
public readonly templateDiagnosticsOptimization: ng.OptimizeFor,
4747
public readonly webWorkerTransform: ts.TransformerFactory<ts.SourceFile>,
@@ -61,6 +61,7 @@ export class AotCompilation extends TypeScriptCompilation {
6161
super();
6262
}
6363

64+
// eslint-disable-next-line max-lines-per-function
6465
async initialize(
6566
tsconfig: string,
6667
hostOptions: AngularHostOptions,
@@ -100,8 +101,8 @@ export class AotCompilation extends TypeScriptCompilation {
100101
if (hostOptions.modifiedFiles) {
101102
this.invalidateFiles(hostOptions.modifiedFiles);
102103

103-
for (const modifiedFile of hostOptions.modifiedFiles) {
104-
if (this.#state) {
104+
if (this.#state && hostOptions.modifiedFiles.size > 0) {
105+
for (const modifiedFile of hostOptions.modifiedFiles) {
105106
// Clear package.json cache if a node modules file was modified
106107
if (!clearPackageJsonCache && modifiedFile.includes('node_modules')) {
107108
clearPackageJsonCache = true;
@@ -117,6 +118,14 @@ export class AotCompilation extends TypeScriptCompilation {
117118
}
118119
}
119120
}
121+
122+
if (compilerOptions.declaration) {
123+
populatePreviousDtsSignatures(
124+
this.#state.typeScriptProgram,
125+
this.#state.compilerHost,
126+
hostOptions.modifiedFiles,
127+
);
128+
}
120129
}
121130
}
122131

@@ -207,25 +216,35 @@ export class AotCompilation extends TypeScriptCompilation {
207216
const componentResourcesDependencies = new Map<string, string[]>();
208217

209218
// Get all files referenced in the TypeScript/Angular program including component resources
210-
const referencedFiles = typeScriptProgram
211-
.getSourceFiles()
212-
.filter((sourceFile) => !angularCompiler.ignoreForEmit.has(sourceFile))
213-
.flatMap((sourceFile) => {
214-
const resourceDependencies = angularCompiler.getResourceDependencies(sourceFile);
215-
componentResourcesDependencies.set(sourceFile.fileName, resourceDependencies);
216-
// Also invalidate Angular diagnostics for a source file if component resources are modified
217-
if (this.#state && hostOptions.modifiedFiles?.size) {
218-
for (const resourceDependency of resourceDependencies) {
219-
if (hostOptions.modifiedFiles.has(resourceDependency)) {
220-
this.#state.diagnosticCache.delete(sourceFile);
221-
// Also mark as affected in case changed template affects diagnostics
222-
affectedFiles.add(sourceFile);
223-
}
219+
const referencedFiles: string[] = [];
220+
for (const sourceFile of typeScriptProgram.getSourceFiles()) {
221+
if (angularCompiler.ignoreForEmit.has(sourceFile)) {
222+
continue;
223+
}
224+
referencedFiles.push(sourceFile.fileName);
225+
if (sourceFile.isDeclarationFile) {
226+
continue;
227+
}
228+
const resourceDependencies = angularCompiler.getResourceDependencies(sourceFile);
229+
componentResourcesDependencies.set(sourceFile.fileName, resourceDependencies);
230+
if (resourceDependencies.length === 0) {
231+
continue;
232+
}
233+
referencedFiles.push(...resourceDependencies);
234+
// Also invalidate Angular diagnostics for a source file if component template resources are modified
235+
if (this.#state && hostOptions.modifiedFiles?.size) {
236+
for (const resourceDependency of resourceDependencies) {
237+
if (
238+
hostOptions.modifiedFiles.has(resourceDependency) &&
239+
!/\.(?:css|scss|sass|less)$/i.test(resourceDependency)
240+
) {
241+
this.#state.diagnosticCache.delete(sourceFile);
242+
// Also mark as affected in case changed template affects diagnostics
243+
affectedFiles.add(sourceFile);
224244
}
225245
}
226-
227-
return [sourceFile.fileName, ...resourceDependencies];
228-
});
246+
}
247+
}
229248

230249
this.#state = new AngularCompilationState(
231250
angularProgram,
@@ -261,63 +280,82 @@ export class AotCompilation extends TypeScriptCompilation {
261280
const syntactic = modes & DiagnosticModes.Syntactic;
262281
const semantic = modes & DiagnosticModes.Semantic;
263282

264-
// Collect program level diagnostics
265-
if (modes & DiagnosticModes.Option) {
266-
yield* typeScriptProgram.getConfigFileParsingDiagnostics();
267-
yield* angularCompiler.getOptionDiagnostics();
268-
yield* typeScriptProgram.getOptionsDiagnostics();
269-
}
270-
if (syntactic) {
271-
yield* typeScriptProgram.getGlobalDiagnostics();
272-
}
273-
274-
// Collect source file specific diagnostics
275-
for (const sourceFile of typeScriptProgram.getSourceFiles()) {
276-
if (angularCompiler.ignoreForDiagnostics.has(sourceFile)) {
277-
continue;
283+
try {
284+
// Collect program level diagnostics
285+
if (modes & DiagnosticModes.Option) {
286+
yield* typeScriptProgram.getConfigFileParsingDiagnostics();
287+
yield* angularCompiler.getOptionDiagnostics();
288+
yield* typeScriptProgram.getOptionsDiagnostics();
278289
}
279-
280290
if (syntactic) {
281-
// TypeScript will use cached diagnostics for files that have not been
282-
// changed or affected for this build when using incremental building.
283-
yield* profileSync(
284-
'NG_DIAGNOSTICS_SYNTACTIC',
285-
() => typeScriptProgram.getSyntacticDiagnostics(sourceFile),
286-
true,
287-
);
291+
yield* typeScriptProgram.getGlobalDiagnostics();
288292
}
289293

290-
if (!semantic) {
291-
continue;
292-
}
294+
// Collect source file specific diagnostics
295+
for (const sourceFile of typeScriptProgram.getSourceFiles()) {
296+
if (angularCompiler.ignoreForDiagnostics.has(sourceFile)) {
297+
continue;
298+
}
293299

294-
yield* profileSync(
295-
'NG_DIAGNOSTICS_SEMANTIC',
296-
() => typeScriptProgram.getSemanticDiagnostics(sourceFile),
297-
true,
298-
);
300+
if (syntactic) {
301+
// TypeScript will use cached diagnostics for files that have not been
302+
// changed or affected for this build when using incremental building.
303+
yield* profileSync(
304+
'NG_DIAGNOSTICS_SYNTACTIC',
305+
() => typeScriptProgram.getSyntacticDiagnostics(sourceFile),
306+
true,
307+
);
308+
}
299309

300-
// Declaration files cannot have template diagnostics
301-
if (sourceFile.isDeclarationFile) {
302-
continue;
303-
}
310+
if (!semantic) {
311+
continue;
312+
}
304313

305-
// Only request Angular template diagnostics for affected files to avoid
306-
// overhead of template diagnostics for unchanged files.
307-
if (affectedFiles.has(sourceFile)) {
308-
const angularDiagnostics = profileSync(
309-
'NG_DIAGNOSTICS_TEMPLATE',
310-
() => angularCompiler.getDiagnosticsForFile(sourceFile, templateDiagnosticsOptimization),
314+
yield* profileSync(
315+
'NG_DIAGNOSTICS_SEMANTIC',
316+
() => typeScriptProgram.getSemanticDiagnostics(sourceFile),
311317
true,
312318
);
313-
diagnosticCache.set(sourceFile, angularDiagnostics);
314-
yield* angularDiagnostics;
315-
} else {
316-
const angularDiagnostics = diagnosticCache.get(sourceFile);
317-
if (angularDiagnostics) {
319+
320+
// Declaration files cannot have template diagnostics
321+
if (sourceFile.isDeclarationFile) {
322+
continue;
323+
}
324+
325+
// Only request Angular template diagnostics for affected files to avoid
326+
// overhead of template diagnostics for unchanged files.
327+
if (affectedFiles.has(sourceFile)) {
328+
const angularDiagnostics = profileSync(
329+
'NG_DIAGNOSTICS_TEMPLATE',
330+
() =>
331+
angularCompiler.getDiagnosticsForFile(sourceFile, templateDiagnosticsOptimization),
332+
true,
333+
);
334+
diagnosticCache.set(sourceFile, angularDiagnostics);
318335
yield* angularDiagnostics;
336+
} else {
337+
const angularDiagnostics = diagnosticCache.get(sourceFile);
338+
if (angularDiagnostics) {
339+
yield* angularDiagnostics;
340+
}
319341
}
320342
}
343+
} finally {
344+
// Angular's template typechecker lazily creates `.ngtypecheck.ts` shims in a new `ts.Program`
345+
// during `getDiagnosticsForFile`. Sync the builder program with `angularCompiler.getCurrentProgram()`
346+
// so the next incremental build does not treat all `.ngtypecheck.ts` files as newly added files.
347+
commitBuilderProgramState(typeScriptProgram);
348+
const currentTsProgram = angularCompiler.getCurrentProgram();
349+
if (currentTsProgram !== typeScriptProgram.getProgram()) {
350+
ensureSourceFileVersions(currentTsProgram);
351+
const updatedBuilder = ts.createEmitAndSemanticDiagnosticsBuilderProgram(
352+
currentTsProgram,
353+
this.#state.compilerHost,
354+
typeScriptProgram,
355+
);
356+
commitBuilderProgramState(updatedBuilder);
357+
this.#state.typeScriptProgram = updatedBuilder;
358+
}
321359
}
322360
}
323361

@@ -522,3 +560,99 @@ function findAffectedFiles(
522560

523561
return affectedFiles;
524562
}
563+
564+
interface InternalBuilderProgram extends ts.EmitAndSemanticDiagnosticsBuilderProgram {
565+
state?: {
566+
fileInfos?: Map<string, { version: string; signature: string | undefined }>;
567+
changedFilesSet?: Set<string>;
568+
oldSignatures?: Map<string, string | false>;
569+
hasCalledUpdateShapeSignature?: Set<string>;
570+
};
571+
}
572+
573+
interface InternalTypeScriptWithBuilderState {
574+
BuilderState?: {
575+
updateShapeSignature?(
576+
state: unknown,
577+
program: ts.Program,
578+
sourceFile: ts.SourceFile,
579+
cancellationToken: ts.CancellationToken | undefined,
580+
host: ts.CompilerHost,
581+
useFileVersionAsSignature: boolean,
582+
): boolean;
583+
};
584+
}
585+
586+
/**
587+
* Commits the internal state of a TypeScript `BuilderProgram` by clearing uncommitted
588+
* `oldSignatures` and `changedFilesSet` entries.
589+
*
590+
* Why this is needed:
591+
* TypeScript's `BuilderProgram` only clears `state.oldSignatures` and `state.changedFilesSet`
592+
* when `getSemanticDiagnostics()` (whole-program, no arguments) is called. Because Angular's
593+
* `collectDiagnostics()` queries `getSemanticDiagnostics(sourceFile)` per-file (skipping
594+
* internal `ignoreForDiagnostics` shims), TypeScript leaves `oldSignatures` populated with `false`.
595+
* When `BuilderState.create` creates the next `BuilderProgram`, any `false` entry in `oldSignatures`
596+
* resets that file's `info.signature` back to `undefined`. Clearing `oldSignatures` and `changedFilesSet`
597+
* commits the current build's state so subsequent incremental builds preserve `.d.ts` signatures.
598+
*/
599+
function commitBuilderProgramState(program: InternalBuilderProgram): void {
600+
program.state?.changedFilesSet?.clear();
601+
program.state?.oldSignatures?.clear();
602+
}
603+
604+
/**
605+
* Ensures `.d.ts` shape signatures (`info.signature`) are computed on the previous `BuilderProgram`
606+
* for modified files before the next `BuilderProgram` is created.
607+
*
608+
* Why this is needed:
609+
* 1. On the initial build (`oldProgram === undefined`), TypeScript's `BuilderState.create` sets
610+
* `state.useFileVersionAsSignature = true`, which initializes `info.signature = sourceFile.version`
611+
* (the SHA-256 hash of the `.ts` source text).
612+
* 2. Normally, during `emitNextAffectedFile()`, TypeScript's `getWriteFileCallback` replaces
613+
* `info.signature` with the hash of the emitted `.d.ts` declaration text. However, TypeScript
614+
* explicitly guards this behind `if (!customTransformers)` (in `ts.createBuilderProgram`).
615+
* Because Angular passes `angularCompiler.prepareEmit().transformers` (`customTransformers`) to
616+
* `emitNextAffectedFile()`, TypeScript skips updating `info.signature`, leaving it equal to the
617+
* `.ts` file hash (`sourceFile.version`).
618+
* 3. On the first incremental watch rebuild (`oldProgram !== undefined`), `useFileVersionAsSignature`
619+
* becomes `false` and TypeScript calls `BuilderState.updateShapeSignature()` (`computeDtsSignature`)
620+
* for each modified file, comparing the newly computed `.d.ts` hash against the previous build's
621+
* `info.signature` (which is still the `.ts` hash). Because a `.d.ts` hash never matches a `.ts`
622+
* hash, TypeScript falsely concludes that the file's public `.d.ts` shape changed and cascades
623+
* re-analysis and re-emission across all transitive importers in `referencedMap`.
624+
* 4. Calling `BuilderState.updateShapeSignature(..., false)` on the previous program when a modified
625+
* file's `signature` is still equal to its `version` replaces the initial `.ts` version hash with
626+
* its true `.d.ts` signature prior to diffing, preventing false-positive `.d.ts` cascades on watch edits.
627+
*/
628+
function populatePreviousDtsSignatures(
629+
oldProgram: InternalBuilderProgram,
630+
oldHost: ts.CompilerHost,
631+
modifiedFiles: ReadonlySet<string>,
632+
): void {
633+
const updateShapeSignature = (ts as InternalTypeScriptWithBuilderState).BuilderState
634+
?.updateShapeSignature;
635+
const oldState = oldProgram.state;
636+
if (!updateShapeSignature || !oldState?.fileInfos) {
637+
return;
638+
}
639+
640+
const program = oldProgram.getProgram();
641+
for (const modifiedFile of modifiedFiles) {
642+
const sf = program.getSourceFile(modifiedFile);
643+
if (!sf || sf.isDeclarationFile) {
644+
continue;
645+
}
646+
647+
const resolvedPath =
648+
(sf as ts.SourceFile & { resolvedPath?: string }).resolvedPath ?? modifiedFile;
649+
const fileInfo = oldState.fileInfos.get(resolvedPath);
650+
if (!fileInfo || fileInfo.signature !== fileInfo.version) {
651+
continue;
652+
}
653+
654+
oldState.hasCalledUpdateShapeSignature?.delete(resolvedPath);
655+
updateShapeSignature(oldState, program, sf, undefined, oldHost, false);
656+
}
657+
oldState.oldSignatures?.clear();
658+
}

‎packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface TransformedConfiguration {
2424

2525
export abstract class TypeScriptCompilation extends AngularCompilation {
2626
static #angularCompilerCliModule?: typeof ng;
27+
#cachedConfiguration?: TransformedConfiguration;
2728

2829
static async loadCompilerCli(): Promise<typeof ng> {
2930
TypeScriptCompilation.#angularCompilerCliModule ??= await import('@angular/compiler-cli');
@@ -35,6 +36,12 @@ export abstract class TypeScriptCompilation extends AngularCompilation {
3536
tsconfig: string,
3637
compilerOptionOverrides?: CompilerOptionOverrides,
3738
): Promise<TransformedConfiguration> {
39+
// When `rootFiles` are explicitly provided (e.g., library builder), avoid re-parsing `tsconfig.json`
40+
// and walking the project directory tree via `readConfiguration` on every watch rebuild (~200-350ms on large libraries).
41+
if (compilerOptionOverrides?.rootFiles?.length && this.#cachedConfiguration) {
42+
return this.#cachedConfiguration;
43+
}
44+
3845
const { readConfiguration } = await TypeScriptCompilation.loadCompilerCli();
3946

4047
const {
@@ -80,19 +87,30 @@ export abstract class TypeScriptCompilation extends AngularCompilation {
8087
tsconfig,
8188
);
8289

83-
return {
90+
const config: TransformedConfiguration = {
8491
compilerOptions,
8592
rootNames,
8693
errors,
8794
warnings,
8895
};
96+
97+
if (compilerOptionOverrides?.rootFiles?.length) {
98+
this.#cachedConfiguration = config;
99+
}
100+
101+
return config;
89102
}
90103

91104
protected readonly sourceFiles = new Map<string, ts.SourceFile>();
92105

93106
protected invalidateFiles(files: Iterable<string>): void {
94107
for (const file of files) {
95-
this.sourceFiles.delete(toPosixPath(file));
108+
const posixFile = toPosixPath(file);
109+
this.sourceFiles.delete(posixFile);
110+
if (posixFile.endsWith('.json')) {
111+
// If a tsconfig changes, we need to re-read the configuration.
112+
this.#cachedConfiguration = undefined;
113+
}
96114
}
97115
}
98116

0 commit comments

Comments
 (0)