Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ slotmap = { version = "1.0.7", features = ["serde"] }
futures = "0.3.32"
futures-channel = "0.3.32"
futures-util = { version = "0.3.32", default-features = false }
rustc-demangle = "0.1"
rustc-hash = "2.1.1"
wasm-bindgen = "0.2.121"
wasm-bindgen-futures = "0.4.71"
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/diff/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ impl VirtualDom {
) {
let scope = &mut self.scopes[scope_id.0];
if SuspenseBoundaryProps::downcast_from_props(&mut *scope.props).is_some() {
// A suspense boundary may rerun (e.g. resolve) while it is itself nested under a
// boundary that is still rendered as suspended. Its rendered state was created
// without a writer, so diffing it against the real dom would emit mutations that
// reference unmounted placeholder element ids. Defer the mutations like any other
// scope under a suspended boundary; the ancestor's own resolve pass will create
// the final state with a writer.
let to = to.filter(|_| self.runtime.scope_should_render(scope_id));
SuspenseBoundaryProps::diff(scope_id, self, to)
} else {
let new_nodes = self.run_scope(scope_id);
Expand Down
20 changes: 17 additions & 3 deletions packages/core/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,10 +350,24 @@ fn MyComponent() -> Element {{
return true;
}

// If this is not a suspended scope, and we are under a frozen context, then we should
// If any boundary above this scope is currently rendered as suspended, this scope's
// rendered state only exists in the background (it was created without a writer), so
// it must not emit mutations. Checking only the nearest boundary is not enough: a
// resolved boundary (or this scope itself, if it is a boundary) may still be nested
// inside a suspended one, so walk the whole parent chain.
let scopes = self.scope_states.borrow();
let scope = &scopes[scope_id.0].as_ref().unwrap();
!matches!(scope.suspense_location(), SuspenseLocation::UnderSuspense(suspense) if suspense.is_suspended())
let mut current = Some(scope_id);
while let Some(id) = current {
let Some(Some(scope)) = scopes.get(id.0) else {
break;
};
if matches!(scope.suspense_location(), SuspenseLocation::UnderSuspense(suspense) if suspense.is_suspended())
{
return false;
}
current = scope.parent_id();
}
true
}

/// Call a listener inside the VirtualDom with data from outside the VirtualDom. **The ElementId passed in must be the id of an element with a listener, not a static node or a text node.**
Expand Down
54 changes: 32 additions & 22 deletions packages/core/src/suspense/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,29 +273,39 @@ impl SuspenseBoundaryProps {
to: Option<&mut M>,
) -> usize {
let mut scope_id = ScopeId(dom.get_mounted_dyn_node(mount, idx));
// If the ScopeId is a placeholder, we need to load up a new scope for this vcomponent. If it's already mounted, then we can just use that
if scope_id.is_placeholder() {
{
let suspense_context = SuspenseContext::new();

let suspense_boundary_location =
crate::scope_context::SuspenseLocation::SuspenseBoundary(
suspense_context.clone(),
);
dom.runtime
.clone()
.with_suspense_location(suspense_boundary_location, || {
let scope_state = dom
.new_scope(component.props.duplicate(), component.name)
.state();
suspense_context.mount(scope_state.id);
scope_id = scope_state.id;
});
}

// Store the scope id for the next render
dom.set_mounted_dyn_node(mount, idx, scope_id.0);
// If the scope already exists, this is a re-creation pass (e.g. an outer suspense
// boundary resolving and rebuilding its subtree). Recreate the boundary's current
// rendered state (the fallback while suspended, or the children once resolved) just
// like any other already-mounted component. Re-running the children from props here
// would mint a second children tree with fresh child scopes and orphan the live
// ones: props.children may hold an unmounted clone left by a memoized props update,
// and the orphaned scopes would later diff against mounts that were never written.
if !scope_id.is_placeholder() {
let new_node = dom.scopes[scope_id.0]
.last_rendered_node
.clone()
.expect("Component to be mounted");
return dom.create_scope(to, scope_id, new_node, parent);
}
{
let suspense_context = SuspenseContext::new();

let suspense_boundary_location =
crate::scope_context::SuspenseLocation::SuspenseBoundary(suspense_context.clone());
dom.runtime
.clone()
.with_suspense_location(suspense_boundary_location, || {
let scope_state = dom
.new_scope(component.props.duplicate(), component.name)
.state();
suspense_context.mount(scope_state.id);
scope_id = scope_state.id;
});
}

// Store the scope id for the next render
dom.set_mounted_dyn_node(mount, idx, scope_id.0);

dom.runtime.clone().with_scope_on_stack(scope_id, || {
let scope_state = &mut dom.scopes[scope_id.0];
let props = Self::downcast_from_props(&mut *scope_state.props).unwrap();
Expand Down
231 changes: 231 additions & 0 deletions packages/core/tests/suspense.rs
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,237 @@ fn toggle_suspense() {
});
}

/// A nested suspense boundary that resolves while an ancestor suspense boundary is still
/// suspended must not write mutations. Its entire rendered state (including its own fallback)
/// was created in the background without a writer, so its mounts hold placeholder element ids
/// (`usize::MAX` / `ElementId(0)`). Writing a resolve diff against that state emits mutations
/// like `ReplaceWith { id: 4294967295 }` which crash the renderer.
///
/// The scenario is a pure-CSR app whose root content suspends (implicit root boundary) while
/// an inner `SuspenseBoundary` suspends and resolves on its own faster future.
#[test]
fn nested_boundary_resolves_under_suspended_root() {
fn app() -> Element {
rsx! {
SlowOuter {}
SuspenseBoundary {
fallback: |_| rsx! { {"inner loading".to_string()} },
FastInner {}
}
}
}

#[component]
fn SlowOuter() -> Element {
let mut resolved = use_signal(|| false);
let task = use_hook(|| {
spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
resolved.set(true);
})
});
if !resolved() {
suspend(task)?;
}
rsx! { div { "outer done" } }
}

#[component]
fn FastInner() -> Element {
let mut resolved = use_signal(|| false);
let task = use_hook(|| {
spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
resolved.set(true);
})
});
if !resolved() {
suspend(task)?;
}
rsx! { div { "inner done" } }
}

fn assert_no_placeholder_ids(mutations: &dioxus_core::Mutations) {
let debug = format!("{:?}", mutations.edits);
let max = usize::MAX.to_string();
assert!(
!debug.contains(&max),
"mutations reference an unmounted placeholder element id: {debug}"
);
assert!(
!debug.contains("ReplaceWith { id: ElementId(0)"),
"mutations replace the root element: {debug}"
);
}

tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap()
.block_on(async {
let mut dom = VirtualDom::new(app);
let mutations = dom.rebuild_to_vec();
assert_no_placeholder_ids(&mutations);

// Drive the client event loop until all suspense is resolved, checking every
// batch of mutations for references to unmounted placeholder ids
let work = async {
loop {
dom.wait_for_work().await;
let mutations = dom.render_immediate_to_vec();
assert_no_placeholder_ids(&mutations);
if !dom.suspended_tasks_remaining() {
break;
}
}
dom
};
let mut dom = tokio::select! {
dom = work => dom,
_ = tokio::time::sleep(std::time::Duration::from_secs(5)) => {
panic!("suspense never resolved")
}
};
// Flush any remaining work
let mutations = dom.render_immediate_to_vec();
assert_no_placeholder_ids(&mutations);

let out = dioxus_ssr::render(&dom);
assert_eq!(out, "<div>outer done</div><div>inner done</div>");
});
}

/// A suspense boundary living inside the suspended background of an outer (here: the
/// implicit root) boundary whose parent re-renders during the outer suspension. The
/// memoized props update replaces `props.children` with an unmounted clone; when the
/// outer boundary resolves, its re-creation pass descends into the existing inner
/// boundary via `SuspenseBoundaryProps::create`, which must reuse the boundary's
/// current rendered state instead of re-running the children from props. Re-running
/// them mints a second children tree with fresh scopes and orphans the previous child
/// scopes: still alive and subscribed, but with writer-less renders whose mounts hold
/// placeholder element ids. Once nothing is suspended anymore, any signal write makes
/// the orphan re-render with a real writer, and removing its stale nodes emits
/// `ReplaceWith { id: usize::MAX }` which crashes the renderer.
#[test]
fn boundary_recreation_keeps_child_scopes() {
use std::time::Duration;
static SHARED: GlobalSignal<i32> = Signal::global(|| 0);

fn app() -> Element {
use_hook(|| {
spawn(async move {
// Re-render Section (and memoize the inner boundary's props) while
// the root is still suspended
tokio::time::sleep(Duration::from_millis(10)).await;
*SHARED.write() = 1;
// Write again after everything has resolved
tokio::time::sleep(Duration::from_millis(200)).await;
*SHARED.write() = 2;
})
});
rsx! {
RootSuspender {}
Section {}
}
}

// Suspends against the implicit root boundary, like a lazy route shim
#[component]
fn RootSuspender() -> Element {
let mut resolved = use_signal(|| false);
let task = use_hook(|| {
spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
resolved.set(true);
})
});
if !resolved() {
suspend(task)?;
}
rsx! { header { "root done" } }
}

#[component]
fn Section() -> Element {
let shared = SHARED();
rsx! {
section {
SuspenseBoundary {
fallback: |_| rsx! { div { "inner loading" } },
// The dynamic text forces a fresh children VNode every render,
// so SuspenseBoundaryProps::memoize replaces props.children
div { "gen {shared}" }
InnerChild {}
}
}
}
}

#[component]
fn InnerChild() -> Element {
let shared = SHARED();
let mut resolved = use_signal(|| false);
let task = use_hook(|| {
spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
resolved.set(true);
})
});
if !resolved() {
suspend(task)?;
}
if shared % 2 == 1 {
// dynamic text root: removal goes through remove_dynamic_node(Text)
rsx! { "inner {shared}" }
} else {
rsx! { span { "inner {shared}" } }
}
}

fn assert_no_placeholder_ids(mutations: &dioxus_core::Mutations) {
let debug = format!("{:?}", mutations.edits);
let max = usize::MAX.to_string();
assert!(
!debug.contains(&max),
"mutations reference an unmounted placeholder element id: {debug}"
);
assert!(
!debug.contains("ReplaceWith { id: ElementId(0)"),
"mutations replace the root element: {debug}"
);
}

tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap()
.block_on(async {
let mut dom = VirtualDom::new(app);
let mutations = dom.rebuild_to_vec();
assert_no_placeholder_ids(&mutations);

// Drive the client event loop through the resolve and past the final
// signal write, checking every batch of mutations
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(600);
loop {
tokio::select! {
_ = dom.wait_for_work() => {
let mutations = dom.render_immediate_to_vec();
assert_no_placeholder_ids(&mutations);
}
_ = tokio::time::sleep_until(deadline) => break,
}
}
assert!(!dom.suspended_tasks_remaining(), "suspense never resolved");

let out = dioxus_ssr::render(&dom);
assert_eq!(
out,
"<header>root done</header><section><div>gen 2</div><span>inner 2</span></section>"
);
});
}

#[test]
fn nested_suspense_resolves_client() {
use Mutation::*;
Expand Down
1 change: 1 addition & 0 deletions packages/wasm-split/wasm-split-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ rust-version = "1.85.0"
[dependencies]
anyhow = { workspace = true }
itertools = { workspace = true }
rustc-demangle = { workspace = true }
walrus = { workspace = true, features = ["parallel"]}
wasmparser = { workspace = true }
id-arena = { workspace = true }
Expand Down
Loading