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>
This commit is contained in:
Evan AlmloffandJonathan Kelley authored and GitHub committed 2025-01-07 19:47:23 -08:00
1 parent 57d637184f
commit d41ab3a756
33 files changed
+644 -222

No files matched your search

+11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
});
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}
}
// The style has neither contents nor src, log an error
(None, Err(err)) => {
err.log("Style");
}
}
})
}
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
View File
@@ -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()
}
}
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
}
@@ -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();
// 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.serialize_errors(vdom, scope);
data.take_from_scope(vdom, scope);
data
}
fn take_from_virtual_dom(&mut self, vdom: &VirtualDom) {
self.take_from_scope(vdom, ScopeId::ROOT)
/// 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 error = vdom.in_runtime(|| {
scope
.consume_context::<ErrorContext>()
.and_then(|error_context| error_context.errors().first().cloned())
});
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
View File
@@ -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
View File
@@ -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
View File
@@ -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<hydrateNodeLine truncated
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<hydrateNodeLine truncated
+1 -1
View File
@@ -1 +1 @@
[6449103750905854967, 3846442265490457921, 13069001215487072322, 8716623267269178440, 5336385715226370016, 14456089431355876478, 12156139214887111728, 5052021921702764563, 12925655762638175824, 5638004933879392817]
[6449103750905854967, 17669692872757955279, 13069001215487072322, 11420464406527728232, 3770103091118609057, 5444526391971481782, 12156139214887111728, 5052021921702764563, 12925655762638175824, 5638004933879392817]
+1 -1
View File
@@ -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 @@
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
@@ -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
View File
@@ -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));
});
}
}
Whitespace-only changes.
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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));