Skip to content

Commit 561d9ec

Browse files
committed
perf(@angular/build): skip semantic affected-file walk when type checking is disabled
When type checking is disabled (`NG_BUILD_TYPE_CHECK=0`), the AOT build still ran a full semantic type-check as a side effect of computing the affected-files set, then discarded the result. `useTypeChecking` already suppresses the *consumption* of semantic diagnostics via the compiler-plugin `diagnoseFiles` mask, but the *production* -- the `findAffectedFiles` -> `getSemanticDiagnosticsOfNextAffectedFile` walk -- was never gated, so disabling type checking still paid for a full semantic pass whose results were never read. The affected set has three consumers: Angular template diagnostics (behind the `semantic` flag, already suppressed when type checking is off), the single-file vs whole-program diagnostics optimization (also diagnostics only), and the emit gate in `emitAffectedFiles()`. The emit gate only matters in the slow TypeScript emit path where cross-file types can change a file's JS; in the isolatedModules fast path emit is per-file/type-erased, so an empty set is safe. Gate the walk so it only runs when type checking is on OR the slow emit path is used, and skip it in the (type-check-off + fast-path) combination.
1 parent 6b990bf commit 561d9ec

2 files changed

Lines changed: 110 additions & 3 deletions

File tree

packages/angular/build/src/tools/angular/compilation/aot-compilation.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type * as ng from '@angular/compiler-cli';
1010
import assert from 'node:assert';
1111
import { relative } from 'node:path';
1212
import ts from 'typescript';
13+
import { useTypeChecking } from '../../../utils/environment-options';
1314
import { profileAsync, profileSync } from '../../esbuild/profiling';
1415
import {
1516
AngularHostOptions,
@@ -181,9 +182,19 @@ export class AotCompilation extends AngularCompilation {
181182
}
182183
}
183184

184-
const affectedFiles = profileSync('NG_FIND_AFFECTED', () =>
185-
findAffectedFiles(typeScriptProgram, angularCompiler, usingBuildInfo),
186-
);
185+
// The affected files walk runs a full semantic type-check as a side effect
186+
// (via `getSemanticDiagnosticsOfNextAffectedFile`). Its result is only consumed to
187+
// scope Angular template diagnostics and to force re-emit of TS-affected files in the
188+
// slow TypeScript emit path. When type-checking is disabled, template/semantic
189+
// diagnostics are already suppressed at the consumption layer (see the compiler-plugin
190+
// `diagnoseFiles` mask), and in the isolatedModules fast path emit is per-file, so the
191+
// set is never read. Skip the walk in that case to avoid a discarded semantic pass.
192+
const affectedFiles =
193+
useTypeChecking || useTypeScriptTranspilation
194+
? profileSync('NG_FIND_AFFECTED', () =>
195+
findAffectedFiles(typeScriptProgram, angularCompiler, usingBuildInfo),
196+
)
197+
: new Set<ts.SourceFile>();
187198

188199
const componentResourcesDependencies = new Map<string, string[]>();
189200

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import assert from 'node:assert/strict';
2+
import { readdir } from 'node:fs/promises';
3+
import { readFile, writeFile } from '../../utils/fs';
4+
import { execWithEnv, ng } from '../../utils/process';
5+
import { updateJsonFile } from '../../utils/project';
6+
7+
const OUTPUT_DIR = 'dist/test-project/browser';
8+
9+
async function readEmittedJs(): Promise<Map<string, string>> {
10+
const contents = new Map<string, string>();
11+
for (const file of (await readdir(OUTPUT_DIR)).sort()) {
12+
if (file.endsWith('.js')) {
13+
contents.set(file, await readFile(`${OUTPUT_DIR}/${file}`));
14+
}
15+
}
16+
17+
return contents;
18+
}
19+
20+
/**
21+
* Verifies that disabling type checking (`NG_BUILD_TYPE_CHECK=0`) does not change the
22+
* emitted JavaScript. When type checking is disabled, the AOT compilation skips the
23+
* semantic "affected files" walk in the isolatedModules fast path (its only remaining
24+
* consumers are diagnostics, which are already suppressed). The emitted output must remain
25+
* byte-for-byte identical to a type-checked build.
26+
*/
27+
export default async function () {
28+
// Disable the persistent disk cache so both builds are cold and independent, keeping the
29+
// comparison free of incremental state carried over between the two runs.
30+
await updateJsonFile('angular.json', (config) => {
31+
config.cli ??= {};
32+
config.cli.cache = { enabled: false };
33+
});
34+
35+
// A type-only cross-file dependency. The interface is used solely as a type (and in the
36+
// template via `user.name`), so it is fully erased from the emitted JS. This is exactly the
37+
// kind of cross-file type relationship that the affected-file walk would type-check.
38+
await writeFile(
39+
'src/app/user.model.ts',
40+
'export interface User {\n id: number;\n name: string;\n}\n',
41+
);
42+
43+
// Root component with both a template and the type-only dependency above.
44+
await writeFile(
45+
'src/app/app.ts',
46+
[
47+
`import { Component, signal } from '@angular/core';`,
48+
`import { RouterOutlet } from '@angular/router';`,
49+
`import type { User } from './user.model';`,
50+
``,
51+
`@Component({`,
52+
` selector: 'app-root',`,
53+
` imports: [RouterOutlet],`,
54+
` template: '<h1>Hello, {{ user.name }}</h1><router-outlet />',`,
55+
`})`,
56+
`export class App {`,
57+
` protected readonly title = signal('test-project');`,
58+
` protected readonly user: User = { id: 1, name: 'Angular' };`,
59+
`}`,
60+
``,
61+
].join('\n'),
62+
);
63+
64+
// Baseline: type checking enabled (default). The affected-file walk runs.
65+
await ng('build', '--configuration=development', '--output-hashing=none');
66+
const withTypeChecking = await readEmittedJs();
67+
68+
// Sanity check that the component (and therefore real emit) is present in the baseline,
69+
// so an all-empty comparison cannot pass trivially.
70+
assert.ok(
71+
[...withTypeChecking.values()].some((contents) => contents.includes('Angular')),
72+
'Expected the baseline build to emit the component.',
73+
);
74+
75+
// Type checking disabled: the affected-file walk is skipped in the isolatedModules fast
76+
// path. The emitted output must be byte-for-byte identical to the baseline.
77+
await execWithEnv('ng', ['build', '--configuration=development', '--output-hashing=none'], {
78+
...process.env,
79+
NG_BUILD_TYPE_CHECK: '0',
80+
});
81+
const withoutTypeChecking = await readEmittedJs();
82+
83+
assert.deepStrictEqual(
84+
[...withoutTypeChecking.keys()],
85+
[...withTypeChecking.keys()],
86+
'Disabling type checking must not change the set of emitted JS files.',
87+
);
88+
89+
for (const [file, baseline] of withTypeChecking) {
90+
assert.strictEqual(
91+
withoutTypeChecking.get(file),
92+
baseline,
93+
`Emitted output for "${file}" changed when type checking was disabled.`,
94+
);
95+
}
96+
}

0 commit comments

Comments
 (0)