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

Closed as not planned

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: 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 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.

Activity

  1. added this to the needsTriage milestone on Jul 21, 2026
  2. kaiguogit commented on Jul 21, 2026

    @kaiguogit
    Author

    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:

    • The over-broad invalidation is a TypeScript 5.5 regression: exportedModulesMap was removed and handleDtsMayChangeOfReferencingExportOfAffectedFile now walks the plain referencedMap transitively (entire import closure through export * barrels) instead of only genuine re-exporters. TS 5.4.5 does not reproduce; 5.5.4–6.0.3 all do.
    • The full-JS-re-emit of the closure is triggered when the edited file exports a const enum (invalidateJsFiles); without const enums the closure 'only' has its cached semantic diagnostics dropped (still expensive in large programs).
    • The walk also overwrites each visited file's shape signature with its text version hash, which is what makes the next first-edit of every closure member explode as well (self-perpetuating, persisted via .tsbuildinfo).

    So for @angular/build the practical mitigations remain as described above, and the root fix is in TypeScript.

  3. alan-agius4 commented on Jul 22, 2026

    @alan-agius4
    Collaborator

    Inactionable AI slop.

  4. kaiguogit commented on Jul 22, 2026

    @kaiguogit
    Author

    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.

  5. angular-automatic-lock-bot commented on Aug 22, 2026

    @angular-automatic-lock-bot

    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.

  6. locked and limited conversation to collaborators on Aug 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions