Fix wasm-split runtime crashes in client side rendered apps#5668
Open
TristenHarr wants to merge 2 commits into
Open
Fix wasm-split runtime crashes in client side rendered apps#5668TristenHarr wants to merge 2 commits into
TristenHarr wants to merge 2 commits into
Conversation
… main Two related resolution bugs corrupted split builds of a large real world app (pure CSR dioxus-web, 30+ split modules, fat LTO): 1. Function symbols were resolved by name against the module's name section. The linker folds identical functions into one body, and the folded names no longer exist in the name section, so those symbols were silently dropped from the call graph. Resolve by the symbol's function index instead, which survives folding, and keep the name lookup as a fallback. 2. build_call_graph translated old module names to new module names by string equality, but dx runs wasm-bindgen with demangling, so the post bindgen name section is demangled while the pre bindgen module is mangled. In the test app only 58 of ~11700 functions matched. The conservative recovery that attaches lost nodes to main was written to a map that was never merged into the real graph. Data symbols reachable from split modules were then pruned from the main module and shipped as chunk load time active segments, which left main statics reading as zeros until a chunk load overwrote memory under live code. In the app this corrupted template node_paths and crashed route resolution with ReplaceWith on an unregistered node. Match names via rustc_demangle, whose Display output is byte identical to what wasm-bindgen writes back, and merge the recovery into the call graph. Also keep any data symbol whose byte range overlaps a retained symbol, since the linker tail merges constants and pruning one would zero bytes inside a kept symbol. A unit test pins the demangling equivalence so a future rustc-demangle or wasm-bindgen change cannot silently regress matching.
Scopes rendered while suspended get no writer, so their mounts hold
placeholder element ids. Two paths let that state reach a real writer:
- run_and_diff_scope passed the raw writer to SuspenseBoundary diffs,
and scope_should_render only checked the nearest boundary, so a
nested boundary resolving under a still suspended ancestor diffed
its never mounted fallback and emitted ReplaceWith(u32::MAX).
- SuspenseBoundaryProps::create re-created existing boundary scopes
from props.children whose mount cell had gone stale after memoize
(VNode::clone copies the Cell by value). That minted fresh child
scopes and orphaned the live ones, which later re-rendered against
unmounted state.
Gate boundary diffs like regular scopes, walk all ancestors in
scope_should_render, and reuse the stored render plus existing scopes
when re-creating a live boundary. The regression tests fail before the
fix with ReplaceWith { id: ElementId(MAX) } in the mutation stream. The
web interpreter then dies with: TypeError: Cannot read properties of
undefined (reading 'listening').
This was referenced Jul 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Turning on
--wasm-spliton a large client-rendered app (no fullstack or hydration, ~30 split modules, fat LTO) crashed on every route resolution withCannot read properties of undefined (reading 'listening')from the interpreter. That error is aReplaceWithpointing at a node id that was never registered. It came down to two unrelated bugs, one in the splitter and one in core suspense.wasm-split-cli
Two things were quietly dropping symbols out of the call graph.
Functions were resolved by name against the name section. LTO folds identical functions and drops the folded names, so those lookups missed and the symbols fell out of the graph. Resolving by the symbol's function index avoids that (I kept the name lookup as a fallback).
The bigger one:
build_call_graphmatched old-module names to new-module names by string equality, but dx runs wasm-bindgen with demangling, so the new names are demangled and the old ones aren't. Almost nothing matched (58 of ~11,700 functions in my app). The code was supposed to fall back and keep the unmatched symbols in main, but it wrote them into a map it never read. So data reachable from a split got pruned out of main and shipped as active data segments in the chunk instead, which left main's own statics (down to templatenode_paths) reading as zero until a chunk load happened to overwrite that memory. Demangling the old names before matching fixes it. I also keep any data symbol whose byte range overlaps one main keeps, since the linker tail-merges constants and pruning one symbol would zero another.core suspense
A scope rendered while suspended gets no writer, so its mounts keep placeholder element ids. Two paths let that state reach a real writer later.
Boundaries weren't gated the way regular scopes are in
run_and_diff_scope, andscope_should_renderonly looked at the nearest boundary. So a nested boundary that resolved while an ancestor was still suspended would diff its own never-mounted fallback and emitReplaceWith(MAX). Fixed by gating boundary diffs the same way and walking the whole parent chain.Separately, when an outer boundary re-creates its subtree,
SuspenseBoundaryProps::createrebuilt inner boundaries fromprops.children, whose mount cell goes stale aftermemoize(VNode::clonecopies theCellby value). That minted a fresh set of child scopes and orphaned the live ones, which later re-rendered against unmounted state. Fixed by reusing the boundary's existing render and scopes when it already exists.Each fix has a test that reproduces the exact bad mutation before it.
Checked with
cargo teston dioxus-core and dioxus-ssr, and against the real app (46 routes) which went from crashing on every split route to clean.The two halves are independent if it's easier to review or merge them separately.