From e89b3d4960faad7c2f9edd77350f526dfe36a112 Mon Sep 17 00:00:00 2001
From: Alan Agius <17563226+alan-agius4@users.noreply.github.com>
Date: Thu, 15 Oct 2026 18:08:07 +0000
Subject: [PATCH] fix(@angular/build): preserve incremental .d.ts signatures
and emit state in watch mode
- Drain newly created .ngtypecheck.ts shims after collectDiagnostics() using emitNextAffectedFile with isolatedModules and assumeChangesOnlyAffectDirectDependencies enabled so TypeScript does not reset .d.ts shape signatures or queue component files into affectedFilesPendingEmit for the next rebuild.
- Ensure before transformers run prior to afterDeclarations during .d.ts-only emits so Angular's declarationTransformFactory always has populated dtsTransforms.
- Only record source files as emitted when JS output is produced (not on .d.ts-only checks), and pass ts.EmitOnly.Js in the fallback loop when .d.ts was already emitted.
- Track transitive importers whose BuilderProgram signatures are reset to sourceFile.version by handleDtsMayChangeOf so populatePreviousDtsSignatures restores their .d.ts signatures on subsequent edits.
---
.../library/tests/behavior/watch_spec.ts | 200 +++++++++++++++++-
.../angular/compilation/aot-compilation.ts | 156 +++++++++++---
2 files changed, 329 insertions(+), 27 deletions(-)
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 e9b7e57ddd9f..add8b5151d81 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
@@ -8,7 +8,7 @@
import { rm } from 'node:fs/promises';
import { executeLibraryBuilder } from '../../builder';
-import { InlineStyleLanguage } from '../../schema';
+import { CompilationMode, InlineStyleLanguage } from '../../schema';
import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup';
describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => {
@@ -649,5 +649,203 @@ describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) =>
},
]);
});
+
+ it('should only rebuild affected entry point when an external template is modified with isolatedModules enabled', async () => {
+ await harness.writeFiles({
+ 'projects/lib/src/lib/lib.component.html': '
Primary Initial
',
+ 'projects/lib/src/lib/lib.component.ts': `
+ import { Component } from '@angular/core';
+
+ @Component({
+ selector: 'lib-primary',
+ templateUrl: './lib.component.html',
+ })
+ export class LibComponent {}
+ `,
+ 'projects/lib/secondary/src/secondary.component.html': 'Secondary Initial
',
+ 'projects/lib/secondary/src/secondary.component.ts': `
+ import { Component } from '@angular/core';
+
+ @Component({
+ selector: 'lib-secondary',
+ templateUrl: './secondary.component.html',
+ })
+ export class SecondaryComponent {}
+ `,
+ 'projects/lib/secondary/src/public-api.ts': `
+ export { SecondaryComponent } from './secondary.component';
+ `,
+ });
+
+ await harness.modifyFile('projects/lib/tsconfig.lib.json', (content) => {
+ const tsconfig = JSON.parse(content);
+ tsconfig.compilerOptions.isolatedModules = true;
+
+ return JSON.stringify(tsconfig, null, 2);
+ });
+
+ await harness.modifyFile('projects/lib/package.json', (content) => {
+ const pkg = JSON.parse(content);
+ pkg.exports = {
+ '.': './src/public-api.ts',
+ './secondary': './secondary/src/public-api.ts',
+ };
+
+ return JSON.stringify(pkg, null, 2);
+ });
+
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ watch: true,
+ });
+
+ await harness.executeWithCases([
+ async ({ result }) => {
+ expect(result?.success).toBeTrue();
+ expect(harness.readFile('dist/lib/fesm2022/lib-secondary.mjs')).toContain(
+ 'Secondary Initial',
+ );
+ expect(harness.readFile('dist/lib/types/lib.d.ts')).toContain('ɵcmp');
+ expect(harness.readFile('dist/lib/types/lib-secondary.d.ts')).toContain('ɵcmp');
+
+ await harness.writeFile(
+ 'projects/lib/secondary/src/secondary.component.html',
+ 'Secondary Updated
',
+ );
+ },
+ ({ result, logs }) => {
+ expect(result?.success).toBeTrue();
+ expect(harness.readFile('dist/lib/fesm2022/lib-secondary.mjs')).toContain(
+ 'Secondary Updated',
+ );
+ expect(harness.readFile('dist/lib/types/lib.d.ts')).toContain('ɵcmp');
+ expect(harness.readFile('dist/lib/types/lib-secondary.d.ts')).toContain('ɵcmp');
+
+ expect(logs).toContain(
+ jasmine.objectContaining({
+ message: jasmine.stringContaining('Compiling lib/secondary...'),
+ }),
+ );
+ expect(logs).not.toContain(
+ jasmine.objectContaining({
+ message: jasmine.stringContaining('Compiling lib...'),
+ }),
+ );
+ },
+ ]);
+ });
+
+ it('should rebuild dependent entry point when a shared component input is added in watch mode', async () => {
+ await harness.writeFiles({
+ 'projects/lib/shared/src/public-api.ts': `
+ import { Component } from '@angular/core';
+
+ @Component({
+ selector: 'lib-shared-icon',
+ template: 'icon',
+ })
+ export class SharedIconComponent {}
+ `,
+ 'projects/lib/src/lib/lib.component.ts': `
+ import { Component } from '@angular/core';
+ import { SharedIconComponent } from 'lib/shared';
+
+ @Component({
+ selector: 'lib-primary',
+ imports: [SharedIconComponent],
+ template: '',
+ })
+ export class LibComponent {}
+ `,
+ });
+
+ await harness.modifyFile('projects/lib/tsconfig.lib.json', (content) => {
+ const tsconfig = JSON.parse(content);
+ tsconfig.compilerOptions.isolatedModules = true;
+
+ return JSON.stringify(tsconfig, null, 2);
+ });
+
+ await harness.modifyFile('projects/lib/package.json', (content) => {
+ const pkg = JSON.parse(content);
+ pkg.exports = {
+ '.': './src/public-api.ts',
+ './shared': './shared/src/public-api.ts',
+ };
+
+ return JSON.stringify(pkg, null, 2);
+ });
+
+ harness.useTarget('build', {
+ ...BASE_OPTIONS,
+ compilationMode: CompilationMode.Partial,
+ watch: true,
+ });
+
+ await harness.executeWithCases([
+ async ({ result }) => {
+ expect(result?.success).toBeTrue();
+
+ // Trigger a rebuild of `lib` first so `lib/shared` undergoes a `.d.ts`-only check
+ await harness.writeFile(
+ 'projects/lib/src/lib/lib.component.ts',
+ `
+ import { Component } from '@angular/core';
+ import { SharedIconComponent } from 'lib/shared';
+
+ @Component({
+ selector: 'lib-primary',
+ imports: [SharedIconComponent],
+ template: '',
+ })
+ export class LibComponent {}
+ `,
+ );
+ },
+ async ({ result, logs }) => {
+ expect(result?.success).toBeTrue();
+ expect(logs).toContain(
+ jasmine.objectContaining({
+ message: jasmine.stringContaining('Compiling lib...'),
+ }),
+ );
+ expect(logs).not.toContain(
+ jasmine.objectContaining({
+ message: jasmine.stringContaining('Compiling lib/shared...'),
+ }),
+ );
+
+ // Add a new @Input() to SharedIconComponent
+ await harness.writeFile(
+ 'projects/lib/shared/src/public-api.ts',
+ `
+ import { Component, Input } from '@angular/core';
+
+ @Component({
+ selector: 'lib-shared-icon',
+ template: '{{ name }}',
+ })
+ export class SharedIconComponent {
+ @Input() name = 'default';
+ }
+ `,
+ );
+ },
+ ({ result, logs }) => {
+ expect(result?.success).toBeTrue();
+ expect(logs).toContain(
+ jasmine.objectContaining({
+ message: jasmine.stringContaining('Compiling lib/shared...'),
+ }),
+ );
+ expect(logs).toContain(
+ jasmine.objectContaining({
+ message: jasmine.stringContaining('Compiling lib...'),
+ }),
+ );
+ expect(harness.readFile('dist/lib/fesm2022/lib.mjs')).toContain('"name"');
+ },
+ ]);
+ });
});
});
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 4ca1480f646c..88171a44fc69 100644
--- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts
+++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts
@@ -47,6 +47,15 @@ const TTC_EXTENSION = '.ngtypecheck.ts';
*/
const TTC_EXTENSION_LENGTH = TTC_EXTENSION.length;
+/**
+ * Internal TypeScript `ts.EmitOnly` enum values used by `Program.emit`.
+ */
+enum EmitOnly {
+ Js = 0,
+ Dts = 1,
+ BuilderSignature = 2,
+}
+
/**
* Checks if a file is an Angular type-checking shim file.
*/
@@ -226,7 +235,7 @@ export class AotCompilation extends TypeScriptCompilation {
);
const filesWithoutDtsSignature = this.#state?.filesWithoutDtsSignature ?? new Set();
- if (!this.#state) {
+ if (!this.#state && !usingBuildInfo && (useTypeChecking || useTypeScriptTranspilation)) {
for (const sourceFile of typeScriptProgram.getSourceFiles()) {
if (
!sourceFile.isDeclarationFile &&
@@ -449,19 +458,30 @@ export class AotCompilation extends TypeScriptCompilation {
typeScriptProgram,
);
+ // Temporarily enable `isolatedModules` and `assumeChangesOnlyAffectDirectDependencies` while
+ // draining the newly created `.ngtypecheck.ts` files. Because each component `.ts` file
+ // references its `.ngtypecheck.ts` shim via `sourceFile.referencedFiles`, TypeScript's
+ // `referencedMap` treats the component `.ts` file as depending on its `.ngtypecheck.ts` shim.
+ // Without both flags (whether `isolatedModules` is enabled or disabled in `tsconfig`),
+ // TypeScript would walk from `.ngtypecheck.ts` to the component `.ts` file and its transitive
+ // importers, resetting their `.d.ts` shape signatures and queueing them into
+ // `affectedFilesPendingEmit` for the next incremental rebuild.
+ const compilerOptions = currentTsProgram.getCompilerOptions();
+ const prevIsolatedModules = compilerOptions.isolatedModules;
+ const prevAssumeDirect = compilerOptions.assumeChangesOnlyAffectDirectDependencies;
+ compilerOptions.isolatedModules = true;
+ compilerOptions.assumeChangesOnlyAffectDirectDependencies = true;
+
const baseEmit = currentTsProgram.emit;
currentTsProgram.emit = () => ({ emitSkipped: true, diagnostics: [] });
try {
- while (
- this.#state.typeScriptProgram.getSemanticDiagnosticsOfNextAffectedFile(
- undefined,
- () => true,
- )
- ) {
+ while (this.#state.typeScriptProgram.emitNextAffectedFile(() => {})) {
/* empty */
}
} finally {
currentTsProgram.emit = baseEmit;
+ compilerOptions.isolatedModules = prevIsolatedModules;
+ compilerOptions.assumeChangesOnlyAffectDirectDependencies = prevAssumeDirect;
}
}
}
@@ -478,11 +498,12 @@ export class AotCompilation extends TypeScriptCompilation {
useTypeScriptTranspilation,
} = this.#state;
const compilerOptions = typeScriptProgram.getCompilerOptions();
- const isLibraryEmit = !!compilerOptions.declaration;
+ const emitDeclarations = !!compilerOptions.declaration;
const buildInfoFilename = compilerOptions.tsBuildInfoFile ?? '.tsbuildinfo';
const emittedFiles = new Map();
const emittedSourceFiles = new Set();
+ const emittedDtsSourceFiles = new Set();
const writeFileCallback: ts.WriteFileCallback = (filename, contents, _a, _b, sourceFiles) => {
if (!sourceFiles?.length && filename.endsWith(buildInfoFilename)) {
// Save builder info contents to specified location
@@ -497,13 +518,20 @@ export class AotCompilation extends TypeScriptCompilation {
return;
}
- angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile);
- emittedSourceFiles.add(sourceFile);
- const targetFilename = isLibraryEmit ? filename : sourceFile.fileName;
+ const isMap = filename.endsWith('.map');
+ const isDts = !isMap && /\.d\.[cm]?ts$/i.test(filename);
+ if (!emitDeclarations || (!isMap && !isDts)) {
+ angularCompiler.incrementalCompilation.recordSuccessfulEmit(sourceFile);
+ emittedSourceFiles.add(sourceFile);
+ } else if (isDts) {
+ emittedDtsSourceFiles.add(sourceFile);
+ }
+
+ const targetFilename = emitDeclarations ? filename : sourceFile.fileName;
emittedFiles.set(targetFilename, { filename: targetFilename, contents });
};
const transformers = angularCompiler.prepareEmit().transformers;
- if (!isLibraryEmit) {
+ if (!emitDeclarations) {
transformers.before ??= [];
transformers.before.push(
replaceBootstrap(() => typeScriptProgram.getProgram().getTypeChecker()),
@@ -513,6 +541,8 @@ export class AotCompilation extends TypeScriptCompilation {
if (!this.browserOnlyBuild) {
transformers.before.push(lazyRoutesTransformer(compilerOptions, compilerHost));
}
+ } else if (transformers.before?.length && transformers.afterDeclarations?.length) {
+ ensurePreDeclarationTransformers(transformers);
}
// Emit is handled in write file callback when using TypeScript
@@ -554,15 +584,14 @@ export class AotCompilation extends TypeScriptCompilation {
continue;
}
- if (
- angularCompiler.incrementalCompilation.safeToSkipEmit(sourceFile) &&
- (!useTypeScriptTranspilation || !affectedFiles.has(sourceFile))
- ) {
+ if (angularCompiler.incrementalCompilation.safeToSkipEmit(sourceFile)) {
continue;
}
if (useTypeScriptTranspilation) {
- const emitOnly = affectedFiles.has(sourceFile) ? undefined : false;
+ const emitOnly = emittedDtsSourceFiles.has(sourceFile)
+ ? (EmitOnly.Js as unknown as boolean)
+ : undefined;
typeScriptProgram.emit(sourceFile, writeFileCallback, undefined, emitOnly, transformers);
continue;
}
@@ -618,6 +647,60 @@ export class AotCompilation extends TypeScriptCompilation {
}
}
+/**
+ * When TypeScript's `BuilderProgram.emitNextAffectedFile` emits `.d.ts`-only (`emitOnly = Dts`)
+ * for a transitive importer, TypeScript skips `transformers.before`. However, Angular's
+ * `declarationTransformFactory` in `afterDeclarations` relies on `ivyTransformFactory` in
+ * `before` having compiled the file's classes to populate `dtsTransforms`.
+ *
+ * This helper ensures `before` transformers have executed on the original `SourceFile` prior to
+ * `afterDeclarations`, and caches the transformed `SourceFile` so a subsequent `.js` emit for the
+ * same file does not re-execute `before` transformers.
+ */
+function ensurePreDeclarationTransformers(transformers: ts.CustomTransformers): void {
+ if (!transformers.before?.length || !transformers.afterDeclarations?.length) {
+ return;
+ }
+
+ const beforeTransformers = transformers.before as ts.TransformerFactory[];
+ const transformedSourceFiles = new Set();
+ transformers.before = beforeTransformers.map((factory, index) =>
+ index === 0
+ ? (context) => {
+ const transform = factory(context);
+
+ return (sourceFile) => {
+ transformedSourceFiles.add(ts.getOriginalNode(sourceFile, ts.isSourceFile));
+
+ return transform(sourceFile);
+ };
+ }
+ : factory,
+ );
+ transformers.afterDeclarations = [
+ (context) => {
+ let beforeTransforms: ts.Transformer[] | undefined;
+
+ return (fileOrBundle) => {
+ if (!ts.isBundle(fileOrBundle)) {
+ const originalSourceFile = ts.getOriginalNode(fileOrBundle, ts.isSourceFile);
+ if (!transformedSourceFiles.has(originalSourceFile)) {
+ transformedSourceFiles.add(originalSourceFile);
+ beforeTransforms ??= beforeTransformers.map((factory) => factory(context));
+ let current = originalSourceFile;
+ for (const transform of beforeTransforms) {
+ current = transform(current);
+ }
+ }
+ }
+
+ return fileOrBundle;
+ };
+ },
+ ...transformers.afterDeclarations,
+ ];
+}
+
function findAffectedFiles(
builder: ts.EmitAndSemanticDiagnosticsBuilderProgram,
{ ignoreForDiagnostics, incrementalCompilation }: ng.NgtscProgram['compiler'],
@@ -627,10 +710,15 @@ function findAffectedFiles(
): Set {
const affectedFiles = new Set();
- // 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.
+ // Temporarily disable `isolatedModules` and `verbatimModuleSyntax` while walking affected files.
+ // In TypeScript, `getIsolatedModules(options)` is true when either `isolatedModules` or
+ // `verbatimModuleSyntax` is enabled, which causes `getFilesAffectedByUpdatedShapeWhenModuleEmit`
+ // to short-circuit and return only `[sourceFileWithUpdatedShape]` instead of walking
+ // `referencedMap` during `getSemanticDiagnosticsOfNextAffectedFile`.
+ // Disabling both flags forces TypeScript to traverse `referencedMap` when a file's `.d.ts` shape
+ // signature changes so that dependent files—especially `.ngtypecheck.ts` shims that reference
+ // directives or pipes used in templates—are visited by the `ignoreSourceFile` callback and have
+ // their own `.d.ts` shape signatures updated along the dependency chain.
const compilerOptions = builder.getCompilerOptions();
const prevIsolatedModules = compilerOptions.isolatedModules;
const prevVerbatimModuleSyntax = compilerOptions.verbatimModuleSyntax;
@@ -640,6 +728,8 @@ function findAffectedFiles(
const program = builder.getProgram();
const baseEmit = program.emit;
const oldProgram = oldBuilder?.getProgram();
+ const hadDtsSignatureComputed = new Set();
+ const tsAffectedFiles: ts.SourceFile[] = [];
program.emit = (
targetSourceFile,
writeFile,
@@ -677,8 +767,9 @@ function findAffectedFiles(
(fileName, text, writeByteOrderMark, onError, sourceFiles, data) => {
const newHasDiagnostics = !!(data as { diagnostics?: unknown[] } | undefined)
?.diagnostics?.length;
- if (text !== oldDtsText || newHasDiagnostics !== oldHasDiagnostics) {
+ if (text !== oldDtsText || oldHasDiagnostics || newHasDiagnostics) {
filesWithoutDtsSignature.delete(targetSourceFile.fileName);
+ hadDtsSignatureComputed.add(targetSourceFile.fileName);
writeFile?.(fileName, text, writeByteOrderMark, onError, sourceFiles, data);
}
},
@@ -691,6 +782,8 @@ function findAffectedFiles(
filesWithoutDtsSignature.delete(targetSourceFile.fileName);
}
+
+ hadDtsSignatureComputed.add(targetSourceFile.fileName);
}
return baseEmit.call(
@@ -716,7 +809,7 @@ function findAffectedFiles(
// 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`
+ // to an official method when available.
const originalFilename = getOriginalFilename(sourceFile.fileName);
const originalSourceFile = builder.getSourceFile(originalFilename);
if (originalSourceFile) {
@@ -725,6 +818,7 @@ function findAffectedFiles(
}
} else {
affectedFiles.add(sourceFile);
+ tsAffectedFiles.push(sourceFile);
}
return true;
@@ -738,6 +832,16 @@ function findAffectedFiles(
compilerOptions.verbatimModuleSyntax = prevVerbatimModuleSyntax;
}
+ // Transitive importers visited by `getSemanticDiagnosticsOfNextAffectedFile` via
+ // `handleDtsMayChangeOf` do not have a `.d.ts` signature computed via `program.emit` and
+ // instead have their BuilderProgram signature reset to `sourceFile.version`. Track them so
+ // `populatePreviousDtsSignatures` restores their `.d.ts` signature if they are modified later.
+ for (const sourceFile of tsAffectedFiles) {
+ if (!sourceFile.isDeclarationFile && !hadDtsSignatureComputed.has(sourceFile.fileName)) {
+ filesWithoutDtsSignature.add(sourceFile.fileName);
+ }
+ }
+
// 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).
@@ -762,7 +866,7 @@ function findAffectedFiles(
for (const sourceFile of builder.getSourceFiles()) {
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`
+ // to an official method when available.
const originalFilename = getOriginalFilename(sourceFile.fileName);
const originalSourceFile = builder.getSourceFile(originalFilename);
if (originalSourceFile) {
@@ -819,7 +923,7 @@ function populatePreviousDtsSignatures(
targetSourceFile?: ts.SourceFile,
writeFile?: ts.WriteFileCallback,
cancellationToken?: ts.CancellationToken,
- emitOnlyDtsFiles?: boolean | number,
+ emitOnlyDtsFiles?: boolean | EmitOnly,
customTransformers?: ts.CustomTransformers,
forceDtsEmit?: boolean,
) => ts.EmitResult;
@@ -829,7 +933,7 @@ function populatePreviousDtsSignatures(
targetSourceFile,
writeFile,
cancellationToken,
- 2, // ts.EmitOnly.BuilderSignature
+ EmitOnly.BuilderSignature,
undefined,
true, // forceDtsEmit
);