From bac51f11da29bba4e369b192cb317dc69f50917e Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:36:06 +0000 Subject: [PATCH] fix(@angular/build): report TypeScript declaration emit diagnostics When TypeScript encounters a declaration emit error (such as `TS2742`, `TS4023`, `TS4060`, or `TS9005`), it skips emitting the `.d.ts` file and returns the diagnostic in `emitResult.diagnostics`. Previously, `AotCompilation.emitAffectedFiles()` discarded `emitResult.diagnostics` and `collectDiagnostics()` did not yield declaration emit diagnostics, causing library builds to fail later with a secondary `UNRESOLVED_IMPORT` error in `rolldown-plugin-dts`. Store declaration emit diagnostics returned by `emitNextAffectedFile()` and `typeScriptProgram.emit()` per `SourceFile` in `AngularCompilationState` (preserving them across incremental rebuilds for files that are not re-emitted) and yield them during `collectDiagnostics()`. --- .../library/tests/behavior/build_spec.ts | 40 ++++++++++ .../library/tests/behavior/watch_spec.ts | 79 +++++++++++++++++++ .../angular/compilation/aot-compilation.ts | 44 ++++++++--- 3 files changed, 153 insertions(+), 10 deletions(-) diff --git a/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts index 0b6de2a26c7f..be6c1d3d3f94 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/build_spec.ts @@ -278,5 +278,45 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => expect(dtsContent).toContain('LibComponent'); expect(dtsContent).toContain('CACHED_BUILD'); }); + + it('should report TypeScript declaration emit diagnostics', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/internal.ts': ` + interface PrivateData { + value: number; + } + + export const internalData: PrivateData = { value: 42 }; + `, + 'projects/lib/src/lib/feature.ts': ` + import { internalData } from './internal'; + + export const data = internalData; + `, + 'projects/lib/src/public-api.ts': ` + export * from './lib/feature'; + export * from './lib/lib.service'; + export * from './lib/lib.component'; + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(/TS4023:.*PrivateData/), + }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringContaining('UNRESOLVED_IMPORT'), + }), + ); + }); }); }); diff --git a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts index d1a41b244320..9f85102fe20c 100644 --- a/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts +++ b/packages/angular/build/src/builders/library/tests/behavior/watch_spec.ts @@ -905,5 +905,84 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => }, ]); }); + + it('should preserve and recover from TypeScript declaration emit diagnostics in watch mode', async () => { + await harness.writeFiles({ + 'projects/lib/src/lib/internal.ts': ` + interface PrivateData { + value: number; + } + + export const internalData: PrivateData = { value: 42 }; + `, + 'projects/lib/src/lib/feature.ts': ` + import { internalData } from './internal'; + + export const data = internalData; + `, + 'projects/lib/src/public-api.ts': ` + export * from './lib/feature'; + export * from './lib/lib.service'; + export * from './lib/lib.component'; + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases( + [ + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(/TS4023:.*PrivateData/), + }), + ); + + // Modify an unrelated file so feature.ts is not re-emitted + await harness.writeFile( + 'projects/lib/src/lib/lib.service.ts', + ` + import { Injectable } from '@angular/core'; + + @Injectable({ providedIn: 'root' }) + export class LibService { + updated = true; + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(/TS4023:.*PrivateData/), + }), + ); + + // Fix the declaration error in feature.ts by adding an explicit type annotation + await harness.writeFile( + 'projects/lib/src/lib/feature.ts', + ` + import { internalData } from './internal'; + + export const data: { value: number } = internalData; + `, + ); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + const dtsContent = harness.readFile('dist/lib/types/lib.d.ts'); + expect(dtsContent).toContain('data'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); }); }); 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 88171a44fc69..2320fa2025d0 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -80,6 +80,7 @@ class AngularCompilationState { public readonly webWorkerTransform: ts.TransformerFactory, public readonly useTypeScriptTranspilation: boolean, public readonly diagnosticCache = new WeakMap(), + public readonly declarationDiagnostics = new WeakMap(), public readonly filesWithoutDtsSignature = new Set(), public readonly useFallbackRootNames = false, ) {} @@ -358,6 +359,7 @@ export class AotCompilation extends TypeScriptCompilation { createWorkerTransformer(hostOptions.processWebWorker.bind(hostOptions)), useTypeScriptTranspilation, this.#state?.diagnosticCache, + this.#state?.declarationDiagnostics, filesWithoutDtsSignature, useFallbackRootNames, ); @@ -377,6 +379,7 @@ export class AotCompilation extends TypeScriptCompilation { const { affectedFiles, angularCompiler, + declarationDiagnostics, diagnosticCache, templateDiagnosticsOptimization, typeScriptProgram, @@ -422,11 +425,16 @@ export class AotCompilation extends TypeScriptCompilation { true, ); - // Declaration files cannot have template diagnostics + // Declaration files cannot have template or declaration emit diagnostics if (sourceFile.isDeclarationFile) { continue; } + const fileDeclarationDiagnostics = declarationDiagnostics.get(sourceFile); + if (fileDeclarationDiagnostics) { + yield* fileDeclarationDiagnostics; + } + // Only request Angular template diagnostics for affected files to avoid // overhead of template diagnostics for unchanged files. if (affectedFiles.has(sourceFile)) { @@ -493,6 +501,7 @@ export class AotCompilation extends TypeScriptCompilation { affectedFiles, angularCompiler, compilerHost, + declarationDiagnostics, typeScriptProgram, webWorkerTransform, useTypeScriptTranspilation, @@ -548,15 +557,22 @@ export class AotCompilation extends TypeScriptCompilation { // Emit is handled in write file callback when using TypeScript if (useTypeScriptTranspilation) { // TypeScript will loop until there are no more affected files in the program - while ( - typeScriptProgram.emitNextAffectedFile( + while (true) { + const affectedEmitResult = typeScriptProgram.emitNextAffectedFile( writeFileCallback, undefined, undefined, transformers, - ) - ) { - /* empty */ + ); + if (!affectedEmitResult) { + break; + } + if ('kind' in affectedEmitResult.affected) { + declarationDiagnostics.set( + affectedEmitResult.affected, + affectedEmitResult.result.diagnostics, + ); + } } } else if (compilerOptions.tsBuildInfoFile) { // Manually get the builder state for the persistent cache @@ -589,10 +605,18 @@ export class AotCompilation extends TypeScriptCompilation { } if (useTypeScriptTranspilation) { - const emitOnly = emittedDtsSourceFiles.has(sourceFile) - ? (EmitOnly.Js as unknown as boolean) - : undefined; - typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, emitOnly, transformers); + const hasEmittedDts = emittedDtsSourceFiles.has(sourceFile); + const emitOnly = hasEmittedDts ? (EmitOnly.Js as unknown as boolean) : undefined; + const emitResult = typeScriptProgram.emit( + sourceFile, + writeFileCallback, + undefined, + emitOnly, + transformers, + ); + if (!hasEmittedDts) { + declarationDiagnostics.set(sourceFile, emitResult.diagnostics); + } continue; }