Skip to content

Commit fecc2bd

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

2 files changed

Lines changed: 218 additions & 67 deletions

File tree

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

Lines changed: 198 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>,
@@ -102,8 +102,8 @@ export class AotCompilation extends TypeScriptCompilation {
102102
if (hostOptions.modifiedFiles) {
103103
this.invalidateFiles(hostOptions.modifiedFiles);
104104

105-
for (const modifiedFile of hostOptions.modifiedFiles) {
106-
if (this.#state) {
105+
if (this.#state && hostOptions.modifiedFiles.size > 0) {
106+
for (const modifiedFile of hostOptions.modifiedFiles) {
107107
// Clear package.json cache if a node modules file was modified
108108
if (!clearPackageJsonCache && modifiedFile.includes('node_modules')) {
109109
clearPackageJsonCache = true;
@@ -119,6 +119,14 @@ export class AotCompilation extends TypeScriptCompilation {
119119
}
120120
}
121121
}
122+
123+
if (compilerOptions.declaration) {
124+
populatePreviousDtsSignatures(
125+
this.#state.typeScriptProgram,
126+
this.#state.compilerHost,
127+
hostOptions.modifiedFiles,
128+
);
129+
}
122130
}
123131
}
124132

@@ -209,25 +217,35 @@ export class AotCompilation extends TypeScriptCompilation {
209217
const componentResourcesDependencies = new Map<string, string[]>();
210218

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

232250
this.#state = new AngularCompilationState(
233251
angularProgram,
@@ -263,63 +281,82 @@ export class AotCompilation extends TypeScriptCompilation {
263281
const syntactic = modes & DiagnosticModes.Syntactic;
264282
const semantic = modes & DiagnosticModes.Semantic;
265283

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

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

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

302-
// Declaration files cannot have template diagnostics
303-
if (sourceFile.isDeclarationFile) {
304-
continue;
305-
}
311+
if (!semantic) {
312+
continue;
313+
}
306314

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

@@ -532,3 +569,99 @@ function findAffectedFiles(
532569

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

‎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');
@@ -36,6 +37,12 @@ export abstract class TypeScriptCompilation extends AngularCompilation {
3637
compilerOptionOverrides?: CompilerOptionOverrides,
3738
buildType: 'application' | 'library' = 'application',
3839
): Promise<TransformedConfiguration> {
40+
// When `rootFiles` are explicitly provided (e.g., library builder), avoid re-parsing `tsconfig.json`
41+
// and walking the project directory tree via `readConfiguration` on every watch rebuild (~200-350ms on large libraries).
42+
if (compilerOptionOverrides?.rootFiles?.length && this.#cachedConfiguration) {
43+
return this.#cachedConfiguration;
44+
}
45+
3946
const { readConfiguration } = await TypeScriptCompilation.loadCompilerCli();
4047

4148
const {
@@ -82,19 +89,30 @@ export abstract class TypeScriptCompilation extends AngularCompilation {
8289
buildType,
8390
);
8491

85-
return {
92+
const config: TransformedConfiguration = {
8693
compilerOptions,
8794
rootNames,
8895
errors,
8996
warnings,
9097
};
98+
99+
if (compilerOptionOverrides?.rootFiles?.length) {
100+
this.#cachedConfiguration = config;
101+
}
102+
103+
return config;
91104
}
92105

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

95108
protected invalidateFiles(files: Iterable<string>): void {
96109
for (const file of files) {
97-
this.sourceFiles.delete(toPosixPath(file));
110+
const posixFile = toPosixPath(file);
111+
this.sourceFiles.delete(posixFile);
112+
if (/\.json$/i.test(posixFile)) {
113+
// If a tsconfig changes, we need to re-read the configuration.
114+
this.#cachedConfiguration = undefined;
115+
}
98116
}
99117
}
100118

0 commit comments

Comments
 (0)