From 0a0c5e7facd7f0cca708da6bfa59852b861e07b2 Mon Sep 17 00:00:00 2001
From: Alan Agius <17563226+alan-agius4@users.noreply.github.com>
Date: Fri, 2 Oct 2026 14:11:40 +0000
Subject: [PATCH 1/3] perf(@angular/build): optimize incremental watch rebuilds
in AOT compilation
- Pre-populate `.d.ts` shape signatures on the previous `BuilderProgram` for modified files prior to diffing when type-checking or TypeScript transpilation is active, preventing false-positive `.d.ts` cascades on incremental watch edits.
- Synchronize `typeScriptProgram` with `angularCompiler.getCurrentProgram()` after `collectDiagnostics()` and clear uncommitted `changedFilesSet` and `oldSignatures` entries so subsequent incremental builds preserve `.d.ts` signatures and do not treat `.ngtypecheck.ts` shims as newly added files.
- Detect affected dependent `.ngtypecheck.ts` shims in `isolatedModules` / `verbatimModuleSyntax` mode via TypeScript's `state.hasCalledUpdateShapeSignature`, ensuring cross-component template diagnostics are re-evaluated when a dependency's `.d.ts` shape changes without cascading on private edits.
- Avoid `filter().flatMap()` allocations and skip `getResourceDependencies()` for declaration files when collecting `referencedFiles`.
---
.../tests/behavior/rebuild-errors_spec.ts | 118 +++++-
.../angular/compilation/aot-compilation.ts | 340 ++++++++++++++----
2 files changed, 376 insertions(+), 82 deletions(-)
diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts
index de53c7223fff..f78a41d3d14e 100644
--- a/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts
@@ -15,12 +15,6 @@ import {
expectNoLog,
} from '../setup';
-/**
- * Maximum time in milliseconds for single build/rebuild
- * This accounts for CI variability.
- */
-export const BUILD_TIMEOUT = 30_000;
-
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
describe('Behavior: "Rebuild Error Detection"', () => {
it('detects template errors with no AOT codegen or TS emit differences', async () => {
@@ -128,6 +122,116 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
);
});
+ it('detects template errors across components on watch rebuild when isolatedModules is enabled', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ watch: true,
+ });
+
+ await harness.modifyFile('tsconfig.json', (content) => {
+ const tsconfig = JSON.parse(content);
+ tsconfig.compilerOptions = {
+ ...tsconfig.compilerOptions,
+ isolatedModules: true,
+ };
+
+ return JSON.stringify(tsconfig);
+ });
+
+ const goodChildComponentContents = `
+ import { Component, Input } from '@angular/core';
+ @Component({
+ selector: 'child',
+ standalone: true,
+ template: '
{{ value }}
',
+ })
+ export class ChildComponent {
+ @Input() value!: number;
+ }
+ `;
+
+ const typeErrorText = `Type 'number' is not assignable to type 'string'.`;
+
+ await harness.writeFiles({
+ 'src/app/child.component.ts': goodChildComponentContents,
+ 'src/app/app.module.ts': `
+ import { NgModule } from '@angular/core';
+ import { BrowserModule } from '@angular/platform-browser';
+ import { AppComponent } from './app.component';
+ @NgModule({
+ imports: [
+ BrowserModule,
+ AppComponent,
+ ],
+ providers: [],
+ bootstrap: [AppComponent]
+ })
+ export class AppModule { }
+ `,
+ 'src/app/app.component.ts': `
+ import { Component } from '@angular/core';
+ import { ChildComponent } from './child.component';
+ @Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [ChildComponent],
+ template: '',
+ })
+ export class AppComponent {}
+ `,
+ });
+
+ await harness.executeWithCases(
+ [
+ async ({ result }) => {
+ expect(result?.success).toBeTrue();
+
+ // Update child component to change input type from number to string
+ await harness.writeFile(
+ 'src/app/child.component.ts',
+ `
+ import { Component, Input } from '@angular/core';
+ @Component({
+ selector: 'child',
+ standalone: true,
+ template: '{{ value }}
',
+ })
+ export class ChildComponent {
+ @Input() value!: string;
+ }
+ `,
+ );
+ },
+ async ({ result, logs }) => {
+ expect(result?.success).toBeFalse();
+ expectLog(logs, typeErrorText);
+
+ // Make an unrelated change to verify error persists
+ await harness.modifyFile('src/main.ts', (content) => content + '\n');
+ },
+ async ({ result, logs }) => {
+ expect(result?.success).toBeFalse();
+ expectLog(logs, typeErrorText);
+
+ // Revert back to number
+ await harness.writeFile('src/app/child.component.ts', goodChildComponentContents);
+ },
+ async ({ result, logs }) => {
+ expect(result?.success).toBeTrue();
+ expectNoLog(logs, typeErrorText);
+
+ // Make an unrelated change to verify error cache cleared
+ await harness.modifyFile('src/main.ts', (content) => content + '\n');
+ },
+ ({ result, logs }) => {
+ expect(result?.success).toBeTrue();
+ expectNoLog(logs, typeErrorText);
+ },
+ ],
+ { outputLogsOnFailure: false },
+ );
+ });
+
it('detects cumulative block syntax errors', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
@@ -234,7 +338,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
await harness.appendToFile('src/app/app.component.html', 'Guten Tag
');
},
({ logs }) => {
- expectNoLog(logs, 'invalid-css-content');
+ expectNoLog(logs, 'Unexpected character "EOF"');
harness.expectFile('dist/browser/main.js').content.toContain('Hello, world!');
harness.expectFile('dist/browser/main.js').content.toContain('Guten Tag');
diff --git a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts
index 6f45199d1421..cd2771c7c692 100644
--- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts
+++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts
@@ -37,11 +37,35 @@ import { printSourceFileWithMap } from './typescript-printer';
*/
const HMR_MODIFIED_FILE_LIMIT = 32;
+/**
+ * Extension for Angular type-checking files.
+ */
+const TTC_EXTENSION = '.ngtypecheck.ts';
+
+/**
+ * Length of the `.ngtypecheck.ts` extension.
+ */
+const TTC_EXTENSION_LENGTH = TTC_EXTENSION.length;
+
+/**
+ * Checks if a file is an Angular type-checking shim file.
+ */
+function isNgTypeCheckFile(fileName: string): boolean {
+ return fileName.endsWith(TTC_EXTENSION);
+}
+
+/**
+ * Returns the original filename for a type-checking shim file.
+ */
+function getOriginalFilename(fileName: string): string {
+ return fileName.slice(0, -TTC_EXTENSION_LENGTH) + '.ts';
+}
+
class AngularCompilationState {
constructor(
public readonly angularProgram: ng.NgtscProgram,
public readonly compilerHost: ng.CompilerHost,
- public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram,
+ public typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram,
public readonly affectedFiles: ReadonlySet,
public readonly templateDiagnosticsOptimization: ng.OptimizeFor,
public readonly webWorkerTransform: ts.TransformerFactory,
@@ -103,8 +127,8 @@ export class AotCompilation extends TypeScriptCompilation {
if (hostOptions.modifiedFiles) {
this.invalidateFiles(hostOptions.modifiedFiles);
- for (const modifiedFile of hostOptions.modifiedFiles) {
- if (this.#state) {
+ if (this.#state && hostOptions.modifiedFiles.size > 0) {
+ for (const modifiedFile of hostOptions.modifiedFiles) {
// Clear package.json cache if a node modules file was modified
if (!clearPackageJsonCache && modifiedFile.includes('node_modules')) {
clearPackageJsonCache = true;
@@ -120,6 +144,14 @@ export class AotCompilation extends TypeScriptCompilation {
}
}
}
+
+ if (useTypeChecking || useTypeScriptTranspilation) {
+ populatePreviousDtsSignatures(
+ this.#state.typeScriptProgram,
+ this.#state.compilerHost,
+ hostOptions.modifiedFiles,
+ );
+ }
}
}
@@ -210,31 +242,49 @@ export class AotCompilation extends TypeScriptCompilation {
const componentResourcesDependencies = new Map();
// Get all files referenced in the TypeScript/Angular program including component resources
- const referencedFiles = [
- ...tsConfigFiles,
- ...typeScriptProgram
- .getSourceFiles()
- .filter((sourceFile) => !angularCompiler.ignoreForEmit.has(sourceFile))
- .flatMap((sourceFile) => {
- const resourceDependencies = angularCompiler.getResourceDependencies(sourceFile);
- componentResourcesDependencies.set(sourceFile.fileName, resourceDependencies);
- // Also invalidate Angular diagnostics for a source file if component resources are modified
- if (this.#state && hostOptions.modifiedFiles?.size) {
- for (const resourceDependency of resourceDependencies) {
- if (
- hostOptions.modifiedFiles.has(resourceDependency) &&
- !/\.(?:css|scss|sass|less)$/i.test(resourceDependency)
- ) {
- this.#state.diagnosticCache.delete(sourceFile);
- // Also mark as affected in case changed template affects diagnostics
- affectedFiles.add(sourceFile);
- }
- }
+ const referencedFiles = [...tsConfigFiles];
+ for (const sourceFile of typeScriptProgram.getSourceFiles()) {
+ if (angularCompiler.ignoreForEmit.has(sourceFile)) {
+ continue;
+ }
+
+ referencedFiles.push(sourceFile.fileName);
+ if (sourceFile.isDeclarationFile) {
+ continue;
+ }
+
+ const resourceDependencies = angularCompiler.getResourceDependencies(sourceFile);
+ componentResourcesDependencies.set(sourceFile.fileName, resourceDependencies);
+
+ if (resourceDependencies.length === 0) {
+ continue;
+ }
+
+ referencedFiles.push(...resourceDependencies);
+
+ // Also invalidate Angular diagnostics for a source file if component resources are modified
+ if (this.#state && hostOptions.modifiedFiles?.size) {
+ for (const resourceDependency of resourceDependencies) {
+ if (
+ !hostOptions.modifiedFiles.has(resourceDependency) ||
+ /\.(?:css|scss|sass|less)$/i.test(resourceDependency)
+ ) {
+ continue;
+ }
+
+ this.#state.diagnosticCache.delete(sourceFile);
+
+ const oldSourceFile = this.#state.typeScriptProgram.getSourceFile(sourceFile.fileName);
+ if (oldSourceFile) {
+ this.#state.diagnosticCache.delete(oldSourceFile);
}
- return [sourceFile.fileName, ...resourceDependencies];
- }),
- ];
+ // Also mark as affected in case changed template affects diagnostics
+ affectedFiles.add(sourceFile);
+ break;
+ }
+ }
+ }
this.#state = new AngularCompilationState(
angularProgram,
@@ -270,63 +320,81 @@ export class AotCompilation extends TypeScriptCompilation {
const syntactic = modes & DiagnosticModes.Syntactic;
const semantic = modes & DiagnosticModes.Semantic;
- // Collect program level diagnostics
- if (modes & DiagnosticModes.Option) {
- yield* typeScriptProgram.getConfigFileParsingDiagnostics();
- yield* angularCompiler.getOptionDiagnostics();
- yield* typeScriptProgram.getOptionsDiagnostics();
- }
- if (syntactic) {
- yield* typeScriptProgram.getGlobalDiagnostics();
- }
-
- // Collect source file specific diagnostics
- for (const sourceFile of typeScriptProgram.getSourceFiles()) {
- if (angularCompiler.ignoreForDiagnostics.has(sourceFile)) {
- continue;
+ try {
+ // Collect program level diagnostics
+ if (modes & DiagnosticModes.Option) {
+ yield* typeScriptProgram.getConfigFileParsingDiagnostics();
+ yield* angularCompiler.getOptionDiagnostics();
+ yield* typeScriptProgram.getOptionsDiagnostics();
}
-
if (syntactic) {
- // TypeScript will use cached diagnostics for files that have not been
- // changed or affected for this build when using incremental building.
- yield* profileSync(
- 'NG_DIAGNOSTICS_SYNTACTIC',
- () => typeScriptProgram.getSyntacticDiagnostics(sourceFile),
- true,
- );
+ yield* typeScriptProgram.getGlobalDiagnostics();
}
- if (!semantic) {
- continue;
- }
+ // Collect source file specific diagnostics
+ for (const sourceFile of typeScriptProgram.getSourceFiles()) {
+ if (angularCompiler.ignoreForDiagnostics.has(sourceFile)) {
+ continue;
+ }
- yield* profileSync(
- 'NG_DIAGNOSTICS_SEMANTIC',
- () => typeScriptProgram.getSemanticDiagnostics(sourceFile),
- true,
- );
+ if (syntactic) {
+ // TypeScript will use cached diagnostics for files that have not been
+ // changed or affected for this build when using incremental building.
+ yield* profileSync(
+ 'NG_DIAGNOSTICS_SYNTACTIC',
+ () => typeScriptProgram.getSyntacticDiagnostics(sourceFile),
+ true,
+ );
+ }
- // Declaration files cannot have template diagnostics
- if (sourceFile.isDeclarationFile) {
- continue;
- }
+ if (!semantic) {
+ continue;
+ }
- // Only request Angular template diagnostics for affected files to avoid
- // overhead of template diagnostics for unchanged files.
- if (affectedFiles.has(sourceFile)) {
- const angularDiagnostics = profileSync(
- 'NG_DIAGNOSTICS_TEMPLATE',
- () => angularCompiler.getDiagnosticsForFile(sourceFile, templateDiagnosticsOptimization),
+ yield* profileSync(
+ 'NG_DIAGNOSTICS_SEMANTIC',
+ () => typeScriptProgram.getSemanticDiagnostics(sourceFile),
true,
);
- diagnosticCache.set(sourceFile, angularDiagnostics);
- yield* angularDiagnostics;
- } else {
- const angularDiagnostics = diagnosticCache.get(sourceFile);
- if (angularDiagnostics) {
+
+ // Declaration files cannot have template diagnostics
+ if (sourceFile.isDeclarationFile) {
+ continue;
+ }
+
+ // Only request Angular template diagnostics for affected files to avoid
+ // overhead of template diagnostics for unchanged files.
+ if (affectedFiles.has(sourceFile)) {
+ const angularDiagnostics = profileSync(
+ 'NG_DIAGNOSTICS_TEMPLATE',
+ () =>
+ angularCompiler.getDiagnosticsForFile(sourceFile, templateDiagnosticsOptimization),
+ true,
+ );
+ diagnosticCache.set(sourceFile, angularDiagnostics);
yield* angularDiagnostics;
+ } else {
+ const angularDiagnostics = diagnosticCache.get(sourceFile);
+ if (angularDiagnostics) {
+ yield* angularDiagnostics;
+ }
}
}
+ } finally {
+ // Angular's template typechecker lazily creates `.ngtypecheck.ts` shims in a new `ts.Program`
+ // during `getDiagnosticsForFile`. Sync the builder program with `angularCompiler.getCurrentProgram()`
+ // so the next incremental build does not treat all `.ngtypecheck.ts` files as newly added files.
+ const currentTsProgram = angularCompiler.getCurrentProgram();
+ if (currentTsProgram !== typeScriptProgram.getProgram()) {
+ ensureSourceFileVersions(currentTsProgram);
+ this.#state.typeScriptProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram(
+ currentTsProgram,
+ this.#state.compilerHost,
+ typeScriptProgram,
+ );
+ }
+
+ commitBuilderProgramState(this.#state.typeScriptProgram);
}
}
@@ -497,10 +565,10 @@ function findAffectedFiles(
// For example, changing @Input property types of a directive used in another component's
// template.
// A TTC shim is a file that has been ignored for diagnostics and has a filename ending in `.ngtypecheck.ts`.
- if (ignoreForDiagnostics.has(sourceFile) && sourceFile.fileName.endsWith('.ngtypecheck.ts')) {
+ if (ignoreForDiagnostics.has(sourceFile) && isNgTypeCheckFile(sourceFile.fileName)) {
// This file name conversion relies on internal compiler logic and should be converted
// to an official method when available. 15 is length of `.ngtypecheck.ts`
- const originalFilename = sourceFile.fileName.slice(0, -15) + '.ts';
+ const originalFilename = getOriginalFilename(sourceFile.fileName);
const originalSourceFile = builder.getSourceFile(originalFilename);
if (originalSourceFile) {
affectedFiles.add(originalSourceFile);
@@ -525,10 +593,10 @@ function findAffectedFiles(
// info is present.
if (includeTTC) {
for (const sourceFile of builder.getSourceFiles()) {
- if (ignoreForDiagnostics.has(sourceFile) && sourceFile.fileName.endsWith('.ngtypecheck.ts')) {
+ if (ignoreForDiagnostics.has(sourceFile) && isNgTypeCheckFile(sourceFile.fileName)) {
// This file name conversion relies on internal compiler logic and should be converted
// to an official method when available. 15 is length of `.ngtypecheck.ts`
- const originalFilename = sourceFile.fileName.slice(0, -15) + '.ts';
+ const originalFilename = getOriginalFilename(sourceFile.fileName);
const originalSourceFile = builder.getSourceFile(originalFilename);
if (originalSourceFile) {
affectedFiles.add(originalSourceFile);
@@ -537,5 +605,127 @@ function findAffectedFiles(
}
}
+ // When `isolatedModules` or `verbatimModuleSyntax` is enabled, TypeScript's `getFilesAffectedByUpdatedShapeWhenModuleEmit`
+ // only returns the modified file itself, so dependent `.ngtypecheck.ts` shims are not yielded by
+ // `getSemanticDiagnosticsOfNextAffectedFile`. However, TypeScript's `handleDtsMayChangeOfReferencingExportOfAffectedFile`
+ // still walks `referencedMap` when a modified file's `.d.ts` shape signature changes and calls
+ // `BuilderState.updateShapeSignature` on all affected dependents, recording them in `state.hasCalledUpdateShapeSignature`.
+ const compilerOptions = builder.getCompilerOptions();
+ const hasCalledUpdateShapeSignature = (builder as InternalBuilderProgram).state
+ ?.hasCalledUpdateShapeSignature;
+ if (
+ (compilerOptions.isolatedModules || compilerOptions.verbatimModuleSyntax) &&
+ affectedFiles.size &&
+ hasCalledUpdateShapeSignature?.size
+ ) {
+ for (const path of hasCalledUpdateShapeSignature) {
+ if (!isNgTypeCheckFile(path)) {
+ continue;
+ }
+
+ const originalFilename = getOriginalFilename(path);
+ const originalSourceFile = builder.getSourceFile(originalFilename);
+ if (originalSourceFile) {
+ affectedFiles.add(originalSourceFile);
+ }
+ }
+ }
+
return affectedFiles;
}
+
+interface InternalBuilderProgram extends ts.EmitAndSemanticDiagnosticsBuilderProgram {
+ state?: {
+ fileInfos?: Map;
+ changedFilesSet?: Set;
+ oldSignatures?: Map;
+ hasCalledUpdateShapeSignature?: Set;
+ };
+}
+
+interface InternalTypeScriptWithBuilderState {
+ BuilderState?: {
+ updateShapeSignature?(
+ state: unknown,
+ program: ts.Program,
+ sourceFile: ts.SourceFile,
+ cancellationToken: ts.CancellationToken | undefined,
+ host: ts.CompilerHost,
+ useFileVersionAsSignature: boolean,
+ ): boolean;
+ };
+}
+
+/**
+ * Commits the internal state of a TypeScript `BuilderProgram` by clearing uncommitted
+ * `oldSignatures` and `changedFilesSet` entries.
+ *
+ * Why this is needed:
+ * TypeScript's `BuilderProgram` only clears `state.oldSignatures` and `state.changedFilesSet`
+ * when `getSemanticDiagnostics()` (whole-program, no arguments) is called. Because Angular's
+ * `collectDiagnostics()` queries `getSemanticDiagnostics(sourceFile)` per-file (skipping
+ * internal `ignoreForDiagnostics` shims), TypeScript leaves `oldSignatures` populated with `false`.
+ * When `BuilderState.create` creates the next `BuilderProgram`, any `false` entry in `oldSignatures`
+ * resets that file's `info.signature` back to `undefined`. Clearing `oldSignatures` and `changedFilesSet`
+ * commits the current build's state so subsequent incremental builds preserve `.d.ts` signatures.
+ */
+function commitBuilderProgramState(program: InternalBuilderProgram): void {
+ program.state?.changedFilesSet?.clear();
+ program.state?.oldSignatures?.clear();
+}
+
+/**
+ * Ensures `.d.ts` shape signatures (`info.signature`) are computed on the previous `BuilderProgram`
+ * for modified files before the next `BuilderProgram` is created.
+ *
+ * Why this is needed:
+ * 1. On the initial build (`oldProgram === undefined`), TypeScript's `BuilderState.create` sets
+ * `state.useFileVersionAsSignature = true`, which initializes `info.signature = sourceFile.version`
+ * (the SHA-256 hash of the `.ts` source text).
+ * 2. Normally, during `emitNextAffectedFile()`, TypeScript's `getWriteFileCallback` replaces
+ * `info.signature` with the hash of the emitted `.d.ts` declaration text. However, TypeScript
+ * explicitly guards this behind `if (!customTransformers)` (in `ts.createBuilderProgram`).
+ * Because Angular passes `angularCompiler.prepareEmit().transformers` (`customTransformers`) to
+ * `emitNextAffectedFile()`, TypeScript skips updating `info.signature`, leaving it equal to the
+ * `.ts` file hash (`sourceFile.version`).
+ * 3. On the first incremental watch rebuild (`oldProgram !== undefined`), `useFileVersionAsSignature`
+ * becomes `false` and TypeScript calls `BuilderState.updateShapeSignature()` (`computeDtsSignature`)
+ * for each modified file, comparing the newly computed `.d.ts` hash against the previous build's
+ * `info.signature` (which is still the `.ts` hash). Because a `.d.ts` hash never matches a `.ts`
+ * hash, TypeScript falsely concludes that the file's public `.d.ts` shape changed and cascades
+ * re-analysis and re-emission across all transitive importers in `referencedMap`.
+ * 4. Calling `BuilderState.updateShapeSignature(..., false)` on the previous program when a modified
+ * file's `signature` is still equal to its `version` replaces the initial `.ts` version hash with
+ * its true `.d.ts` signature prior to diffing, preventing false-positive `.d.ts` cascades on watch edits.
+ */
+function populatePreviousDtsSignatures(
+ oldProgram: InternalBuilderProgram,
+ oldHost: ts.CompilerHost,
+ modifiedFiles: ReadonlySet,
+): void {
+ const updateShapeSignature = (ts as InternalTypeScriptWithBuilderState).BuilderState
+ ?.updateShapeSignature;
+ const oldState = oldProgram.state;
+ if (!updateShapeSignature || !oldState?.fileInfos) {
+ return;
+ }
+
+ const program = oldProgram.getProgram();
+ for (const modifiedFile of modifiedFiles) {
+ const sf = program.getSourceFile(modifiedFile);
+ if (!sf || sf.isDeclarationFile) {
+ continue;
+ }
+
+ const resolvedPath =
+ (sf as ts.SourceFile & { resolvedPath?: string }).resolvedPath ?? modifiedFile;
+ const fileInfo = oldState.fileInfos.get(resolvedPath);
+ if (!fileInfo || fileInfo.signature !== fileInfo.version) {
+ continue;
+ }
+
+ oldState.hasCalledUpdateShapeSignature?.delete(resolvedPath);
+ updateShapeSignature(oldState, program, sf, undefined, oldHost, false);
+ }
+ oldState.oldSignatures?.clear();
+}
From 9b5acdde1ca4adc2411c941992fa4b26e9935923 Mon Sep 17 00:00:00 2001
From: Alan Agius <17563226+alan-agius4@users.noreply.github.com>
Date: Mon, 12 Oct 2026 07:13:10 +0000
Subject: [PATCH 2/3] fixup! perf(@angular/build): optimize incremental watch
rebuilds in AOT compilation
---
.../tests/behavior/rebuild-errors_spec.ts | 119 ++++++
.../build/src/tools/angular/angular-host.ts | 11 +-
.../angular/compilation/aot-compilation.ts | 361 +++++++++++-------
3 files changed, 353 insertions(+), 138 deletions(-)
diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts
index f78a41d3d14e..5e7bdeb3fd9c 100644
--- a/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts
+++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts
@@ -232,6 +232,125 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
);
});
+ it('detects template errors when an input becomes required or a selector is renamed on watch rebuild', async () => {
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ watch: true,
+ });
+
+ await harness.modifyFile('tsconfig.json', (content) => {
+ const tsconfig = JSON.parse(content);
+ tsconfig.compilerOptions = {
+ ...tsconfig.compilerOptions,
+ isolatedModules: true,
+ };
+
+ return JSON.stringify(tsconfig);
+ });
+
+ const goodChildComponentContents = `
+ import { Component, input } from '@angular/core';
+ @Component({
+ selector: 'app-child',
+ standalone: true,
+ template: '{{ label() }}
',
+ })
+ export class ChildComponent {
+ label = input('');
+ }
+ `;
+
+ const requiredInputErrorText = `Required input 'label' from component ChildComponent must be specified.`;
+ const unknownElementErrorText = `'app-child' is not a known element`;
+
+ await harness.writeFiles({
+ 'src/app/child.component.ts': goodChildComponentContents,
+ 'src/app/app.module.ts': `
+ import { NgModule } from '@angular/core';
+ import { BrowserModule } from '@angular/platform-browser';
+ import { AppComponent } from './app.component';
+ @NgModule({
+ imports: [
+ BrowserModule,
+ AppComponent,
+ ],
+ providers: [],
+ bootstrap: [AppComponent]
+ })
+ export class AppModule { }
+ `,
+ 'src/app/app.component.ts': `
+ import { Component } from '@angular/core';
+ import { ChildComponent } from './child.component';
+ @Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [ChildComponent],
+ template: '',
+ })
+ export class AppComponent {}
+ `,
+ });
+
+ await harness.executeWithCases(
+ [
+ async ({ result }) => {
+ expect(result?.success).toBeTrue();
+
+ // Make input required using signal input.required() (which has the same TS .d.ts type InputSignal)
+ await harness.writeFile(
+ 'src/app/child.component.ts',
+ `
+ import { Component, input } from '@angular/core';
+ @Component({
+ selector: 'app-child',
+ standalone: true,
+ template: '{{ label() }}
',
+ })
+ export class ChildComponent {
+ label = input.required();
+ }
+ `,
+ );
+ },
+ async ({ result, logs }) => {
+ expect(result?.success).toBeFalse();
+ expectLog(logs, requiredInputErrorText);
+
+ // Revert required input and rename selector instead
+ await harness.writeFile(
+ 'src/app/child.component.ts',
+ `
+ import { Component, input } from '@angular/core';
+ @Component({
+ selector: 'app-child-renamed',
+ standalone: true,
+ template: '{{ label() }}
',
+ })
+ export class ChildComponent {
+ label = input('');
+ }
+ `,
+ );
+ },
+ async ({ result, logs }) => {
+ expect(result?.success).toBeFalse();
+ expectNoLog(logs, requiredInputErrorText);
+ expectLog(logs, unknownElementErrorText);
+
+ // Revert back to valid child component
+ await harness.writeFile('src/app/child.component.ts', goodChildComponentContents);
+ },
+ ({ result, logs }) => {
+ expect(result?.success).toBeTrue();
+ expectNoLog(logs, requiredInputErrorText);
+ expectNoLog(logs, unknownElementErrorText);
+ },
+ ],
+ { outputLogsOnFailure: false },
+ );
+ });
+
it('detects cumulative block syntax errors', async () => {
harness.useTarget('build', {
...BASE_OPTIONS,
diff --git a/packages/angular/build/src/tools/angular/angular-host.ts b/packages/angular/build/src/tools/angular/angular-host.ts
index 9322b5683dc5..f30df5bab64d 100644
--- a/packages/angular/build/src/tools/angular/angular-host.ts
+++ b/packages/angular/build/src/tools/angular/angular-host.ts
@@ -35,7 +35,10 @@ export interface AngularHostOptions {
* field. The `version` field is required when used with a TypeScript BuilderProgram.
* @param program The TypeScript Program instance to patch.
*/
-export function ensureSourceFileVersions(program: ts.Program): void {
+export function ensureSourceFileVersions(
+ program: ts.Program,
+ previousProgram?: ts.BuilderProgram,
+): void {
const baseGetSourceFiles = program.getSourceFiles;
// TODO: Update Angular compiler to add versions to all internal files and remove this
program.getSourceFiles = function (...parameters) {
@@ -45,7 +48,11 @@ export function ensureSourceFileVersions(program: ts.Program): void {
for (const file of files) {
if (file.version === undefined) {
- file.version = calculateHash(file.text);
+ const previousFile: (ts.SourceFile & { version?: string }) | undefined =
+ previousProgram && !file.fileName.endsWith('.ngtypecheck.ts')
+ ? previousProgram.getSourceFile(file.fileName)
+ : undefined;
+ file.version = previousFile?.version ?? calculateHash(file.text);
}
}
diff --git a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts
index cd2771c7c692..1b3952787112 100644
--- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts
+++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts
@@ -71,6 +71,7 @@ class AngularCompilationState {
public readonly webWorkerTransform: ts.TransformerFactory,
public readonly useTypeScriptTranspilation: boolean,
public readonly diagnosticCache = new WeakMap(),
+ public readonly filesWithoutDtsSignature = new Set(),
) {}
get angularCompiler() {
@@ -148,8 +149,8 @@ export class AotCompilation extends TypeScriptCompilation {
if (useTypeChecking || useTypeScriptTranspilation) {
populatePreviousDtsSignatures(
this.#state.typeScriptProgram,
- this.#state.compilerHost,
hostOptions.modifiedFiles,
+ this.#state.filesWithoutDtsSignature,
);
}
}
@@ -187,6 +188,18 @@ export class AotCompilation extends TypeScriptCompilation {
configurationDiagnostics,
);
+ const filesWithoutDtsSignature = this.#state?.filesWithoutDtsSignature ?? new Set();
+ if (!this.#state) {
+ for (const sourceFile of typeScriptProgram.getSourceFiles()) {
+ if (
+ !sourceFile.isDeclarationFile &&
+ !angularCompiler.ignoreForDiagnostics.has(sourceFile)
+ ) {
+ filesWithoutDtsSignature.add(sourceFile.fileName);
+ }
+ }
+ }
+
await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync());
let templateUpdates;
@@ -235,7 +248,14 @@ export class AotCompilation extends TypeScriptCompilation {
const affectedFiles =
useTypeChecking || useTypeScriptTranspilation
? profileSync('NG_FIND_AFFECTED', () =>
- findAffectedFiles(typeScriptProgram, angularCompiler, usingBuildInfo),
+ findAffectedFiles(
+ typeScriptProgram,
+ angularCompiler,
+ usingBuildInfo,
+ this.#state?.typeScriptProgram,
+ hostOptions.modifiedFiles,
+ filesWithoutDtsSignature,
+ ),
)
: new Set();
@@ -295,6 +315,7 @@ export class AotCompilation extends TypeScriptCompilation {
createWorkerTransformer(hostOptions.processWebWorker.bind(hostOptions)),
useTypeScriptTranspilation,
this.#state?.diagnosticCache,
+ filesWithoutDtsSignature,
);
return {
@@ -386,15 +407,28 @@ export class AotCompilation extends TypeScriptCompilation {
// so the next incremental build does not treat all `.ngtypecheck.ts` files as newly added files.
const currentTsProgram = angularCompiler.getCurrentProgram();
if (currentTsProgram !== typeScriptProgram.getProgram()) {
- ensureSourceFileVersions(currentTsProgram);
+ ensureSourceFileVersions(currentTsProgram, typeScriptProgram);
this.#state.typeScriptProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram(
currentTsProgram,
this.#state.compilerHost,
typeScriptProgram,
);
- }
- commitBuilderProgramState(this.#state.typeScriptProgram);
+ const baseEmit = currentTsProgram.emit;
+ currentTsProgram.emit = () => ({ emitSkipped: true, diagnostics: [] });
+ try {
+ while (
+ this.#state.typeScriptProgram.getSemanticDiagnosticsOfNextAffectedFile(
+ undefined,
+ () => true,
+ )
+ ) {
+ /* empty */
+ }
+ } finally {
+ currentTsProgram.emit = baseEmit;
+ }
+ }
}
}
@@ -487,7 +521,7 @@ export class AotCompilation extends TypeScriptCompilation {
if (
angularCompiler.incrementalCompilation.safeToSkipEmit(sourceFile) &&
- !affectedFiles.has(sourceFile)
+ (!useTypeScriptTranspilation || !affectedFiles.has(sourceFile))
) {
continue;
}
@@ -551,40 +585,141 @@ export class AotCompilation extends TypeScriptCompilation {
function findAffectedFiles(
builder: ts.EmitAndSemanticDiagnosticsBuilderProgram,
- { ignoreForDiagnostics }: ng.NgtscProgram['compiler'],
+ { ignoreForDiagnostics, incrementalCompilation }: ng.NgtscProgram['compiler'],
includeTTC: boolean,
+ oldBuilder: ts.EmitAndSemanticDiagnosticsBuilderProgram | undefined,
+ modifiedFiles: ReadonlySet | undefined,
+ filesWithoutDtsSignature: Set,
): Set {
const affectedFiles = new Set();
- // eslint-disable-next-line no-constant-condition
- while (true) {
- const result = builder.getSemanticDiagnosticsOfNextAffectedFile(undefined, (sourceFile) => {
- // If the affected file is a TTC shim, add the shim's original source file.
- // This ensures that changes that affect TTC are typechecked even when the changes
- // are otherwise unrelated from a TS perspective and do not result in Ivy codegen changes.
- // For example, changing @Input property types of a directive used in another component's
- // template.
- // A TTC shim is a file that has been ignored for diagnostics and has a filename ending in `.ngtypecheck.ts`.
- if (ignoreForDiagnostics.has(sourceFile) && isNgTypeCheckFile(sourceFile.fileName)) {
- // This file name conversion relies on internal compiler logic and should be converted
- // to an official method when available. 15 is length of `.ngtypecheck.ts`
- const originalFilename = getOriginalFilename(sourceFile.fileName);
- const originalSourceFile = builder.getSourceFile(originalFilename);
- if (originalSourceFile) {
- affectedFiles.add(originalSourceFile);
+ // Temporarily disable `isolatedModules` and `verbatimModuleSyntax` while walking affected
+ // files so TypeScript's `getFilesAffectedByUpdatedShapeWhenModuleEmit` yields dependent
+ // `.ngtypecheck.ts` shims to the `ignoreSourceFile` callback when a file's `.d.ts` shape
+ // changes, instead of returning only the modified file itself.
+ const compilerOptions = builder.getCompilerOptions();
+ const prevIsolatedModules = compilerOptions.isolatedModules;
+ const prevVerbatimModuleSyntax = compilerOptions.verbatimModuleSyntax;
+ compilerOptions.isolatedModules = false;
+ compilerOptions.verbatimModuleSyntax = false;
+
+ const program = builder.getProgram();
+ const baseEmit = program.emit;
+ const oldProgram = oldBuilder?.getProgram();
+ program.emit = (
+ targetSourceFile,
+ writeFile,
+ cancellationToken,
+ emitOnly,
+ customTransformers,
+ ...rest
+ ) => {
+ if (targetSourceFile) {
+ if (isNgTypeCheckFile(targetSourceFile.fileName)) {
+ return { emitSkipped: true, diagnostics: [] };
+ }
+
+ if (oldProgram && filesWithoutDtsSignature.has(targetSourceFile.fileName)) {
+ const oldSourceFile = !modifiedFiles?.has(targetSourceFile.fileName)
+ ? oldProgram.getSourceFile(targetSourceFile.fileName)
+ : undefined;
+ if (oldSourceFile) {
+ let oldDtsText: string | undefined;
+ let oldHasDiagnostics = false;
+ oldProgram.emit(
+ oldSourceFile,
+ (_fileName, text, _bom, _onError, _sourceFiles, data) => {
+ oldDtsText = text;
+ oldHasDiagnostics = !!(data as { diagnostics?: unknown[] } | undefined)?.diagnostics
+ ?.length;
+ },
+ cancellationToken,
+ emitOnly,
+ customTransformers,
+ ...(rest as []),
+ );
+
+ return baseEmit.call(
+ program,
+ targetSourceFile,
+ (fileName, text, writeByteOrderMark, onError, sourceFiles, data) => {
+ const newHasDiagnostics = !!(data as { diagnostics?: unknown[] } | undefined)
+ ?.diagnostics?.length;
+ if (text !== oldDtsText || newHasDiagnostics !== oldHasDiagnostics) {
+ filesWithoutDtsSignature.delete(targetSourceFile.fileName);
+ writeFile?.(fileName, text, writeByteOrderMark, onError, sourceFiles, data);
+ }
+ },
+ cancellationToken,
+ emitOnly,
+ customTransformers,
+ ...(rest as []),
+ );
}
- return true;
+ filesWithoutDtsSignature.delete(targetSourceFile.fileName);
}
+ }
+
+ return baseEmit.call(
+ program,
+ targetSourceFile,
+ writeFile,
+ cancellationToken,
+ emitOnly,
+ customTransformers,
+ ...(rest as []),
+ );
+ };
- return false;
- });
+ try {
+ while (
+ builder.getSemanticDiagnosticsOfNextAffectedFile(undefined, (sourceFile) => {
+ if (ignoreForDiagnostics.has(sourceFile)) {
+ // If the affected file is a TTC shim, add the shim's original source file.
+ // This ensures that changes that affect TTC are typechecked even when the changes
+ // are otherwise unrelated from a TS perspective and do not result in Ivy codegen changes.
+ // For example, changing @Input property types of a directive used in another component's
+ // template.
+ // A TTC shim is a file that has been ignored for diagnostics and has a filename ending in `.ngtypecheck.ts`.
+ if (isNgTypeCheckFile(sourceFile.fileName)) {
+ // This file name conversion relies on internal compiler logic and should be converted
+ // to an official method when available. 15 is length of `.ngtypecheck.ts`
+ const originalFilename = getOriginalFilename(sourceFile.fileName);
+ const originalSourceFile = builder.getSourceFile(originalFilename);
+ if (originalSourceFile) {
+ affectedFiles.add(originalSourceFile);
+ }
+ }
+ } else {
+ affectedFiles.add(sourceFile);
+ }
- if (!result) {
- break;
+ return true;
+ })
+ ) {
+ /* empty */
}
+ } finally {
+ program.emit = baseEmit;
+ compilerOptions.isolatedModules = prevIsolatedModules;
+ compilerOptions.verbatimModuleSyntax = prevVerbatimModuleSyntax;
+ }
- affectedFiles.add(result.affected as ts.SourceFile);
+ // Also include files identified by Angular's incremental compilation graph as needing
+ // re-emission or template re-typechecking (such as selector renames or required input changes
+ // that only affect Angular metadata without altering standard TypeScript `.d.ts` signatures).
+ for (const sourceFile of builder.getSourceFiles()) {
+ if (sourceFile.isDeclarationFile || ignoreForDiagnostics.has(sourceFile)) {
+ continue;
+ }
+
+ if (
+ !incrementalCompilation.safeToSkipEmit(sourceFile) ||
+ incrementalCompilation.priorTypeCheckingResultsFor(sourceFile) === null
+ ) {
+ affectedFiles.add(sourceFile);
+ }
}
// Add all files with associated template type checking files.
@@ -605,127 +740,81 @@ function findAffectedFiles(
}
}
- // When `isolatedModules` or `verbatimModuleSyntax` is enabled, TypeScript's `getFilesAffectedByUpdatedShapeWhenModuleEmit`
- // only returns the modified file itself, so dependent `.ngtypecheck.ts` shims are not yielded by
- // `getSemanticDiagnosticsOfNextAffectedFile`. However, TypeScript's `handleDtsMayChangeOfReferencingExportOfAffectedFile`
- // still walks `referencedMap` when a modified file's `.d.ts` shape signature changes and calls
- // `BuilderState.updateShapeSignature` on all affected dependents, recording them in `state.hasCalledUpdateShapeSignature`.
- const compilerOptions = builder.getCompilerOptions();
- const hasCalledUpdateShapeSignature = (builder as InternalBuilderProgram).state
- ?.hasCalledUpdateShapeSignature;
- if (
- (compilerOptions.isolatedModules || compilerOptions.verbatimModuleSyntax) &&
- affectedFiles.size &&
- hasCalledUpdateShapeSignature?.size
- ) {
- for (const path of hasCalledUpdateShapeSignature) {
- if (!isNgTypeCheckFile(path)) {
- continue;
- }
-
- const originalFilename = getOriginalFilename(path);
- const originalSourceFile = builder.getSourceFile(originalFilename);
- if (originalSourceFile) {
- affectedFiles.add(originalSourceFile);
- }
- }
- }
-
return affectedFiles;
}
-interface InternalBuilderProgram extends ts.EmitAndSemanticDiagnosticsBuilderProgram {
- state?: {
- fileInfos?: Map;
- changedFilesSet?: Set;
- oldSignatures?: Map;
- hasCalledUpdateShapeSignature?: Set;
- };
-}
-
-interface InternalTypeScriptWithBuilderState {
- BuilderState?: {
- updateShapeSignature?(
- state: unknown,
- program: ts.Program,
- sourceFile: ts.SourceFile,
- cancellationToken: ts.CancellationToken | undefined,
- host: ts.CompilerHost,
- useFileVersionAsSignature: boolean,
- ): boolean;
- };
-}
-
-/**
- * Commits the internal state of a TypeScript `BuilderProgram` by clearing uncommitted
- * `oldSignatures` and `changedFilesSet` entries.
- *
- * Why this is needed:
- * TypeScript's `BuilderProgram` only clears `state.oldSignatures` and `state.changedFilesSet`
- * when `getSemanticDiagnostics()` (whole-program, no arguments) is called. Because Angular's
- * `collectDiagnostics()` queries `getSemanticDiagnostics(sourceFile)` per-file (skipping
- * internal `ignoreForDiagnostics` shims), TypeScript leaves `oldSignatures` populated with `false`.
- * When `BuilderState.create` creates the next `BuilderProgram`, any `false` entry in `oldSignatures`
- * resets that file's `info.signature` back to `undefined`. Clearing `oldSignatures` and `changedFilesSet`
- * commits the current build's state so subsequent incremental builds preserve `.d.ts` signatures.
- */
-function commitBuilderProgramState(program: InternalBuilderProgram): void {
- program.state?.changedFilesSet?.clear();
- program.state?.oldSignatures?.clear();
-}
-
/**
- * Ensures `.d.ts` shape signatures (`info.signature`) are computed on the previous `BuilderProgram`
- * for modified files before the next `BuilderProgram` is created.
+ * Ensures `.d.ts` shape signatures are computed on the previous `BuilderProgram` for modified
+ * files before the next `BuilderProgram` is created.
*
- * Why this is needed:
- * 1. On the initial build (`oldProgram === undefined`), TypeScript's `BuilderState.create` sets
- * `state.useFileVersionAsSignature = true`, which initializes `info.signature = sourceFile.version`
- * (the SHA-256 hash of the `.ts` source text).
- * 2. Normally, during `emitNextAffectedFile()`, TypeScript's `getWriteFileCallback` replaces
- * `info.signature` with the hash of the emitted `.d.ts` declaration text. However, TypeScript
- * explicitly guards this behind `if (!customTransformers)` (in `ts.createBuilderProgram`).
- * Because Angular passes `angularCompiler.prepareEmit().transformers` (`customTransformers`) to
- * `emitNextAffectedFile()`, TypeScript skips updating `info.signature`, leaving it equal to the
- * `.ts` file hash (`sourceFile.version`).
- * 3. On the first incremental watch rebuild (`oldProgram !== undefined`), `useFileVersionAsSignature`
- * becomes `false` and TypeScript calls `BuilderState.updateShapeSignature()` (`computeDtsSignature`)
- * for each modified file, comparing the newly computed `.d.ts` hash against the previous build's
- * `info.signature` (which is still the `.ts` hash). Because a `.d.ts` hash never matches a `.ts`
- * hash, TypeScript falsely concludes that the file's public `.d.ts` shape changed and cascades
- * re-analysis and re-emission across all transitive importers in `referencedMap`.
- * 4. Calling `BuilderState.updateShapeSignature(..., false)` on the previous program when a modified
- * file's `signature` is still equal to its `version` replaces the initial `.ts` version hash with
- * its true `.d.ts` signature prior to diffing, preventing false-positive `.d.ts` cascades on watch edits.
+ * On the initial build (`oldProgram === undefined`), TypeScript initializes each file's shape
+ * signature to its `.ts` source version hash rather than emitting `.d.ts` declarations for every
+ * file. When a file is modified on a watch rebuild, TypeScript computes its new `.d.ts` signature
+ * and compares it against the previous program's stored signature. Emitting the `.d.ts` signature
+ * on `oldProgram` for the modified file replaces its initial `.ts` version hash with its true
+ * `.d.ts` signature prior to diffing, avoiding false-positive `.d.ts` cascades across importers.
*/
function populatePreviousDtsSignatures(
- oldProgram: InternalBuilderProgram,
- oldHost: ts.CompilerHost,
+ oldProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram,
modifiedFiles: ReadonlySet,
+ filesWithoutDtsSignature: Set,
): void {
- const updateShapeSignature = (ts as InternalTypeScriptWithBuilderState).BuilderState
- ?.updateShapeSignature;
- const oldState = oldProgram.state;
- if (!updateShapeSignature || !oldState?.fileInfos) {
- return;
- }
-
- const program = oldProgram.getProgram();
+ const filesToUpdate: ts.SourceFile[] = [];
for (const modifiedFile of modifiedFiles) {
- const sf = program.getSourceFile(modifiedFile);
- if (!sf || sf.isDeclarationFile) {
+ if (!filesWithoutDtsSignature.delete(modifiedFile)) {
continue;
}
- const resolvedPath =
- (sf as ts.SourceFile & { resolvedPath?: string }).resolvedPath ?? modifiedFile;
- const fileInfo = oldState.fileInfos.get(resolvedPath);
- if (!fileInfo || fileInfo.signature !== fileInfo.version) {
- continue;
+ const sf = oldProgram.getSourceFile(modifiedFile);
+ if (sf && !sf.isDeclarationFile) {
+ filesToUpdate.push(sf);
}
+ }
- oldState.hasCalledUpdateShapeSignature?.delete(resolvedPath);
- updateShapeSignature(oldState, program, sf, undefined, oldHost, false);
+ if (filesToUpdate.length === 0) {
+ return;
+ }
+
+ const compilerOptions = oldProgram.getCompilerOptions();
+ const prevDeclaration = compilerOptions.declaration;
+ const prevComposite = compilerOptions.composite;
+ const prevNoEmit = compilerOptions.noEmit;
+ const prevNoEmitOnError = compilerOptions.noEmitOnError;
+
+ compilerOptions.declaration = true;
+ compilerOptions.composite = false;
+ compilerOptions.noEmit = false;
+ compilerOptions.noEmitOnError = false;
+
+ const program = oldProgram.getProgram();
+ const baseEmit = program.emit as (
+ targetSourceFile?: ts.SourceFile,
+ writeFile?: ts.WriteFileCallback,
+ cancellationToken?: ts.CancellationToken,
+ emitOnlyDtsFiles?: boolean | number,
+ customTransformers?: ts.CustomTransformers,
+ forceDtsEmit?: boolean,
+ ) => ts.EmitResult;
+ program.emit = (targetSourceFile, writeFile, cancellationToken) =>
+ baseEmit.call(
+ program,
+ targetSourceFile,
+ writeFile,
+ cancellationToken,
+ 2, // ts.EmitOnly.BuilderSignature
+ undefined,
+ true, // forceDtsEmit
+ );
+
+ try {
+ for (const sf of filesToUpdate) {
+ oldProgram.emit(sf, () => {}, undefined, true);
+ }
+ } finally {
+ program.emit = baseEmit;
+ compilerOptions.declaration = prevDeclaration;
+ compilerOptions.composite = prevComposite;
+ compilerOptions.noEmit = prevNoEmit;
+ compilerOptions.noEmitOnError = prevNoEmitOnError;
}
- oldState.oldSignatures?.clear();
}
From 3375f53a8143635901343cfd226f9d8ac6c65d1c Mon Sep 17 00:00:00 2001
From: Alan Agius <17563226+alan-agius4@users.noreply.github.com>
Date: Mon, 12 Oct 2026 08:20:52 +0000
Subject: [PATCH 3/3] fixup! perf(@angular/build): optimize incremental watch
rebuilds in AOT compilation
---
packages/angular/build/src/tools/esbuild/bundler-context.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts
index a4228caa5782..202ab514cbfe 100644
--- a/packages/angular/build/src/tools/esbuild/bundler-context.ts
+++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts
@@ -328,7 +328,11 @@ export class BundlerContext {
const initialFiles = new Map();
for (const outputFile of result.outputFiles) {
// Entries in the metafile are relative to the `absWorkingDir` option which is set to the workspaceRoot
- const relativeFilePath = relative(this.workspaceRoot, outputFile.path);
+ // `outputFile.path` may already be relative if the `OutputFile` instance was cached in `additionalResults`
+ // across an incremental rebuild.
+ const relativeFilePath = isAbsolute(outputFile.path)
+ ? relative(this.workspaceRoot, outputFile.path)
+ : outputFile.path;
const entryPoint = result.metafile.outputs[relativeFilePath]?.entryPoint;
outputFile.path = relativeFilePath;