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 415f24d269c0..cfa23f4f6bbb 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,6 +8,7 @@ import { rm } from 'node:fs/promises'; import { executeLibraryBuilder } from '../../builder'; +import { CompilationMode } from '../../schema'; import { BASE_OPTIONS, LIBRARY_BUILDER_INFO, describeLibraryBuilder } from '../setup'; describeLibraryBuilder(executeLibraryBuilder, LIBRARY_BUILDER_INFO, (harness) => { @@ -601,5 +602,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 749962e3def6..d90714a08481 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. */ @@ -189,7 +198,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 && @@ -413,19 +422,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; } } } @@ -442,11 +462,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 @@ -461,13 +482,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()), @@ -477,6 +505,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 @@ -518,15 +548,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; } @@ -582,6 +611,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'], @@ -591,10 +674,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; @@ -604,6 +692,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, @@ -641,8 +731,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); } }, @@ -655,6 +746,8 @@ function findAffectedFiles( filesWithoutDtsSignature.delete(targetSourceFile.fileName); } + + hadDtsSignatureComputed.add(targetSourceFile.fileName); } return baseEmit.call( @@ -680,7 +773,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) { @@ -689,6 +782,7 @@ function findAffectedFiles( } } else { affectedFiles.add(sourceFile); + tsAffectedFiles.push(sourceFile); } return true; @@ -702,6 +796,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). @@ -726,7 +830,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) { @@ -783,7 +887,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; @@ -793,7 +897,7 @@ function populatePreviousDtsSignatures( targetSourceFile, writeFile, cancellationToken, - 2, // ts.EmitOnly.BuilderSignature + EmitOnly.BuilderSignature, undefined, true, // forceDtsEmit );