@@ -41,7 +41,7 @@ class AngularCompilationState {
4141 constructor (
4242 public readonly angularProgram : ng . NgtscProgram ,
4343 public readonly compilerHost : ng . CompilerHost ,
44- public readonly typeScriptProgram : ts . EmitAndSemanticDiagnosticsBuilderProgram ,
44+ public typeScriptProgram : ts . EmitAndSemanticDiagnosticsBuilderProgram ,
4545 public readonly affectedFiles : ReadonlySet < ts . SourceFile > ,
4646 public readonly templateDiagnosticsOptimization : ng . OptimizeFor ,
4747 public readonly webWorkerTransform : ts . TransformerFactory < ts . SourceFile > ,
@@ -61,6 +61,7 @@ export class AotCompilation extends TypeScriptCompilation {
6161 super ( ) ;
6262 }
6363
64+ // eslint-disable-next-line max-lines-per-function
6465 async initialize (
6566 tsconfig : string ,
6667 hostOptions : AngularHostOptions ,
@@ -100,8 +101,8 @@ export class AotCompilation extends TypeScriptCompilation {
100101 if ( hostOptions . modifiedFiles ) {
101102 this . invalidateFiles ( hostOptions . modifiedFiles ) ;
102103
103- for ( const modifiedFile of hostOptions . modifiedFiles ) {
104- if ( this . #state ) {
104+ if ( this . #state && hostOptions . modifiedFiles . size > 0 ) {
105+ for ( const modifiedFile of hostOptions . modifiedFiles ) {
105106 // Clear package.json cache if a node modules file was modified
106107 if ( ! clearPackageJsonCache && modifiedFile . includes ( 'node_modules' ) ) {
107108 clearPackageJsonCache = true ;
@@ -117,6 +118,14 @@ export class AotCompilation extends TypeScriptCompilation {
117118 }
118119 }
119120 }
121+
122+ if ( compilerOptions . declaration ) {
123+ populatePreviousDtsSignatures (
124+ this . #state. typeScriptProgram ,
125+ this . #state. compilerHost ,
126+ hostOptions . modifiedFiles ,
127+ ) ;
128+ }
120129 }
121130 }
122131
@@ -207,25 +216,35 @@ export class AotCompilation extends TypeScriptCompilation {
207216 const componentResourcesDependencies = new Map < string , string [ ] > ( ) ;
208217
209218 // Get all files referenced in the TypeScript/Angular program including component resources
210- const referencedFiles = typeScriptProgram
211- . getSourceFiles ( )
212- . filter ( ( sourceFile ) => ! angularCompiler . ignoreForEmit . has ( sourceFile ) )
213- . flatMap ( ( sourceFile ) => {
214- const resourceDependencies = angularCompiler . getResourceDependencies ( sourceFile ) ;
215- componentResourcesDependencies . set ( sourceFile . fileName , resourceDependencies ) ;
216- // Also invalidate Angular diagnostics for a source file if component resources are modified
217- if ( this . #state && hostOptions . modifiedFiles ?. size ) {
218- for ( const resourceDependency of resourceDependencies ) {
219- if ( hostOptions . modifiedFiles . has ( resourceDependency ) ) {
220- this . #state. diagnosticCache . delete ( sourceFile ) ;
221- // Also mark as affected in case changed template affects diagnostics
222- affectedFiles . add ( sourceFile ) ;
223- }
219+ const referencedFiles : string [ ] = [ ] ;
220+ for ( const sourceFile of typeScriptProgram . getSourceFiles ( ) ) {
221+ if ( angularCompiler . ignoreForEmit . has ( sourceFile ) ) {
222+ continue ;
223+ }
224+ referencedFiles . push ( sourceFile . fileName ) ;
225+ if ( sourceFile . isDeclarationFile ) {
226+ continue ;
227+ }
228+ const resourceDependencies = angularCompiler . getResourceDependencies ( sourceFile ) ;
229+ componentResourcesDependencies . set ( sourceFile . fileName , resourceDependencies ) ;
230+ if ( resourceDependencies . length === 0 ) {
231+ continue ;
232+ }
233+ referencedFiles . push ( ...resourceDependencies ) ;
234+ // Also invalidate Angular diagnostics for a source file if component template resources are modified
235+ if ( this . #state && hostOptions . modifiedFiles ?. size ) {
236+ for ( const resourceDependency of resourceDependencies ) {
237+ if (
238+ hostOptions . modifiedFiles . has ( resourceDependency ) &&
239+ ! / \. (?: c s s | s c s s | s a s s | l e s s ) $ / i. test ( resourceDependency )
240+ ) {
241+ this . #state. diagnosticCache . delete ( sourceFile ) ;
242+ // Also mark as affected in case changed template affects diagnostics
243+ affectedFiles . add ( sourceFile ) ;
224244 }
225245 }
226-
227- return [ sourceFile . fileName , ...resourceDependencies ] ;
228- } ) ;
246+ }
247+ }
229248
230249 this . #state = new AngularCompilationState (
231250 angularProgram ,
@@ -319,6 +338,22 @@ export class AotCompilation extends TypeScriptCompilation {
319338 }
320339 }
321340 }
341+
342+ // Angular's template typechecker lazily creates `.ngtypecheck.ts` shims in a new `ts.Program`
343+ // during `getDiagnosticsForFile`. Sync the builder program with `angularCompiler.getCurrentProgram()`
344+ // so the next incremental build does not treat all `.ngtypecheck.ts` files as newly added files.
345+ commitBuilderProgramState ( typeScriptProgram ) ;
346+ const currentTsProgram = angularCompiler . getCurrentProgram ( ) ;
347+ if ( currentTsProgram !== typeScriptProgram . getProgram ( ) ) {
348+ ensureSourceFileVersions ( currentTsProgram ) ;
349+ const updatedBuilder = ts . createEmitAndSemanticDiagnosticsBuilderProgram (
350+ currentTsProgram ,
351+ this . #state. compilerHost ,
352+ typeScriptProgram ,
353+ ) ;
354+ commitBuilderProgramState ( updatedBuilder ) ;
355+ this . #state. typeScriptProgram = updatedBuilder ;
356+ }
322357 }
323358
324359 override emitAffectedFiles ( ) : Iterable < EmitFileResult > {
@@ -522,3 +557,99 @@ function findAffectedFiles(
522557
523558 return affectedFiles ;
524559}
560+
561+ interface InternalBuilderProgram extends ts . EmitAndSemanticDiagnosticsBuilderProgram {
562+ state ?: {
563+ fileInfos ?: Map < string , { version : string ; signature : string | undefined } > ;
564+ changedFilesSet ?: Set < string > ;
565+ oldSignatures ?: Map < string , string | false > ;
566+ hasCalledUpdateShapeSignature ?: Set < string > ;
567+ } ;
568+ }
569+
570+ interface InternalTypeScriptWithBuilderState {
571+ BuilderState ?: {
572+ updateShapeSignature ?(
573+ state : unknown ,
574+ program : ts . Program ,
575+ sourceFile : ts . SourceFile ,
576+ cancellationToken : ts . CancellationToken | undefined ,
577+ host : ts . CompilerHost ,
578+ useFileVersionAsSignature : boolean ,
579+ ) : boolean ;
580+ } ;
581+ }
582+
583+ /**
584+ * Commits the internal state of a TypeScript `BuilderProgram` by clearing uncommitted
585+ * `oldSignatures` and `changedFilesSet` entries.
586+ *
587+ * Why this is needed:
588+ * TypeScript's `BuilderProgram` only clears `state.oldSignatures` and `state.changedFilesSet`
589+ * when `getSemanticDiagnostics()` (whole-program, no arguments) is called. Because Angular's
590+ * `collectDiagnostics()` queries `getSemanticDiagnostics(sourceFile)` per-file (skipping
591+ * internal `ignoreForDiagnostics` shims), TypeScript leaves `oldSignatures` populated with `false`.
592+ * When `BuilderState.create` creates the next `BuilderProgram`, any `false` entry in `oldSignatures`
593+ * resets that file's `info.signature` back to `undefined`. Clearing `oldSignatures` and `changedFilesSet`
594+ * commits the current build's state so subsequent incremental builds preserve `.d.ts` signatures.
595+ */
596+ function commitBuilderProgramState ( program : InternalBuilderProgram ) : void {
597+ program . state ?. changedFilesSet ?. clear ( ) ;
598+ program . state ?. oldSignatures ?. clear ( ) ;
599+ }
600+
601+ /**
602+ * Ensures `.d.ts` shape signatures (`info.signature`) are computed on the previous `BuilderProgram`
603+ * for modified files before the next `BuilderProgram` is created.
604+ *
605+ * Why this is needed:
606+ * 1. On the initial build (`oldProgram === undefined`), TypeScript's `BuilderState.create` sets
607+ * `state.useFileVersionAsSignature = true`, which initializes `info.signature = sourceFile.version`
608+ * (the SHA-256 hash of the `.ts` source text).
609+ * 2. Normally, during `emitNextAffectedFile()`, TypeScript's `getWriteFileCallback` replaces
610+ * `info.signature` with the hash of the emitted `.d.ts` declaration text. However, TypeScript
611+ * explicitly guards this behind `if (!customTransformers)` (in `ts.createBuilderProgram`).
612+ * Because Angular passes `angularCompiler.prepareEmit().transformers` (`customTransformers`) to
613+ * `emitNextAffectedFile()`, TypeScript skips updating `info.signature`, leaving it equal to the
614+ * `.ts` file hash (`sourceFile.version`).
615+ * 3. On the first incremental watch rebuild (`oldProgram !== undefined`), `useFileVersionAsSignature`
616+ * becomes `false` and TypeScript calls `BuilderState.updateShapeSignature()` (`computeDtsSignature`)
617+ * for each modified file, comparing the newly computed `.d.ts` hash against the previous build's
618+ * `info.signature` (which is still the `.ts` hash). Because a `.d.ts` hash never matches a `.ts`
619+ * hash, TypeScript falsely concludes that the file's public `.d.ts` shape changed and cascades
620+ * re-analysis and re-emission across all transitive importers in `referencedMap`.
621+ * 4. Calling `BuilderState.updateShapeSignature(..., false)` on the previous program when a modified
622+ * file's `signature` is still equal to its `version` replaces the initial `.ts` version hash with
623+ * its true `.d.ts` signature prior to diffing, preventing false-positive `.d.ts` cascades on watch edits.
624+ */
625+ function populatePreviousDtsSignatures (
626+ oldProgram : InternalBuilderProgram ,
627+ oldHost : ts . CompilerHost ,
628+ modifiedFiles : ReadonlySet < string > ,
629+ ) : void {
630+ const updateShapeSignature = ( ts as InternalTypeScriptWithBuilderState ) . BuilderState
631+ ?. updateShapeSignature ;
632+ const oldState = oldProgram . state ;
633+ if ( ! updateShapeSignature || ! oldState ?. fileInfos ) {
634+ return ;
635+ }
636+
637+ const program = oldProgram . getProgram ( ) ;
638+ for ( const modifiedFile of modifiedFiles ) {
639+ const sf = program . getSourceFile ( modifiedFile ) ;
640+ if ( ! sf || sf . isDeclarationFile ) {
641+ continue ;
642+ }
643+
644+ const resolvedPath =
645+ ( sf as ts . SourceFile & { resolvedPath ?: string } ) . resolvedPath ?? modifiedFile ;
646+ const fileInfo = oldState . fileInfos . get ( resolvedPath ) ;
647+ if ( ! fileInfo || fileInfo . signature !== fileInfo . version ) {
648+ continue ;
649+ }
650+
651+ oldState . hasCalledUpdateShapeSignature ?. delete ( resolvedPath ) ;
652+ updateShapeSignature ( oldState , program , sf , undefined , oldHost , false ) ;
653+ }
654+ oldState . oldSignatures ?. clear ( ) ;
655+ }
0 commit comments