diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts index de53c7223fff..5e7bdeb3fd9c 100644 --- a/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts @@ -15,12 +15,6 @@ import { expectNoLog, } from '../setup'; -/** - * Maximum time in milliseconds for single build/rebuild - * This accounts for CI variability. - */ -export const BUILD_TIMEOUT = 30_000; - describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { describe('Behavior: "Rebuild Error Detection"', () => { it('detects template errors with no AOT codegen or TS emit differences', async () => { @@ -128,6 +122,235 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { ); }); + it('detects template errors across components on watch rebuild when isolatedModules is enabled', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions = { + ...tsconfig.compilerOptions, + isolatedModules: true, + }; + + return JSON.stringify(tsconfig); + }); + + const goodChildComponentContents = ` + import { Component, Input } from '@angular/core'; + @Component({ + selector: 'child', + standalone: true, + template: '

{{ value }}

', + }) + export class ChildComponent { + @Input() value!: number; + } + `; + + const typeErrorText = `Type 'number' is not assignable to type 'string'.`; + + await harness.writeFiles({ + 'src/app/child.component.ts': goodChildComponentContents, + 'src/app/app.module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + @NgModule({ + imports: [ + BrowserModule, + AppComponent, + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + import { ChildComponent } from './child.component'; + @Component({ + selector: 'app-root', + standalone: true, + imports: [ChildComponent], + template: '', + }) + export class AppComponent {} + `, + }); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Update child component to change input type from number to string + await harness.writeFile( + 'src/app/child.component.ts', + ` + import { Component, Input } from '@angular/core'; + @Component({ + selector: 'child', + standalone: true, + template: '

{{ value }}

', + }) + export class ChildComponent { + @Input() value!: string; + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, typeErrorText); + + // Make an unrelated change to verify error persists + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, typeErrorText); + + // Revert back to number + await harness.writeFile('src/app/child.component.ts', goodChildComponentContents); + }, + async ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, typeErrorText); + + // Make an unrelated change to verify error cache cleared + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, typeErrorText); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('detects template errors when an input becomes required or a selector is renamed on watch rebuild', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions = { + ...tsconfig.compilerOptions, + isolatedModules: true, + }; + + return JSON.stringify(tsconfig); + }); + + const goodChildComponentContents = ` + import { Component, input } from '@angular/core'; + @Component({ + selector: 'app-child', + standalone: true, + template: '

{{ label() }}

', + }) + export class ChildComponent { + label = input(''); + } + `; + + const requiredInputErrorText = `Required input 'label' from component ChildComponent must be specified.`; + const unknownElementErrorText = `'app-child' is not a known element`; + + await harness.writeFiles({ + 'src/app/child.component.ts': goodChildComponentContents, + 'src/app/app.module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + @NgModule({ + imports: [ + BrowserModule, + AppComponent, + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + import { ChildComponent } from './child.component'; + @Component({ + selector: 'app-root', + standalone: true, + imports: [ChildComponent], + template: '', + }) + export class AppComponent {} + `, + }); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Make input required using signal input.required() (which has the same TS .d.ts type InputSignal) + await harness.writeFile( + 'src/app/child.component.ts', + ` + import { Component, input } from '@angular/core'; + @Component({ + selector: 'app-child', + standalone: true, + template: '

{{ label() }}

', + }) + export class ChildComponent { + label = input.required(); + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, requiredInputErrorText); + + // Revert required input and rename selector instead + await harness.writeFile( + 'src/app/child.component.ts', + ` + import { Component, input } from '@angular/core'; + @Component({ + selector: 'app-child-renamed', + standalone: true, + template: '

{{ label() }}

', + }) + export class ChildComponent { + label = input(''); + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectNoLog(logs, requiredInputErrorText); + expectLog(logs, unknownElementErrorText); + + // Revert back to valid child component + await harness.writeFile('src/app/child.component.ts', goodChildComponentContents); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, requiredInputErrorText); + expectNoLog(logs, unknownElementErrorText); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + it('detects cumulative block syntax errors', async () => { harness.useTarget('build', { ...BASE_OPTIONS, @@ -234,7 +457,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { await harness.appendToFile('src/app/app.component.html', '
Guten Tag
'); }, ({ logs }) => { - expectNoLog(logs, 'invalid-css-content'); + expectNoLog(logs, 'Unexpected character "EOF"'); harness.expectFile('dist/browser/main.js').content.toContain('Hello, world!'); harness.expectFile('dist/browser/main.js').content.toContain('Guten Tag'); diff --git a/packages/angular/build/src/tools/angular/angular-host.ts b/packages/angular/build/src/tools/angular/angular-host.ts index 9322b5683dc5..f30df5bab64d 100644 --- a/packages/angular/build/src/tools/angular/angular-host.ts +++ b/packages/angular/build/src/tools/angular/angular-host.ts @@ -35,7 +35,10 @@ export interface AngularHostOptions { * field. The `version` field is required when used with a TypeScript BuilderProgram. * @param program The TypeScript Program instance to patch. */ -export function ensureSourceFileVersions(program: ts.Program): void { +export function ensureSourceFileVersions( + program: ts.Program, + previousProgram?: ts.BuilderProgram, +): void { const baseGetSourceFiles = program.getSourceFiles; // TODO: Update Angular compiler to add versions to all internal files and remove this program.getSourceFiles = function (...parameters) { @@ -45,7 +48,11 @@ export function ensureSourceFileVersions(program: ts.Program): void { for (const file of files) { if (file.version === undefined) { - file.version = calculateHash(file.text); + const previousFile: (ts.SourceFile & { version?: string }) | undefined = + previousProgram && !file.fileName.endsWith('.ngtypecheck.ts') + ? previousProgram.getSourceFile(file.fileName) + : undefined; + file.version = previousFile?.version ?? calculateHash(file.text); } } 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 6f45199d1421..1b3952787112 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -37,16 +37,41 @@ import { printSourceFileWithMap } from './typescript-printer'; */ const HMR_MODIFIED_FILE_LIMIT = 32; +/** + * Extension for Angular type-checking files. + */ +const TTC_EXTENSION = '.ngtypecheck.ts'; + +/** + * Length of the `.ngtypecheck.ts` extension. + */ +const TTC_EXTENSION_LENGTH = TTC_EXTENSION.length; + +/** + * Checks if a file is an Angular type-checking shim file. + */ +function isNgTypeCheckFile(fileName: string): boolean { + return fileName.endsWith(TTC_EXTENSION); +} + +/** + * Returns the original filename for a type-checking shim file. + */ +function getOriginalFilename(fileName: string): string { + return fileName.slice(0, -TTC_EXTENSION_LENGTH) + '.ts'; +} + class AngularCompilationState { constructor( public readonly angularProgram: ng.NgtscProgram, public readonly compilerHost: ng.CompilerHost, - public readonly typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, + public typeScriptProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, public readonly affectedFiles: ReadonlySet, public readonly templateDiagnosticsOptimization: ng.OptimizeFor, public readonly webWorkerTransform: ts.TransformerFactory, public readonly useTypeScriptTranspilation: boolean, public readonly diagnosticCache = new WeakMap(), + public readonly filesWithoutDtsSignature = new Set(), ) {} get angularCompiler() { @@ -103,8 +128,8 @@ export class AotCompilation extends TypeScriptCompilation { if (hostOptions.modifiedFiles) { this.invalidateFiles(hostOptions.modifiedFiles); - for (const modifiedFile of hostOptions.modifiedFiles) { - if (this.#state) { + if (this.#state && hostOptions.modifiedFiles.size > 0) { + for (const modifiedFile of hostOptions.modifiedFiles) { // Clear package.json cache if a node modules file was modified if (!clearPackageJsonCache && modifiedFile.includes('node_modules')) { clearPackageJsonCache = true; @@ -120,6 +145,14 @@ export class AotCompilation extends TypeScriptCompilation { } } } + + if (useTypeChecking || useTypeScriptTranspilation) { + populatePreviousDtsSignatures( + this.#state.typeScriptProgram, + hostOptions.modifiedFiles, + this.#state.filesWithoutDtsSignature, + ); + } } } @@ -155,6 +188,18 @@ export class AotCompilation extends TypeScriptCompilation { configurationDiagnostics, ); + const filesWithoutDtsSignature = this.#state?.filesWithoutDtsSignature ?? new Set(); + if (!this.#state) { + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if ( + !sourceFile.isDeclarationFile && + !angularCompiler.ignoreForDiagnostics.has(sourceFile) + ) { + filesWithoutDtsSignature.add(sourceFile.fileName); + } + } + } + await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync()); let templateUpdates; @@ -203,38 +248,63 @@ export class AotCompilation extends TypeScriptCompilation { const affectedFiles = useTypeChecking || useTypeScriptTranspilation ? profileSync('NG_FIND_AFFECTED', () => - findAffectedFiles(typeScriptProgram, angularCompiler, usingBuildInfo), + findAffectedFiles( + typeScriptProgram, + angularCompiler, + usingBuildInfo, + this.#state?.typeScriptProgram, + hostOptions.modifiedFiles, + filesWithoutDtsSignature, + ), ) : new Set(); const componentResourcesDependencies = new Map(); // Get all files referenced in the TypeScript/Angular program including component resources - const referencedFiles = [ - ...tsConfigFiles, - ...typeScriptProgram - .getSourceFiles() - .filter((sourceFile) => !angularCompiler.ignoreForEmit.has(sourceFile)) - .flatMap((sourceFile) => { - const resourceDependencies = angularCompiler.getResourceDependencies(sourceFile); - componentResourcesDependencies.set(sourceFile.fileName, resourceDependencies); - // Also invalidate Angular diagnostics for a source file if component resources are modified - if (this.#state && hostOptions.modifiedFiles?.size) { - for (const resourceDependency of resourceDependencies) { - if ( - hostOptions.modifiedFiles.has(resourceDependency) && - !/\.(?:css|scss|sass|less)$/i.test(resourceDependency) - ) { - this.#state.diagnosticCache.delete(sourceFile); - // Also mark as affected in case changed template affects diagnostics - affectedFiles.add(sourceFile); - } - } + const referencedFiles = [...tsConfigFiles]; + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (angularCompiler.ignoreForEmit.has(sourceFile)) { + continue; + } + + referencedFiles.push(sourceFile.fileName); + if (sourceFile.isDeclarationFile) { + continue; + } + + const resourceDependencies = angularCompiler.getResourceDependencies(sourceFile); + componentResourcesDependencies.set(sourceFile.fileName, resourceDependencies); + + if (resourceDependencies.length === 0) { + continue; + } + + referencedFiles.push(...resourceDependencies); + + // Also invalidate Angular diagnostics for a source file if component resources are modified + if (this.#state && hostOptions.modifiedFiles?.size) { + for (const resourceDependency of resourceDependencies) { + if ( + !hostOptions.modifiedFiles.has(resourceDependency) || + /\.(?:css|scss|sass|less)$/i.test(resourceDependency) + ) { + continue; } - return [sourceFile.fileName, ...resourceDependencies]; - }), - ]; + this.#state.diagnosticCache.delete(sourceFile); + + const oldSourceFile = this.#state.typeScriptProgram.getSourceFile(sourceFile.fileName); + if (oldSourceFile) { + this.#state.diagnosticCache.delete(oldSourceFile); + } + + // Also mark as affected in case changed template affects diagnostics + affectedFiles.add(sourceFile); + break; + } + } + } this.#state = new AngularCompilationState( angularProgram, @@ -245,6 +315,7 @@ export class AotCompilation extends TypeScriptCompilation { createWorkerTransformer(hostOptions.processWebWorker.bind(hostOptions)), useTypeScriptTranspilation, this.#state?.diagnosticCache, + filesWithoutDtsSignature, ); return { @@ -270,61 +341,92 @@ export class AotCompilation extends TypeScriptCompilation { const syntactic = modes & DiagnosticModes.Syntactic; const semantic = modes & DiagnosticModes.Semantic; - // Collect program level diagnostics - if (modes & DiagnosticModes.Option) { - yield* typeScriptProgram.getConfigFileParsingDiagnostics(); - yield* angularCompiler.getOptionDiagnostics(); - yield* typeScriptProgram.getOptionsDiagnostics(); - } - if (syntactic) { - yield* typeScriptProgram.getGlobalDiagnostics(); - } - - // Collect source file specific diagnostics - for (const sourceFile of typeScriptProgram.getSourceFiles()) { - if (angularCompiler.ignoreForDiagnostics.has(sourceFile)) { - continue; + try { + // Collect program level diagnostics + if (modes & DiagnosticModes.Option) { + yield* typeScriptProgram.getConfigFileParsingDiagnostics(); + yield* angularCompiler.getOptionDiagnostics(); + yield* typeScriptProgram.getOptionsDiagnostics(); } - if (syntactic) { - // TypeScript will use cached diagnostics for files that have not been - // changed or affected for this build when using incremental building. - yield* profileSync( - 'NG_DIAGNOSTICS_SYNTACTIC', - () => typeScriptProgram.getSyntacticDiagnostics(sourceFile), - true, - ); + yield* typeScriptProgram.getGlobalDiagnostics(); } - if (!semantic) { - continue; - } + // Collect source file specific diagnostics + for (const sourceFile of typeScriptProgram.getSourceFiles()) { + if (angularCompiler.ignoreForDiagnostics.has(sourceFile)) { + continue; + } - yield* profileSync( - 'NG_DIAGNOSTICS_SEMANTIC', - () => typeScriptProgram.getSemanticDiagnostics(sourceFile), - true, - ); + if (syntactic) { + // TypeScript will use cached diagnostics for files that have not been + // changed or affected for this build when using incremental building. + yield* profileSync( + 'NG_DIAGNOSTICS_SYNTACTIC', + () => typeScriptProgram.getSyntacticDiagnostics(sourceFile), + true, + ); + } - // Declaration files cannot have template diagnostics - if (sourceFile.isDeclarationFile) { - continue; - } + if (!semantic) { + continue; + } - // Only request Angular template diagnostics for affected files to avoid - // overhead of template diagnostics for unchanged files. - if (affectedFiles.has(sourceFile)) { - const angularDiagnostics = profileSync( - 'NG_DIAGNOSTICS_TEMPLATE', - () => angularCompiler.getDiagnosticsForFile(sourceFile, templateDiagnosticsOptimization), + yield* profileSync( + 'NG_DIAGNOSTICS_SEMANTIC', + () => typeScriptProgram.getSemanticDiagnostics(sourceFile), true, ); - diagnosticCache.set(sourceFile, angularDiagnostics); - yield* angularDiagnostics; - } else { - const angularDiagnostics = diagnosticCache.get(sourceFile); - if (angularDiagnostics) { + + // Declaration files cannot have template diagnostics + if (sourceFile.isDeclarationFile) { + continue; + } + + // Only request Angular template diagnostics for affected files to avoid + // overhead of template diagnostics for unchanged files. + if (affectedFiles.has(sourceFile)) { + const angularDiagnostics = profileSync( + 'NG_DIAGNOSTICS_TEMPLATE', + () => + angularCompiler.getDiagnosticsForFile(sourceFile, templateDiagnosticsOptimization), + true, + ); + diagnosticCache.set(sourceFile, angularDiagnostics); yield* angularDiagnostics; + } else { + const angularDiagnostics = diagnosticCache.get(sourceFile); + if (angularDiagnostics) { + yield* angularDiagnostics; + } + } + } + } finally { + // Angular's template typechecker lazily creates `.ngtypecheck.ts` shims in a new `ts.Program` + // during `getDiagnosticsForFile`. Sync the builder program with `angularCompiler.getCurrentProgram()` + // so the next incremental build does not treat all `.ngtypecheck.ts` files as newly added files. + const currentTsProgram = angularCompiler.getCurrentProgram(); + if (currentTsProgram !== typeScriptProgram.getProgram()) { + ensureSourceFileVersions(currentTsProgram, typeScriptProgram); + this.#state.typeScriptProgram = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + currentTsProgram, + this.#state.compilerHost, + typeScriptProgram, + ); + + const baseEmit = currentTsProgram.emit; + currentTsProgram.emit = () => ({ emitSkipped: true, diagnostics: [] }); + try { + while ( + this.#state.typeScriptProgram.getSemanticDiagnosticsOfNextAffectedFile( + undefined, + () => true, + ) + ) { + /* empty */ + } + } finally { + currentTsProgram.emit = baseEmit; } } } @@ -419,7 +521,7 @@ export class AotCompilation extends TypeScriptCompilation { if ( angularCompiler.incrementalCompilation.safeToSkipEmit(sourceFile) && - !affectedFiles.has(sourceFile) + (!useTypeScriptTranspilation || !affectedFiles.has(sourceFile)) ) { continue; } @@ -483,40 +585,141 @@ export class AotCompilation extends TypeScriptCompilation { function findAffectedFiles( builder: ts.EmitAndSemanticDiagnosticsBuilderProgram, - { ignoreForDiagnostics }: ng.NgtscProgram['compiler'], + { ignoreForDiagnostics, incrementalCompilation }: ng.NgtscProgram['compiler'], includeTTC: boolean, + oldBuilder: ts.EmitAndSemanticDiagnosticsBuilderProgram | undefined, + modifiedFiles: ReadonlySet | undefined, + filesWithoutDtsSignature: Set, ): Set { const affectedFiles = new Set(); - // eslint-disable-next-line no-constant-condition - while (true) { - const result = builder.getSemanticDiagnosticsOfNextAffectedFile(undefined, (sourceFile) => { - // If the affected file is a TTC shim, add the shim's original source file. - // This ensures that changes that affect TTC are typechecked even when the changes - // are otherwise unrelated from a TS perspective and do not result in Ivy codegen changes. - // For example, changing @Input property types of a directive used in another component's - // template. - // A TTC shim is a file that has been ignored for diagnostics and has a filename ending in `.ngtypecheck.ts`. - if (ignoreForDiagnostics.has(sourceFile) && sourceFile.fileName.endsWith('.ngtypecheck.ts')) { - // 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` - const originalFilename = sourceFile.fileName.slice(0, -15) + '.ts'; - const originalSourceFile = builder.getSourceFile(originalFilename); - if (originalSourceFile) { - affectedFiles.add(originalSourceFile); + // 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. + const compilerOptions = builder.getCompilerOptions(); + const prevIsolatedModules = compilerOptions.isolatedModules; + const prevVerbatimModuleSyntax = compilerOptions.verbatimModuleSyntax; + compilerOptions.isolatedModules = false; + compilerOptions.verbatimModuleSyntax = false; + + const program = builder.getProgram(); + const baseEmit = program.emit; + const oldProgram = oldBuilder?.getProgram(); + program.emit = ( + targetSourceFile, + writeFile, + cancellationToken, + emitOnly, + customTransformers, + ...rest + ) => { + if (targetSourceFile) { + if (isNgTypeCheckFile(targetSourceFile.fileName)) { + return { emitSkipped: true, diagnostics: [] }; + } + + if (oldProgram && filesWithoutDtsSignature.has(targetSourceFile.fileName)) { + const oldSourceFile = !modifiedFiles?.has(targetSourceFile.fileName) + ? oldProgram.getSourceFile(targetSourceFile.fileName) + : undefined; + if (oldSourceFile) { + let oldDtsText: string | undefined; + let oldHasDiagnostics = false; + oldProgram.emit( + oldSourceFile, + (_fileName, text, _bom, _onError, _sourceFiles, data) => { + oldDtsText = text; + oldHasDiagnostics = !!(data as { diagnostics?: unknown[] } | undefined)?.diagnostics + ?.length; + }, + cancellationToken, + emitOnly, + customTransformers, + ...(rest as []), + ); + + return baseEmit.call( + program, + targetSourceFile, + (fileName, text, writeByteOrderMark, onError, sourceFiles, data) => { + const newHasDiagnostics = !!(data as { diagnostics?: unknown[] } | undefined) + ?.diagnostics?.length; + if (text !== oldDtsText || newHasDiagnostics !== oldHasDiagnostics) { + filesWithoutDtsSignature.delete(targetSourceFile.fileName); + writeFile?.(fileName, text, writeByteOrderMark, onError, sourceFiles, data); + } + }, + cancellationToken, + emitOnly, + customTransformers, + ...(rest as []), + ); } - return true; + filesWithoutDtsSignature.delete(targetSourceFile.fileName); } + } + + return baseEmit.call( + program, + targetSourceFile, + writeFile, + cancellationToken, + emitOnly, + customTransformers, + ...(rest as []), + ); + }; + + try { + while ( + builder.getSemanticDiagnosticsOfNextAffectedFile(undefined, (sourceFile) => { + if (ignoreForDiagnostics.has(sourceFile)) { + // If the affected file is a TTC shim, add the shim's original source file. + // This ensures that changes that affect TTC are typechecked even when the changes + // are otherwise unrelated from a TS perspective and do not result in Ivy codegen changes. + // For example, changing @Input property types of a directive used in another component's + // template. + // 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` + const originalFilename = getOriginalFilename(sourceFile.fileName); + const originalSourceFile = builder.getSourceFile(originalFilename); + if (originalSourceFile) { + affectedFiles.add(originalSourceFile); + } + } + } else { + affectedFiles.add(sourceFile); + } - return false; - }); + return true; + }) + ) { + /* empty */ + } + } finally { + program.emit = baseEmit; + compilerOptions.isolatedModules = prevIsolatedModules; + compilerOptions.verbatimModuleSyntax = prevVerbatimModuleSyntax; + } - if (!result) { - break; + // 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). + for (const sourceFile of builder.getSourceFiles()) { + if (sourceFile.isDeclarationFile || ignoreForDiagnostics.has(sourceFile)) { + continue; } - affectedFiles.add(result.affected as ts.SourceFile); + if ( + !incrementalCompilation.safeToSkipEmit(sourceFile) || + incrementalCompilation.priorTypeCheckingResultsFor(sourceFile) === null + ) { + affectedFiles.add(sourceFile); + } } // Add all files with associated template type checking files. @@ -525,10 +728,10 @@ function findAffectedFiles( // info is present. if (includeTTC) { for (const sourceFile of builder.getSourceFiles()) { - if (ignoreForDiagnostics.has(sourceFile) && sourceFile.fileName.endsWith('.ngtypecheck.ts')) { + 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` - const originalFilename = sourceFile.fileName.slice(0, -15) + '.ts'; + const originalFilename = getOriginalFilename(sourceFile.fileName); const originalSourceFile = builder.getSourceFile(originalFilename); if (originalSourceFile) { affectedFiles.add(originalSourceFile); @@ -539,3 +742,79 @@ function findAffectedFiles( return affectedFiles; } + +/** + * Ensures `.d.ts` shape signatures are computed on the previous `BuilderProgram` for modified + * files before the next `BuilderProgram` is created. + * + * On the initial build (`oldProgram === undefined`), TypeScript initializes each file's shape + * signature to its `.ts` source version hash rather than emitting `.d.ts` declarations for every + * file. When a file is modified on a watch rebuild, TypeScript computes its new `.d.ts` signature + * and compares it against the previous program's stored signature. Emitting the `.d.ts` signature + * on `oldProgram` for the modified file replaces its initial `.ts` version hash with its true + * `.d.ts` signature prior to diffing, avoiding false-positive `.d.ts` cascades across importers. + */ +function populatePreviousDtsSignatures( + oldProgram: ts.EmitAndSemanticDiagnosticsBuilderProgram, + modifiedFiles: ReadonlySet, + filesWithoutDtsSignature: Set, +): void { + const filesToUpdate: ts.SourceFile[] = []; + for (const modifiedFile of modifiedFiles) { + if (!filesWithoutDtsSignature.delete(modifiedFile)) { + continue; + } + + const sf = oldProgram.getSourceFile(modifiedFile); + if (sf && !sf.isDeclarationFile) { + filesToUpdate.push(sf); + } + } + + if (filesToUpdate.length === 0) { + return; + } + + const compilerOptions = oldProgram.getCompilerOptions(); + const prevDeclaration = compilerOptions.declaration; + const prevComposite = compilerOptions.composite; + const prevNoEmit = compilerOptions.noEmit; + const prevNoEmitOnError = compilerOptions.noEmitOnError; + + compilerOptions.declaration = true; + compilerOptions.composite = false; + compilerOptions.noEmit = false; + compilerOptions.noEmitOnError = false; + + const program = oldProgram.getProgram(); + const baseEmit = program.emit as ( + targetSourceFile?: ts.SourceFile, + writeFile?: ts.WriteFileCallback, + cancellationToken?: ts.CancellationToken, + emitOnlyDtsFiles?: boolean | number, + customTransformers?: ts.CustomTransformers, + forceDtsEmit?: boolean, + ) => ts.EmitResult; + program.emit = (targetSourceFile, writeFile, cancellationToken) => + baseEmit.call( + program, + targetSourceFile, + writeFile, + cancellationToken, + 2, // ts.EmitOnly.BuilderSignature + undefined, + true, // forceDtsEmit + ); + + try { + for (const sf of filesToUpdate) { + oldProgram.emit(sf, () => {}, undefined, true); + } + } finally { + program.emit = baseEmit; + compilerOptions.declaration = prevDeclaration; + compilerOptions.composite = prevComposite; + compilerOptions.noEmit = prevNoEmit; + compilerOptions.noEmitOnError = prevNoEmitOnError; + } +} diff --git a/packages/angular/build/src/tools/esbuild/bundler-context.ts b/packages/angular/build/src/tools/esbuild/bundler-context.ts index a4228caa5782..202ab514cbfe 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-context.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-context.ts @@ -328,7 +328,11 @@ export class BundlerContext { const initialFiles = new Map(); for (const outputFile of result.outputFiles) { // Entries in the metafile are relative to the `absWorkingDir` option which is set to the workspaceRoot - const relativeFilePath = relative(this.workspaceRoot, outputFile.path); + // `outputFile.path` may already be relative if the `OutputFile` instance was cached in `additionalResults` + // across an incremental rebuild. + const relativeFilePath = isAbsolute(outputFile.path) + ? relative(this.workspaceRoot, outputFile.path) + : outputFile.path; const entryPoint = result.metafile.outputs[relativeFilePath]?.entryPoint; outputFile.path = relativeFilePath;