Repository navigation
ng build --watch: first edit of each file rebuilds its entire dependent cone — TS shape signatures get overwritten with text version hashes during watch startup #33619
Description
Command
ng build --watch (application builder, @angular/build:application)
Is this a regression?
Partially. The behavior exists at least since @angular-devkit/build-angular:browser-esbuild
17.x + TS 5.4, but the cost per affected file roughly doubled with @angular/build 21 + TS 5.9
(emit + semantic diagnostics of the cone are re-done, where TS 5.4 emitted 1 file and served
diagnostics from cache).
Description
In a large single-project workspace (~7,800 TS files, deep barrel imports), with the build
cache enabled:
- The first edit of a file after starting the watch triggers a near-full recompilation:
~1,150 files marked affected by TypeScript, all of them re-emitted through the TS emitter
and template re-checked — 70–95s — even when the edit is a single appended blank line. - The second edit of the same file behaves correctly: 1 affected file, 7–10s.
- The first edit of a different file then pays that file's dependent cone once, and so on
— "first save per dependency cone per watch session is slow".
Root cause (instrumented, TypeScript 5.9.3)
We added probes to BuilderState.create and BuilderState.updateShapeSignature in
typescript.js and logged the signature lifecycle of one file across a session
(hashes abbreviated):
| When | Event | prev signature | new signature |
|---|---|---|---|
| watch start | restored from .tsbuildinfo |
— | da14a6 |
| initial build | affected-walk computes the real d.ts shape hash | da14a6 |
29e2fc |
| initial build | emit-time updateShapeSignature(..., useFileVersionAsSignature = true) |
29e2fc |
a682c7 = the file's TEXT version hash |
| 1st edit (whitespace) | walk recomputes the real shape hash | a682c7 |
29e2fc → text-hash vs shape-hash can never match → false shapeChanged: true → 1,150 affected |
| 2nd edit (whitespace) | walk recomputes | 29e2fc |
29e2fc → shapeChanged: false → 1 affected |
TypeScript's builder has an emit-time shortcut that stores a file's text version hash as
its shape signature instead of computing the d.ts hash
(handleDtsMayChangeOf… → updateShapeSignature(…, /*useFileVersionAsSignature*/ true)).
Every file processed through that path during the watch-startup build is left with a
"poisoned" signature, and its first subsequent edit produces a guaranteed false shape-change,
invalidating its entire dependent cone.
At watch startup the restored program virtually always sees some changed files vs. the
buildinfo snapshot, so the cones of those files get poisoned — in barrel-heavy monorepos this
is effectively most of the program.
Negative control: bare TypeScript does not reproduce this
We drove ts.createEmitAndSemanticDiagnosticsBuilderProgram directly (same call pattern as
AotCompilation: semantic affected-walk with an ignoreSourceFile callback, then
emitNextAffectedFile loop, buildinfo persisted, program restored via ts.readBuilderProgram,
one file touched) against a synthetic 500-dependent barrel project, across TypeScript 5.4.5,
5.5.4, 5.6.3, 5.7.3, 5.8.3, 5.9.3, with variants (inlineSourceMap, shim-style files, custom
transformers passed to emit). Every version/variant collapses correctly: restart-save =
2 affected / 2 emitted, steady saves = 1/1. The poisoning requires the changed-file + emit
interleaving that @angular/build's watch startup produces, which is why we're filing here
(a TypeScript companion issue can follow — the offending shortcut is TS-internal).
Experiment: neutralizing the shortcut fixes it
A 2-line patch to typescript.js — (1) BuilderState.create returns
useFileVersionAsSignature: false unconditionally; (2) updateShapeSignature ignores the
caller's useFileVersionAsSignature override — was tested on the same workspace:
| Scenario | Unpatched | Patched |
|---|---|---|
| Initial build | 68–100s | 92.6s (no measurable extra cost) |
| Session's 1st save | 70.6s / 1,150 affected (false shape-change) | 96.0s / 1,148 — but TS now correctly reports shapeChanged: false; the remaining cone is Angular TTC shim regeneration (see below) |
| 1st touch of an unrelated file | 75.8s / 2,141 affected | 7.2s / 1 affected |
| Restart → next session's 1st save | full cone (poisoned buildinfo) | signatures compare clean across restart |
| All other saves | 7–10s | 7–10s |
So with true shape signatures, "first save per cone" becomes "first save per session".
Note: TypeScript already has an internal escape hatch — disableUseFileVersionAsSignature
(third parameter of BuilderState.create, used by the language-service path) — but the
incremental-builder API hardcodes it to false, and the emit-time override bypasses it
anyway. There is currently no public knob.
Secondary observation (Angular-side)
With the TS layer fixed, the session's first update still rebuilds one cone: the Angular
compiler regenerates the TTC shims for the changed file's whole import cone on the first
update of a session (~1,147 shim files with new versions → TS must walk them as changed
files), and only regenerates the touched file's shim on later updates. Its
incremental/semantic state is in-memory only, so this cost recurs once per watch session.
Minimal reproduction recipe
Any large workspace with barrel imports shows it; measure with:
NG_BUILD_DEBUG_PERF=1 NG_BUILD_PARALLEL_TS=0 ng build --watch
# wait for initial build, then:
echo "" >> src/app/<some-widely-imported-file>.ts # 1st edit: near-initial-build time
echo "" >> src/app/<some-widely-imported-file>.ts # 2nd edit: fast
echo "" >> src/app/<some-other-area-file>.ts # 1st edit of another cone: slow againA synthetic generator (N components importing a shared type through a barrel index.ts)
reproduces the same pattern; happy to provide the probe patches for
BuilderState.create/updateShapeSignature used to capture the signature lifecycle.
Environment
- Angular CLI / @angular/build: 21.2.12
- TypeScript: 5.9.3
- Node: v22.19.0
- OS: Linux x64
- Single-project workspace, ~7,800 TS files,
incremental+ CLI cache enabled,
sourceMap.scripts: true(TS-emit path)
Related
- ng serve --watch is as slow as ng build on rebuild #24832 (
ng serve --watchas slow asng buildon rebuild — same symptom family) - Watch mode: rebuild takes too long after adding a simple
console.logto file (~2 minutes) microsoft/TypeScript#42173 (closed as Design Limitation — same symptom family, no mechanism)
Ask
- Coordinate with TypeScript to expose/respect
disableUseFileVersionAsSignaturein the
incremental builder API (or stop overwriting real shape hashes with version hashes at emit
time), and adopt it in@angular/build— our measurements show no meaningful cost and a
10x improvement for first-edit rebuilds in barrel-heavy workspaces. - Consider making the first-update TTC shim regeneration incremental (or persistable), so
the once-per-session cone disappears as well.
Activity
kaiguogit commentedon Jul 21, 2026
Update: filed the TypeScript-side issue with a minimal standalone repro and an exact regression boundary: microsoft/TypeScript#63664.
Key new findings since filing this issue:
exportedModulesMapwas removed andhandleDtsMayChangeOfReferencingExportOfAffectedFilenow walks the plainreferencedMaptransitively (entire import closure throughexport *barrels) instead of only genuine re-exporters. TS 5.4.5 does not reproduce; 5.5.4–6.0.3 all do.const enum(invalidateJsFiles); without const enums the closure 'only' has its cached semantic diagnostics dropped (still expensive in large programs)..tsbuildinfo).So for @angular/build the practical mitigations remain as described above, and the root fix is in TypeScript.
alan-agius4 commentedon Jul 22, 2026
Inactionable AI slop.
kaiguogit commentedon Jul 22, 2026
Indeed inactionable in Angular. The issue is in typescript. I reported in Angular mainly to reference the typescript issue microsoft/TypeScript#63664 in case other people encounter the same issue after upgrading to Angular 21 and typescript 5.9.
angular-automatic-lock-bot commentedon Aug 22, 2026
This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.
Read more about our automatic conversation locking policy.
This action has been performed automatically by a bot.