@@ -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 ,
@@ -261,63 +280,82 @@ export class AotCompilation extends TypeScriptCompilation {
261280 const syntactic = modes & DiagnosticModes . Syntactic ;
262281 const semantic = modes & DiagnosticModes . Semantic ;
263282
264- // Collect program level diagnostics
265- if ( modes & DiagnosticModes . Option ) {
266- yield * typeScriptProgram . getConfigFileParsingDiagnostics ( ) ;
267- yield * angularCompiler . getOptionDiagnostics ( ) ;
268- yield * typeScriptProgram . getOptionsDiagnostics ( ) ;
269- }
270- if ( syntactic ) {
271- yield * typeScriptProgram . getGlobalDiagnostics ( ) ;
272- }
273-
274- // Collect source file specific diagnostics
275- for ( const sourceFile of typeScriptProgram . getSourceFiles ( ) ) {
276- if ( angularCompiler . ignoreForDiagnostics . has ( sourceFile ) ) {
277- continue ;
283+ try {
284+ // Collect program level diagnostics
285+ if ( modes & DiagnosticModes . Option ) {
286+ yield * typeScriptProgram . getConfigFileParsingDiagnostics ( ) ;
287+ yield * angularCompiler . getOptionDiagnostics ( ) ;
288+ yield * typeScriptProgram . getOptionsDiagnostics ( ) ;
278289 }
279-
280290 if ( syntactic ) {
281- // TypeScript will use cached diagnostics for files that have not been
282- // changed or affected for this build when using incremental building.
283- yield * profileSync (
284- 'NG_DIAGNOSTICS_SYNTACTIC' ,
285- ( ) => typeScriptProgram . getSyntacticDiagnostics ( sourceFile ) ,
286- true ,
287- ) ;
291+ yield * typeScriptProgram . getGlobalDiagnostics ( ) ;
288292 }
289293
290- if ( ! semantic ) {
291- continue ;
292- }
294+ // Collect source file specific diagnostics
295+ for ( const sourceFile of typeScriptProgram . getSourceFiles ( ) ) {
296+ if ( angularCompiler . ignoreForDiagnostics . has ( sourceFile ) ) {
297+ continue ;
298+ }
293299
294- yield * profileSync (
295- 'NG_DIAGNOSTICS_SEMANTIC' ,
296- ( ) => typeScriptProgram . getSemanticDiagnostics ( sourceFile ) ,
297- true ,
298- ) ;
300+ if ( syntactic ) {
301+ // TypeScript will use cached diagnostics for files that have not been
302+ // changed or affected for this build when using incremental building.
303+ yield * profileSync (
304+ 'NG_DIAGNOSTICS_SYNTACTIC' ,
305+ ( ) => typeScriptProgram . getSyntacticDiagnostics ( sourceFile ) ,
306+ true ,
307+ ) ;
308+ }
299309
300- // Declaration files cannot have template diagnostics
301- if ( sourceFile . isDeclarationFile ) {
302- continue ;
303- }
310+ if ( ! semantic ) {
311+ continue ;
312+ }
304313
305- // Only request Angular template diagnostics for affected files to avoid
306- // overhead of template diagnostics for unchanged files.
307- if ( affectedFiles . has ( sourceFile ) ) {
308- const angularDiagnostics = profileSync (
309- 'NG_DIAGNOSTICS_TEMPLATE' ,
310- ( ) => angularCompiler . getDiagnosticsForFile ( sourceFile , templateDiagnosticsOptimization ) ,
314+ yield * profileSync (
315+ 'NG_DIAGNOSTICS_SEMANTIC' ,
316+ ( ) => typeScriptProgram . getSemanticDiagnostics ( sourceFile ) ,
311317 true ,
312318 ) ;
313- diagnosticCache . set ( sourceFile , angularDiagnostics ) ;
314- yield * angularDiagnostics ;
315- } else {
316- const angularDiagnostics = diagnosticCache . get ( sourceFile ) ;
317- if ( angularDiagnostics ) {
319+
320+ // Declaration files cannot have template diagnostics
321+ if ( sourceFile . isDeclarationFile ) {
322+ continue ;
323+ }
324+
325+ // Only request Angular template diagnostics for affected files to avoid
326+ // overhead of template diagnostics for unchanged files.
327+ if ( affectedFiles . has ( sourceFile ) ) {
328+ const angularDiagnostics = profileSync (
329+ 'NG_DIAGNOSTICS_TEMPLATE' ,
330+ ( ) =>
331+ angularCompiler . getDiagnosticsForFile ( sourceFile , templateDiagnosticsOptimization ) ,
332+ true ,
333+ ) ;
334+ diagnosticCache . set ( sourceFile , angularDiagnostics ) ;
318335 yield * angularDiagnostics ;
336+ } else {
337+ const angularDiagnostics = diagnosticCache . get ( sourceFile ) ;
338+ if ( angularDiagnostics ) {
339+ yield * angularDiagnostics ;
340+ }
319341 }
320342 }
343+ } finally {
344+ // Angular's template typechecker lazily creates `.ngtypecheck.ts` shims in a new `ts.Program`
345+ // during `getDiagnosticsForFile`. Sync the builder program with `angularCompiler.getCurrentProgram()`
346+ // so the next incremental build does not treat all `.ngtypecheck.ts` files as newly added files.
347+ commitBuilderProgramState ( typeScriptProgram ) ;
348+ const currentTsProgram = angularCompiler . getCurrentProgram ( ) ;
349+ if ( currentTsProgram !== typeScriptProgram . getProgram ( ) ) {
350+ ensureSourceFileVersions ( currentTsProgram ) ;
351+ const updatedBuilder = ts . createEmitAndSemanticDiagnosticsBuilderProgram (
352+ currentTsProgram ,
353+ this . #state. compilerHost ,
354+ typeScriptProgram ,
355+ ) ;
356+ commitBuilderProgramState ( updatedBuilder ) ;
357+ this . #state. typeScriptProgram = updatedBuilder ;
358+ }
321359 }
322360 }
323361
@@ -522,3 +560,99 @@ function findAffectedFiles(
522560
523561 return affectedFiles ;
524562}
563+
564+ interface InternalBuilderProgram extends ts . EmitAndSemanticDiagnosticsBuilderProgram {
565+ state ?: {
566+ fileInfos ?: Map < string , { version : string ; signature : string | undefined } > ;
567+ changedFilesSet ?: Set < string > ;
568+ oldSignatures ?: Map < string , string | false > ;
569+ hasCalledUpdateShapeSignature ?: Set < string > ;
570+ } ;
571+ }
572+
573+ interface InternalTypeScriptWithBuilderState {
574+ BuilderState ?: {
575+ updateShapeSignature ?(
576+ state : unknown ,
577+ program : ts . Program ,
578+ sourceFile : ts . SourceFile ,
579+ cancellationToken : ts . CancellationToken | undefined ,
580+ host : ts . CompilerHost ,
581+ useFileVersionAsSignature : boolean ,
582+ ) : boolean ;
583+ } ;
584+ }
585+
586+ /**
587+ * Commits the internal state of a TypeScript `BuilderProgram` by clearing uncommitted
588+ * `oldSignatures` and `changedFilesSet` entries.
589+ *
590+ * Why this is needed:
591+ * TypeScript's `BuilderProgram` only clears `state.oldSignatures` and `state.changedFilesSet`
592+ * when `getSemanticDiagnostics()` (whole-program, no arguments) is called. Because Angular's
593+ * `collectDiagnostics()` queries `getSemanticDiagnostics(sourceFile)` per-file (skipping
594+ * internal `ignoreForDiagnostics` shims), TypeScript leaves `oldSignatures` populated with `false`.
595+ * When `BuilderState.create` creates the next `BuilderProgram`, any `false` entry in `oldSignatures`
596+ * resets that file's `info.signature` back to `undefined`. Clearing `oldSignatures` and `changedFilesSet`
597+ * commits the current build's state so subsequent incremental builds preserve `.d.ts` signatures.
598+ */
599+ function commitBuilderProgramState ( program : InternalBuilderProgram ) : void {
600+ program . state ?. changedFilesSet ?. clear ( ) ;
601+ program . state ?. oldSignatures ?. clear ( ) ;
602+ }
603+
604+ /**
605+ * Ensures `.d.ts` shape signatures (`info.signature`) are computed on the previous `BuilderProgram`
606+ * for modified files before the next `BuilderProgram` is created.
607+ *
608+ * Why this is needed:
609+ * 1. On the initial build (`oldProgram === undefined`), TypeScript's `BuilderState.create` sets
610+ * `state.useFileVersionAsSignature = true`, which initializes `info.signature = sourceFile.version`
611+ * (the SHA-256 hash of the `.ts` source text).
612+ * 2. Normally, during `emitNextAffectedFile()`, TypeScript's `getWriteFileCallback` replaces
613+ * `info.signature` with the hash of the emitted `.d.ts` declaration text. However, TypeScript
614+ * explicitly guards this behind `if (!customTransformers)` (in `ts.createBuilderProgram`).
615+ * Because Angular passes `angularCompiler.prepareEmit().transformers` (`customTransformers`) to
616+ * `emitNextAffectedFile()`, TypeScript skips updating `info.signature`, leaving it equal to the
617+ * `.ts` file hash (`sourceFile.version`).
618+ * 3. On the first incremental watch rebuild (`oldProgram !== undefined`), `useFileVersionAsSignature`
619+ * becomes `false` and TypeScript calls `BuilderState.updateShapeSignature()` (`computeDtsSignature`)
620+ * for each modified file, comparing the newly computed `.d.ts` hash against the previous build's
621+ * `info.signature` (which is still the `.ts` hash). Because a `.d.ts` hash never matches a `.ts`
622+ * hash, TypeScript falsely concludes that the file's public `.d.ts` shape changed and cascades
623+ * re-analysis and re-emission across all transitive importers in `referencedMap`.
624+ * 4. Calling `BuilderState.updateShapeSignature(..., false)` on the previous program when a modified
625+ * file's `signature` is still equal to its `version` replaces the initial `.ts` version hash with
626+ * its true `.d.ts` signature prior to diffing, preventing false-positive `.d.ts` cascades on watch edits.
627+ */
628+ function populatePreviousDtsSignatures (
629+ oldProgram : InternalBuilderProgram ,
630+ oldHost : ts . CompilerHost ,
631+ modifiedFiles : ReadonlySet < string > ,
632+ ) : void {
633+ const updateShapeSignature = ( ts as InternalTypeScriptWithBuilderState ) . BuilderState
634+ ?. updateShapeSignature ;
635+ const oldState = oldProgram . state ;
636+ if ( ! updateShapeSignature || ! oldState ?. fileInfos ) {
637+ return ;
638+ }
639+
640+ const program = oldProgram . getProgram ( ) ;
641+ for ( const modifiedFile of modifiedFiles ) {
642+ const sf = program . getSourceFile ( modifiedFile ) ;
643+ if ( ! sf || sf . isDeclarationFile ) {
644+ continue ;
645+ }
646+
647+ const resolvedPath =
648+ ( sf as ts . SourceFile & { resolvedPath ?: string } ) . resolvedPath ?? modifiedFile ;
649+ const fileInfo = oldState . fileInfos . get ( resolvedPath ) ;
650+ if ( ! fileInfo || fileInfo . signature !== fileInfo . version ) {
651+ continue ;
652+ }
653+
654+ oldState . hasCalledUpdateShapeSignature ?. delete ( resolvedPath ) ;
655+ updateShapeSignature ( oldState , program , sf , undefined , oldHost , false ) ;
656+ }
657+ oldState . oldSignatures ?. clear ( ) ;
658+ }
0 commit comments