diff --git a/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts b/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts index d3d4e19a30f9..4ba7c9eaa051 100644 --- a/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts +++ b/packages/angular/build/src/builders/unit-test/tests/options/include_spec.ts @@ -110,5 +110,172 @@ describeBuilder(execute, UNIT_TEST_BUILDER_INFO, (harness) => { const { result } = await harness.executeOnce(); expect(result?.success).toBeTrue(); }); + + it('should not compile unimported application files when testing standalone code', async () => { + await harness.writeFiles({ + 'src/tsconfig.spec.json': JSON.stringify({ + extends: '../tsconfig.json', + compilerOptions: { + outDir: '../out-tsc/spec', + types: ['jasmine', '@angular/localize'], + }, + files: ['polyfills.ts'], + include: ['**/*.ts', '**/*.d.ts'], + }), + 'src/app/services/test.service.spec.ts': ` + describe('TestService', () => { + it('should succeed', () => { + expect(true).toBe(true); + }); + });`, + 'src/app/unused-broken.ts': ` + // This unimported file has a TypeScript error and should not be compiled for standalone tests + export const invalidNumber: number = 'not a number'; + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app/services/test.service.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should resolve NgModule scope when testing a non-standalone component without importing the NgModule', async () => { + await harness.writeFiles({ + 'src/tsconfig.spec.json': JSON.stringify({ + extends: '../tsconfig.json', + compilerOptions: { + outDir: '../out-tsc/spec', + types: ['jasmine', '@angular/localize'], + }, + files: ['polyfills.ts'], + include: ['**/*.ts', '**/*.d.ts'], + }), + 'src/app/app.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + template: '

Hello, {{ title }}

', + standalone: false, + }) + export class App { + title = 'test'; + } + `, + 'src/app/app-module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { RouterModule } from '@angular/router'; + import { App } from './app'; + + @NgModule({ + declarations: [App], + imports: [BrowserModule, RouterModule.forRoot([])], + bootstrap: [App], + }) + export class AppModule {} + `, + 'src/app/app.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { RouterModule } from '@angular/router'; + import { App } from './app'; + + describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterModule.forRoot([])], + declarations: [App], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + expect(fixture.componentInstance).toBeTruthy(); + }); + }); + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app/app.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('should not compile unimported files when a non-standalone component NgModule is imported by the test', async () => { + await harness.writeFiles({ + 'src/tsconfig.spec.json': JSON.stringify({ + extends: '../tsconfig.json', + compilerOptions: { + outDir: '../out-tsc/spec', + types: ['jasmine', '@angular/localize'], + }, + files: ['polyfills.ts'], + include: ['**/*.ts', '**/*.d.ts'], + }), + 'src/app/app.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-root', + template: '

Hello, {{ title }}

', + standalone: false, + }) + export class App { + title = 'test'; + } + `, + 'src/app/app-module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { RouterModule } from '@angular/router'; + import { App } from './app'; + + @NgModule({ + declarations: [App], + imports: [BrowserModule, RouterModule.forRoot([])], + bootstrap: [App], + }) + export class AppModule {} + `, + 'src/app/app.spec.ts': ` + import { TestBed } from '@angular/core/testing'; + import { App } from './app'; + import { AppModule } from './app-module'; + + describe('App', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppModule], + }).compileComponents(); + }); + + it('should create the app', () => { + const fixture = TestBed.createComponent(App); + expect(fixture.componentInstance).toBeTruthy(); + }); + }); + `, + 'src/app/unused-broken.ts': ` + // This unimported file has a TypeScript error and should not be compiled when AppModule is already imported + export const invalidNumber: number = 'not a number'; + `, + }); + + harness.useTarget('test', { + ...BASE_OPTIONS, + include: ['src/app/app.spec.ts'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); }); }); diff --git a/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts b/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts index 2d001dfeff7c..3c09b87f69d7 100644 --- a/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts +++ b/packages/angular/build/src/tools/angular/compilation/angular-compilation_spec.ts @@ -91,8 +91,12 @@ describe('AngularCompilation', () => { return this.sourceFiles; } - public async testLoadConfiguration(tsconfig: string, overrides?: CompilerOptionOverrides) { - return this.loadConfiguration(tsconfig, overrides); + public async testLoadConfiguration( + tsconfig: string, + overrides?: CompilerOptionOverrides, + buildType?: 'application' | 'library', + ) { + return this.loadConfiguration(tsconfig, overrides, buildType); } } @@ -162,12 +166,83 @@ describe('AngularCompilation', () => { jasmine.any(Map), ); expect(result.rootNames).toEqual(['/src/main.ts']); + expect(result.fallbackRootNames).toBeUndefined(); expect(result.compilerOptions.target).toBe(ts.ScriptTarget.ES2022); expect(result.compilerOptions.inlineSources).toBe(true); expect(result.warnings.length).toBeGreaterThan(0); expect(result.tsConfigFiles).toEqual(['tsconfig.json']); }); + it('filters rootNames to rootFiles while retaining ambient declaration files and setting fallbackRootNames', async () => { + const compilation = new MockTypeScriptCompilation(); + const mockReadConfig = jasmine.createSpy('readConfiguration').and.returnValue({ + options: { target: ts.ScriptTarget.ES2022 }, + rootNames: [ + '/src/app/included.spec.ts', + '/src/app/excluded.spec.ts', + '/src/app/app-module.ts', + '/src/env.d.ts', + '/src/types.d.mts', + '/src/legacy.d.cts', + ], + errors: [], + }); + spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({ + readConfiguration: mockReadConfig, + } as unknown as typeof import('@angular/compiler-cli')); + + const result = await compilation.testLoadConfiguration('tsconfig.spec.json', { + rootFiles: ['/src/app/included.spec.ts'], + }); + + expect(result.rootNames).toEqual([ + '/src/app/included.spec.ts', + '/src/env.d.ts', + '/src/types.d.mts', + '/src/legacy.d.cts', + ]); + expect(result.fallbackRootNames).toEqual([ + '/src/app/included.spec.ts', + '/src/app/excluded.spec.ts', + '/src/app/app-module.ts', + '/src/env.d.ts', + '/src/types.d.mts', + '/src/legacy.d.cts', + ]); + }); + + it('omits fallbackRootNames when buildType is "library" or when rootFiles covers all tsconfig files', async () => { + const compilation = new MockTypeScriptCompilation(); + const mockReadConfig = jasmine.createSpy('readConfiguration').and.returnValue({ + options: { target: ts.ScriptTarget.ES2022 }, + rootNames: ['/src/public-api.ts', '/src/internal.ts', '/src/env.d.ts'], + errors: [], + }); + spyOn(TypeScriptCompilation, 'loadCompilerCli').and.resolveTo({ + readConfiguration: mockReadConfig, + } as unknown as typeof import('@angular/compiler-cli')); + + const libraryResult = await compilation.testLoadConfiguration( + 'tsconfig.lib.json', + { rootFiles: ['/src/public-api.ts'] }, + 'library', + ); + expect(libraryResult.rootNames).toEqual(['/src/public-api.ts', '/src/env.d.ts']); + expect(libraryResult.fallbackRootNames).toBeUndefined(); + + const fullSetResult = await compilation.testLoadConfiguration( + 'tsconfig.app.json', + { rootFiles: ['/src/public-api.ts', '/src/internal.ts'] }, + 'application', + ); + expect(fullSetResult.rootNames).toEqual([ + '/src/public-api.ts', + '/src/internal.ts', + '/src/env.d.ts', + ]); + expect(fullSetResult.fallbackRootNames).toBeUndefined(); + }); + it('passes extendedConfigCache to readConfiguration and invalidates entries on config change', async () => { const compilation = new MockTypeScriptCompilation(); let passedCache: Map | undefined; 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..4ca1480f646c 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -72,6 +72,7 @@ class AngularCompilationState { public readonly useTypeScriptTranspilation: boolean, public readonly diagnosticCache = new WeakMap(), public readonly filesWithoutDtsSignature = new Set(), + public readonly useFallbackRootNames = false, ) {} get angularCompiler() { @@ -100,6 +101,7 @@ export class AotCompilation extends TypeScriptCompilation { const { compilerOptions, rootNames, + fallbackRootNames, errors: configurationDiagnostics, warnings, tsConfigFiles, @@ -165,13 +167,48 @@ export class AotCompilation extends TypeScriptCompilation { this.sourceFiles, ); + // If a previous watch rebuild already fell back to the full tsconfig root file set, + // start directly with fallbackRootNames to avoid repeating the two-step check. + let useFallbackRootNames = this.#state?.useFallbackRootNames ?? false; + // Create the Angular specific program that contains the Angular compiler - const angularProgram = profileSync( + let angularProgram = profileSync( 'NG_CREATE_PROGRAM', - () => new NgtscProgram(rootNames, compilerOptions, host, this.#state?.angularProgram), + () => + new NgtscProgram( + (useFallbackRootNames && fallbackRootNames) || rootNames, + compilerOptions, + host, + this.#state?.angularProgram, + ), ); - const angularCompiler = angularProgram.compiler; - const angularTypeScriptProgram = angularProgram.getTsProgram(); + let angularCompiler = angularProgram.compiler; + + await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync()); + + let angularTypeScriptProgram = angularProgram.getTsProgram(); + + // When rootFiles filtering is active and a non-standalone component without a resolved + // NgModule scope is encountered, its declaring NgModule may not be directly imported by + // the test entry points. Fall back to the full tsconfig root file set if it contains + // additional uncompiled files so the Angular compiler can discover all NgModules and + // resolve component compilation scopes. + if ( + !useFallbackRootNames && + fallbackRootNames?.some((file) => !angularTypeScriptProgram.getSourceFile(file)) && + hasUnscopedComponent(angularCompiler) + ) { + useFallbackRootNames = true; + angularProgram = profileSync( + 'NG_CREATE_PROGRAM_FALLBACK', + () => new NgtscProgram(fallbackRootNames, compilerOptions, host, angularProgram), + ); + angularCompiler = angularProgram.compiler; + + await profileAsync('NG_ANALYZE_PROGRAM_FALLBACK', () => angularCompiler.analyzeAsync()); + angularTypeScriptProgram = angularProgram.getTsProgram(); + } + ensureSourceFileVersions(angularTypeScriptProgram); let oldProgram = this.#state?.typeScriptProgram; @@ -200,8 +237,6 @@ export class AotCompilation extends TypeScriptCompilation { } } - await profileAsync('NG_ANALYZE_PROGRAM', () => angularCompiler.analyzeAsync()); - let templateUpdates; if (useHmr && hostOptions.modifiedFiles && this.#state) { const componentNodes = collectHmrCandidates( @@ -315,6 +350,7 @@ export class AotCompilation extends TypeScriptCompilation { useTypeScriptTranspilation, this.#state?.diagnosticCache, filesWithoutDtsSignature, + useFallbackRootNames, ); return { @@ -810,3 +846,57 @@ function populatePreviousDtsSignatures( compilerOptions.noEmitOnError = prevNoEmitOnError; } } + +/** + * Checks whether the analyzed Angular program contains any non-standalone `@Component` + * declarations in user source files that lack an owning `NgModule` scope. + */ +function hasUnscopedComponent(angularCompiler: ng.NgCompiler): boolean { + // `incrementalCompilation.state` is typed as the `IncrementalState` union + // (`AnalyzedIncrementalState | DeltaIncrementalState | FreshIncrementalState`), where only + // `AnalyzedIncrementalState` (populated once `analyzeAsync()` completes) contains + // `priorAnalysis`. Because the `IncrementalStateKind` discriminant enum is not re-exported + // from `@angular/compiler-cli`, narrow the union via `'priorAnalysis' in state`. + const state = angularCompiler.incrementalCompilation.state; + if (!('priorAnalysis' in state)) { + return false; + } + + let templateTypeChecker: ReturnType | undefined; + + for (const [sourceFile, records] of state.priorAnalysis) { + if (records.length === 0 || angularCompiler.ignoreForEmit.has(sourceFile)) { + continue; + } + + for (const { node } of records) { + if (!ts.isClassDeclaration(node)) { + continue; + } + + const meta = angularCompiler.getMeta(node); + if (meta && 'isComponent' in meta && meta.isComponent && !meta.isStandalone) { + if (!templateTypeChecker) { + // `NgtscProgram` initializes `NgCompiler` with `enableTemplateTypeChecker = false`, which + // guards the public `getTemplateTypeChecker()` getter. Temporarily enable the flag after + // `analyzeAsync()` has already constructed `TemplateTypeCheckerImpl` so we can query + // `getOwningNgModule()` without altering `typeCheckingConfig.enableTemplateTypeChecker`. + const compiler = angularCompiler as { enableTemplateTypeChecker: boolean }; + const previous = compiler.enableTemplateTypeChecker; + try { + compiler.enableTemplateTypeChecker = true; + templateTypeChecker = angularCompiler.getTemplateTypeChecker(); + } finally { + compiler.enableTemplateTypeChecker = previous; + } + } + + if (templateTypeChecker.getOwningNgModule(node) === null) { + return true; + } + } + } + } + + return false; +} diff --git a/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts index eba7a10a5cb4..6601500fbcc9 100644 --- a/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/typescript-compilation.ts @@ -18,6 +18,7 @@ import { convertTypeScriptDiagnostic } from './diagnostics'; export interface TransformedConfiguration { compilerOptions: ng.CompilerOptions; rootNames: string[]; + fallbackRootNames?: string[]; errors: ts.Diagnostic[]; warnings: PartialMessage[]; tsConfigFiles: readonly string[]; @@ -75,16 +76,37 @@ export abstract class TypeScriptCompilation extends AngularCompilation { const tsConfigFiles = [toPosixPath(tsconfig), ...this.#extendedConfigCache.keys()]; let rootNames = originalRootNames; + let fallbackRootNames: string[] | undefined; if (compilerOptionOverrides?.rootFiles?.length) { + // When explicit rootFiles are provided (e.g., filtered unit test entry points), restrict + // the initial program to only those entry points so TypeScript resolves only their + // transitive imports and skips unrelated or non-included test/application files. const rootFilesSet = new Set( compilerOptionOverrides.rootFiles.map((file) => canonicalizePath(toPosixPath(file))), ); + const fallbackFilesSet = new Set(rootFilesSet); for (const file of originalRootNames) { + const canonicalFile = canonicalizePath(toPosixPath(file)); + // Ambient declaration files (.d.ts) are not imported directly, so they must always be + // retained from the tsconfig to preserve global type definitions. if (/\.d\.[cm]?ts$/i.test(file)) { - rootFilesSet.add(canonicalizePath(toPosixPath(file))); + rootFilesSet.add(canonicalFile); } + fallbackFilesSet.add(canonicalFile); } rootNames = [...rootFilesSet]; + // For application builds (including unit tests), preserve the full set of tsconfig root + // files as a fallback in case the AOT compilation encounters a non-standalone component + // whose declaring NgModule is not directly imported by the entry points. Note that when a + // test tsconfig uses broad globs (e.g., `src/**/*.ts`), this fallback set will also include + // non-included spec files; this is an acceptable trade-off to keep compilation agnostic of + // test-file naming conventions and only occurs for legacy non-standalone components lacking + // explicit NgModule imports. Library builds are excluded because all public NgModules must + // be reachable from the library entry points and should never resolve compilation scope from + // unexported tsconfig files. + if (buildType === 'application' && fallbackFilesSet.size > rootFilesSet.size) { + fallbackRootNames = [...fallbackFilesSet]; + } } const { compilerOptions, warnings } = transformCompilerOptions( @@ -98,6 +120,7 @@ export abstract class TypeScriptCompilation extends AngularCompilation { return { compilerOptions, rootNames, + fallbackRootNames, errors, warnings, tsConfigFiles,