ErrorBoundary SSR and serialization of errors to support hydration

This commit is contained in:
Greg Johnston
2024-04-17 21:29:14 -04:00
parent 851e1f73fd
commit 42b99dd912
16 changed files with 377 additions and 66 deletions

View File

@@ -4,11 +4,13 @@ edition = "2021"
version.workspace = true
[dependencies]
any_error = { workspace = true }
or_poisoned = { workspace = true }
futures = "0.3"
serde = { version = "1", features = ["derive"] }
wasm-bindgen = { version = "0.2", optional = true }
js-sys = { version = "0.3", optional = true }
once_cell = "1.19.0"
[features]
browser = ["dep:wasm-bindgen", "dep:js-sys"]

View File

@@ -41,4 +41,28 @@ impl SharedContext for CsrSharedContext {
#[inline(always)]
fn set_is_hydrating(&self, _is_hydrating: bool) {}
#[inline(always)]
fn errors(
&self,
_boundary_id: &SerializedDataId,
) -> Vec<(any_error::ErrorId, any_error::Error)> {
Vec::new()
}
#[inline(always)]
fn take_errors(
&self,
) -> Vec<(SerializedDataId, any_error::ErrorId, any_error::Error)> {
Vec::new()
}
#[inline(always)]
fn register_error(
&self,
_error_boundary: SerializedDataId,
_error_id: any_error::ErrorId,
_error: any_error::Error,
) {
}
}

View File

@@ -1,20 +1,61 @@
use super::{SerializedDataId, SharedContext};
use crate::{PinnedFuture, PinnedStream};
use any_error::{Error, ErrorId};
use core::fmt::Debug;
use js_sys::Array;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use wasm_bindgen::prelude::wasm_bindgen;
use once_cell::sync::Lazy;
use std::{
fmt::Display,
sync::atomic::{AtomicBool, AtomicUsize, Ordering},
};
use wasm_bindgen::{prelude::wasm_bindgen, JsCast};
#[wasm_bindgen]
extern "C" {
static __RESOLVED_RESOURCES: Array;
static __SERIALIZED_ERRORS: Array;
}
fn serialized_errors() -> Vec<(SerializedDataId, ErrorId, Error)> {
__SERIALIZED_ERRORS
.iter()
.flat_map(|value| {
value.dyn_ref::<Array>().map(|value| {
let error_boundary_id = value.get(0).as_f64().unwrap() as usize;
let error_id = value.get(1).as_f64().unwrap() as usize;
let value = value
.get(2)
.as_string()
.expect("Expected a [number, string] tuple");
(
SerializedDataId(error_boundary_id),
ErrorId::from(error_id),
Error::from(SerializedError(value)),
)
})
})
.collect()
}
/// An error that has been serialized across the network boundary.
#[derive(Debug, Clone)]
struct SerializedError(String);
impl Display for SerializedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self.0, f)
}
}
impl std::error::Error for SerializedError {}
#[derive(Default)]
/// The shared context that should be used in the browser while hydrating.
pub struct HydrateSharedContext {
id: AtomicUsize,
is_hydrating: AtomicBool,
errors: Lazy<Vec<(SerializedDataId, ErrorId, Error)>>,
}
impl HydrateSharedContext {
@@ -23,6 +64,7 @@ impl HydrateSharedContext {
Self {
id: AtomicUsize::new(0),
is_hydrating: AtomicBool::new(true),
errors: Lazy::new(serialized_errors),
}
}
@@ -34,6 +76,7 @@ impl HydrateSharedContext {
Self {
id: AtomicUsize::new(0),
is_hydrating: AtomicBool::new(false),
errors: Lazy::new(serialized_errors),
}
}
}
@@ -73,6 +116,32 @@ impl SharedContext for HydrateSharedContext {
}
fn set_is_hydrating(&self, is_hydrating: bool) {
self.is_hydrating.store(true, Ordering::Relaxed)
self.is_hydrating.store(is_hydrating, Ordering::Relaxed)
}
fn errors(&self, boundary_id: &SerializedDataId) -> Vec<(ErrorId, Error)> {
self.errors
.iter()
.filter_map(|(boundary, id, error)| {
if boundary == boundary_id {
Some((id.clone(), error.clone()))
} else {
None
}
})
.collect()
}
#[inline(always)]
fn register_error(
&self,
_error_boundary: SerializedDataId,
_error_id: ErrorId,
_error: Error,
) {
}
fn take_errors(&self) -> Vec<(SerializedDataId, ErrorId, Error)> {
self.errors.clone()
}
}

View File

@@ -18,6 +18,7 @@ mod csr;
#[cfg_attr(docsrs, doc(cfg(feature = "browser")))]
mod hydrate;
mod ssr;
use any_error::{Error, ErrorId};
#[cfg(feature = "browser")]
pub use csr::*;
use futures::Stream;
@@ -96,4 +97,18 @@ pub trait SharedContext: Debug {
/// For example, in an app with "islands," this should be `true` inside islands and
/// false elsewhere.
fn set_is_hydrating(&self, is_hydrating: bool);
/// Returns all errors that have been registered, removing them from the list.
fn take_errors(&self) -> Vec<(SerializedDataId, ErrorId, Error)>;
/// Returns the set of errors that have been registered with a particular boundary.
fn errors(&self, boundary_id: &SerializedDataId) -> Vec<(ErrorId, Error)>;
/// Registers an error with the context to be shared from server to client.
fn register_error(
&self,
error_boundary: SerializedDataId,
error_id: ErrorId,
error: Error,
);
}

View File

@@ -1,5 +1,6 @@
use super::{SerializedDataId, SharedContext};
use crate::{PinnedFuture, PinnedStream};
use any_error::{Error, ErrorId};
use futures::{
stream::{self, FuturesUnordered},
StreamExt,
@@ -10,7 +11,7 @@ use std::{
mem,
sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
RwLock,
Arc, RwLock,
},
};
@@ -21,6 +22,7 @@ pub struct SsrSharedContext {
is_hydrating: AtomicBool,
sync_buf: RwLock<Vec<ResolvedData>>,
async_buf: RwLock<Vec<(SerializedDataId, PinnedFuture<String>)>>,
errors: Arc<RwLock<Vec<(SerializedDataId, ErrorId, Error)>>>,
}
impl SsrSharedContext {
@@ -75,7 +77,7 @@ impl SharedContext for SsrSharedContext {
// 1) initial, synchronous setup chunk
let mut initial_chunk = String::new();
// resolved synchronous resources
// resolved synchronous resources and errors
initial_chunk.push_str("__RESOLVED_RESOURCES=[");
for resolved in sync_data {
resolved.write_to_buf(&mut initial_chunk);
@@ -83,6 +85,16 @@ impl SharedContext for SsrSharedContext {
}
initial_chunk.push_str("];");
initial_chunk.push_str("__SERIALIZED_ERRORS=[");
for error in mem::take(&mut *self.errors.write().or_poisoned()) {
write!(
initial_chunk,
"[{}, {}, {:?}],",
error.0 .0, error.1, error.2
);
}
initial_chunk.push_str("];");
// pending async resources
initial_chunk.push_str("__PENDING_RESOURCES=[");
for (id, _) in &async_data {
@@ -96,10 +108,24 @@ impl SharedContext for SsrSharedContext {
// 2) async resources as they resolve
let async_data = async_data
.into_iter()
.map(|(id, data)| async move {
let data = data.await;
let data = data.replace('<', "\\u003c");
format!("__RESOLVED_RESOURCES[{}] = {:?};", id.0, data)
.map(|(id, data)| {
let errors = Arc::clone(&self.errors);
async move {
let data = data.await;
let data = data.replace('<', "\\u003c");
let mut val =
format!("__RESOLVED_RESOURCES[{}] = {:?};", id.0, data);
for error in mem::take(&mut *errors.write().or_poisoned()) {
write!(
val,
"__SERIALIZED_ERRORS.push([{}, {}, {:?}]);",
error.0 .0,
error.1,
error.2.to_string()
);
}
val
}
})
.collect::<FuturesUnordered<_>>();
@@ -123,6 +149,38 @@ impl SharedContext for SsrSharedContext {
fn set_is_hydrating(&self, is_hydrating: bool) {
self.is_hydrating.store(is_hydrating, Ordering::Relaxed)
}
fn errors(&self, boundary_id: &SerializedDataId) -> Vec<(ErrorId, Error)> {
self.errors
.read()
.or_poisoned()
.iter()
.filter_map(|(boundary, id, error)| {
if boundary == boundary_id {
Some((id.clone(), error.clone()))
} else {
None
}
})
.collect()
}
fn register_error(
&self,
error_boundary_id: SerializedDataId,
error_id: ErrorId,
error: Error,
) {
self.errors.write().or_poisoned().push((
error_boundary_id,
error_id,
error,
));
}
fn take_errors(&self) -> Vec<(SerializedDataId, ErrorId, Error)> {
mem::take(&mut *self.errors.write().or_poisoned())
}
}
#[derive(Debug)]