@@ -41,7 +41,7 @@ class AngularCompilationState {
4141 constructor (
4242 public readonly angularProgram : ng . NgtscProgram ,
4343 public readonly compilerHost : ng . CompilerHost ,
44- public readonly typeScriptProgram : ts . EmitAndSemanticDiagnosticsBuilderProgram ,
44+ public typeScriptProgram : ts . EmitAndSemanticDiagnosticsBuilderProgram ,
4545 public readonly affectedFiles : ReadonlySet < ts . SourceFile > ,
4646 public readonly templateDiagnosticsOptimization : ng . OptimizeFor ,
4747 public readonly webWorkerTransform : ts . TransformerFactory < ts . SourceFile > ,
@@ -102,8 +102,8 @@ export class AotCompilation extends TypeScriptCompilation {
102102 if ( hostOptions . modifiedFiles ) {
103103 this . invalidateFiles ( hostOptions . modifiedFiles ) ;
104104
105- for ( const modifiedFile of hostOptions . modifiedFiles ) {
106- if ( this . #state ) {
105+ if ( this . #state && hostOptions . modifiedFiles . size > 0 ) {
106+ for ( const modifiedFile of hostOptions . modifiedFiles ) {
107107 // Clear package.json cache if a node modules file was modified
108108 if ( ! clearPackageJsonCache && modifiedFile . includes ( 'node_modules' ) ) {
109109 clearPackageJsonCache = true ;
@@ -119,6 +119,14 @@ export class AotCompilation extends TypeScriptCompilation {
119119 }
120120 }
121121 }
122+
123+ if ( compilerOptions . declaration ) {
124+ populatePreviousDtsSignatures (
125+ this . #state. typeScriptProgram ,
126+ this . #state. compilerHost ,
127+ hostOptions . modifiedFiles ,
128+ ) ;
129+ }
122130 }
123131 }
124132
@@ -209,25 +217,35 @@ export class AotCompilation extends TypeScriptCompilation {
209217 const componentResourcesDependencies = new Map < string , string [ ] > ( ) ;
210218
211219 // Get all files referenced in the TypeScript/Angular program including component resources
212- const referencedFiles = typeScriptProgram
213- . getSourceFiles ( )
214- . filter ( ( sourceFile ) => ! angularCompiler . ignoreForEmit . has ( sourceFile ) )
215- . flatMap ( ( sourceFile ) => {
216- const resourceDependencies = angularCompiler . getResourceDependencies ( sourceFile ) ;
217- componentResourcesDependencies . set ( sourceFile . fileName , resourceDependencies ) ;
218- // Also invalidate Angular diagnostics for a source file if component resources are modified
219- if ( this . #state && hostOptions . modifiedFiles ?. size ) {
220- for ( const resourceDependency of resourceDependencies ) {
221- if ( hostOptions . modifiedFiles . has ( resourceDependency ) ) {
222- this . #state. diagnosticCache . delete ( sourceFile ) ;
223- // Also mark as affected in case changed template affects diagnostics
224- affectedFiles . add ( sourceFile ) ;
225- }
220+ const referencedFiles : string [ ] = [ ] ;
221+ for ( const sourceFile of typeScriptProgram . getSourceFiles ( ) ) {
222+ if ( angularCompiler . ignoreForEmit . has ( sourceFile ) ) {
223+ continue ;
224+ }
225+ referencedFiles . push ( sourceFile . fileName ) ;
226+ if ( sourceFile . isDeclarationFile ) {
227+ continue ;
228+ }
229+ const resourceDependencies = angularCompiler . getResourceDependencies ( sourceFile ) ;
230+ componentResourcesDependencies . set ( sourceFile . fileName , resourceDependencies ) ;
231+ if ( resourceDependencies . length === 0 ) {
232+ continue ;
233+ }
234+ referencedFiles . push ( ...resourceDependencies ) ;
235+ // Also invalidate Angular diagnostics for a source file if component template resources are modified
236+ if ( this . #state && hostOptions . modifiedFiles ?. size ) {
237+ for ( const resourceDependency of resourceDependencies ) {
238+ if (
239+ hostOptions . modifiedFiles . has ( resourceDependency ) &&
240+ ! / \. (?: c s s | s c s s | s a s s | l e s s ) $ / i. test ( resourceDependency )
241+ ) {
242+ this . #state. diagnosticCache . delete ( sourceFile ) ;
243+ // Also mark as affected in case changed template affects diagnostics
244+ affectedFiles . add ( sourceFile ) ;
226245 }
227246 }
228-
229- return [ sourceFile . fileName , ...resourceDependencies ] ;
230- } ) ;
247+ }
248+ }
231249
232250 this . #state = new AngularCompilationState (
233251 angularProgram ,
@@ -263,63 +281,82 @@ export class AotCompilation extends TypeScriptCompilation {
263281 const syntactic = modes & DiagnosticModes . Syntactic ;
264282 const semantic = modes & DiagnosticModes . Semantic ;
265283
266- // Collect program level diagnostics
267- if ( modes & DiagnosticModes . Option ) {
268- yield * typeScriptProgram . getConfigFileParsingDiagnostics ( ) ;
269- yield * angularCompiler . getOptionDiagnostics ( ) ;
270- yield * typeScriptProgram . getOptionsDiagnostics ( ) ;
271- }
272- if ( syntactic ) {
273- yield * typeScriptProgram . getGlobalDiagnostics ( ) ;
274- }
275-
276- // Collect source file specific diagnostics
277- for ( const sourceFile of typeScriptProgram . getSourceFiles ( ) ) {
278- if ( angularCompiler . ignoreForDiagnostics . has ( sourceFile ) ) {
279- continue ;
284+ try {
285+ // Collect program level diagnostics
286+ if ( modes & DiagnosticModes . Option ) {
287+ yield * typeScriptProgram . getConfigFileParsingDiagnostics ( ) ;
288+ yield * angularCompiler . getOptionDiagnostics ( ) ;
289+ yield * typeScriptProgram . getOptionsDiagnostics ( ) ;
280290 }
281-
282291 if ( syntactic ) {
283- // TypeScript will use cached diagnostics for files that have not been
284- // changed or affected for this build when using incremental building.
285- yield * profileSync (
286- 'NG_DIAGNOSTICS_SYNTACTIC' ,
287- ( ) => typeScriptProgram . getSyntacticDiagnostics ( sourceFile ) ,
288- true ,
289- ) ;
292+ yield * typeScriptProgram . getGlobalDiagnostics ( ) ;
290293 }
291294
292- if ( ! semantic ) {
293- continue ;
294- }
295+ // Collect source file specific diagnostics
296+ for ( const sourceFile of typeScriptProgram . getSourceFiles ( ) ) {
297+ if ( angularCompiler . ignoreForDiagnostics . has ( sourceFile ) ) {
298+ continue ;
299+ }
295300
296- yield * profileSync (
297- 'NG_DIAGNOSTICS_SEMANTIC' ,
298- ( ) => typeScriptProgram . getSemanticDiagnostics ( sourceFile ) ,
299- true ,
300- ) ;
301+ if ( syntactic ) {
302+ // TypeScript will use cached diagnostics for files that have not been
303+ // changed or affected for this build when using incremental building.
304+ yield * profileSync (
305+ 'NG_DIAGNOSTICS_SYNTACTIC' ,
306+ ( ) => typeScriptProgram . getSyntacticDiagnostics ( sourceFile ) ,
307+ true ,
308+ ) ;
309+ }
301310
302- // Declaration files cannot have template diagnostics
303- if ( sourceFile . isDeclarationFile ) {
304- continue ;
305- }
311+ if ( ! semantic ) {
312+ continue ;
313+ }
306314
307- // Only request Angular template diagnostics for affected files to avoid
308- // overhead of template diagnostics for unchanged files.
309- if ( affectedFiles . has ( sourceFile ) ) {
310- const angularDiagnostics = profileSync (
311- 'NG_DIAGNOSTICS_TEMPLATE' ,
312- ( ) => angularCompiler . getDiagnosticsForFile ( sourceFile , templateDiagnosticsOptimization ) ,
315+ yield * profileSync (
316+ 'NG_DIAGNOSTICS_SEMANTIC' ,
317+ ( ) => typeScriptProgram . getSemanticDiagnostics ( sourceFile ) ,
313318 true ,
314319 ) ;
315- diagnosticCache . set ( sourceFile , angularDiagnostics ) ;
316- yield * angularDiagnostics ;
317- } else {
318- const angularDiagnostics = diagnosticCache . get ( sourceFile ) ;
319- if ( angularDiagnostics ) {
320+
321+ // Declaration files cannot have template diagnostics
322+ if ( sourceFile . isDeclarationFile ) {
323+ continue ;
324+ }
325+
326+ // Only request Angular template diagnostics for affected files to avoid
327+ // overhead of template diagnostics for unchanged files.
328+ if ( affectedFiles . has ( sourceFile ) ) {
329+ const angularDiagnostics = profileSync (
330+ 'NG_DIAGNOSTICS_TEMPLATE' ,
331+ ( ) =>
332+ angularCompiler . getDiagnosticsForFile ( sourceFile , templateDiagnosticsOptimization ) ,
333+ true ,
334+ ) ;
335+ diagnosticCache . set ( sourceFile , angularDiagnostics ) ;
320336 yield * angularDiagnostics ;
337+ } else {
338+ const angularDiagnostics = diagnosticCache . get ( sourceFile ) ;
339+ if ( angularDiagnostics ) {
340+ yield * angularDiagnostics ;
341+ }
321342 }
322343 }
344+ } finally {
345+ // Angular's template typechecker lazily creates `.ngtypecheck.ts` shims in a new `ts.Program`
346+ // during `getDiagnosticsForFile`. Sync the builder program with `angularCompiler.getCurrentProgram()`
347+ // so the next incremental build does not treat all `.ngtypecheck.ts` files as newly added files.
348+ commitBuilderProgramState ( typeScriptProgram ) ;
349+ const currentTsProgram = angularCompiler . getCurrentProgram ( ) ;
350+ if ( currentTsProgram !== typeScriptProgram . getProgram ( ) ) {
351+ ensureSourceFileVersions ( currentTsProgram ) ;
352+ const updatedBuilder = ts . createEmitAndSemanticDiagnosticsBuilderProgram (
353+ currentTsProgram ,
354+ this . #state. compilerHost ,
355+ typeScriptProgram ,
356+ ) ;
357+ commitBuilderProgramState ( updatedBuilder ) ;
358+ this . #state. typeScriptProgram = updatedBuilder ;
359+ }
323360 }
324361 }
325362
@@ -532,3 +569,99 @@ function findAffectedFiles(
532569
533570 return affectedFiles ;
534571}
572+
573+ interface InternalBuilderProgram extends ts . EmitAndSemanticDiagnosticsBuilderProgram {
574+ state ?: {
575+ fileInfos ?: Map < string , { version : string ; signature : string | undefined } > ;
576+ changedFilesSet ?: Set < string > ;
577+ oldSignatures ?: Map < string , string | false > ;
578+ hasCalledUpdateShapeSignature ?: Set < string > ;
579+ } ;
580+ }
581+
582+ interface InternalTypeScriptWithBuilderState {
583+ BuilderState ?: {
584+ updateShapeSignature ?(
585+ state : unknown ,
586+ program : ts . Program ,
587+ sourceFile : ts . SourceFile ,
588+ cancellationToken : ts . CancellationToken | undefined ,
589+ host : ts . CompilerHost ,
590+ useFileVersionAsSignature : boolean ,
591+ ) : boolean ;
592+ } ;
593+ }
594+
595+ /**
596+ * Commits the internal state of a TypeScript `BuilderProgram` by clearing uncommitted
597+ * `oldSignatures` and `changedFilesSet` entries.
598+ *
599+ * Why this is needed:
600+ * TypeScript's `BuilderProgram` only clears `state.oldSignatures` and `state.changedFilesSet`
601+ * when `getSemanticDiagnostics()` (whole-program, no arguments) is called. Because Angular's
602+ * `collectDiagnostics()` queries `getSemanticDiagnostics(sourceFile)` per-file (skipping
603+ * internal `ignoreForDiagnostics` shims), TypeScript leaves `oldSignatures` populated with `false`.
604+ * When `BuilderState.create` creates the next `BuilderProgram`, any `false` entry in `oldSignatures`
605+ * resets that file's `info.signature` back to `undefined`. Clearing `oldSignatures` and `changedFilesSet`
606+ * commits the current build's state so subsequent incremental builds preserve `.d.ts` signatures.
607+ */
608+ function commitBuilderProgramState ( program : InternalBuilderProgram ) : void {
609+ program . state ?. changedFilesSet ?. clear ( ) ;
610+ program . state ?. oldSignatures ?. clear ( ) ;
611+ }
612+
613+ /**
614+ * Ensures `.d.ts` shape signatures (`info.signature`) are computed on the previous `BuilderProgram`
615+ * for modified files before the next `BuilderProgram` is created.
616+ *
617+ * Why this is needed:
618+ * 1. On the initial build (`oldProgram === undefined`), TypeScript's `BuilderState.create` sets
619+ * `state.useFileVersionAsSignature = true`, which initializes `info.signature = sourceFile.version`
620+ * (the SHA-256 hash of the `.ts` source text).
621+ * 2. Normally, during `emitNextAffectedFile()`, TypeScript's `getWriteFileCallback` replaces
622+ * `info.signature` with the hash of the emitted `.d.ts` declaration text. However, TypeScript
623+ * explicitly guards this behind `if (!customTransformers)` (in `ts.createBuilderProgram`).
624+ * Because Angular passes `angularCompiler.prepareEmit().transformers` (`customTransformers`) to
625+ * `emitNextAffectedFile()`, TypeScript skips updating `info.signature`, leaving it equal to the
626+ * `.ts` file hash (`sourceFile.version`).
627+ * 3. On the first incremental watch rebuild (`oldProgram !== undefined`), `useFileVersionAsSignature`
628+ * becomes `false` and TypeScript calls `BuilderState.updateShapeSignature()` (`computeDtsSignature`)
629+ * for each modified file, comparing the newly computed `.d.ts` hash against the previous build's
630+ * `info.signature` (which is still the `.ts` hash). Because a `.d.ts` hash never matches a `.ts`
631+ * hash, TypeScript falsely concludes that the file's public `.d.ts` shape changed and cascades
632+ * re-analysis and re-emission across all transitive importers in `referencedMap`.
633+ * 4. Calling `BuilderState.updateShapeSignature(..., false)` on the previous program when a modified
634+ * file's `signature` is still equal to its `version` replaces the initial `.ts` version hash with
635+ * its true `.d.ts` signature prior to diffing, preventing false-positive `.d.ts` cascades on watch edits.
636+ */
637+ function populatePreviousDtsSignatures (
638+ oldProgram : InternalBuilderProgram ,
639+ oldHost : ts . CompilerHost ,
640+ modifiedFiles : ReadonlySet < string > ,
641+ ) : void {
642+ const updateShapeSignature = ( ts as InternalTypeScriptWithBuilderState ) . BuilderState
643+ ?. updateShapeSignature ;
644+ const oldState = oldProgram . state ;
645+ if ( ! updateShapeSignature || ! oldState ?. fileInfos ) {
646+ return ;
647+ }
648+
649+ const program = oldProgram . getProgram ( ) ;
650+ for ( const modifiedFile of modifiedFiles ) {
651+ const sf = program . getSourceFile ( modifiedFile ) ;
652+ if ( ! sf || sf . isDeclarationFile ) {
653+ continue ;
654+ }
655+
656+ const resolvedPath =
657+ ( sf as ts . SourceFile & { resolvedPath ?: string } ) . resolvedPath ?? modifiedFile ;
658+ const fileInfo = oldState . fileInfos . get ( resolvedPath ) ;
659+ if ( ! fileInfo || fileInfo . signature !== fileInfo . version ) {
660+ continue ;
661+ }
662+
663+ oldState . hasCalledUpdateShapeSignature ?. delete ( resolvedPath ) ;
664+ updateShapeSignature ( oldState , program , sf , undefined , oldHost , false ) ;
665+ }
666+ oldState . oldSignatures ?. clear ( ) ;
667+ }
0 commit comments