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.
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-onlyleaf.tsxedit is visible throughPage.tsx; the client compiler repeatedly reportsmodule outgoings change detected: src/root.tsx?react-router-routeand 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:
With verbose
codeSplittingCachelogging enabled, all 13 observed baseline hot rebuilds miss on the root route.rebuild chunk graphis 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 staticvirtual/react-router/with-propsimport; 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:CodeSplitter::prepareuses different semantics: it filters non-module/non-context and weak dependencies, partitions each connection withget_parent_block, sorts ordered dependencies bysource_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:
Its affected route has two sync imports, a re-export of the edited non-leaf module, and one stable async import:
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:
The corrected optimized binding reuses stable topology and still rebuilds for each real topology change:
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:source_orderordering;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.tsxitself 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 chunksand before runtime modules/assets are attached. Reusing the previous finalBuildChunkGraphArtifactis 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.