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

+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));