Wait for the suspense boundary above the router to resolve before sending the first streaming chunk (#3891)

* Change the suspense streaming context after suspense is resolved in the router

* Wait for the suspense context before streaming the first chunk in ssr

* forward more response parts

* warn

* extract the hydration format into a crate

* Fix hydration when no data is sent

* forward web and server features

* Fix suspense that starts on the client

* fix use_server_future on the client

* Add documentation for fullstack hooks and protocol

* downgrade rust edition

* fix clippy

* fix tests

* fix clippy

* Fix pure web compile

* undo changes to the fullstack router example

* fix formatting
This commit is contained in:
Evan Almloff authored and GitHub committed 2025-04-08 16:01:21 -05:00
1 parent 4f74ef81d1
commit 84221f55a0
45 files changed
+988 -533

No files matched your search

+2 -2
View File
@@ -22,6 +22,7 @@ dioxus-interpreter-js = { workspace = true, features = [
"minimal_bindings",
"webonly",
] }
dioxus-fullstack-protocol = { workspace = true, features = ["web"], optional = true }
generational-box = { workspace = true }
js-sys = "0.3.70"
@@ -39,7 +40,6 @@ serde_json = { version = "1.0", optional = true }
serde = { version = "1.0", optional = true }
serde-wasm-bindgen = { version = "0.5.0", optional = true }
ciborium = { workspace = true, optional = true }
async-trait = { version = "0.1.58", optional = true }
[dependencies.web-sys]
@@ -86,7 +86,7 @@ lazy-js-bundle = { workspace = true }
[features]
default = ["mounted", "file_engine", "devtools", "document"]
hydrate = ["web-sys/Comment", "ciborium", "dep:serde"]
hydrate = ["web-sys/Comment", "dep:serde", "dep:dioxus-fullstack-protocol"]
mounted = [
"web-sys/Element",
"dioxus-html/mounted",
-161
View File
@@ -1,161 +0,0 @@
use std::cell::{Cell, RefCell};
use std::io::Cursor;
use dioxus_core::CapturedError;
use serde::de::DeserializeOwned;
thread_local! {
static SERVER_DATA: RefCell<Option<HTMLDataCursor>> = const { RefCell::new(None) };
}
/// 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(),
None => Err(TakeDataError::DataNotAvailable),
})
}
/// Run a closure with the server data
pub(crate) fn with_server_data<O>(server_data: HTMLDataCursor, f: impl FnOnce() -> O) -> O {
// Set the server data that will be used during hydration
set_server_data(server_data);
let out = f();
// Hydrating the suspense node **should** eat all the server data, but just in case, remove it
remove_server_data();
out
}
fn set_server_data(data: HTMLDataCursor) {
SERVER_DATA.with_borrow_mut(|server_data| *server_data = Some(data));
}
fn remove_server_data() {
SERVER_DATA.with_borrow_mut(|server_data| server_data.take());
}
/// Data that is deserialized from the server during hydration
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],
debug_types: Option<Vec<String>>,
debug_locations: Option<Vec<String>>,
) -> Self {
let deserialized = ciborium::from_reader(Cursor::new(data)).unwrap();
Self::new(deserialized, debug_types, debug_locations)
}
/// Get the error if there is one
pub(crate) fn error(&self) -> Option<CapturedError> {
self.error.clone()
}
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,
#[cfg(debug_assertions)]
debug_types,
#[cfg(debug_assertions)]
debug_locations,
};
// The first item is always an error if it exists
let error = myself
.take::<Option<CapturedError>>()
.ok()
.flatten()
.flatten();
myself.error = error;
myself
}
#[track_caller]
pub fn take<T: DeserializeOwned>(&self) -> Result<Option<T>, TakeDataError> {
let current = self.index.get();
if current >= self.data.len() {
tracing::trace!(
"Tried to take more data than was available, len: {}, index: {}; This is normal if the server function was started on the client, but may indicate a bug if the server function result should be deserialized from the server",
self.data.len(),
current
);
return Err(TakeDataError::DataNotAvailable);
}
let bytes = self.data[current].as_ref();
self.index.set(current + 1);
match bytes {
Some(bytes) => match ciborium::from_reader(Cursor::new(bytes)) {
Ok(x) => Ok(Some(x)),
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),
}
}
}
/// An error that can occur when trying to take data from the server
#[derive(Debug)]
pub enum TakeDataError {
/// Deserializing the data failed
DeserializationError(ciborium::de::Error<std::io::Error>),
/// No data was available
DataNotAvailable,
}
impl std::fmt::Display for TakeDataError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DeserializationError(e) => write!(f, "DeserializationError: {}", e),
Self::DataNotAvailable => write!(f, "DataNotAvailable"),
}
}
}
impl std::error::Error for TakeDataError {}
+4 -5
View File
@@ -4,11 +4,10 @@
//! 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 crate::dom::WebsysDom;
use crate::with_server_data;
use crate::HTMLDataCursor;
use dioxus_core::prelude::*;
use dioxus_core::AttributeValue;
use dioxus_core::{DynamicNode, ElementId};
use dioxus_fullstack_protocol::HydrationContext;
use futures_channel::mpsc::UnboundedReceiver;
use std::fmt::Write;
use RehydrationError::*;
@@ -146,12 +145,12 @@ impl WebsysDom {
#[cfg(not(debug_assertions))]
let debug_locations = None;
let server_data = HTMLDataCursor::from_serialized(&data, debug_types, debug_locations);
let server_data = HydrationContext::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() {
if let Some(error) = server_data.error_entry().get().ok().flatten() {
dom.in_runtime(|| id.throw_error(error));
}
with_server_data(server_data, || {
server_data.in_context(|| {
// rerun the scope with the new data
SuspenseBoundaryProps::resolve_suspense(
id,
-4
View File
@@ -1,10 +1,6 @@
#[cfg(feature = "hydrate")]
mod deserialize;
#[cfg(feature = "hydrate")]
mod hydrate;
#[cfg(feature = "hydrate")]
pub use deserialize::*;
#[cfg(feature = "hydrate")]
#[allow(unused)]
pub use hydrate::*;
+5 -3
View File
@@ -81,6 +81,8 @@ pub async fn run(mut virtual_dom: VirtualDom, web_config: Config) -> ! {
if should_hydrate {
#[cfg(feature = "hydrate")]
{
use dioxus_fullstack_protocol::HydrationContext;
websys_dom.skip_mutations = true;
// Get the initial hydration data from the client
#[wasm_bindgen::prelude::wasm_bindgen(inline_js = r#"
@@ -113,12 +115,12 @@ pub async fn run(mut virtual_dom: VirtualDom, web_config: Config) -> ! {
let debug_locations = None;
let server_data =
HTMLDataCursor::from_serialized(&hydration_data, debug_types, debug_locations);
HydrationContext::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() {
if let Some(error) = server_data.error_entry().get().ok().flatten() {
virtual_dom.in_runtime(|| dioxus_core::ScopeId::APP.throw_error(error));
}
with_server_data(server_data, || {
server_data.in_context(|| {
virtual_dom.rebuild(&mut websys_dom);
});
websys_dom.skip_mutations = false;