浏览代码

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)
Evan Almloff 10 月之前
父节点
当前提交
b0a2e2418c

+ 1 - 0
Cargo.lock

@@ -2903,6 +2903,7 @@ version = "0.1.0"
 dependencies = [
 dependencies = [
  "dioxus",
  "dioxus",
  "serde",
  "serde",
+ "tokio",
 ]
 ]
 
 
 [[package]]
 [[package]]

+ 8 - 8
packages/core/src/error_boundary.rs

@@ -40,13 +40,11 @@ impl Display for CapturedPanic {
 impl Error for CapturedPanic {}
 impl Error for CapturedPanic {}
 
 
 /// Provide an error boundary to catch errors from child components
 /// Provide an error boundary to catch errors from child components
-pub fn use_error_boundary() -> ErrorContext {
-    use_hook(|| {
-        provide_context(ErrorContext::new(
-            Vec::new(),
-            current_scope_id().unwrap_or_else(|e| panic!("{}", e)),
-        ))
-    })
+pub fn provide_error_boundary() -> ErrorContext {
+    provide_context(ErrorContext::new(
+        Vec::new(),
+        current_scope_id().unwrap_or_else(|e| panic!("{}", e)),
+    ))
 }
 }
 
 
 /// A trait for any type that can be downcast to a concrete type and implements Debug. This is automatically implemented for all types that implement Any + Debug.
 /// A trait for any type that can be downcast to a concrete type and implements Debug. This is automatically implemented for all types that implement Any + Debug.
@@ -750,7 +748,7 @@ impl<
 /// Error boundaries are quick to implement, but it can be useful to individually handle errors in your components to provide a better user experience when you know that an error is likely to occur.
 /// Error boundaries are quick to implement, but it can be useful to individually handle errors in your components to provide a better user experience when you know that an error is likely to occur.
 #[allow(non_upper_case_globals, non_snake_case)]
 #[allow(non_upper_case_globals, non_snake_case)]
 pub fn ErrorBoundary(props: ErrorBoundaryProps) -> Element {
 pub fn ErrorBoundary(props: ErrorBoundaryProps) -> Element {
-    let error_boundary = use_error_boundary();
+    let error_boundary = use_hook(provide_error_boundary);
     let errors = error_boundary.errors();
     let errors = error_boundary.errors();
     if errors.is_empty() {
     if errors.is_empty() {
         std::result::Result::Ok({
         std::result::Result::Ok({
@@ -768,6 +766,8 @@ pub fn ErrorBoundary(props: ErrorBoundaryProps) -> Element {
             )
             )
         })
         })
     } else {
     } else {
+        tracing::trace!("scope id: {:?}", current_scope_id());
+        tracing::trace!("handling errors: {:?}", errors);
         (props.handle_error.0)(error_boundary.clone())
         (props.handle_error.0)(error_boundary.clone())
     }
     }
 }
 }

+ 4 - 4
packages/core/src/lib.rs

@@ -88,10 +88,10 @@ pub mod prelude {
     pub use crate::innerlude::{
     pub use crate::innerlude::{
         consume_context, consume_context_from_scope, current_owner, current_scope_id,
         consume_context, consume_context_from_scope, current_owner, current_scope_id,
         fc_to_builder, generation, has_context, needs_update, needs_update_any, parent_scope,
         fc_to_builder, generation, has_context, needs_update, needs_update_any, parent_scope,
-        provide_context, provide_root_context, queue_effect, remove_future, schedule_update,
-        schedule_update_any, spawn, spawn_forever, spawn_isomorphic, suspend, throw_error,
-        try_consume_context, use_after_render, use_before_render, use_drop, use_error_boundary,
-        use_hook, use_hook_with_cleanup, with_owner, AnyValue, Attribute, Callback, Component,
+        provide_context, provide_error_boundary, provide_root_context, queue_effect, remove_future,
+        schedule_update, schedule_update_any, spawn, spawn_forever, spawn_isomorphic, suspend,
+        throw_error, try_consume_context, use_after_render, use_before_render, use_drop, use_hook,
+        use_hook_with_cleanup, with_owner, AnyValue, Attribute, Callback, Component,
         ComponentFunction, Context, Element, ErrorBoundary, ErrorContext, Event, EventHandler,
         ComponentFunction, Context, Element, ErrorBoundary, ErrorContext, Event, EventHandler,
         Fragment, HasAttributes, IntoAttributeValue, IntoDynNode, OptionStringFromMarker,
         Fragment, HasAttributes, IntoAttributeValue, IntoDynNode, OptionStringFromMarker,
         Properties, ReactiveContext, RenderError, RenderReturn, Runtime, RuntimeGuard, ScopeId,
         Properties, ReactiveContext, RenderError, RenderReturn, Runtime, RuntimeGuard, ScopeId,

+ 6 - 6
packages/core/src/scope_context.rs

@@ -190,10 +190,10 @@ impl Scope {
     /// Clones the state if it exists.
     /// Clones the state if it exists.
     pub fn consume_context<T: 'static + Clone>(&self) -> Option<T> {
     pub fn consume_context<T: 'static + Clone>(&self) -> Option<T> {
         tracing::trace!(
         tracing::trace!(
-            "looking for context {} ({:?}) in {}",
+            "looking for context {} ({:?}) in {:?}",
             std::any::type_name::<T>(),
             std::any::type_name::<T>(),
             std::any::TypeId::of::<T>(),
             std::any::TypeId::of::<T>(),
-            self.name
+            self.id
         );
         );
         if let Some(this_ctx) = self.has_context() {
         if let Some(this_ctx) = self.has_context() {
             return Some(this_ctx);
             return Some(this_ctx);
@@ -207,10 +207,10 @@ impl Scope {
                     return None;
                     return None;
                 };
                 };
                 tracing::trace!(
                 tracing::trace!(
-                    "looking for context {} ({:?}) in {}",
+                    "looking for context {} ({:?}) in {:?}",
                     std::any::type_name::<T>(),
                     std::any::type_name::<T>(),
                     std::any::TypeId::of::<T>(),
                     std::any::TypeId::of::<T>(),
-                    parent.name
+                    parent.id
                 );
                 );
                 if let Some(shared) = parent.has_context() {
                 if let Some(shared) = parent.has_context() {
                     return Some(shared);
                     return Some(shared);
@@ -277,10 +277,10 @@ impl Scope {
     /// ```
     /// ```
     pub fn provide_context<T: 'static + Clone>(&self, value: T) -> T {
     pub fn provide_context<T: 'static + Clone>(&self, value: T) -> T {
         tracing::trace!(
         tracing::trace!(
-            "providing context {} ({:?}) in {}",
+            "providing context {} ({:?}) in {:?}",
             std::any::type_name::<T>(),
             std::any::type_name::<T>(),
             std::any::TypeId::of::<T>(),
             std::any::TypeId::of::<T>(),
-            self.name
+            self.id
         );
         );
         let mut contexts = self.shared_contexts.borrow_mut();
         let mut contexts = self.shared_contexts.borrow_mut();
 
 

+ 10 - 2
packages/fullstack/src/html_storage/serialize.rs

@@ -1,7 +1,7 @@
 use dioxus_lib::prelude::dioxus_core::DynamicNode;
 use dioxus_lib::prelude::dioxus_core::DynamicNode;
 use dioxus_lib::prelude::{
 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;
 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.
     /// 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 {
     pub(crate) fn extract_from_suspense_boundary(vdom: &VirtualDom, scope: ScopeId) -> Self {
         let mut data = Self::default();
         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.take_from_scope(vdom, scope);
         data
         data
     }
     }

+ 79 - 13
packages/fullstack/src/render.rs

@@ -1,5 +1,5 @@
 //! A shared pool of renderers for efficient server side rendering.
 //! 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_interpreter_js::INITIALIZE_STREAMING_JS;
 use dioxus_ssr::{
 use dioxus_ssr::{
     incremental::{CachedRender, RenderFreshness},
     incremental::{CachedRender, RenderFreshness},
@@ -15,6 +15,13 @@ use tokio::task::JoinHandle;
 use crate::prelude::*;
 use crate::prelude::*;
 use dioxus_lib::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>
 fn spawn_platform<Fut>(f: impl FnOnce() -> Fut + Send + 'static) -> JoinHandle<Fut::Output>
 where
 where
     Fut: Future + 'static,
     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(
     fn check_cached_route(
         &self,
         &self,
         route: &str,
         route: &str,
@@ -91,6 +99,8 @@ impl SsrRendererPool {
         None
         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(
     async fn render_to(
         self: Arc<Self>,
         self: Arc<Self>,
         cfg: &ServeConfig,
         cfg: &ServeConfig,
@@ -181,6 +191,9 @@ impl SsrRendererPool {
             {
             {
                 let scope_to_mount_mapping = scope_to_mount_mapping.clone();
                 let scope_to_mount_mapping = scope_to_mount_mapping.clone();
                 let stream = stream.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| {
                 renderer.set_render_components(move |renderer, to, vdom, scope| {
                     let is_suspense_boundary =
                     let is_suspense_boundary =
                         SuspenseContext::downcast_suspense_boundary_from_scope(
                         SuspenseContext::downcast_suspense_boundary_from_scope(
@@ -191,10 +204,47 @@ impl SsrRendererPool {
                         .is_some();
                         .is_some();
                     if is_suspense_boundary {
                     if is_suspense_boundary {
                         let mount = stream.render_placeholder(
                         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,
                             &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 {
                     } else {
                         renderer.render_scope(to, vdom, scope)?
                         renderer.render_scope(to, vdom, scope)?
                     }
                     }
@@ -233,12 +283,12 @@ impl SsrRendererPool {
 
 
                 // Just rerender the resolved nodes
                 // Just rerender the resolved nodes
                 for scope in resolved_suspense_nodes {
                 for scope in resolved_suspense_nodes {
-                    let mount = {
+                    let pending_suspense_boundary = {
                         let mut lock = scope_to_mount_mapping.write().unwrap();
                         let mut lock = scope_to_mount_mapping.write().unwrap();
                         lock.remove(&scope)
                         lock.remove(&scope)
                     };
                     };
                     // If the suspense boundary was immediately removed, it may not have a mount. We can just skip resolving it
                     // 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();
                         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
                         // 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| {
                         let render_suspense = |into: &mut String| {
@@ -247,7 +297,7 @@ impl SsrRendererPool {
                         };
                         };
                         let resolved_data = serialize_server_data(&virtual_dom, scope);
                         let resolved_data = serialize_server_data(&virtual_dom, scope);
                         if let Err(err) = stream.replace_placeholder(
                         if let Err(err) = stream.replace_placeholder(
-                            mount,
+                            pending_suspense_boundary.mount,
                             render_suspense,
                             render_suspense,
                             resolved_data,
                             resolved_data,
                             &mut resolved_chunk,
                             &mut resolved_chunk,
@@ -258,13 +308,22 @@ impl SsrRendererPool {
                         }
                         }
 
 
                         stream.render(resolved_chunk);
                         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 {
 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
     // 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)
     // Extract any data we serialized for hydration (from server futures)

+ 4 - 0
packages/playwright-tests/fullstack.spec.js

@@ -28,4 +28,8 @@ test("hydration", async ({ page }) => {
 
 
   // Expect the page to contain the updated counter text.
   // Expect the page to contain the updated counter text.
   await expect(main).toContainText("Server said: Hello from the server!");
   await expect(main).toContainText("Server said: Hello from the server!");
+
+  // Make sure the error that was thrown on the server is shown in the error boundary on the client
+  const errors = page.locator("#errors");
+  await expect(errors).toContainText("Hmm, something went wrong.");
 });
 });

+ 2 - 1
packages/playwright-tests/fullstack/Cargo.toml

@@ -9,8 +9,9 @@ publish = false
 [dependencies]
 [dependencies]
 dioxus = { workspace = true, features = ["fullstack"] }
 dioxus = { workspace = true, features = ["fullstack"] }
 serde = "1.0.159"
 serde = "1.0.159"
+tokio = { workspace = true, features = ["full"], optional = true }
 
 
 [features]
 [features]
 default = []
 default = []
-server = ["dioxus/axum"]
+server = ["dioxus/axum", "dep:tokio"]
 web = ["dioxus/web"]
 web = ["dioxus/web"]

+ 44 - 1
packages/playwright-tests/fullstack/src/main.rs

@@ -5,7 +5,7 @@
 // - Hydration
 // - Hydration
 
 
 #![allow(non_snake_case)]
 #![allow(non_snake_case)]
-use dioxus::prelude::*;
+use dioxus::{prelude::*, CapturedError};
 
 
 fn main() {
 fn main() {
     LaunchBuilder::fullstack().launch(app);
     LaunchBuilder::fullstack().launch(app);
@@ -31,6 +31,10 @@ fn app() -> Element {
             "Run a server function!"
             "Run a server function!"
         }
         }
         "Server said: {text}"
         "Server said: {text}"
+        div {
+            id: "errors",
+            Errors {}
+        }
     }
     }
 }
 }
 
 
@@ -45,3 +49,42 @@ async fn post_server_data(data: String) -> Result<(), ServerFnError> {
 async fn get_server_data() -> Result<String, ServerFnError> {
 async fn get_server_data() -> Result<String, ServerFnError> {
     Ok("Hello from the server!".to_string())
     Ok("Hello from the server!".to_string())
 }
 }
+
+#[server]
+async fn server_error() -> Result<String, ServerFnError> {
+    tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
+    Err(ServerFnError::new("the server threw an error!"))
+}
+
+#[component]
+fn Errors() -> Element {
+    rsx! {
+        // This is a tricky case for suspense https://github.com/DioxusLabs/dioxus/issues/2570
+        // Root suspense boundary is already resolved when the inner suspense boundary throws an error.
+        // We need to throw the error from the inner suspense boundary on the server to the hydrated
+        // suspense boundary on the client
+        ErrorBoundary {
+            handle_error: |_| rsx! {
+                "Hmm, something went wrong."
+            },
+            SuspenseBoundary {
+                fallback: |_: SuspenseContext| rsx! {
+                    div {
+                        "Loading..."
+                    }
+                },
+                ThrowsError {}
+            }
+        }
+    }
+}
+
+#[component]
+pub fn ThrowsError() -> Element {
+    use_server_future(server_error)?
+        .unwrap()
+        .map_err(|err| RenderError::Aborted(CapturedError::from_display(err)))?;
+    rsx! {
+        "success"
+    }
+}

+ 21 - 2
packages/web/src/hydration/deserialize.rs

@@ -1,6 +1,7 @@
 use std::cell::{Cell, RefCell};
 use std::cell::{Cell, RefCell};
 use std::io::Cursor;
 use std::io::Cursor;
 
 
+use dioxus_core::CapturedError;
 use serde::de::DeserializeOwned;
 use serde::de::DeserializeOwned;
 
 
 thread_local! {
 thread_local! {
@@ -37,6 +38,7 @@ fn remove_server_data() {
 
 
 /// Data that is deserialized from the server during hydration
 /// Data that is deserialized from the server during hydration
 pub(crate) struct HTMLDataCursor {
 pub(crate) struct HTMLDataCursor {
+    error: Option<CapturedError>,
     data: Vec<Option<Vec<u8>>>,
     data: Vec<Option<Vec<u8>>>,
     index: Cell<usize>,
     index: Cell<usize>,
 }
 }
@@ -47,11 +49,28 @@ impl HTMLDataCursor {
         Self::new(deserialized)
         Self::new(deserialized)
     }
     }
 
 
+    /// Get the error if there is one
+    pub(crate) fn error(&self) -> Option<CapturedError> {
+        self.error.clone()
+    }
+
     fn new(data: Vec<Option<Vec<u8>>>) -> Self {
     fn new(data: Vec<Option<Vec<u8>>>) -> Self {
-        Self {
+        let mut myself = Self {
+            error: None,
             data,
             data,
             index: Cell::new(0),
             index: Cell::new(0),
-        }
+        };
+
+        // The first item is always an error if it exists
+        let error = myself
+            .take::<Option<CapturedError>>()
+            .ok()
+            .flatten()
+            .flatten();
+
+        myself.error = error;
+
+        myself
     }
     }
 
 
     pub fn take<T: DeserializeOwned>(&self) -> Result<Option<T>, TakeDataError> {
     pub fn take<T: DeserializeOwned>(&self) -> Result<Option<T>, TakeDataError> {

+ 6 - 1
packages/web/src/hydration/hydrate.rs

@@ -138,7 +138,12 @@ impl WebsysDom {
             self.interpreter.base().push_root(node);
             self.interpreter.base().push_root(node);
         }
         }
 
 
-        with_server_data(HTMLDataCursor::from_serialized(&data), || {
+        let server_data = HTMLDataCursor::from_serialized(&data);
+        // If the server serialized an error into the suspense boundary, throw it on the client so that it bubbles up to the nearest error boundary
+        if let Some(error) = server_data.error() {
+            dom.in_runtime(|| id.throw_error(error));
+        }
+        with_server_data(server_data, || {
             // rerun the scope with the new data
             // rerun the scope with the new data
             SuspenseBoundaryProps::resolve_suspense(
             SuspenseBoundaryProps::resolve_suspense(
                 id,
                 id,

+ 5 - 1
packages/web/src/lib.rs

@@ -24,7 +24,7 @@ use std::{panic, rc::Rc};
 
 
 pub use crate::cfg::Config;
 pub use crate::cfg::Config;
 use crate::hydration::SuspenseMessage;
 use crate::hydration::SuspenseMessage;
-use dioxus_core::VirtualDom;
+use dioxus_core::{ScopeId, VirtualDom};
 use futures_util::{pin_mut, select, FutureExt, StreamExt};
 use futures_util::{pin_mut, select, FutureExt, StreamExt};
 
 
 mod cfg;
 mod cfg;
@@ -98,6 +98,10 @@ pub async fn run(virtual_dom: VirtualDom, web_config: Config) -> ! {
             }
             }
             let hydration_data = get_initial_hydration_data().to_vec();
             let hydration_data = get_initial_hydration_data().to_vec();
             let server_data = HTMLDataCursor::from_serialized(&hydration_data);
             let server_data = HTMLDataCursor::from_serialized(&hydration_data);
+            // If the server serialized an error into the root suspense boundary, throw it into the root scope
+            if let Some(error) = server_data.error() {
+                dom.in_runtime(|| ScopeId::APP.throw_error(error));
+            }
             with_server_data(server_data, || {
             with_server_data(server_data, || {
                 dom.rebuild(&mut websys_dom);
                 dom.rebuild(&mut websys_dom);
             });
             });