feat: ErrorBoundary

This commit is contained in:
Greg Johnston 2024-04-02 13:44:08 -04:00
parent 1edec6c36a
commit d7c62622ae
9 changed files with 556 additions and 22 deletions

View file

@ -4,3 +4,4 @@ edition = "2021"
version.workspace = true
[dependencies]
pin-project-lite = "0.2"

View file

@ -1,4 +1,14 @@
use std::{error, fmt, ops, sync::Arc};
use std::{
cell::RefCell,
error, fmt,
future::Future,
ops,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
/* Wrapper Types */
/// This is a result type into which any error can be converted.
///
@ -39,3 +49,81 @@ where
Error(Arc::new(value))
}
}
/// Implements behavior that allows for global or scoped error handling.
///
/// This allows for both "throwing" errors to register them, and "clearing" errors when they are no
/// longer valid. This is useful for something like a user interface, in which an error can be
/// "thrown" on some invalid user input, and later "cleared" if the user corrects the input.
/// Keeping a unique identifier for each error allows the UI to be updated accordingly.
pub trait ErrorHook: Send + Sync {
/// Handles the given error, returning a unique identifier.
fn throw(&self, error: Error) -> ErrorId;
/// Clears the error associated with the given identifier.
fn clear(&self, id: &ErrorId);
}
/// A unique identifier for an error. This is returned when you call [`throw`], which calls a
/// global error handler.
#[derive(Debug, PartialEq, Eq, Hash, Clone, Default)]
pub struct ErrorId(usize);
thread_local! {
static ERROR_HOOK: RefCell<Option<Arc<dyn ErrorHook>>> = RefCell::new(None);
}
/// Sets the current thread-local error hook, which will be invoked when [`throw`] is called.
pub fn set_error_hook(hook: Arc<dyn ErrorHook>) {
ERROR_HOOK.with_borrow_mut(|this| *this = Some(hook))
}
/// Invokes the error hook set by [`set_error_hook`] with the given error.
pub fn throw(error: impl Into<Error>) -> ErrorId {
ERROR_HOOK
.with_borrow(|hook| hook.as_ref().map(|hook| hook.throw(error.into())))
.unwrap_or_default()
}
/// Clears the given error from the current error hook.
pub fn clear(id: &ErrorId) {
ERROR_HOOK
.with_borrow(|hook| hook.as_ref().map(|hook| hook.clear(id)))
.unwrap_or_default()
}
pin_project_lite::pin_project! {
/// A [`Future`] that reads the error hook that is set when it is created, and sets this as the
/// current error hook whenever it is polled.
pub struct ErrorHookFuture<Fut> {
hook: Option<Arc<dyn ErrorHook>>,
#[pin]
inner: Fut
}
}
impl<Fut> ErrorHookFuture<Fut> {
/// Reads the current hook and wraps the given [`Future`], returning a new `Future` that will
/// set the error hook whenever it is polled.
pub fn new(inner: Fut) -> Self {
Self {
hook: ERROR_HOOK.with_borrow(Clone::clone),
inner,
}
}
}
impl<Fut> Future for ErrorHookFuture<Fut>
where
Fut: Future,
{
type Output = Fut::Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let this = self.project();
if let Some(hook) = &this.hook {
set_error_hook(Arc::clone(hook))
}
this.inner.poll(cx)
}
}

View file

@ -3,9 +3,9 @@ use leptos::{
prelude::*,
reactive_graph::{
computed::AsyncDerived,
signal::{signal, RwSignal},
signal::{signal, ArcRwSignal},
},
view, ErrorBoundary, IntoView, Transition,
view, ErrorBoundary, Errors, IntoView, Transition,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
@ -54,6 +54,25 @@ pub fn fetch_example() -> impl IntoView {
// 2) we'd need to make sure there was a thread-local spawner set up
let cats = AsyncDerived::new_unsync(move || fetch_cats(cat_count.get()));
let fallback = move |errors: &ArcRwSignal<Errors>| {
let errors = errors.clone();
let error_list = move || {
errors.with(|errors| {
errors
.iter()
.map(|(_, e)| view! { <li>{e.to_string()}</li> })
.collect::<Vec<_>>()
})
};
view! {
<div class="error">
<h2>"Error"</h2>
<ul>{error_list}</ul>
</div>
}
};
// TODO weaving together Transition and ErrorBoundary is hard with the new async API for
// suspense, because Transition expects a Future as its children, and ErrorBoundary isn't a
// future
@ -79,7 +98,7 @@ pub fn fetch_example() -> impl IntoView {
}
/>
</label>
<ErrorBoundary fallback=|e| view! { <p class="error">{e.to_string()}</p> }>
<ErrorBoundary fallback>
<Transition fallback=|| view! { <div>"Loading..."</div> }>
{cats_view()}
</Transition>

View file

@ -25,6 +25,7 @@ oco = { workspace = true }
paste = "1"
rand = { version = "0.8", optional = true }
reactive_graph = { workspace = true, features = ["serde"] }
rustc-hash = "1"
tachys = { workspace = true, features = ["reactive_graph"] }
thiserror = "1"
tracing = "0.1"

View file

@ -1,5 +1,8 @@
use crate::into_view::{IntoView, View};
use std::sync::Arc;
use std::{
fmt::{self, Debug},
sync::Arc,
};
use tachys::{
renderer::dom::Dom,
view::{
@ -197,6 +200,12 @@ where
/// allow the compiler to optimize the view more effectively.
pub struct TypedChildrenMut<T>(Box<dyn FnMut() -> View<T> + Send>);
impl<T> Debug for TypedChildrenMut<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("TypedChildrenMut").finish()
}
}
impl<T> TypedChildrenMut<T> {
pub fn into_inner(self) -> impl FnMut() -> View<T> + Send {
self.0

View file

@ -1,7 +1,20 @@
use crate::{children::TypedChildrenMut, IntoView};
use any_error::Error;
use any_error::{Error, ErrorHook, ErrorId};
use leptos_macro::component;
use tachys::view::error_boundary::TryCatchBoundary;
use reactive_graph::{
computed::ArcMemo,
effect::RenderEffect,
signal::ArcRwSignal,
traits::{Get, GetUntracked, Track, Update, With},
};
use rustc_hash::FxHashMap;
use std::{marker::PhantomData, sync::Arc};
use tachys::{
either::Either,
reactive_graph::RenderEffectState,
renderer::Renderer,
view::{Mountable, Render, RenderHtml},
};
///
/// ## Beginner's Tip: ErrorBoundary Requires Your Error To Implement std::error::Error.
@ -33,11 +46,319 @@ pub fn ErrorBoundary<FalFn, Fal, Chil>(
fallback: FalFn,
) -> impl IntoView
where
FalFn: FnMut(Error) -> Fal + Clone + Send + 'static,
FalFn: FnMut(&ArcRwSignal<Errors>) -> Fal + Clone + Send + 'static,
Fal: IntoView + 'static,
Chil: IntoView + 'static,
{
let mut children = children.into_inner();
// TODO dev-mode warning about Suspense/ErrorBoundary ordering
move || children().catch(fallback.clone())
let hook = Arc::new(ErrorBoundaryErrorHook::default());
let errors = hook.errors.clone();
let errors_empty = ArcMemo::new({
let errors = errors.clone();
move |_| errors.with(|map| map.is_empty())
});
let hook = hook as Arc<dyn ErrorHook>;
// provide the error hook and render children
any_error::set_error_hook(Arc::clone(&hook));
ErrorBoundaryView {
errors,
errors_empty,
children,
fallback,
fal_ty: PhantomData,
rndr: PhantomData,
}
}
#[derive(Debug)]
struct ErrorBoundaryView<Chil, FalFn, Fal, Rndr> {
errors: ArcRwSignal<Errors>,
errors_empty: ArcMemo<bool>,
children: TypedChildrenMut<Chil>,
fallback: FalFn,
fal_ty: PhantomData<Fal>,
rndr: PhantomData<Rndr>,
}
impl<Chil, FalFn, Fal, Rndr> Render<Rndr>
for ErrorBoundaryView<Chil, FalFn, Fal, Rndr>
where
Chil: Render<Rndr> + 'static,
Chil::State: 'static,
Fal: Render<Rndr> + 'static,
Fal::State: 'static,
FalFn: FnMut(&ArcRwSignal<Errors>) -> Fal + Send + 'static,
Rndr: Renderer,
{
type State = ErrorBoundaryViewState<Chil, Fal, Rndr>;
type FallibleState = ();
fn build(self) -> Self::State {
let Self {
errors,
errors_empty,
children,
mut fallback,
fal_ty,
rndr,
} = self;
let placeholder = Rndr::create_placeholder();
let mut children = Some(children);
let effect = RenderEffect::new({
let placeholder = placeholder.clone();
move |prev: Option<
Either<Chil::State, (Fal::State, Chil::State)>,
>| {
errors_empty.track();
if let Some(prev) = prev {
match (errors_empty.get_untracked(), prev) {
// no errors, and already showing children
(true, Either::Left(children)) => {
Either::Left(children)
}
// no errors, and was showing fallback
(true, Either::Right((mut fallback, mut children))) => {
fallback.unmount();
Rndr::mount_before(
&mut children,
placeholder.as_ref(),
);
Either::Left(children)
}
// yes errors, and was showing children
(false, Either::Left(mut chil)) => {
chil.unmount();
let mut fal = fallback(&errors).build();
Rndr::mount_before(&mut fal, placeholder.as_ref());
Either::Right((fal, chil))
}
// yes errors, and was showing fallback
(false, Either::Right(_)) => todo!(),
}
} else {
let children = children.take().unwrap();
let mut children = children.into_inner();
let children = children().into_inner().build();
if errors_empty.get_untracked() {
Either::Left(children)
} else {
Either::Right((fallback(&errors).build(), children))
}
}
}
});
ErrorBoundaryViewState {
effect,
placeholder,
chil_ty: PhantomData,
fal_ty,
rndr,
}
}
fn rebuild(self, state: &mut Self::State) {}
fn try_build(self) -> any_error::Result<Self::FallibleState> {
todo!()
}
fn try_rebuild(
self,
state: &mut Self::FallibleState,
) -> any_error::Result<()> {
todo!()
}
}
impl<Chil, FalFn, Fal, Rndr> RenderHtml<Rndr>
for ErrorBoundaryView<Chil, FalFn, Fal, Rndr>
where
Chil: Render<Rndr> + 'static,
Chil::State: 'static,
Fal: Render<Rndr> + 'static,
Fal::State: 'static,
FalFn: FnMut(&ArcRwSignal<Errors>) -> Fal + Send + 'static,
Rndr: Renderer,
{
const MIN_LENGTH: usize = 0; //Chil::MIN_LENGTH;
fn to_html_with_buf(
self,
buf: &mut String,
position: &mut tachys::view::Position,
) {
todo!()
}
fn hydrate<const FROM_SERVER: bool>(
self,
cursor: &tachys::hydration::Cursor<Rndr>,
position: &tachys::view::PositionState,
) -> Self::State {
todo!()
}
}
struct ErrorBoundaryViewState<Chil, Fal, Rndr>
where
Chil: Render<Rndr>,
Chil::State: 'static,
Fal: Render<Rndr>,
Fal::State: 'static,
Rndr: Renderer,
{
effect: RenderEffect<Either<Chil::State, (Fal::State, Chil::State)>>,
placeholder: Rndr::Placeholder,
chil_ty: PhantomData<Chil>,
fal_ty: PhantomData<Fal>,
rndr: PhantomData<Rndr>,
}
impl<Chil, Fal, Rndr> Mountable<Rndr>
for ErrorBoundaryViewState<Chil, Fal, Rndr>
where
Chil: Render<Rndr>,
Fal: Render<Rndr>,
Rndr: Renderer,
{
fn unmount(&mut self) {
self.effect.with_value_mut(|state| match state {
Either::Left(chil) => chil.unmount(),
Either::Right((fal, _)) => fal.unmount(),
});
self.placeholder.unmount();
}
fn mount(
&mut self,
parent: &<Rndr as Renderer>::Element,
marker: Option<&<Rndr as Renderer>::Node>,
) {
self.placeholder.mount(parent, marker);
self.effect.with_value_mut(|state| match state {
Either::Left(chil) => {
chil.mount(parent, Some(self.placeholder.as_ref()))
}
Either::Right((fal, _)) => {
fal.mount(parent, Some(self.placeholder.as_ref()))
}
});
}
fn insert_before_this(
&self,
parent: &<Rndr as Renderer>::Element,
child: &mut dyn Mountable<Rndr>,
) -> bool {
self.effect
.with_value_mut(|state| match state {
Either::Left(chil) => chil.insert_before_this(parent, child),
Either::Right((fal, _)) => {
fal.insert_before_this(parent, child)
}
})
.unwrap_or(false)
}
}
#[derive(Debug, Default)]
struct ErrorBoundaryErrorHook {
errors: ArcRwSignal<Errors>,
}
impl ErrorHook for ErrorBoundaryErrorHook {
fn throw(&self, error: Error) -> ErrorId {
let key = ErrorId::default();
self.errors.update(|map| {
map.insert(key.clone(), error);
});
key
}
fn clear(&self, id: &any_error::ErrorId) {
self.errors.update(|map| {
map.remove(id);
});
}
}
/// A struct to hold all the possible errors that could be provided by child Views
#[derive(Debug, Clone, Default)]
#[repr(transparent)]
pub struct Errors(FxHashMap<ErrorId, Error>);
impl Errors {
/// Returns `true` if there are no errors.
#[inline(always)]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// Add an error to Errors that will be processed by `<ErrorBoundary/>`
pub fn insert<E>(&mut self, key: ErrorId, error: E)
where
E: Into<Error>,
{
self.0.insert(key, error.into());
}
/// Add an error with the default key for errors outside the reactive system
pub fn insert_with_default_key<E>(&mut self, error: E)
where
E: Into<Error>,
{
self.0.insert(Default::default(), error.into());
}
/// Remove an error to Errors that will be processed by `<ErrorBoundary/>`
pub fn remove(&mut self, key: &ErrorId) -> Option<Error> {
self.0.remove(key)
}
/// An iterator over all the errors, in arbitrary order.
#[inline(always)]
pub fn iter(&self) -> Iter<'_> {
Iter(self.0.iter())
}
}
impl IntoIterator for Errors {
type Item = (ErrorId, Error);
type IntoIter = IntoIter;
#[inline(always)]
fn into_iter(self) -> Self::IntoIter {
IntoIter(self.0.into_iter())
}
}
/// An owning iterator over all the errors contained in the [`Errors`] struct.
#[repr(transparent)]
pub struct IntoIter(std::collections::hash_map::IntoIter<ErrorId, Error>);
impl Iterator for IntoIter {
type Item = (ErrorId, Error);
#[inline(always)]
fn next(
&mut self,
) -> std::option::Option<<Self as std::iter::Iterator>::Item> {
self.0.next()
}
}
/// An iterator over all the errors contained in the [`Errors`] struct.
#[repr(transparent)]
pub struct Iter<'a>(std::collections::hash_map::Iter<'a, ErrorId, Error>);
impl<'a> Iterator for Iter<'a> {
type Item = (&'a ErrorId, &'a Error);
#[inline(always)]
fn next(
&mut self,
) -> std::option::Option<<Self as std::iter::Iterator>::Item> {
self.0.next()
}
}

View file

@ -14,6 +14,12 @@ pub struct View<T>(T)
where
T: Sized;
impl<T> View<T> {
pub fn into_inner(self) -> T {
self.0
}
}
pub trait IntoView: Sized + Render<Dom> + RenderHtml<Dom> + Send
//+ AddAnyAttr<Dom>
{

View file

@ -1,6 +1,7 @@
use super::{Position, PositionState, RenderHtml};
use crate::{
hydration::Cursor,
renderer::CastFrom,
ssr::StreamBuilder,
view::{Mountable, Render, Renderer},
};
@ -13,30 +14,103 @@ where
R: Renderer,
E: Into<AnyError> + 'static,
{
type State = <Option<T> as Render<R>>::State;
type State = ResultState<T::State, R>;
type FallibleState = T::State;
fn build(self) -> Self::State {
self.ok().build()
let placeholder = R::create_placeholder();
let state = match self {
Ok(view) => Ok(view.build()),
Err(e) => Err(any_error::throw(e.into())),
};
ResultState { placeholder, state }
}
fn rebuild(self, state: &mut Self::State) {
self.ok().rebuild(state);
match (&mut state.state, self) {
// both errors: throw the new error and replace
(Err(prev), Err(new)) => {
*prev = any_error::throw(new.into());
}
// both Ok: need to rebuild child
(Ok(old), Ok(new)) => {
T::rebuild(new, old);
}
// Ok => Err: unmount, replace with marker, and throw
(Ok(old), Err(err)) => {
old.unmount();
state.state = Err(any_error::throw(err));
}
// Err => Ok: clear error and build
(Err(err), Ok(new)) => {
any_error::clear(err);
let mut new_state = new.build();
R::mount_before(&mut new_state, state.placeholder.as_ref());
state.state = Ok(new_state);
}
}
}
fn try_build(self) -> any_error::Result<Self::FallibleState> {
let inner = self.map_err(Into::into)?;
let state = inner.build();
Ok(state)
todo!()
}
fn try_rebuild(
self,
state: &mut Self::FallibleState,
) -> any_error::Result<()> {
let inner = self.map_err(Into::into)?;
inner.rebuild(state);
Ok(())
todo!()
}
}
/// View state for a `Result<_, _>` view.
pub struct ResultState<T, R>
where
T: Mountable<R>,
R: Renderer,
{
/// Marks the location of this view.
placeholder: R::Placeholder,
/// The view state.
state: Result<T, any_error::ErrorId>,
}
impl<T, R> Mountable<R> for ResultState<T, R>
where
T: Mountable<R>,
R: Renderer,
{
fn unmount(&mut self) {
if let Ok(ref mut state) = self.state {
state.unmount();
}
// TODO investigate: including this seems to break error boundaries, although it doesn't
// make sense to me why it would be a problem
// self.placeholder.unmount();
}
fn mount(&mut self, parent: &R::Element, marker: Option<&R::Node>) {
self.placeholder.mount(parent, marker);
if let Ok(ref mut state) = self.state {
state.mount(parent, Some(self.placeholder.as_ref()));
}
}
fn insert_before_this(
&self,
parent: &R::Element,
child: &mut dyn Mountable<R>,
) -> bool {
if self
.state
.as_ref()
.map(|n| n.insert_before_this(parent, child))
== Ok(true)
{
true
} else {
self.placeholder.insert_before_this(parent, child)
}
}
}
@ -82,7 +156,22 @@ where
cursor: &Cursor<R>,
position: &PositionState,
) -> Self::State {
self.ok().hydrate::<FROM_SERVER>(cursor, position)
// hydrate the state, if it exists
let state = self
.map(|s| s.hydrate::<FROM_SERVER>(cursor, position))
.map_err(|e| any_error::throw(e.into()));
// pull the placeholder
if position.get() == Position::FirstChild {
cursor.child();
} else {
cursor.sibling();
}
let placeholder = cursor.current().to_owned();
let placeholder = R::Placeholder::cast_from(placeholder).unwrap();
position.set(Position::NextChild);
ResultState { placeholder, state }
}
}

View file

@ -154,7 +154,7 @@ where
if let Some(ref mut state) = self.state {
state.unmount();
}
R::remove(self.placeholder.as_ref());
self.placeholder.unmount();
}
fn mount(&mut self, parent: &R::Element, marker: Option<&R::Node>) {