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

@ -1,6 +1,7 @@
use std::{
cell::RefCell,
error, fmt,
error,
fmt::{self, Display},
future::Future,
ops,
pin::Pin,
@ -69,6 +70,18 @@ pub trait ErrorHook: Send + Sync {
#[derive(Debug, PartialEq, Eq, Hash, Clone, Default)]
pub struct ErrorId(usize);
impl Display for ErrorId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
Display::fmt(&self.0, f)
}
}
impl From<usize> for ErrorId {
fn from(value: usize) -> Self {
Self(value)
}
}
thread_local! {
static ERROR_HOOK: RefCell<Option<Arc<dyn ErrorHook>>> = RefCell::new(None);
}

View File

@ -3,7 +3,6 @@ use leptos::{component, prelude::*, signal, view, ErrorBoundary, IntoView};
#[component]
pub fn App() -> impl IntoView {
let (value, set_value) = signal("".parse::<i32>());
let guard = value.read();
view! {
<h1>"Error Handling"</h1>
@ -11,42 +10,41 @@ pub fn App() -> impl IntoView {
"Type a number (or something that's not a number!)"
<input
type="text"
value=move || value.get().map(|n| n.to_string()).unwrap_or_default()// TODO guard support here
value=move || value.get().unwrap_or_default()
// when input changes, try to parse a number from the input
on:input:target=move |ev| set_value.set(ev.target().value().parse::<i32>())
/>
// If an `Err(_) had been rendered inside the <ErrorBoundary/>,
/> // If an `Err(_) has been rendered inside the <ErrorBoundary/>,
// the fallback will be displayed. Otherwise, the children of the
// <ErrorBoundary/> will be displayed.
<ErrorBoundary
// the fallback receives a signal containing current errors
fallback=|errors| {
let errors = errors.clone();
view! {
<div class="error">
<p>"Not a number! Errors: "</p>
// we can render a list of errors
// as strings, if we'd like
<ul>
{move || errors.get()
.into_iter()
.map(|(_, e)| view! { <li>{e.to_string()}</li>})
// the fallback receives a signal containing current errors
<ErrorBoundary fallback=|errors| {
let errors = errors.clone();
view! {
<div class="error">
<p>"Not a number! Errors: "</p>
// we can render a list of errors
// as strings, if we'd like
<ul>
{move || {
errors
.read()
.iter()
.map(|(_, e)| view! { <li>{e.to_string()}</li> })
.collect::<Vec<_>>()
}
</ul>
</div>
}
}}
</ul>
</div>
}
>
}>
<p>
"You entered "
// because `value` is `Result<i32, _>`,
// it will render the `i32` if it is `Ok`,
"You entered " // it will render the `i32` if it is `Ok`,
// and render nothing and trigger the error boundary
// if it is `Err`. It's a signal, so this will dynamically
// update when `value` changes
<strong>{move || value.get()}</strong> // TODO render signal directly
<strong>{value}</strong>
</p>
</ErrorBoundary>
</label>

View File

@ -118,9 +118,7 @@ pub fn Todos() -> impl IntoView {
view! {
<div>
// fallback=move || view! { <p>"Loading..."</p> }>
<Suspense fallback=move || view! { <p>"Loading..."</p> }>
<Transition fallback=move || view! { <p>"Loading..."</p> }>
<ErrorBoundary fallback=|errors| view! { <ErrorTemplate errors/> }>
// {existing_todos}
<ul>
@ -156,7 +154,7 @@ pub fn Todos() -> impl IntoView {
// {pending_todos}
</ul>
</ErrorBoundary>
</Suspense>
</Transition>
</div>
}
}

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)]

View File

@ -70,7 +70,11 @@ use server_fn::{
,
};
use std::{
collections::HashSet, fmt::Debug, io, pin::Pin, sync::Arc,
collections::HashSet,
fmt::{Debug, Write},
io,
pin::Pin,
sync::Arc,
thread::available_parallelism,
};
use tracing::Instrument;
@ -739,11 +743,13 @@ where
// TODO nonce
let shared_context = Owner::current_shared_context().unwrap();
let shared_context = shared_context
.pending_data()
.unwrap()
.map(|chunk| format!("<script>{chunk}</script>"));
futures::stream::select(app_stream, shared_context)
let chunks = Box::pin(
shared_context
.pending_data()
.unwrap()
.map(|chunk| format!("<script>{chunk}</script>")),
);
futures::stream::select(app_stream, chunks)
});
let stream = meta_context.inject_meta_context(stream).await;

View File

@ -14,7 +14,7 @@ any_error = { workspace = true }
any_spawner = { workspace = true, features = ["wasm-bindgen"] }
base64 = { version = "0.22", optional = true }
cfg-if = "1"
hydration_context = { workspace = true, optional = true }
hydration_context = { workspace = true }
leptos_dom = { workspace = true }
leptos_macro = { workspace = true }
leptos_reactive = { workspace = true }
@ -54,7 +54,6 @@ futures = "0.3.30"
default = ["serde"]
hydration = [
"leptos_server/hydration",
"dep:hydration_context",
"hydration_context/browser",
]
csr = ["leptos_macro/csr", "leptos_reactive/csr"]

View File

@ -1,17 +1,19 @@
use crate::{children::TypedChildren, IntoView};
use any_error::{Error, ErrorHook, ErrorId};
use hydration_context::{SerializedDataId, SharedContext};
use leptos_macro::component;
use leptos_reactive::Effect;
use reactive_graph::{
computed::ArcMemo,
owner::Owner,
signal::ArcRwSignal,
traits::{Get, Update, With},
traits::{Get, Update, With, WithUntracked},
};
use rustc_hash::FxHashMap;
use std::{marker::PhantomData, sync::Arc};
use tachys::{
hydration::Cursor,
renderer::Renderer,
renderer::{CastFrom, Renderer},
ssr::StreamBuilder,
view::{Mountable, Position, PositionState, Render, RenderHtml},
};
@ -49,7 +51,15 @@ where
Fal: IntoView + 'static,
Chil: IntoView + 'static,
{
let hook = Arc::new(ErrorBoundaryErrorHook::default());
let sc = Owner::current_shared_context();
let boundary_id = sc.as_ref().map(|sc| sc.next_id()).unwrap_or_default();
let initial_errors =
sc.map(|sc| sc.errors(&boundary_id)).unwrap_or_default();
let hook = Arc::new(ErrorBoundaryErrorHook::new(
boundary_id.clone(),
initial_errors,
));
let errors = hook.errors.clone();
let errors_empty = ArcMemo::new({
let errors = errors.clone();
@ -62,18 +72,22 @@ where
let mut children = Some(children.into_inner()());
move || ErrorBoundaryView {
boundary_id: boundary_id.clone(),
errors_empty: errors_empty.get(),
children: children.take(),
fallback: Some((fallback.clone())(&errors)),
errors: errors.clone(),
rndr: PhantomData,
}
}
#[derive(Debug)]
struct ErrorBoundaryView<Chil, Fal, Rndr> {
boundary_id: SerializedDataId,
errors_empty: bool,
children: Option<Chil>,
fallback: Fal,
errors: ArcRwSignal<Errors>,
rndr: PhantomData<Rndr>,
}
@ -203,9 +217,11 @@ where
async fn resolve(self) -> Self::AsyncOutput {
let ErrorBoundaryView {
boundary_id,
errors_empty,
children,
fallback,
errors,
..
} = self;
let children = match children {
@ -213,15 +229,52 @@ where
Some(children) => Some(children.resolve().await),
};
ErrorBoundaryView {
boundary_id,
errors_empty,
children,
fallback,
errors,
rndr: PhantomData,
}
}
fn to_html_with_buf(self, buf: &mut String, position: &mut Position) {
todo!()
// first, attempt to serialize the children to HTML, then check for errors
let mut new_buf = String::with_capacity(Chil::MIN_LENGTH);
let mut new_pos = *position;
self.children.to_html_with_buf(&mut new_buf, &mut new_pos);
// any thrown errors would've been caught here
if self.errors.with_untracked(|map| map.is_empty()) {
buf.push_str(&new_buf);
} else {
// otherwise, serialize the fallback instead
self.fallback.to_html_with_buf(buf, position);
}
}
fn to_html_async_with_buf<const OUT_OF_ORDER: bool>(
self,
buf: &mut StreamBuilder,
position: &mut Position,
) where
Self: Sized,
{
// first, attempt to serialize the children to HTML, then check for errors
let mut new_buf = StreamBuilder::new(buf.clone_id());
let mut new_pos = *position;
self.children
.to_html_async_with_buf::<OUT_OF_ORDER>(&mut new_buf, &mut new_pos);
// any thrown errors would've been caught here
if self.errors.with_untracked(|map| map.is_empty()) {
buf.append(new_buf);
} else {
// otherwise, serialize the fallback instead
let mut fallback = String::with_capacity(Fal::MIN_LENGTH);
self.fallback.to_html_with_buf(&mut fallback, position);
buf.push_sync(&fallback);
}
}
fn hydrate<const FROM_SERVER: bool>(
@ -229,21 +282,75 @@ where
cursor: &Cursor<Rndr>,
position: &PositionState,
) -> Self::State {
todo!()
let children = self.children.expect(
"tried to hydrate ErrorBoundary but children were not present",
);
let (children, fallback) = if self.errors_empty {
(
children.hydrate::<FROM_SERVER>(cursor, position),
self.fallback.build(),
)
} else {
(
children.build(),
self.fallback.hydrate::<FROM_SERVER>(cursor, position),
)
};
cursor.sibling();
let placeholder = cursor.current().to_owned();
let placeholder = Rndr::Placeholder::cast_from(placeholder).unwrap();
position.set(Position::NextChild);
ErrorBoundaryViewState {
showing_fallback: !self.errors_empty,
children,
fallback,
placeholder,
}
}
}
#[derive(Debug, Default)]
#[derive(Debug)]
struct ErrorBoundaryErrorHook {
errors: ArcRwSignal<Errors>,
id: SerializedDataId,
shared_context: Option<Arc<dyn SharedContext + Send + Sync>>,
}
impl ErrorBoundaryErrorHook {
pub fn new(
id: SerializedDataId,
initial_errors: impl IntoIterator<Item = (ErrorId, Error)>,
) -> Self {
Self {
errors: ArcRwSignal::new(Errors(
initial_errors.into_iter().collect(),
)),
id,
shared_context: Owner::current_shared_context(),
}
}
}
impl ErrorHook for ErrorBoundaryErrorHook {
fn throw(&self, error: Error) -> ErrorId {
let key = ErrorId::default();
// generate a unique ID
let key = ErrorId::default(); // TODO unique ID...
// register it with the shared context, so that it can be serialized from server to client
// as needed
if let Some(sc) = &self.shared_context {
sc.register_error(self.id.clone(), key.clone(), error.clone());
}
// add it to the reactive map of errors
self.errors.update(|map| {
map.insert(key.clone(), error);
});
// return the key, which will be owned by the Result being rendered and can be used to
// unregister this error if it is rebuilt
key
}

View File

@ -3,6 +3,7 @@ use crate::{
into_view::View,
IntoView,
};
use any_error::ErrorHookFuture;
use any_spawner::Executor;
use futures::FutureExt;
use leptos_macro::component;
@ -141,7 +142,9 @@ where
{
buf.next_id();
let mut fut = Box::pin(self.children.resolve());
let mut fut = Box::pin(ScopedFuture::new(ErrorHookFuture::new(
self.children.resolve(),
)));
match fut.as_mut().now_or_never() {
Some(resolved) => {
Either::<Fal, _>::Right(resolved)

View File

@ -3,7 +3,7 @@ use crate::{
html::{
attribute::*,
class::{class, Class, IntoClass},
element::ElementType,
element::{ElementType, HasElementType},
event::{on, on_target, EventDescriptor, On, Targeted},
property::{property, IntoProperty, Property},
style::{style, IntoStyle, Style},
@ -112,13 +112,13 @@ pub trait OnTargetAttribute<E, F, T, Rndr> {
impl<T, E, F, Rndr> OnTargetAttribute<E, F, Self, Rndr> for T
where
Self: ElementType,
T: AddAnyAttr<Rndr>,
T: AddAnyAttr<Rndr> + HasElementType,
E: EventDescriptor + Send + 'static,
E::EventType: 'static,
E::EventType: From<Rndr::Event>,
F: FnMut(Targeted<E::EventType, <Self as ElementType>::Output, Rndr>)
+ 'static,
F: FnMut(
Targeted<E::EventType, <Self as HasElementType>::ElementType, Rndr>,
) + 'static,
Rndr: DomRenderer,
{
type Output = <Self as AddAnyAttr<Rndr>>::Output<On<Rndr>>;

View File

@ -156,12 +156,23 @@ pub trait ElementType: Send {
fn tag(&self) -> &str;
}
pub trait HasElementType {
type ElementType;
}
pub trait ElementWithChildren {}
pub trait CreateElement<R: Renderer> {
fn create_element(&self) -> R::Element;
}
impl<E, At, Ch, Rndr> HasElementType for HtmlElement<E, At, Ch, Rndr>
where
E: ElementType,
{
type ElementType = E::Output;
}
impl<E, At, Ch, Rndr> Render<Rndr> for HtmlElement<E, At, Ch, Rndr>
where
E: CreateElement<Rndr>,

View File

@ -161,8 +161,11 @@ where
buf: &mut String,
position: &mut super::Position,
) {
if let Ok(inner) = self {
inner.to_html_with_buf(buf, position);
match self {
Ok(inner) => inner.to_html_with_buf(buf, position),
Err(e) => {
any_error::throw(e);
}
}
}
@ -173,8 +176,13 @@ where
) where
Self: Sized,
{
if let Ok(inner) = self {
inner.to_html_async_with_buf::<OUT_OF_ORDER>(buf, position);
match self {
Ok(inner) => {
inner.to_html_async_with_buf::<OUT_OF_ORDER>(buf, position)
}
Err(e) => {
any_error::throw(e);
}
}
}