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 7cefe8890ecc..cd4e1cdae7dd 100644 --- a/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts +++ b/packages/angular/build/src/tools/angular/compilation/aot-compilation.ts @@ -10,6 +10,7 @@ import type * as ng from '@angular/compiler-cli'; import assert from 'node:assert'; import { relative } from 'node:path'; import ts from 'typescript'; +import { useTypeChecking } from '../../../utils/environment-options'; import { profileAsync, profileSync } from '../../esbuild/profiling'; import { AngularHostOptions, @@ -181,9 +182,19 @@ export class AotCompilation extends AngularCompilation { } } - const affectedFiles = profileSync('NG_FIND_AFFECTED', () => - findAffectedFiles(typeScriptProgram, angularCompiler, usingBuildInfo), - ); + // The affected files walk runs a full semantic type-check as a side effect + // (via `getSemanticDiagnosticsOfNextAffectedFile`). Its result is only consumed to + // scope Angular template diagnostics and to force re-emit of TS-affected files in the + // slow TypeScript emit path. When type-checking is disabled, template/semantic + // diagnostics are already suppressed at the consumption layer (see the compiler-plugin + // `diagnoseFiles` mask), and in the isolatedModules fast path emit is per-file, so the + // set is never read. Skip the walk in that case to avoid a discarded semantic pass. + const affectedFiles = + useTypeChecking || useTypeScriptTranspilation + ? profileSync('NG_FIND_AFFECTED', () => + findAffectedFiles(typeScriptProgram, angularCompiler, usingBuildInfo), + ) + : new Set(); const componentResourcesDependencies = new Map(); diff --git a/tests/e2e/tests/build/type-check-disabled-emit.ts b/tests/e2e/tests/build/type-check-disabled-emit.ts new file mode 100644 index 000000000000..e2b831e1360e --- /dev/null +++ b/tests/e2e/tests/build/type-check-disabled-emit.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import { readdir } from 'node:fs/promises'; +import { readFile, writeFile } from '../../utils/fs'; +import { execWithEnv, ng } from '../../utils/process'; +import { updateJsonFile } from '../../utils/project'; + +const OUTPUT_DIR = 'dist/test-project/browser'; + +async function readEmittedJs(): Promise> { + const contents = new Map(); + for (const file of (await readdir(OUTPUT_DIR)).sort()) { + if (file.endsWith('.js')) { + contents.set(file, await readFile(`${OUTPUT_DIR}/${file}`)); + } + } + + return contents; +} + +/** + * Verifies that disabling type checking (`NG_BUILD_TYPE_CHECK=0`) does not change the + * emitted JavaScript. When type checking is disabled, the AOT compilation skips the + * semantic "affected files" walk in the isolatedModules fast path (its only remaining + * consumers are diagnostics, which are already suppressed). The emitted output must remain + * byte-for-byte identical to a type-checked build. + */ +export default async function () { + // Disable the persistent disk cache so both builds are cold and independent, keeping the + // comparison free of incremental state carried over between the two runs. + await updateJsonFile('angular.json', (config) => { + config.cli ??= {}; + config.cli.cache = { enabled: false }; + }); + + // A type-only cross-file dependency. The interface is used solely as a type (and in the + // template via `user.name`), so it is fully erased from the emitted JS. This is exactly the + // kind of cross-file type relationship that the affected-file walk would type-check. + await writeFile( + 'src/app/user.model.ts', + 'export interface User {\n id: number;\n name: string;\n}\n', + ); + + // Root component with both a template and the type-only dependency above. + await writeFile( + 'src/app/app.ts', + [ + `import { Component, signal } from '@angular/core';`, + `import { RouterOutlet } from '@angular/router';`, + `import type { User } from './user.model';`, + ``, + `@Component({`, + ` selector: 'app-root',`, + ` imports: [RouterOutlet],`, + ` template: '

Hello, {{ user.name }}

',`, + `})`, + `export class App {`, + ` protected readonly title = signal('test-project');`, + ` protected readonly user: User = { id: 1, name: 'Angular' };`, + `}`, + ``, + ].join('\n'), + ); + + // Baseline: type checking enabled (default). The affected-file walk runs. + await ng('build', '--configuration=development', '--output-hashing=none'); + const withTypeChecking = await readEmittedJs(); + + // Sanity check that the component (and therefore real emit) is present in the baseline, + // so an all-empty comparison cannot pass trivially. + assert.ok( + [...withTypeChecking.values()].some((contents) => contents.includes('Angular')), + 'Expected the baseline build to emit the component.', + ); + + // Type checking disabled: the affected-file walk is skipped in the isolatedModules fast + // path. The emitted output must be byte-for-byte identical to the baseline. + await execWithEnv('ng', ['build', '--configuration=development', '--output-hashing=none'], { + ...process.env, + NG_BUILD_TYPE_CHECK: '0', + }); + const withoutTypeChecking = await readEmittedJs(); + + assert.deepStrictEqual( + [...withoutTypeChecking.keys()], + [...withTypeChecking.keys()], + 'Disabling type checking must not change the set of emitted JS files.', + ); + + for (const [file, baseline] of withTypeChecking) { + assert.strictEqual( + withoutTypeChecking.get(file), + baseline, + `Emitted output for "${file}" changed when type checking was disabled.`, + ); + } +}