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,