Skip to content

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

@kaiguogit

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: true1,150 affected
2nd edit (whitespace) walk recomputes 29e2fc 29e2fcshapeChanged: false1 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 again

A 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

Ask

  1. Coordinate with TypeScript to expose/respect disableUseFileVersionAsSignature in 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.
  2. Consider making the first-update TTC shim regeneration incremental (or persistable), so
    the once-per-session cone disappears as well.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions