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
);