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;