浏览代码

Fix hydration of suspense fragments that contain text nodes (#3287)

* Fix hydration of suspense fragments that contain text nodes

* fix release builds

* disable link deduplication

* restore meta tag deduplication

* Add a test for the failed hydration case

* make changes to the document trait non-breaking

* remove info log from server future hook

* restore default implementation - this was also a breaking change

* fix semver breakage

* clean up server_future

* Cleanup unused code

* fix clippy

---------

Co-authored-by: Jonathan Kelley <jkelleyrtp@gmail.com>
Evan Almloff 5 月之前
父节点
当前提交
d41ab3a756
共有 33 个文件被更改,包括 642 次插入220 次删除
  1. 11 0
      .cargo/config.toml
  2. 7 8
      packages/core/src/root_wrapper.rs
  3. 46 1
      packages/desktop/src/document.rs
  4. 22 21
      packages/document/src/document.rs
  5. 10 3
      packages/document/src/elements/link.rs
  6. 2 1
      packages/document/src/elements/meta.rs
  7. 15 3
      packages/document/src/elements/script.rs
  8. 36 4
      packages/document/src/elements/style.rs
  9. 17 38
      packages/fullstack/src/document/server.rs
  10. 11 16
      packages/fullstack/src/document/web.rs
  11. 5 2
      packages/fullstack/src/hooks/server_cached.rs
  12. 27 30
      packages/fullstack/src/hooks/server_future.rs
  13. 64 16
      packages/fullstack/src/html_storage/mod.rs
  14. 49 32
      packages/fullstack/src/html_storage/serialize.rs
  15. 20 2
      packages/fullstack/src/render.rs
  16. 22 3
      packages/fullstack/src/streaming.rs
  17. 1 1
      packages/interpreter/src/js/core.js
  18. 1 1
      packages/interpreter/src/js/hash.txt
  19. 1 1
      packages/interpreter/src/js/hydrate.js
  20. 1 1
      packages/interpreter/src/js/initialize_streaming.js
  21. 7 0
      packages/interpreter/src/lib.rs
  22. 19 5
      packages/interpreter/src/ts/core.ts
  23. 9 4
      packages/interpreter/src/ts/hydrate.ts
  24. 12 3
      packages/interpreter/src/ts/hydrate_types.ts
  25. 8 3
      packages/interpreter/src/ts/initialize_streaming.ts
  26. 54 1
      packages/liveview/src/document.rs
  27. 0 0
      packages/playwright-tests/nested-suspense/assets/style.css
  28. 4 0
      packages/playwright-tests/nested-suspense/src/main.rs
  29. 54 1
      packages/web/src/document.rs
  30. 49 7
      packages/web/src/hydration/deserialize.rs
  31. 29 11
      packages/web/src/hydration/hydrate.rs
  32. 8 0
      packages/web/src/hydration/mod.rs
  33. 21 1
      packages/web/src/lib.rs

+ 11 - 0
.cargo/config.toml

@@ -0,0 +1,11 @@
+[profile]
+
+[profile.dioxus-wasm]
+inherits = "dev"
+opt-level = 2
+
+[profile.dioxus-server]
+inherits = "dev"
+
+[profile.dioxus-android]
+inherits = "dev"

+ 7 - 8
packages/core/src/root_wrapper.rs

@@ -13,27 +13,26 @@ pub(crate) fn RootScopeWrapper(props: RootProps<VComponent>) -> Element {
         None,
         TEMPLATE,
         Box::new([DynamicNode::Component(
-            fc_to_builder(ErrorBoundary)
-                .children(Element::Ok(VNode::new(
+            fc_to_builder(SuspenseBoundary)
+                .fallback(|_| Element::Ok(VNode::placeholder()))
+                .children(Ok(VNode::new(
                     None,
                     TEMPLATE,
                     Box::new([DynamicNode::Component({
-                        #[allow(unused_imports)]
-                        fc_to_builder(SuspenseBoundary)
-                            .fallback(|_| Element::Ok(VNode::placeholder()))
-                            .children(Ok(VNode::new(
+                        fc_to_builder(ErrorBoundary)
+                            .children(Element::Ok(VNode::new(
                                 None,
                                 TEMPLATE,
                                 Box::new([DynamicNode::Component(props.0)]),
                                 Box::new([]),
                             )))
                             .build()
-                            .into_vcomponent(SuspenseBoundary)
+                            .into_vcomponent(ErrorBoundary)
                     })]),
                     Box::new([]),
                 )))
                 .build()
-                .into_vcomponent(ErrorBoundary),
+                .into_vcomponent(SuspenseBoundary),
         )]),
         Box::new([]),
     ))

+ 46 - 1
packages/desktop/src/document.rs

@@ -1,4 +1,8 @@
-use dioxus_document::{Document, Eval, EvalError, Evaluator};
+use dioxus_core::prelude::queue_effect;
+use dioxus_document::{
+    create_element_in_head, Document, Eval, EvalError, Evaluator, LinkProps, MetaProps,
+    ScriptProps, StyleProps,
+};
 use generational_box::{AnyStorage, GenerationalBox, UnsyncStorage};
 
 use crate::{query::Query, DesktopContext};
@@ -7,6 +11,7 @@ use crate::{query::Query, DesktopContext};
 pub const NATIVE_EVAL_JS: &str = include_str!("./js/native_eval.js");
 
 /// Represents the desktop-target's provider of evaluators.
+#[derive(Clone)]
 pub struct DesktopDocument {
     pub(crate) desktop_ctx: DesktopContext,
 }
@@ -25,6 +30,46 @@ impl Document for DesktopDocument {
     fn set_title(&self, title: String) {
         self.desktop_ctx.window.set_title(&title);
     }
+
+    /// Create a new meta tag in the head
+    fn create_meta(&self, props: MetaProps) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(create_element_in_head("meta", &props.attributes(), None));
+        });
+    }
+
+    /// Create a new script tag in the head
+    fn create_script(&self, props: ScriptProps) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(create_element_in_head(
+                "script",
+                &props.attributes(),
+                props.script_contents().ok(),
+            ));
+        });
+    }
+
+    /// Create a new style tag in the head
+    fn create_style(&self, props: StyleProps) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(create_element_in_head(
+                "style",
+                &props.attributes(),
+                props.style_contents().ok(),
+            ));
+        });
+    }
+
+    /// Create a new link tag in the head
+    fn create_link(&self, props: LinkProps) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(create_element_in_head("link", &props.attributes(), None));
+        });
+    }
 }
 
 /// Represents a desktop-target's JavaScript evaluator.

+ 22 - 21
packages/document/src/document.rs

@@ -30,7 +30,10 @@ fn format_attributes(attributes: &[(&str, String)]) -> String {
     formatted
 }
 
-fn create_element_in_head(
+/// Create a new element in the head with javascript through the [`Document::eval`] method
+///
+/// This can be used to implement the head element creation logic for most [`Document`] implementations.
+pub fn create_element_in_head(
     tag: &str,
     attributes: &[(&str, String)],
     children: Option<String>,
@@ -74,6 +77,8 @@ pub trait Document: 'static {
         attributes: &[(&str, String)],
         contents: Option<String>,
     ) {
+        // This default implementation remains to make the trait compatible with the 0.6 version, but it should not be used
+        // The element should only be created inside an effect so it is not called while the component is suspended
         self.eval(create_element_in_head(name, attributes, contents));
     }
 
@@ -86,30 +91,13 @@ pub trait Document: 'static {
     /// Create a new script tag in the head
     fn create_script(&self, props: ScriptProps) {
         let attributes = props.attributes();
-        match (&props.src, props.script_contents()) {
-            // The script has inline contents, render it as a script tag
-            (_, Ok(contents)) => self.create_head_element("script", &attributes, Some(contents)),
-            // The script has a src, render it as a script tag without a body
-            (Some(_), _) => self.create_head_element("script", &attributes, None),
-            // The script has neither contents nor src, log an error
-            (None, Err(err)) => err.log("Script"),
-        }
+        self.create_head_element("script", &attributes, props.script_contents().ok());
     }
 
     /// Create a new style tag in the head
     fn create_style(&self, props: StyleProps) {
-        let mut attributes = props.attributes();
-        match (&props.href, props.style_contents()) {
-            // The style has inline contents, render it as a style tag
-            (_, Ok(contents)) => self.create_head_element("style", &attributes, Some(contents)),
-            // The style has a src, render it as a link tag
-            (Some(_), _) => {
-                attributes.push(("type", "text/css".into()));
-                self.create_head_element("link", &attributes, None)
-            }
-            // The style has neither contents nor src, log an error
-            (None, Err(err)) => err.log("Style"),
-        };
+        let attributes = props.attributes();
+        self.create_head_element("style", &attributes, props.style_contents().ok());
     }
 
     /// Create a new link tag in the head
@@ -117,6 +105,13 @@ pub trait Document: 'static {
         let attributes = props.attributes();
         self.create_head_element("link", &attributes, None);
     }
+
+    /// Check if we should create a new head component at all. If it returns false, the head component will be skipped.
+    ///
+    /// This runs once per head component and is used to hydrate head components in fullstack.
+    fn create_head_component(&self) -> bool {
+        true
+    }
 }
 
 /// A document that does nothing
@@ -148,4 +143,10 @@ impl Document for NoOpDocument {
         }
         Eval::new(owner.insert(Box::new(NoOpEvaluator)))
     }
+
+    fn set_title(&self, _: String) {}
+    fn create_meta(&self, _: MetaProps) {}
+    fn create_script(&self, _: ScriptProps) {}
+    fn create_style(&self, _: StyleProps) {}
+    fn create_link(&self, _: LinkProps) {}
 }

+ 10 - 3
packages/document/src/elements/link.rs

@@ -25,7 +25,8 @@ pub struct LinkProps {
 }
 
 impl LinkProps {
-    pub(crate) fn attributes(&self) -> Vec<(&'static str, String)> {
+    /// Get all the attributes for the link tag
+    pub fn attributes(&self) -> Vec<(&'static str, String)> {
         let mut attributes = Vec::new();
         if let Some(rel) = &self.rel {
             attributes.push(("rel", rel.clone()));
@@ -104,12 +105,18 @@ pub fn Link(props: LinkProps) -> Element {
     use_update_warning(&props, "Link {}");
 
     use_hook(|| {
+        let document = document();
+        let mut insert_link = document.create_head_component();
         if let Some(href) = &props.href {
             if !should_insert_link(href) {
-                return;
+                insert_link = false;
             }
         }
-        let document = document();
+
+        if !insert_link {
+            return;
+        }
+
         document.create_link(props);
     });
 

+ 2 - 1
packages/document/src/elements/meta.rs

@@ -16,7 +16,8 @@ pub struct MetaProps {
 }
 
 impl MetaProps {
-    pub(crate) fn attributes(&self) -> Vec<(&'static str, String)> {
+    /// Get all the attributes for the meta tag
+    pub fn attributes(&self) -> Vec<(&'static str, String)> {
         let mut attributes = Vec::new();
         if let Some(property) = &self.property {
             attributes.push(("property", property.clone()));

+ 15 - 3
packages/document/src/elements/script.rs

@@ -22,7 +22,8 @@ pub struct ScriptProps {
 }
 
 impl ScriptProps {
-    pub(crate) fn attributes(&self) -> Vec<(&'static str, String)> {
+    /// Get all the attributes for the script tag
+    pub fn attributes(&self) -> Vec<(&'static str, String)> {
         let mut attributes = Vec::new();
         if let Some(defer) = &self.defer {
             attributes.push(("defer", defer.to_string()));
@@ -90,13 +91,24 @@ pub fn Script(props: ScriptProps) -> Element {
     use_update_warning(&props, "Script {}");
 
     use_hook(|| {
+        let document = document();
+        let mut insert_script = document.create_head_component();
         if let Some(src) = &props.src {
             if !should_insert_script(src) {
-                return;
+                insert_script = false;
             }
         }
 
-        let document = document();
+        if !insert_script {
+            return;
+        }
+
+        // Make sure the props are in a valid form - they must either have a source or children
+        if let (None, Err(err)) = (&props.src, props.script_contents()) {
+            // If the script has neither contents nor src, log an error
+            err.log("Script")
+        }
+
         document.create_script(props);
     });
 

+ 36 - 4
packages/document/src/elements/style.rs

@@ -17,7 +17,8 @@ pub struct StyleProps {
 }
 
 impl StyleProps {
-    pub(crate) fn attributes(&self) -> Vec<(&'static str, String)> {
+    /// Get all the attributes for the style tag
+    pub fn attributes(&self) -> Vec<(&'static str, String)> {
         let mut attributes = Vec::new();
         if let Some(href) = &self.href {
             attributes.push(("href", href.clone()));
@@ -71,13 +72,44 @@ pub fn Style(props: StyleProps) -> Element {
     use_update_warning(&props, "Style {}");
 
     use_hook(|| {
+        let document = document();
+        let mut insert_style = document.create_head_component();
         if let Some(href) = &props.href {
             if !should_insert_style(href) {
-                return;
+                insert_style = false;
             }
         }
-        let document = document();
-        document.create_style(props);
+        if !insert_style {
+            return;
+        }
+        let mut attributes = props.attributes();
+        match (&props.href, props.style_contents()) {
+            // The style has inline contents, render it as a style tag
+            (_, Ok(_)) => document.create_style(props),
+            // The style has a src, render it as a link tag
+            (Some(_), _) => {
+                attributes.push(("type", "text/css".into()));
+                document.create_link(LinkProps {
+                    media: props.media,
+                    title: props.title,
+                    r#type: Some("text/css".to_string()),
+                    additional_attributes: props.additional_attributes,
+                    href: props.href,
+                    rel: None,
+                    disabled: None,
+                    r#as: None,
+                    sizes: None,
+                    crossorigin: None,
+                    referrerpolicy: None,
+                    fetchpriority: None,
+                    hreflang: None,
+                    integrity: None,
+                    blocking: None,
+                });
+            }
+            // The style has neither contents nor src, log an error
+            (None, Err(err)) => err.log("Style"),
+        };
     });
 
     VNode::empty()

+ 17 - 38
packages/fullstack/src/document/server.rs

@@ -59,12 +59,13 @@ impl ServerDocument {
 
     /// Write the head element into the serialized context for hydration
     /// We write true if the head element was written to the DOM during server side rendering
+    #[track_caller]
     pub(crate) fn serialize_for_hydration(&self) {
         // We only serialize the head elements if the web document feature is enabled
         #[cfg(feature = "document")]
         {
             let serialize = crate::html_storage::serialize_context();
-            serialize.push(&!self.0.borrow().streaming);
+            serialize.push(&!self.0.borrow().streaming, std::panic::Location::caller());
         }
     }
 }
@@ -76,13 +77,10 @@ impl Document for ServerDocument {
 
     fn set_title(&self, title: String) {
         self.warn_if_streaming();
-        self.serialize_for_hydration();
         self.0.borrow_mut().title = Some(title);
     }
 
     fn create_meta(&self, props: MetaProps) {
-        self.warn_if_streaming();
-        self.serialize_for_hydration();
         self.0.borrow_mut().meta.push(rsx! {
             meta {
                 name: props.name,
@@ -96,8 +94,6 @@ impl Document for ServerDocument {
     }
 
     fn create_script(&self, props: ScriptProps) {
-        self.warn_if_streaming();
-        self.serialize_for_hydration();
         let children = props.script_contents().ok();
         self.0.borrow_mut().script.push(rsx! {
             script {
@@ -117,42 +113,19 @@ impl Document for ServerDocument {
     }
 
     fn create_style(&self, props: StyleProps) {
-        self.warn_if_streaming();
-        self.serialize_for_hydration();
-        match (&props.href, props.style_contents()) {
-            // The style has inline contents, render it as a style tag
-            (_, Ok(contents)) => self.0.borrow_mut().script.push(rsx! {
-                style {
-                    media: props.media,
-                    nonce: props.nonce,
-                    title: props.title,
-                    ..props.additional_attributes,
-                    {contents}
-                }
-            }),
-            // The style has a href, render it as a link tag
-            (Some(_), _) => {
-                self.0.borrow_mut().script.push(rsx! {
-                    link {
-                        rel: "stylesheet",
-                        href: props.href,
-                        media: props.media,
-                        nonce: props.nonce,
-                        title: props.title,
-                        ..props.additional_attributes,
-                    }
-                });
-            }
-            // The style has neither contents nor src, log an error
-            (None, Err(err)) => {
-                err.log("Style");
+        let contents = props.style_contents().ok();
+        self.0.borrow_mut().script.push(rsx! {
+            style {
+                media: props.media,
+                nonce: props.nonce,
+                title: props.title,
+                ..props.additional_attributes,
+                {contents}
             }
-        }
+        })
     }
 
     fn create_link(&self, props: LinkProps) {
-        self.warn_if_streaming();
-        self.serialize_for_hydration();
         self.0.borrow_mut().link.push(rsx! {
             link {
                 rel: props.rel,
@@ -172,4 +145,10 @@ impl Document for ServerDocument {
             }
         })
     }
+
+    fn create_head_component(&self) -> bool {
+        self.warn_if_streaming();
+        self.serialize_for_hydration();
+        true
+    }
 }

+ 11 - 16
packages/fullstack/src/document/web.rs

@@ -1,7 +1,7 @@
 #![allow(unused)]
 //! On the client, we use the [`WebDocument`] implementation to render the head for any elements that were not rendered on the server.
 
-use dioxus_lib::document::*;
+use dioxus_lib::{document::*, prelude::queue_effect};
 use dioxus_web::WebDocument;
 
 fn head_element_written_on_server() -> bool {
@@ -12,6 +12,7 @@ fn head_element_written_on_server() -> bool {
 }
 
 /// A document provider for fullstack web clients
+#[derive(Clone)]
 pub struct FullstackWebDocument;
 
 impl Document for FullstackWebDocument {
@@ -19,38 +20,32 @@ impl Document for FullstackWebDocument {
         WebDocument.eval(js)
     }
 
+    /// Set the title of the document
     fn set_title(&self, title: String) {
-        if head_element_written_on_server() {
-            return;
-        }
         WebDocument.set_title(title);
     }
 
+    /// Create a new meta tag in the head
     fn create_meta(&self, props: MetaProps) {
-        if head_element_written_on_server() {
-            return;
-        }
         WebDocument.create_meta(props);
     }
 
+    /// Create a new script tag in the head
     fn create_script(&self, props: ScriptProps) {
-        if head_element_written_on_server() {
-            return;
-        }
         WebDocument.create_script(props);
     }
 
+    /// Create a new style tag in the head
     fn create_style(&self, props: StyleProps) {
-        if head_element_written_on_server() {
-            return;
-        }
         WebDocument.create_style(props);
     }
 
+    /// Create a new link tag in the head
     fn create_link(&self, props: LinkProps) {
-        if head_element_written_on_server() {
-            return;
-        }
         WebDocument.create_link(props);
     }
+
+    fn create_head_component(&self) -> bool {
+        !head_element_written_on_server()
+    }
 }

+ 5 - 2
packages/fullstack/src/hooks/server_cached.rs

@@ -20,20 +20,23 @@ use serde::{de::DeserializeOwned, Serialize};
 ///    unimplemented!()
 /// }
 /// ```
+#[track_caller]
 pub fn use_server_cached<O: 'static + Clone + Serialize + DeserializeOwned>(
     server_fn: impl Fn() -> O,
 ) -> O {
-    use_hook(|| server_cached(server_fn))
+    let location = std::panic::Location::caller();
+    use_hook(|| server_cached(server_fn, location))
 }
 
 pub(crate) fn server_cached<O: 'static + Clone + Serialize + DeserializeOwned>(
     value: impl FnOnce() -> O,
+    #[allow(unused)] location: &'static std::panic::Location<'static>,
 ) -> O {
     #[cfg(feature = "server")]
     {
         let serialize = crate::html_storage::serialize_context();
         let data = value();
-        serialize.push(&data);
+        serialize.push(&data, location);
         data
     }
     #[cfg(all(not(feature = "server"), feature = "web"))]

+ 27 - 30
packages/fullstack/src/hooks/server_future.rs

@@ -56,6 +56,7 @@ use std::future::Future;
 /// }
 /// ```
 #[must_use = "Consider using `cx.spawn` to run a future without reading its value"]
+#[track_caller]
 pub fn use_server_future<T, F>(
     mut future: impl FnMut() -> F + 'static,
 ) -> Result<Resource<T>, RenderError>
@@ -65,10 +66,14 @@ where
 {
     #[cfg(feature = "server")]
     let serialize_context = crate::html_storage::use_serialize_context();
+
     // We always create a storage entry, even if the data isn't ready yet to make it possible to deserialize pending server futures on the client
     #[cfg(feature = "server")]
     let server_storage_entry = use_hook(|| serialize_context.create_entry());
 
+    #[cfg(feature = "server")]
+    let caller = std::panic::Location::caller();
+
     // If this is the first run and we are on the web client, the data might be cached
     #[cfg(feature = "web")]
     let initial_web_result = use_hook(|| {
@@ -80,33 +85,28 @@ where
     let resource = use_resource(move || {
         #[cfg(feature = "server")]
         let serialize_context = serialize_context.clone();
+
         let user_fut = future();
+
         #[cfg(feature = "web")]
         let initial_web_result = initial_web_result.clone();
 
+        #[allow(clippy::let_and_return)]
         async move {
             // If this is the first run and we are on the web client, the data might be cached
             #[cfg(feature = "web")]
-            {
-                let initial = initial_web_result.borrow_mut().take();
-                match initial {
-                    // This isn't the first run
-                    None => {}
-                    // This is the first run
-                    Some(first_run) => {
-                        match first_run {
-                            // THe data was deserialized successfully from the server
-                            Ok(Some(o)) => return o,
-                            // The data is still pending from the server. Don't try to resolve it on the client
-                            Ok(None) => {
-                                tracing::trace!("Waiting for server data");
-                                std::future::pending::<()>().await;
-                            }
-                            // The data was not available on the server, rerun the future
-                            Err(_) => {}
-                        }
-                    }
-                }
+            match initial_web_result.take() {
+                // The data was deserialized successfully from the server
+                Some(Ok(Some(o))) => return o,
+
+                // The data is still pending from the server. Don't try to resolve it on the client
+                Some(Ok(None)) => std::future::pending::<()>().await,
+
+                // The data was not available on the server, rerun the future
+                Some(Err(_)) => {}
+
+                // This isn't the first run, so we don't need do anything
+                None => {}
             }
 
             // Otherwise just run the future itself
@@ -114,9 +114,8 @@ where
 
             // If this is the first run and we are on the server, cache the data in the slot we reserved for it
             #[cfg(feature = "server")]
-            serialize_context.insert(server_storage_entry, &out);
+            serialize_context.insert(server_storage_entry, &out, caller);
 
-            #[allow(clippy::let_and_return)]
             out
         }
     });
@@ -127,14 +126,12 @@ where
     });
 
     // Suspend if the value isn't ready
-    match resource.state().cloned() {
-        UseResourceState::Pending => {
-            let task = resource.task();
-            if !task.paused() {
-                return Err(suspend(task).unwrap_err());
-            }
-            Ok(resource)
+    if resource.state().cloned() == UseResourceState::Pending {
+        let task = resource.task();
+        if !task.paused() {
+            return Err(suspend(task).unwrap_err());
         }
-        _ => Ok(resource),
     }
+
+    Ok(resource)
 }

+ 64 - 16
packages/fullstack/src/html_storage/mod.rs

@@ -1,11 +1,8 @@
-#![allow(unused)]
-use base64::Engine;
-use dioxus_lib::prelude::{has_context, provide_context, use_hook};
-use serialize::serde_to_writable;
-use std::{cell::RefCell, io::Cursor, rc::Rc, sync::atomic::AtomicUsize};
+#![cfg(feature = "server")]
 
-use base64::engine::general_purpose::STANDARD;
-use serde::{de::DeserializeOwned, Serialize};
+use dioxus_lib::prelude::{has_context, provide_context, use_hook};
+use serde::Serialize;
+use std::{cell::RefCell, rc::Rc};
 
 pub(crate) mod serialize;
 
@@ -21,13 +18,22 @@ impl SerializeContext {
     }
 
     /// Insert data into an entry that was created with [`Self::create_entry`]
-    pub(crate) fn insert<T: Serialize>(&self, id: usize, value: &T) {
-        self.data.borrow_mut().insert(id, value);
+    pub(crate) fn insert<T: Serialize>(
+        &self,
+        id: usize,
+        value: &T,
+        location: &'static std::panic::Location<'static>,
+    ) {
+        self.data.borrow_mut().insert(id, value, location);
     }
 
     /// Push resolved data into the serialized server data
-    pub(crate) fn push<T: Serialize>(&self, data: &T) {
-        self.data.borrow_mut().push(data);
+    pub(crate) fn push<T: Serialize>(
+        &self,
+        data: &T,
+        location: &'static std::panic::Location<'static>,
+    ) {
+        self.data.borrow_mut().push(data, location);
     }
 }
 
@@ -39,31 +45,73 @@ pub(crate) fn serialize_context() -> SerializeContext {
     has_context().unwrap_or_else(|| provide_context(SerializeContext::default()))
 }
 
-#[derive(serde::Serialize, serde::Deserialize, Default)]
-#[serde(transparent)]
+#[derive(Default)]
 pub(crate) struct HTMLData {
+    /// The data required for hydration
     pub data: Vec<Option<Vec<u8>>>,
+    /// The types of each serialized data
+    ///
+    /// NOTE: we don't store this in the main data vec because we don't want to include it in
+    /// release mode and we can't assume both the client and server are built with debug assertions
+    /// matching
+    #[cfg(debug_assertions)]
+    pub debug_types: Vec<Option<String>>,
+    /// The locations of each serialized data
+    #[cfg(debug_assertions)]
+    pub debug_locations: Vec<Option<String>>,
 }
 
 impl HTMLData {
     /// Create a new entry in the data that will be sent to the client without inserting any data. Returns an id that can be used to insert data into the entry once it is ready.
-    pub(crate) fn create_entry(&mut self) -> usize {
+    fn create_entry(&mut self) -> usize {
         let id = self.data.len();
         self.data.push(None);
+        #[cfg(debug_assertions)]
+        {
+            self.debug_types.push(None);
+            self.debug_locations.push(None);
+        }
         id
     }
 
     /// Insert data into an entry that was created with [`Self::create_entry`]
-    pub(crate) fn insert<T: Serialize>(&mut self, id: usize, value: &T) {
+    fn insert<T: Serialize>(
+        &mut self,
+        id: usize,
+        value: &T,
+        location: &'static std::panic::Location<'static>,
+    ) {
         let mut serialized = Vec::new();
         ciborium::into_writer(value, &mut serialized).unwrap();
         self.data[id] = Some(serialized);
+        #[cfg(debug_assertions)]
+        {
+            self.debug_types[id] = Some(std::any::type_name::<T>().to_string());
+            self.debug_locations[id] = Some(location.to_string());
+        }
     }
 
     /// Push resolved data into the serialized server data
-    pub(crate) fn push<T: Serialize>(&mut self, data: &T) {
+    fn push<T: Serialize>(&mut self, data: &T, location: &'static std::panic::Location<'static>) {
         let mut serialized = Vec::new();
         ciborium::into_writer(data, &mut serialized).unwrap();
         self.data.push(Some(serialized));
+        #[cfg(debug_assertions)]
+        {
+            self.debug_types
+                .push(Some(std::any::type_name::<T>().to_string()));
+            self.debug_locations.push(Some(location.to_string()));
+        }
+    }
+
+    /// Extend this data with the data from another [`HTMLData`]
+    pub(crate) fn extend(&mut self, other: &Self) {
+        self.data.extend_from_slice(&other.data);
+        #[cfg(debug_assertions)]
+        {
+            self.debug_types.extend_from_slice(&other.debug_types);
+            self.debug_locations
+                .extend_from_slice(&other.debug_locations);
+        }
     }
 }

+ 49 - 32
packages/fullstack/src/html_storage/serialize.rs

@@ -1,45 +1,29 @@
-use dioxus_lib::prelude::dioxus_core::DynamicNode;
-use dioxus_lib::prelude::{
-    has_context, try_consume_context, ErrorContext, ScopeId, SuspenseBoundaryProps,
-    SuspenseContext, VNode, VirtualDom,
-};
-use serde::Serialize;
-
-use base64::engine::general_purpose::STANDARD;
 use base64::Engine;
+use dioxus_lib::prelude::dioxus_core::DynamicNode;
+use dioxus_lib::prelude::{has_context, ErrorContext, ScopeId, SuspenseContext, VNode, VirtualDom};
 
 use super::SerializeContext;
 
-#[allow(unused)]
-pub(crate) fn serde_to_writable<T: Serialize>(
-    value: &T,
-    write_to: &mut impl std::fmt::Write,
-) -> Result<(), ciborium::ser::Error<std::fmt::Error>> {
-    let mut serialized = Vec::new();
-    ciborium::into_writer(value, &mut serialized).unwrap();
-    write_to.write_str(STANDARD.encode(serialized).as_str())?;
-    Ok(())
-}
-
 impl super::HTMLData {
     /// Walks through the suspense boundary in a depth first order and extracts the data from the context API.
     /// 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();
+        data.serialize_errors(vdom, scope);
+        data.take_from_scope(vdom, scope);
+        data
+    }
+
+    /// Get the errors from the suspense boundary
+    fn serialize_errors(&mut self, vdom: &VirtualDom, scope: ScopeId) {
         // 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(|| {
+        let 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
-    }
-
-    fn take_from_virtual_dom(&mut self, vdom: &VirtualDom) {
-        self.take_from_scope(vdom, ScopeId::ROOT)
+        self.push(&error, std::panic::Location::caller());
     }
 
     fn take_from_scope(&mut self, vdom: &VirtualDom, scope: ScopeId) {
@@ -48,9 +32,7 @@ impl super::HTMLData {
                 // Grab any serializable server context from this scope
                 let context: Option<SerializeContext> = has_context();
                 if let Some(context) = context {
-                    let borrow = context.data.borrow();
-                    let mut data = borrow.data.iter().cloned();
-                    self.data.extend(data)
+                    self.extend(&context.data.borrow());
                 }
             });
         });
@@ -91,9 +73,44 @@ impl super::HTMLData {
 
     #[cfg(feature = "server")]
     /// Encode data as base64. This is intended to be used in the server to send data to the client.
-    pub(crate) fn serialized(&self) -> String {
+    pub(crate) fn serialized(&self) -> SerializedHydrationData {
         let mut serialized = Vec::new();
         ciborium::into_writer(&self.data, &mut serialized).unwrap();
-        base64::engine::general_purpose::STANDARD.encode(serialized)
+
+        let data = base64::engine::general_purpose::STANDARD.encode(serialized);
+
+        let format_js_list_of_strings = |list: &[Option<String>]| {
+            let body = list
+                .iter()
+                .map(|s| match s {
+                    Some(s) => format!(r#""{s}""#),
+                    None => r#""unknown""#.to_string(),
+                })
+                .collect::<Vec<_>>()
+                .join(",");
+            format!("[{}]", body)
+        };
+
+        SerializedHydrationData {
+            data,
+            #[cfg(debug_assertions)]
+            debug_types: format_js_list_of_strings(&self.debug_types),
+            #[cfg(debug_assertions)]
+            debug_locations: format_js_list_of_strings(&self.debug_locations),
+        }
     }
 }
+
+#[cfg(feature = "server")]
+/// Data that was serialized on the server for hydration on the client. This includes
+/// extra information about the types and sources of the serialized data in debug mode
+pub(crate) struct SerializedHydrationData {
+    /// The base64 encoded serialized data
+    pub data: String,
+    /// A list of the types of each serialized data
+    #[cfg(debug_assertions)]
+    pub debug_types: String,
+    /// A list of the locations of each serialized data
+    #[cfg(debug_assertions)]
+    pub debug_locations: String,
+}

+ 20 - 2
packages/fullstack/src/render.rs

@@ -1,5 +1,6 @@
 //! A shared pool of renderers for efficient server side rendering.
 use crate::document::ServerDocument;
+use crate::html_storage::serialize::SerializedHydrationData;
 use crate::streaming::{Mount, StreamingRenderer};
 use dioxus_cli_config::base_path;
 use dioxus_interpreter_js::INITIALIZE_STREAMING_JS;
@@ -408,7 +409,7 @@ fn start_capturing_errors(suspense_scope: ScopeId) {
     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) -> SerializedHydrationData {
     // 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)
     let html_data =
@@ -530,10 +531,27 @@ impl FullstackHTMLTemplate {
         // Collect the initial server data from the root node. For most apps, no use_server_futures will be resolved initially, so this will be full on `None`s.
         // Sending down those Nones are still important to tell the client not to run the use_server_futures that are already running on the backend
         let resolved_data = serialize_server_data(virtual_dom, ScopeId::ROOT);
+        // We always send down the data required to hydrate components on the client
+        let raw_data = resolved_data.data;
         write!(
             to,
-            r#"<script>window.initial_dioxus_hydration_data="{resolved_data}";</script>"#,
+            r#"<script>window.initial_dioxus_hydration_data="{raw_data}";"#,
         )?;
+        #[cfg(debug_assertions)]
+        {
+            // In debug mode, we also send down the type names and locations of the serialized data
+            let debug_types = &resolved_data.debug_types;
+            let debug_locations = &resolved_data.debug_locations;
+            write!(
+                to,
+                r#"window.initial_dioxus_hydration_debug_types={debug_types};"#,
+            )?;
+            write!(
+                to,
+                r#"window.initial_dioxus_hydration_debug_locations={debug_locations};"#,
+            )?;
+        }
+        write!(to, r#"</script>"#,)?;
         to.write_str(&index.post_main)?;
 
         Ok(())

+ 22 - 3
packages/fullstack/src/streaming.rs

@@ -33,6 +33,8 @@ use std::{
     sync::{Arc, RwLock},
 };
 
+use crate::html_storage::serialize::SerializedHydrationData;
+
 /// Sections are identified by a unique id based on the suspense path. We only track the path of suspense boundaries because the client may render different components than the server.
 #[derive(Clone, Debug, Default)]
 struct MountPath {
@@ -109,7 +111,7 @@ impl<E> StreamingRenderer<E> {
         &self,
         id: Mount,
         html: impl FnOnce(&mut W) -> std::fmt::Result,
-        data: impl Display,
+        resolved_data: SerializedHydrationData,
         into: &mut W,
     ) -> std::fmt::Result {
         // Then replace the suspense placeholder with the new content
@@ -119,10 +121,27 @@ impl<E> StreamingRenderer<E> {
         html(into)?;
         // Restore the old path
         *self.current_path.write().unwrap() = old_path;
+        // dx_hydrate accepts 2-4 arguments. The first two are required, the rest are optional
+        // The arguments are:
+        // 1. The id of the nodes we are hydrating under
+        // 2. The serialized data required to hydrate those components
+        // 3. (in debug mode) The type names of the serialized data
+        // 4. (in debug mode) The locations of the serialized data
+        let raw_data = resolved_data.data;
         write!(
             into,
-            r#"</div><script>window.dx_hydrate([{id}], "{data}")</script>"#
-        )
+            r#"</div><script>window.dx_hydrate([{id}], "{raw_data}""#
+        )?;
+        #[cfg(debug_assertions)]
+        {
+            // In debug mode, we also send down the type names and locations of the serialized data
+            let debug_types = &resolved_data.debug_types;
+            let debug_locations = &resolved_data.debug_locations;
+            write!(into, r#", {debug_types}, {debug_locations}"#,)?;
+        }
+        write!(into, r#")</script>"#)?;
+
+        Ok(())
     }
 
     /// Close the stream with an error

+ 1 - 1
packages/interpreter/src/js/core.js

@@ -1 +1 @@
-function setAttributeInner(node,field,value,ns){if(ns==="style"){node.style.setProperty(field,value);return}if(ns){node.setAttributeNS(ns,field,value);return}switch(field){case"value":if(node.value!==value)node.value=value;break;case"initial_value":node.defaultValue=value;break;case"checked":node.checked=truthy(value);break;case"initial_checked":node.defaultChecked=truthy(value);break;case"selected":node.selected=truthy(value);break;case"initial_selected":node.defaultSelected=truthy(value);break;case"dangerous_inner_html":node.innerHTML=value;break;default:if(!truthy(value)&&isBoolAttr(field))node.removeAttribute(field);else node.setAttribute(field,value)}}var truthy=function(val){return val==="true"||val===!0},isBoolAttr=function(field){switch(field){case"allowfullscreen":case"allowpaymentrequest":case"async":case"autofocus":case"autoplay":case"checked":case"controls":case"default":case"defer":case"disabled":case"formnovalidate":case"hidden":case"ismap":case"itemscope":case"loop":case"multiple":case"muted":case"nomodule":case"novalidate":case"open":case"playsinline":case"readonly":case"required":case"reversed":case"selected":case"truespeed":case"webkitdirectory":return!0;default:return!1}};class BaseInterpreter{global;local;root;handler;resizeObserver;intersectionObserver;nodes;stack;templates;m;constructor(){}initialize(root,handler=null){this.global={},this.local={},this.root=root,this.nodes=[root],this.stack=[root],this.templates={},this.handler=handler,root.setAttribute("data-dioxus-id","0")}handleResizeEvent(entry){const target=entry.target;let event=new CustomEvent("resize",{bubbles:!1,detail:entry});target.dispatchEvent(event)}createResizeObserver(element){if(!this.resizeObserver)this.resizeObserver=new ResizeObserver((entries)=>{for(let entry of entries)this.handleResizeEvent(entry)});this.resizeObserver.observe(element)}removeResizeObserver(element){if(this.resizeObserver)this.resizeObserver.unobserve(element)}handleIntersectionEvent(entry){const target=entry.target;let event=new CustomEvent("visible",{bubbles:!1,detail:entry});target.dispatchEvent(event)}createIntersectionObserver(element){if(!this.intersectionObserver)this.intersectionObserver=new IntersectionObserver((entries)=>{for(let entry of entries)this.handleIntersectionEvent(entry)});this.intersectionObserver.observe(element)}removeIntersectionObserver(element){if(this.intersectionObserver)this.intersectionObserver.unobserve(element)}createListener(event_name,element,bubbles){if(event_name=="resize")this.createResizeObserver(element);else if(event_name=="visible")this.createIntersectionObserver(element);if(bubbles)if(this.global[event_name]===void 0)this.global[event_name]={active:1,callback:this.handler},this.root.addEventListener(event_name,this.handler);else this.global[event_name].active++;else{const id=element.getAttribute("data-dioxus-id");if(!this.local[id])this.local[id]={};element.addEventListener(event_name,this.handler)}}removeListener(element,event_name,bubbles){if(event_name=="resize")this.removeResizeObserver(element);else if(event_name=="visible")this.removeIntersectionObserver(element);else if(bubbles)this.removeBubblingListener(event_name);else this.removeNonBubblingListener(element,event_name)}removeBubblingListener(event_name){if(this.global[event_name].active--,this.global[event_name].active===0)this.root.removeEventListener(event_name,this.global[event_name].callback),delete this.global[event_name]}removeNonBubblingListener(element,event_name){const id=element.getAttribute("data-dioxus-id");if(delete this.local[id][event_name],Object.keys(this.local[id]).length===0)delete this.local[id];element.removeEventListener(event_name,this.handler)}removeAllNonBubblingListeners(element){const id=element.getAttribute("data-dioxus-id");delete this.local[id]}getNode(id){return this.nodes[id]}pushRoot(node){this.stack.push(node)}appendChildren(id,many){const root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k<many;k++)root.appendChild(els[k])}loadChild(ptr,len){let node=this.stack[this.stack.length-1],ptr_end=ptr+len;for(;ptr<ptr_end;ptr++){let end=this.m.getUint8(ptr);for(node=node.firstChild;end>0;end--)node=node.nextSibling}return node}saveTemplate(nodes,tmpl_id){this.templates[tmpl_id]=nodes}hydrate_node(hydrateNode,ids){const split=hydrateNode.getAttribute("data-node-hydration").split(","),id=ids[parseInt(split[0])];if(this.nodes[id]=hydrateNode,split.length>1){hydrateNode.listening=split.length-1,hydrateNode.setAttribute("data-dioxus-id",id.toString());for(let j=1;j<split.length;j++){const split2=split[j].split(":"),event_name=split2[0],bubbles=split2[1]==="1";this.createListener(event_name,hydrateNode,bubbles)}}}hydrate(ids,underNodes){for(let i=0;i<underNodes.length;i++){const under=underNodes[i];if(under instanceof HTMLElement){if(under.getAttribute("data-node-hydration"))this.hydrate_node(under,ids);const hydrateNodes=under.querySelectorAll("[data-node-hydration]");for(let i2=0;i2<hydrateNodes.length;i2++)this.hydrate_node(hydrateNodes[i2],ids)}const treeWalker=document.createTreeWalker(under,NodeFilter.SHOW_COMMENT);while(treeWalker.currentNode){const currentNode=treeWalker.currentNode;if(currentNode.nodeType===Node.COMMENT_NODE){const id=currentNode.textContent,placeholderSplit=id.split("placeholder");if(placeholderSplit.length>1){if(this.nodes[ids[parseInt(placeholderSplit[1])]]=currentNode,!treeWalker.nextNode())break;continue}const textNodeSplit=id.split("node-id");if(textNodeSplit.length>1){let next=currentNode.nextSibling;currentNode.remove();let commentAfterText,textNode;if(next.nodeType===Node.COMMENT_NODE){const newText=next.parentElement.insertBefore(document.createTextNode(""),next);commentAfterText=next,textNode=newText}else textNode=next,commentAfterText=textNode.nextSibling;treeWalker.currentNode=commentAfterText,this.nodes[ids[parseInt(textNodeSplit[1])]]=textNode;let exit=!treeWalker.nextNode();if(commentAfterText.remove(),exit)break;continue}}if(!treeWalker.nextNode())break}}}setAttributeInner(node,field,value,ns){setAttributeInner(node,field,value,ns)}}export{BaseInterpreter};
+function setAttributeInner(node,field,value,ns){if(ns==="style"){node.style.setProperty(field,value);return}if(ns){node.setAttributeNS(ns,field,value);return}switch(field){case"value":if(node.value!==value)node.value=value;break;case"initial_value":node.defaultValue=value;break;case"checked":node.checked=truthy(value);break;case"initial_checked":node.defaultChecked=truthy(value);break;case"selected":node.selected=truthy(value);break;case"initial_selected":node.defaultSelected=truthy(value);break;case"dangerous_inner_html":node.innerHTML=value;break;default:if(!truthy(value)&&isBoolAttr(field))node.removeAttribute(field);else node.setAttribute(field,value)}}var truthy=function(val){return val==="true"||val===!0},isBoolAttr=function(field){switch(field){case"allowfullscreen":case"allowpaymentrequest":case"async":case"autofocus":case"autoplay":case"checked":case"controls":case"default":case"defer":case"disabled":case"formnovalidate":case"hidden":case"ismap":case"itemscope":case"loop":case"multiple":case"muted":case"nomodule":case"novalidate":case"open":case"playsinline":case"readonly":case"required":case"reversed":case"selected":case"truespeed":case"webkitdirectory":return!0;default:return!1}};class BaseInterpreter{global;local;root;handler;resizeObserver;intersectionObserver;nodes;stack;templates;m;constructor(){}initialize(root,handler=null){this.global={},this.local={},this.root=root,this.nodes=[root],this.stack=[root],this.templates={},this.handler=handler,root.setAttribute("data-dioxus-id","0")}handleResizeEvent(entry){const target=entry.target;let event=new CustomEvent("resize",{bubbles:!1,detail:entry});target.dispatchEvent(event)}createResizeObserver(element){if(!this.resizeObserver)this.resizeObserver=new ResizeObserver((entries)=>{for(let entry of entries)this.handleResizeEvent(entry)});this.resizeObserver.observe(element)}removeResizeObserver(element){if(this.resizeObserver)this.resizeObserver.unobserve(element)}handleIntersectionEvent(entry){const target=entry.target;let event=new CustomEvent("visible",{bubbles:!1,detail:entry});target.dispatchEvent(event)}createIntersectionObserver(element){if(!this.intersectionObserver)this.intersectionObserver=new IntersectionObserver((entries)=>{for(let entry of entries)this.handleIntersectionEvent(entry)});this.intersectionObserver.observe(element)}removeIntersectionObserver(element){if(this.intersectionObserver)this.intersectionObserver.unobserve(element)}createListener(event_name,element,bubbles){if(event_name=="resize")this.createResizeObserver(element);else if(event_name=="visible")this.createIntersectionObserver(element);if(bubbles)if(this.global[event_name]===void 0)this.global[event_name]={active:1,callback:this.handler},this.root.addEventListener(event_name,this.handler);else this.global[event_name].active++;else{const id=element.getAttribute("data-dioxus-id");if(!this.local[id])this.local[id]={};element.addEventListener(event_name,this.handler)}}removeListener(element,event_name,bubbles){if(event_name=="resize")this.removeResizeObserver(element);else if(event_name=="visible")this.removeIntersectionObserver(element);else if(bubbles)this.removeBubblingListener(event_name);else this.removeNonBubblingListener(element,event_name)}removeBubblingListener(event_name){if(this.global[event_name].active--,this.global[event_name].active===0)this.root.removeEventListener(event_name,this.global[event_name].callback),delete this.global[event_name]}removeNonBubblingListener(element,event_name){const id=element.getAttribute("data-dioxus-id");if(delete this.local[id][event_name],Object.keys(this.local[id]).length===0)delete this.local[id];element.removeEventListener(event_name,this.handler)}removeAllNonBubblingListeners(element){const id=element.getAttribute("data-dioxus-id");delete this.local[id]}getNode(id){return this.nodes[id]}pushRoot(node){this.stack.push(node)}appendChildren(id,many){const root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k<many;k++)root.appendChild(els[k])}loadChild(ptr,len){let node=this.stack[this.stack.length-1],ptr_end=ptr+len;for(;ptr<ptr_end;ptr++){let end=this.m.getUint8(ptr);for(node=node.firstChild;end>0;end--)node=node.nextSibling}return node}saveTemplate(nodes,tmpl_id){this.templates[tmpl_id]=nodes}hydrate_node(hydrateNode,ids){const split=hydrateNode.getAttribute("data-node-hydration").split(","),id=ids[parseInt(split[0])];if(this.nodes[id]=hydrateNode,split.length>1){hydrateNode.listening=split.length-1,hydrateNode.setAttribute("data-dioxus-id",id.toString());for(let j=1;j<split.length;j++){const split2=split[j].split(":"),event_name=split2[0],bubbles=split2[1]==="1";this.createListener(event_name,hydrateNode,bubbles)}}}hydrate(ids,underNodes){for(let i=0;i<underNodes.length;i++){const under=underNodes[i];if(under instanceof HTMLElement){if(under.getAttribute("data-node-hydration"))this.hydrate_node(under,ids);const hydrateNodes=under.querySelectorAll("[data-node-hydration]");for(let i2=0;i2<hydrateNodes.length;i2++)this.hydrate_node(hydrateNodes[i2],ids)}const treeWalker=document.createTreeWalker(under,NodeFilter.SHOW_COMMENT);let nextSibling=under.nextSibling,continueToNextNode=()=>{if(!treeWalker.nextNode())return!1;return treeWalker.currentNode!==nextSibling};while(treeWalker.currentNode){const currentNode=treeWalker.currentNode;if(currentNode.nodeType===Node.COMMENT_NODE){const id=currentNode.textContent,placeholderSplit=id.split("placeholder");if(placeholderSplit.length>1){if(this.nodes[ids[parseInt(placeholderSplit[1])]]=currentNode,!continueToNextNode())break;continue}const textNodeSplit=id.split("node-id");if(textNodeSplit.length>1){let next=currentNode.nextSibling;currentNode.remove();let commentAfterText,textNode;if(next.nodeType===Node.COMMENT_NODE){const newText=next.parentElement.insertBefore(document.createTextNode(""),next);commentAfterText=next,textNode=newText}else textNode=next,commentAfterText=textNode.nextSibling;treeWalker.currentNode=commentAfterText,this.nodes[ids[parseInt(textNodeSplit[1])]]=textNode;let exit=currentNode===under||!continueToNextNode();if(commentAfterText.remove(),exit)break;continue}}if(!continueToNextNode())break}}}setAttributeInner(node,field,value,ns){setAttributeInner(node,field,value,ns)}}export{BaseInterpreter};

+ 1 - 1
packages/interpreter/src/js/hash.txt

@@ -1 +1 @@
-[6449103750905854967, 3846442265490457921, 13069001215487072322, 8716623267269178440, 5336385715226370016, 14456089431355876478, 12156139214887111728, 5052021921702764563, 12925655762638175824, 5638004933879392817]
+[6449103750905854967, 17669692872757955279, 13069001215487072322, 11420464406527728232, 3770103091118609057, 5444526391971481782, 12156139214887111728, 5052021921702764563, 12925655762638175824, 5638004933879392817]

+ 1 - 1
packages/interpreter/src/js/hydrate.js

@@ -1 +1 @@
-function register_rehydrate_chunk_for_streaming(callback){window.hydration_callback=callback;for(let i=0;i<window.hydrate_queue.length;i++){const[id,data]=window.hydrate_queue[i];window.hydration_callback(id,data)}}export{register_rehydrate_chunk_for_streaming};
+function register_rehydrate_chunk_for_streaming(callback){return register_rehydrate_chunk_for_streaming_debug(callback)}function register_rehydrate_chunk_for_streaming_debug(callback){window.hydration_callback=callback;for(let i=0;i<window.hydrate_queue.length;i++){const[id,data,debug_types,debug_locations]=window.hydrate_queue[i];window.hydration_callback(id,data,debug_types,debug_locations)}}export{register_rehydrate_chunk_for_streaming_debug,register_rehydrate_chunk_for_streaming};

+ 1 - 1
packages/interpreter/src/js/initialize_streaming.js

@@ -1 +1 @@
-window.hydrate_queue=[];window.dx_hydrate=(id,data)=>{const decoded=atob(data),bytes=Uint8Array.from(decoded,(c)=>c.charCodeAt(0));if(window.hydration_callback)window.hydration_callback(id,bytes);else window.hydrate_queue.push([id,bytes])};
+window.hydrate_queue=[];window.dx_hydrate=(id,data,debug_types,debug_locations)=>{const decoded=atob(data),bytes=Uint8Array.from(decoded,(c)=>c.charCodeAt(0));if(window.hydration_callback)window.hydration_callback(id,bytes,debug_types,debug_locations);else window.hydrate_queue.push([id,bytes,debug_types,debug_locations])};

+ 7 - 0
packages/interpreter/src/lib.rs

@@ -47,6 +47,13 @@ pub mod minimal_bindings {
         pub fn register_rehydrate_chunk_for_streaming(
             closure: &wasm_bindgen::closure::Closure<dyn FnMut(Vec<u32>, js_sys::Uint8Array)>,
         );
+
+        /// Register a callback that that will be called to hydrate a node at the given id with data from the server
+        pub fn register_rehydrate_chunk_for_streaming_debug(
+            closure: &wasm_bindgen::closure::Closure<
+                dyn FnMut(Vec<u32>, js_sys::Uint8Array, Option<Vec<String>>, Option<Vec<String>>),
+            >,
+        );
     }
 
     #[wasm_bindgen(module = "/src/js/patch_console.js")]

+ 19 - 5
packages/interpreter/src/ts/core.ts

@@ -31,7 +31,7 @@ export class BaseInterpreter {
   // sledgehammer is generating this...
   m: any;
 
-  constructor() { }
+  constructor() {}
 
   initialize(root: HTMLElement, handler: EventListener | null = null) {
     this.global = {};
@@ -45,7 +45,7 @@ export class BaseInterpreter {
     this.handler = handler;
 
     // make sure to set the root element's ID so it still registers events
-    root.setAttribute('data-dioxus-id', "0");
+    root.setAttribute("data-dioxus-id", "0");
   }
 
   handleResizeEvent(entry: ResizeObserverEntry) {
@@ -241,6 +241,18 @@ export class BaseInterpreter {
         NodeFilter.SHOW_COMMENT
       );
 
+      let nextSibling = under.nextSibling;
+      // Continue to the next node. Returns false if we should stop traversing because we've reached the end of the children
+      // of the root
+      let continueToNextNode = () => {
+        // stop traversing if there are no more nodes
+        if (!treeWalker.nextNode()) {
+          return false;
+        }
+        // stop traversing if we have reached the next sibling of the root node
+        return treeWalker.currentNode !== nextSibling;
+      };
+
       while (treeWalker.currentNode) {
         const currentNode = treeWalker.currentNode as ChildNode;
         if (currentNode.nodeType === Node.COMMENT_NODE) {
@@ -251,7 +263,7 @@ export class BaseInterpreter {
 
           if (placeholderSplit.length > 1) {
             this.nodes[ids[parseInt(placeholderSplit[1])]] = currentNode;
-            if (!treeWalker.nextNode()) {
+            if (!continueToNextNode()) {
               break;
             }
             continue;
@@ -284,7 +296,9 @@ export class BaseInterpreter {
             }
             treeWalker.currentNode = commentAfterText;
             this.nodes[ids[parseInt(textNodeSplit[1])]] = textNode;
-            let exit = !treeWalker.nextNode();
+            // Stop traversing if we started on a comment node (which has no children)
+            // or the next sibling stops the walk
+            let exit = currentNode === under || !continueToNextNode();
             // remove the comment node after the text node
             commentAfterText.remove();
             if (exit) {
@@ -293,7 +307,7 @@ export class BaseInterpreter {
             continue;
           }
         }
-        if (!treeWalker.nextNode()) {
+        if (!continueToNextNode()) {
           break;
         }
       }

+ 9 - 4
packages/interpreter/src/ts/hydrate.ts

@@ -1,11 +1,16 @@
 import "./hydrate_types";
+import { HydrationCallback } from "./hydrate_types";
 
-export function register_rehydrate_chunk_for_streaming(
-  callback: (id: number[], data: Uint8Array) => void
+export function register_rehydrate_chunk_for_streaming(callback: HydrationCallback): void {
+  return register_rehydrate_chunk_for_streaming_debug(callback);
+}
+
+export function register_rehydrate_chunk_for_streaming_debug(
+  callback: HydrationCallback
 ): void {
   window.hydration_callback = callback;
   for (let i = 0; i < window.hydrate_queue.length; i++) {
-    const [id, data] = window.hydrate_queue[i];
-    window.hydration_callback(id, data);
+    const [id, data, debug_types, debug_locations] = window.hydrate_queue[i];
+    window.hydration_callback(id, data, debug_types, debug_locations);
   }
 }

+ 12 - 3
packages/interpreter/src/ts/hydrate_types.ts

@@ -1,8 +1,17 @@
-export {};
+export { };
+
+export type HydrationCallback = (
+  id: number[],
+  data: Uint8Array,
+  debug_types: string[] | null,
+  debug_locations: string[] | null
+) => void;
 
 declare global {
   interface Window {
-    hydrate_queue: [number[], Uint8Array][];
-    hydration_callback: null | ((id: number[], data: Uint8Array) => void);
+    hydrate_queue: [number[], Uint8Array, string[] | null, string[] | null][];
+    hydration_callback:
+    | null
+    | HydrationCallback;
   }
 }

+ 8 - 3
packages/interpreter/src/ts/initialize_streaming.ts

@@ -4,13 +4,18 @@ import "./hydrate_types";
 window.hydrate_queue = [];
 
 // @ts-ignore
-window.dx_hydrate = (id: number[], data: string) => {
+window.dx_hydrate = (
+  id: number[],
+  data: string,
+  debug_types: string[] | null,
+  debug_locations: string[] | null
+) => {
   // First convert the base64 encoded string to a Uint8Array
   const decoded = atob(data);
   const bytes = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
   if (window.hydration_callback) {
-    window.hydration_callback(id, bytes);
+    window.hydration_callback(id, bytes, debug_types, debug_locations);
   } else {
-    window.hydrate_queue.push([id, bytes]);
+    window.hydrate_queue.push([id, bytes, debug_types, debug_locations]);
   }
 };

+ 54 - 1
packages/liveview/src/document.rs

@@ -1,5 +1,9 @@
+use dioxus_core::prelude::queue_effect;
 use dioxus_core::ScopeId;
-use dioxus_document::{Document, Eval, EvalError, Evaluator};
+use dioxus_document::{
+    create_element_in_head, Document, Eval, EvalError, Evaluator, LinkProps, MetaProps,
+    ScriptProps, StyleProps,
+};
 use dioxus_history::History;
 use generational_box::{AnyStorage, GenerationalBox, UnsyncStorage};
 use std::rc::Rc;
@@ -75,6 +79,7 @@ pub fn init_document() {
 }
 
 /// Reprints the liveview-target's provider of evaluators.
+#[derive(Clone)]
 pub struct LiveviewDocument {
     query: QueryEngine,
 }
@@ -83,4 +88,52 @@ impl Document for LiveviewDocument {
     fn eval(&self, js: String) -> Eval {
         Eval::new(LiveviewEvaluator::create(self.query.clone(), js))
     }
+
+    /// Set the title of the document
+    fn set_title(&self, title: String) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(format!("document.title = {title:?};"));
+        });
+    }
+
+    /// Create a new meta tag in the head
+    fn create_meta(&self, props: MetaProps) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(create_element_in_head("meta", &props.attributes(), None));
+        });
+    }
+
+    /// Create a new script tag in the head
+    fn create_script(&self, props: ScriptProps) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(create_element_in_head(
+                "script",
+                &props.attributes(),
+                props.script_contents().ok(),
+            ));
+        });
+    }
+
+    /// Create a new style tag in the head
+    fn create_style(&self, props: StyleProps) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(create_element_in_head(
+                "style",
+                &props.attributes(),
+                props.style_contents().ok(),
+            ));
+        });
+    }
+
+    /// Create a new link tag in the head
+    fn create_link(&self, props: LinkProps) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(create_element_in_head("link", &props.attributes(), None));
+        });
+    }
 }

+ 0 - 0
packages/playwright-tests/nested-suspense/assets/style.css


+ 4 - 0
packages/playwright-tests/nested-suspense/src/main.rs

@@ -23,6 +23,9 @@ fn app() -> Element {
     rsx! {
         SuspenseBoundary {
             fallback: move |_| rsx! {},
+            document::Style {
+                href: asset!("/assets/style.css")
+            }
             LoadTitle {}
         }
         MessageWithLoader { id: 0 }
@@ -48,6 +51,7 @@ fn LoadTitle() -> Element {
         .unwrap();
 
     rsx! {
+        "title loaded"
         document::Title { "{title.title}" }
     }
 }

+ 54 - 1
packages/web/src/document.rs

@@ -1,5 +1,9 @@
+use dioxus_core::prelude::queue_effect;
 use dioxus_core::ScopeId;
-use dioxus_document::{Document, Eval, EvalError, Evaluator};
+use dioxus_document::{
+    create_element_in_head, Document, Eval, EvalError, Evaluator, LinkProps, MetaProps,
+    ScriptProps, StyleProps,
+};
 use dioxus_history::History;
 use generational_box::{AnyStorage, GenerationalBox, UnsyncStorage};
 use js_sys::Function;
@@ -69,11 +73,60 @@ pub fn init_document() {
 }
 
 /// The web-target's document provider.
+#[derive(Clone)]
 pub struct WebDocument;
 impl Document for WebDocument {
     fn eval(&self, js: String) -> Eval {
         Eval::new(WebEvaluator::create(js))
     }
+
+    /// Set the title of the document
+    fn set_title(&self, title: String) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(format!("document.title = {title:?};"));
+        });
+    }
+
+    /// Create a new meta tag in the head
+    fn create_meta(&self, props: MetaProps) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(create_element_in_head("meta", &props.attributes(), None));
+        });
+    }
+
+    /// Create a new script tag in the head
+    fn create_script(&self, props: ScriptProps) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(create_element_in_head(
+                "script",
+                &props.attributes(),
+                props.script_contents().ok(),
+            ));
+        });
+    }
+
+    /// Create a new style tag in the head
+    fn create_style(&self, props: StyleProps) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(create_element_in_head(
+                "style",
+                &props.attributes(),
+                props.style_contents().ok(),
+            ));
+        });
+    }
+
+    /// Create a new link tag in the head
+    fn create_link(&self, props: LinkProps) {
+        let myself = self.clone();
+        queue_effect(move || {
+            myself.eval(create_element_in_head("link", &props.attributes(), None));
+        });
+    }
 }
 
 /// Required to avoid blocking the Rust WASM thread.

+ 49 - 7
packages/web/src/hydration/deserialize.rs

@@ -11,6 +11,7 @@ thread_local! {
 /// Try to take the next item from the server data cursor. This will only be set during the first run of a component before hydration.
 /// This will return `None` if no data was pushed for this instance or if serialization fails
 // TODO: evan better docs
+#[track_caller]
 pub fn take_server_data<T: DeserializeOwned>() -> Result<Option<T>, TakeDataError> {
     SERVER_DATA.with_borrow(|data| match data.as_ref() {
         Some(data) => data.take(),
@@ -40,13 +41,21 @@ fn remove_server_data() {
 pub(crate) struct HTMLDataCursor {
     error: Option<CapturedError>,
     data: Vec<Option<Vec<u8>>>,
+    #[cfg(debug_assertions)]
+    debug_types: Option<Vec<String>>,
+    #[cfg(debug_assertions)]
+    debug_locations: Option<Vec<String>>,
     index: Cell<usize>,
 }
 
 impl HTMLDataCursor {
-    pub(crate) fn from_serialized(data: &[u8]) -> Self {
+    pub(crate) fn from_serialized(
+        data: &[u8],
+        debug_types: Option<Vec<String>>,
+        debug_locations: Option<Vec<String>>,
+    ) -> Self {
         let deserialized = ciborium::from_reader(Cursor::new(data)).unwrap();
-        Self::new(deserialized)
+        Self::new(deserialized, debug_types, debug_locations)
     }
 
     /// Get the error if there is one
@@ -54,11 +63,19 @@ impl HTMLDataCursor {
         self.error.clone()
     }
 
-    fn new(data: Vec<Option<Vec<u8>>>) -> Self {
+    fn new(
+        data: Vec<Option<Vec<u8>>>,
+        #[allow(unused)] debug_types: Option<Vec<String>>,
+        #[allow(unused)] debug_locations: Option<Vec<String>>,
+    ) -> Self {
         let mut myself = Self {
+            index: Cell::new(0),
             error: None,
             data,
-            index: Cell::new(0),
+            #[cfg(debug_assertions)]
+            debug_types,
+            #[cfg(debug_assertions)]
+            debug_locations,
         };
 
         // The first item is always an error if it exists
@@ -73,6 +90,7 @@ impl HTMLDataCursor {
         myself
     }
 
+    #[track_caller]
     pub fn take<T: DeserializeOwned>(&self) -> Result<Option<T>, TakeDataError> {
         let current = self.index.get();
         if current >= self.data.len() {
@@ -88,9 +106,33 @@ impl HTMLDataCursor {
         match bytes {
             Some(bytes) => match ciborium::from_reader(Cursor::new(bytes)) {
                 Ok(x) => Ok(Some(x)),
-                Err(e) => {
-                    tracing::error!("Error deserializing data: {:?}", e);
-                    Err(TakeDataError::DeserializationError(e))
+                Err(err) => {
+                    #[cfg(debug_assertions)]
+                    {
+                        let debug_type = self
+                            .debug_types
+                            .as_ref()
+                            .and_then(|types| types.get(current));
+                        let debug_locations = self
+                            .debug_locations
+                            .as_ref()
+                            .and_then(|locations| locations.get(current));
+
+                        if let (Some(debug_type), Some(debug_locations)) =
+                            (debug_type, debug_locations)
+                        {
+                            let client_type = std::any::type_name::<T>();
+                            let client_location = std::panic::Location::caller();
+                            // We we have debug types and a location, we can provide a more helpful error message
+                            tracing::error!(
+                                "Error deserializing data: {err:?}\n\nThis type was serialized on the server at {debug_locations} with the type name {debug_type}. The client failed to deserialize the type {client_type} at {client_location}.",
+                            );
+                            return Err(TakeDataError::DeserializationError(err));
+                        }
+                    }
+                    // Otherwise, just log the generic deserialization error
+                    tracing::error!("Error deserializing data: {:?}", err);
+                    Err(TakeDataError::DeserializationError(err))
                 }
             },
             None => Ok(None),

+ 29 - 11
packages/web/src/hydration/hydrate.rs

@@ -3,8 +3,6 @@
 //! 2. As we render the virtual dom initially, keep track of the server ids of the suspense boundaries
 //! 3. Register a callback for dx_hydrate(id, data) that takes some new data, reruns the suspense boundary with that new data and then rehydrates the node
 
-use std::fmt::Write;
-
 use crate::dom::WebsysDom;
 use crate::with_server_data;
 use crate::HTMLDataCursor;
@@ -12,6 +10,7 @@ use dioxus_core::prelude::*;
 use dioxus_core::AttributeValue;
 use dioxus_core::{DynamicNode, ElementId};
 use futures_channel::mpsc::UnboundedReceiver;
+use std::fmt::Write;
 use RehydrationError::*;
 
 use super::SuspenseMessage;
@@ -113,6 +112,10 @@ impl WebsysDom {
         let SuspenseMessage {
             suspense_path,
             data,
+            #[cfg(debug_assertions)]
+            debug_types,
+            #[cfg(debug_assertions)]
+            debug_locations,
         } = message;
 
         let document = web_sys::window().unwrap().document().unwrap();
@@ -138,7 +141,12 @@ impl WebsysDom {
             self.interpreter.base().push_root(node);
         }
 
-        let server_data = HTMLDataCursor::from_serialized(&data);
+        #[cfg(not(debug_assertions))]
+        let debug_types = None;
+        #[cfg(not(debug_assertions))]
+        let debug_locations = None;
+
+        let server_data = HTMLDataCursor::from_serialized(&data, debug_types, debug_locations);
         // 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));
@@ -205,15 +213,25 @@ impl WebsysDom {
         vdom: &VirtualDom,
     ) -> Result<UnboundedReceiver<SuspenseMessage>, RehydrationError> {
         let (mut tx, rx) = futures_channel::mpsc::unbounded();
-        let closure = move |path: Vec<u32>, data: js_sys::Uint8Array| {
-            let data = data.to_vec();
-            _ = tx.start_send(SuspenseMessage {
-                suspense_path: path,
-                data,
-            });
-        };
+        let closure =
+            move |path: Vec<u32>,
+                  data: js_sys::Uint8Array,
+                  #[allow(unused)] debug_types: Option<Vec<String>>,
+                  #[allow(unused)] debug_locations: Option<Vec<String>>| {
+                let data = data.to_vec();
+                _ = tx.start_send(SuspenseMessage {
+                    suspense_path: path,
+                    data,
+                    #[cfg(debug_assertions)]
+                    debug_types,
+                    #[cfg(debug_assertions)]
+                    debug_locations,
+                });
+            };
         let closure = wasm_bindgen::closure::Closure::new(closure);
-        dioxus_interpreter_js::minimal_bindings::register_rehydrate_chunk_for_streaming(&closure);
+        dioxus_interpreter_js::minimal_bindings::register_rehydrate_chunk_for_streaming_debug(
+            &closure,
+        );
         closure.forget();
 
         // Rehydrate the root scope that was rendered on the server. We will likely run into suspense boundaries.

+ 8 - 0
packages/web/src/hydration/mod.rs

@@ -18,4 +18,12 @@ pub(crate) struct SuspenseMessage {
     #[cfg(feature = "hydrate")]
     /// The data to hydrate the suspense boundary with
     data: Vec<u8>,
+    #[cfg(feature = "hydrate")]
+    #[cfg(debug_assertions)]
+    /// The type names of the data
+    debug_types: Option<Vec<String>>,
+    #[cfg(feature = "hydrate")]
+    #[cfg(debug_assertions)]
+    /// The location of the data in the source code
+    debug_locations: Option<Vec<String>>,
 }

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

@@ -88,12 +88,32 @@ pub async fn run(mut virtual_dom: VirtualDom, web_config: Config) -> ! {
                     const decoded = atob(window.initial_dioxus_hydration_data);
                     return Uint8Array.from(decoded, (c) => c.charCodeAt(0))
                 }
+                export function get_initial_hydration_debug_types() {
+                    return window.initial_dioxus_hydration_debug_types;
+                }
+                export function get_initial_hydration_debug_locations() {
+                    return window.initial_dioxus_hydration_debug_locations;
+                }
             "#)]
             extern "C" {
                 fn get_initial_hydration_data() -> js_sys::Uint8Array;
+                fn get_initial_hydration_debug_types() -> Option<Vec<String>>;
+                fn get_initial_hydration_debug_locations() -> Option<Vec<String>>;
             }
             let hydration_data = get_initial_hydration_data().to_vec();
-            let server_data = HTMLDataCursor::from_serialized(&hydration_data);
+
+            // If we are running in debug mode, also get the debug types and locations
+            #[cfg(debug_assertions)]
+            let debug_types = get_initial_hydration_debug_types();
+            #[cfg(not(debug_assertions))]
+            let debug_types = None;
+            #[cfg(debug_assertions)]
+            let debug_locations = get_initial_hydration_debug_locations();
+            #[cfg(not(debug_assertions))]
+            let debug_locations = None;
+
+            let server_data =
+                HTMLDataCursor::from_serialized(&hydration_data, debug_types, debug_locations);
             // If the server serialized an error into the root suspense boundary, throw it into the root scope
             if let Some(error) = server_data.error() {
                 virtual_dom.in_runtime(|| dioxus_core::ScopeId::APP.throw_error(error));