Skip to content

[Perf]: unchanged async outgoings falsely invalidate the chunk-graph cache during HMR #14769

Description

@matthewdavis-oai

Incremental chunk-graph cache falsely misses for an unchanged module with async outgoings

We traced slow React Router HMR in a large Rsbuild application to BuildChunkGraphArtifact::can_skip_rebuilding_legacy. A text-only leaf.tsx edit is visible through Page.tsx; the client compiler repeatedly reports module outgoings change detected: src/root.tsx?react-router-route and fully rebuilds the chunk graph even though route topology is unchanged.

Attribution

The focused native trace attributes the client rebuild primarily to code splitting, followed by SplitChunks:

Compilation:code_splitting        2.10s   2.22s   2.58s
Compilation:SplitChunks            0.60s   0.91s   0.79s
Compilation:create_hash           ~0.11s
Compilation::create_chunk_assets ~0.06s
Compilation:process_assets        ~0.22s

With verbose codeSplittingCache logging enabled, all 13 observed baseline hot rebuilds miss on the root route. rebuild chunk graph is 1.51--2.84 s (median 2.13 s). The browser harness completes 10/10 true HMRs but reports a 6.17 s median web compile and 7.37 s median edit-to-visible-DOM time; there are no CSS requests or reloads.

The corrected compiler then completes the same 10/10 browser run with zero hot chunk-graph misses: median web compile drops to 5.05 s (-1.13 s, 18%), edit-to-visible-DOM to 6.18 s (-1.20 s, 16%), hot-manifest start from 6.52 s to 5.45 s, and hot-JS start from 7.18 s to 6.03 s. Node remains comparable (3.21 s baseline, 3.33 s candidate); no CSS requests or reloads occur. A final uncontended run with one uniform native binding, constant-size visible tokens, and verbose diagnostic stats removed again completes 10/10: web 5.03 s, visible DOM 5.97 s, manifest 0.44 s, node 3.21 s, zero CSS/reloads. Additional post-run cleanup rebuilds also reuse the graph.

The root becomes affected because the Tailwind loader watches the edited source and extracted CSS uses CssDependency::could_affect_referencing_module() == AffectType::Transitive. This explains the reach into the root route; it does not require a CSS-content change. The React Router route transform wraps component exports and inserts a static virtual/react-router/with-props import; it does not inject an async import for this route.

Cause

ModuleGraphModule::all_dependencies() explicitly includes both root and async-block dependencies and is ordered by dependency-creation order. The old cache predicate flattened that list, grouped it by target module, and compared the result only with the cached root block:

modules.get(&DependenciesBlockIdentifier::Module(module))

CodeSplitter::prepare uses different semantics: it filters non-module/non-context and weak dependencies, partitions each connection with get_parent_block, sorts ordered dependencies by source_order, appends unordered dependencies, and stores root and async blocks independently. Consequently, an unchanged async outgoing was compared against the root block and guaranteed a false miss; creation-order/source-order differences could also falsely miss for sync dependencies.

Native reproduction and measurements

The regression/fix stacked in #14772 uses the fixture:

tests/rspack-test/watchCases/build-chunk-graph/async-block-outgoings/

Its affected route has two sync imports, a re-export of the edited non-leaf module, and one stable async import:

import "./side-effect";
import { first } from "./sync-first";
import { second } from "./sync-second";
export { value } from "./leaf";

export const sync = `${first}:${second}`;
export const load = () => import(/* webpackChunkName: "lazy" */ "./lazy");

The 250-entry watch/HMR matrix edits the body, renames the async chunk, retargets it, and adds a second async block. Baseline rebuilds on every edit:

round  cause                 rebuild  time
1      leaf-only             yes      253ms  module outgoings change detected: route.js
2      rename-chunk          yes      212ms
3      leaf-after-rename     yes      234ms  module outgoings change detected: route.js
4      retarget-async        yes      231ms
5      leaf-after-retarget   yes      200ms  module outgoings change detected: route.js
6      add-async             yes      238ms
7      leaf-after-add        yes      263ms  module outgoings change detected: route.js

The corrected optimized binding reuses stable topology and still rebuilds for each real topology change:

round  cause                 rebuild  time
1      leaf-only             no       173ms
2      rename-chunk          yes      166ms  module async blocks change detected: route.js
3      leaf-after-rename     no       151ms
4      retarget-async        yes      148ms  module removal detected
5      leaf-after-retarget   no       159ms
6      add-async             yes      163ms  module async blocks change detected: route.js
7      leaf-after-add        no       165ms

Unchanged-edit median drops from 244 ms to 162 ms in the small fixture; more importantly, the client can avoid a 1.5--2.8 s chunk-graph rebuild. Both matrices emit valid hot updates: the final edited token is present in src_route_js.*.hot-update.js, and all 250 runtime updates are emitted. A second matrix with a non-leaf edit, mixed ESM/CJS/re-export, persistent cache, SplitChunks, and extracted/context CSS imported by the affected module also reuses correctly (189 ms, no errors).

Fix and remaining work

The corrected predicate mirrors CodeSplitter::prepare:

  • checks that the affected module's async-block IDs/order and existing chunk-group options are unchanged;
  • conservatively rebuilds for nested blocks;
  • applies the same module/context/weak filtering and source_order ordering;
  • groups current connections separately for the root and each async block, then compares each with its corresponding cached block across runtimes;
  • preserves existing active/false-connection and module-removal behavior.

The fix is intentionally scoped to legacy chunk-graph reuse. SplitChunks still runs after a successful cache hit because the saved graph snapshot precedes that optimization. A post-fix focused trace confirms that the client consistently reuses code splitting (21--29 ms, 7 mutations/5 affected modules), while SplitChunks still costs 0.81--1.40 s. The same trace attributes another 0.92--1.11 s to the complete loader chain rebuilding styles/root.css; leaf.tsx itself builds in roughly 4--5 ms. Tailwind instrumentation is only 54--82 ms on these edits (3--4 ms scan, 51--78 ms dependency registration, essentially zero utility generation); most of the chain delay is downstream processing of its unchanged approximately 1.9 MB CSS output. These are separate incremental opportunities, not residual chunk-graph-cache misses.

A correctness-preserving SplitChunks fast path must capture the reusable graph immediately after optimize chunks and before runtime modules/assets are attached. Reusing the previous final BuildChunkGraphArtifact is unsafe. It should require a code-splitting cache hit, unchanged per-source-type module sizes and selection metadata, unchanged export usage when enabled, and no function-valued SplitChunks filters/name/test/layer callbacks; real topology or threshold changes must fall back to the existing path.

Current fix and validation

The shared connection-preparation helper, root/async outgoing comparison, stable async identity, terminal-leaf reuse, and eight-step incremental-watch regression are stacked in #14772 (d138a89..e8740f6, ce7ca20). The final fixture covers stable body/source-position edits, rename/retarget/add/remove topology misses, synchronous ordering/side effects, terminal-leaf reuse, and emitted async values. Validation includes optimized native builds, topology and extracted-CSS/non-leaf matrices, emitted-hot-update content checks, focused Rust/core/watcher suites, syntax/formatting checks, and the 10/10 full-browser HMR run.

The earlier standalone patch, local repro paths, and binding digests are historical and superseded by the consolidated source/test diff in #14772.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions