Skip to content

Commit 9269238

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

2 files changed

Lines changed: 171 additions & 22 deletions

File tree

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

Lines changed: 151 additions & 20 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,
@@ -319,6 +338,22 @@ export class AotCompilation extends TypeScriptCompilation {
319338
}
320339
}
321340
}
341+
342+
// Angular's template typechecker lazily creates `.ngtypecheck.ts` shims in a new `ts.Program`
343+
// during `getDiagnosticsForFile`. Sync the builder program with `angularCompiler.getCurrentProgram()`
344+
// so the next incremental build does not treat all `.ngtypecheck.ts` files as newly added files.
345+
commitBuilderProgramState(typeScriptProgram);
346+
const currentTsProgram = angularCompiler.getCurrentProgram();
347+
if (currentTsProgram !== typeScriptProgram.getProgram()) {
348+
ensureSourceFileVersions(currentTsProgram);
349+
const updatedBuilder = ts.createEmitAndSemanticDiagnosticsBuilderProgram(
350+
currentTsProgram,
351+
this.#state.compilerHost,
352+
typeScriptProgram,
353+
);
354+
commitBuilderProgramState(updatedBuilder);
355+
this.#state.typeScriptProgram = updatedBuilder;
356+
}
322357
}
323358

324359
override emitAffectedFiles(): Iterable<EmitFileResult> {
@@ -522,3 +557,99 @@ function findAffectedFiles(
522557

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

‎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)