Bubble errors from the server to the client on suspense boundaries (#2772)

* Bubble errors from the server to the client on suspense boundaries

* make resolving suspense boundaries O(n)
This commit is contained in:
Evan Almloff authored and GitHub committed 2024-08-02 19:01:55 -07:00
1 parent 3003362b52
commit b0a2e2418c
12 files changed
+190 -39

No files matched your search

@@ -1,7 +1,7 @@
use dioxus_lib::prelude::dioxus_core::DynamicNode;
use dioxus_lib::prelude::{
has_context, try_consume_context, ScopeId, SuspenseBoundaryProps, SuspenseContext, VNode,
VirtualDom,
has_context, try_consume_context, ErrorContext, ScopeId, SuspenseBoundaryProps,
SuspenseContext, VNode, VirtualDom,
};
use serde::Serialize;
@@ -26,6 +26,14 @@ impl super::HTMLData {
/// We use depth first order instead of relying on the order the hooks are called in because during suspense on the server, the order that futures are run in may be non deterministic.
pub(crate) fn extract_from_suspense_boundary(vdom: &VirtualDom, scope: ScopeId) -> Self {
let mut data = Self::default();
// If there is an error boundary on the suspense boundary, grab the error from the context API
// and throw it on the client so that it bubbles up to the nearest error boundary
let mut error = vdom.in_runtime(|| {
scope
.consume_context::<ErrorContext>()
.and_then(|error_context| error_context.errors().first().cloned())
});
data.push(&error);
data.take_from_scope(vdom, scope);
data
}
+79 -13
View File
@@ -1,5 +1,5 @@
//! A shared pool of renderers for efficient server side rendering.
use crate::streaming::StreamingRenderer;
use crate::streaming::{Mount, StreamingRenderer};
use dioxus_interpreter_js::INITIALIZE_STREAMING_JS;
use dioxus_ssr::{
incremental::{CachedRender, RenderFreshness},
@@ -15,6 +15,13 @@ use tokio::task::JoinHandle;
use crate::prelude::*;
use dioxus_lib::prelude::*;
/// A suspense boundary that is pending with a placeholder in the client
struct PendingSuspenseBoundary {
mount: Mount,
children: Vec<ScopeId>,
}
/// Spawn a task in the background. If wasm is enabled, this will use the single threaded tokio runtime
fn spawn_platform<Fut>(f: impl FnOnce() -> Fut + Send + 'static) -> JoinHandle<Fut::Output>
where
Fut: Future + 'static,
@@ -56,6 +63,7 @@ impl SsrRendererPool {
}
}
/// Look for a cached route in the incremental cache and send it into the render channel if it exists
fn check_cached_route(
&self,
route: &str,
@@ -91,6 +99,8 @@ impl SsrRendererPool {
None
}
/// Render a virtual dom into a stream. This method will return immediately and continue streaming the result in the background
/// The streaming is canceled when the stream the function returns is dropped
async fn render_to(
self: Arc<Self>,
cfg: &ServeConfig,
@@ -181,6 +191,9 @@ impl SsrRendererPool {
{
let scope_to_mount_mapping = scope_to_mount_mapping.clone();
let stream = stream.clone();
// We use a stack to keep track of what suspense boundaries we are nested in to add children to the correct boundary
// The stack starts with the root scope because the root is a suspense boundary
let pending_suspense_boundaries_stack = RwLock::new(vec![]);
renderer.set_render_components(move |renderer, to, vdom, scope| {
let is_suspense_boundary =
SuspenseContext::downcast_suspense_boundary_from_scope(
@@ -191,10 +204,47 @@ impl SsrRendererPool {
.is_some();
if is_suspense_boundary {
let mount = stream.render_placeholder(
|to| renderer.render_scope(to, vdom, scope),
|to| {
{
pending_suspense_boundaries_stack
.write()
.unwrap()
.push(scope);
}
let out = renderer.render_scope(to, vdom, scope);
{
pending_suspense_boundaries_stack.write().unwrap().pop();
}
out
},
&mut *to,
)?;
scope_to_mount_mapping.write().unwrap().insert(scope, mount);
// Add the suspense boundary to the list of pending suspense boundaries
// We will replace the mount with the resolved contents later once the suspense boundary is resolved
let mut scope_to_mount_mapping_write =
scope_to_mount_mapping.write().unwrap();
scope_to_mount_mapping_write.insert(
scope,
PendingSuspenseBoundary {
mount,
children: vec![],
},
);
// Add the scope to the list of children of the parent suspense boundary
let pending_suspense_boundaries_stack =
pending_suspense_boundaries_stack.read().unwrap();
// If there is a parent suspense boundary, add the scope to the list of children
// This suspense boundary will start capturing errors when the parent is resolved
if let Some(parent) = pending_suspense_boundaries_stack.last() {
let parent = scope_to_mount_mapping_write.get_mut(parent).unwrap();
parent.children.push(scope);
}
// Otherwise this is a root suspense boundary, so we need to start capturing errors immediately
else {
vdom.in_runtime(|| {
start_capturing_errors(scope);
});
}
} else {
renderer.render_scope(to, vdom, scope)?
}
@@ -233,12 +283,12 @@ impl SsrRendererPool {
// Just rerender the resolved nodes
for scope in resolved_suspense_nodes {
let mount = {
let pending_suspense_boundary = {
let mut lock = scope_to_mount_mapping.write().unwrap();
lock.remove(&scope)
};
// If the suspense boundary was immediately removed, it may not have a mount. We can just skip resolving it
if let Some(mount) = mount {
if let Some(pending_suspense_boundary) = pending_suspense_boundary {
let mut resolved_chunk = String::new();
// After we replace the placeholder in the dom with javascript, we need to send down the resolved data so that the client can hydrate the node
let render_suspense = |into: &mut String| {
@@ -247,7 +297,7 @@ impl SsrRendererPool {
};
let resolved_data = serialize_server_data(&virtual_dom, scope);
if let Err(err) = stream.replace_placeholder(
mount,
pending_suspense_boundary.mount,
render_suspense,
resolved_data,
&mut resolved_chunk,
@@ -258,13 +308,22 @@ impl SsrRendererPool {
}
stream.render(resolved_chunk);
}
// Freeze the suspense boundary to prevent future reruns of any child nodes of the suspense boundary
if let Some(suspense) = SuspenseContext::downcast_suspense_boundary_from_scope(
&virtual_dom.runtime(),
scope,
) {
suspense.freeze();
// Freeze the suspense boundary to prevent future reruns of any child nodes of the suspense boundary
if let Some(suspense) =
SuspenseContext::downcast_suspense_boundary_from_scope(
&virtual_dom.runtime(),
scope,
)
{
suspense.freeze();
// Go to every child suspense boundary and add an error boundary. Since we cannot rerun any nodes above the child suspense boundary,
// we need to capture the errors and send them to the client as it resolves
virtual_dom.in_runtime(|| {
for &suspense_scope in pending_suspense_boundary.children.iter() {
start_capturing_errors(suspense_scope);
}
});
}
}
}
}
@@ -305,6 +364,13 @@ impl SsrRendererPool {
}
}
/// Start capturing errors at a suspense boundary. If the parent suspense boundary is frozen, we need to capture the errors in the suspense boundary
/// and send them to the client to continue bubbling up
fn start_capturing_errors(suspense_scope: ScopeId) {
// Add an error boundary to the scope
suspense_scope.in_runtime(provide_error_boundary);
}
fn serialize_server_data(virtual_dom: &VirtualDom, scope: ScopeId) -> String {
// After we replace the placeholder in the dom with javascript, we need to send down the resolved data so that the client can hydrate the node
// Extract any data we serialized for hydration (from server futures)