docs: document `<ErrorBoundary/>`/`<Suspense/>` relationship (#1210)

This commit is contained in:
Greg Johnston 2023-06-21 11:17:20 -04:00 committed by GitHub
parent bbc7799b7c
commit 2cb8171105
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 181 additions and 142 deletions

View File

@ -34,10 +34,7 @@ pub fn App(cx: Scope) -> impl IntoView {
</header> </header>
<main> <main>
<Routes> <Routes>
<Route path="" view=|cx| view! { <Route path="" view=|cx| view! { cx, <ExampleErrors/> }/>
cx,
<ExampleErrors/>
}/>
</Routes> </Routes>
</main> </main>
</Router> </Router>
@ -66,7 +63,7 @@ pub fn ExampleErrors(cx: Scope) -> impl IntoView {
// note that the error boundaries could be placed above in the Router or lower down // note that the error boundaries could be placed above in the Router or lower down
// in a particular route. The generated errors on the entire page contribute to the // in a particular route. The generated errors on the entire page contribute to the
// final status code sent by the server when producing ssr pages. // final status code sent by the server when producing ssr pages.
<ErrorBoundary fallback=|cx, errors| view!{cx, <ErrorTemplate errors=errors/>}> <ErrorBoundary fallback=|cx, errors| view!{ cx, <ErrorTemplate errors=errors/>}>
<ReturnsError/> <ReturnsError/>
</ErrorBoundary> </ErrorBoundary>
</div> </div>

View File

@ -177,12 +177,7 @@ pub fn TodoApp(cx: Scope) -> impl IntoView {
<hr/> <hr/>
<main> <main>
<Routes> <Routes>
<Route path="" view=|cx| view! { <Route path="" view=|cx| view! { cx, <Todos/> }/> //Route
cx,
<ErrorBoundary fallback=|cx, errors| view!{cx, <ErrorTemplate errors=errors/>}>
<Todos/>
</ErrorBoundary>
}/> //Route
<Route path="signup" view=move |cx| view! { <Route path="signup" view=move |cx| view! {
cx, cx,
<Signup action=signup/> <Signup action=signup/>
@ -226,69 +221,71 @@ pub fn Todos(cx: Scope) -> impl IntoView {
<input type="submit" value="Add"/> <input type="submit" value="Add"/>
</MultiActionForm> </MultiActionForm>
<Transition fallback=move || view! {cx, <p>"Loading..."</p> }> <Transition fallback=move || view! {cx, <p>"Loading..."</p> }>
{move || { <ErrorBoundary fallback=|cx, errors| view!{ cx, <ErrorTemplate errors=errors/>}>
let existing_todos = { {move || {
move || { let existing_todos = {
todos.read(cx) move || {
.map(move |todos| match todos { todos.read(cx)
Err(e) => { .map(move |todos| match todos {
view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_view(cx) Err(e) => {
} view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_view(cx)
Ok(todos) => {
if todos.is_empty() {
view! { cx, <p>"No tasks were found."</p> }.into_view(cx)
} else {
todos
.into_iter()
.map(move |todo| {
view! {
cx,
<li>
{todo.title}
": Created at "
{todo.created_at}
" by "
{
todo.user.unwrap_or_default().username
}
<ActionForm action=delete_todo>
<input type="hidden" name="id" value={todo.id}/>
<input type="submit" value="X"/>
</ActionForm>
</li>
}
})
.collect_view(cx)
} }
} Ok(todos) => {
}) if todos.is_empty() {
.unwrap_or_default() view! { cx, <p>"No tasks were found."</p> }.into_view(cx)
} } else {
}; todos
.into_iter()
let pending_todos = move || { .map(move |todo| {
submissions view! {
.get() cx,
.into_iter() <li>
.filter(|submission| submission.pending().get()) {todo.title}
.map(|submission| { ": Created at "
view! { {todo.created_at}
cx, " by "
<li class="pending">{move || submission.input.get().map(|data| data.title) }</li> {
todo.user.unwrap_or_default().username
}
<ActionForm action=delete_todo>
<input type="hidden" name="id" value={todo.id}/>
<input type="submit" value="X"/>
</ActionForm>
</li>
}
})
.collect_view(cx)
}
}
})
.unwrap_or_default()
} }
}) };
.collect_view(cx)
};
view! { let pending_todos = move || {
cx, submissions
<ul> .get()
{existing_todos} .into_iter()
{pending_todos} .filter(|submission| submission.pending().get())
</ul> .map(|submission| {
view! {
cx,
<li class="pending">{move || submission.input.get().map(|data| data.title) }</li>
}
})
.collect_view(cx)
};
view! {
cx,
<ul>
{existing_todos}
{pending_todos}
</ul>
}
} }
} }
} </ErrorBoundary>
</Transition> </Transition>
</div> </div>
} }

View File

@ -87,22 +87,27 @@ fn Post(cx: Scope) -> impl IntoView {
} }
}); });
let post_view = move || { // this view needs to take the `Scope` from the `<Suspense/>`, not
post.with(cx, |post| { // from the parent component, so we take that as an argument and
post.clone().map(|post| { // pass it in under the `<Suspense/>` so that it is correct
view! { cx, let post_view = move |cx| {
// render content move || {
<h1>{&post.title}</h1> post.with(cx, |post| {
<p>{&post.content}</p> post.clone().map(|post| {
view! { cx,
// render content
<h1>{&post.title}</h1>
<p>{&post.content}</p>
// since we're using async rendering for this page, // since we're using async rendering for this page,
// this metadata should be included in the actual HTML <head> // this metadata should be included in the actual HTML <head>
// when it's first served // when it's first served
<Title text=post.title/> <Title text=post.title/>
<Meta name="description" content=post.content/> <Meta name="description" content=post.content/>
} }
})
}) })
}) }
}; };
view! { cx, view! { cx,
@ -121,7 +126,7 @@ fn Post(cx: Scope) -> impl IntoView {
</div> </div>
} }
}> }>
{post_view} {post_view(cx)}
</ErrorBoundary> </ErrorBoundary>
</Suspense> </Suspense>
} }

View File

@ -115,9 +115,7 @@ pub fn TodoApp(cx: Scope) -> impl IntoView {
</header> </header>
<main> <main>
<Routes> <Routes>
<Route path="" view=|cx| view! { cx, <Route path="" view=|cx| view! { cx, <Todos/> }/>
<Todos/>
}/>
</Routes> </Routes>
</main> </main>
</Router> </Router>

View File

@ -117,9 +117,7 @@ pub fn TodoApp(cx: Scope) -> impl IntoView {
<Routes> <Routes>
<Route path="" view=|cx| view! { <Route path="" view=|cx| view! {
cx, cx,
<ErrorBoundary fallback=|cx, errors| view!{cx, <ErrorTemplate errors=errors/>}>
<Todos/> <Todos/>
</ErrorBoundary>
}/> //Route }/> //Route
</Routes> </Routes>
</main> </main>
@ -151,63 +149,65 @@ pub fn Todos(cx: Scope) -> impl IntoView {
<input type="submit" value="Add"/> <input type="submit" value="Add"/>
</MultiActionForm> </MultiActionForm>
<Transition fallback=move || view! {cx, <p>"Loading..."</p> }> <Transition fallback=move || view! {cx, <p>"Loading..."</p> }>
{move || { <ErrorBoundary fallback=|cx, errors| view!{cx, <ErrorTemplate errors/>}>
let existing_todos = { {move || {
move || { let existing_todos = {
todos.read(cx) move || {
.map(move |todos| match todos { todos.read(cx)
Err(e) => { .map(move |todos| match todos {
view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_view(cx) Err(e) => {
} view! { cx, <pre class="error">"Server Error: " {e.to_string()}</pre>}.into_view(cx)
Ok(todos) => {
if todos.is_empty() {
view! { cx, <p>"No tasks were found."</p> }.into_view(cx)
} else {
todos
.into_iter()
.map(move |todo| {
view! {
cx,
<li>
{todo.title}
<ActionForm action=delete_todo>
<input type="hidden" name="id" value={todo.id}/>
<input type="submit" value="X"/>
</ActionForm>
</li>
}
})
.collect_view(cx)
} }
} Ok(todos) => {
}) if todos.is_empty() {
.unwrap_or_default() view! { cx, <p>"No tasks were found."</p> }.into_view(cx)
} } else {
}; todos
.into_iter()
let pending_todos = move || { .map(move |todo| {
submissions view! {
.get() cx,
.into_iter() <li>
.filter(|submission| submission.pending().get()) {todo.title}
.map(|submission| { <ActionForm action=delete_todo>
view! { <input type="hidden" name="id" value={todo.id}/>
cx, <input type="submit" value="X"/>
<li class="pending">{move || submission.input.get().map(|data| data.title) }</li> </ActionForm>
</li>
}
})
.collect_view(cx)
}
}
})
.unwrap_or_default()
} }
}) };
.collect_view(cx)
};
view! { let pending_todos = move || {
cx, submissions
<ul> .get()
{existing_todos} .into_iter()
{pending_todos} .filter(|submission| submission.pending().get())
</ul> .map(|submission| {
view! {
cx,
<li class="pending">{move || submission.input.get().map(|data| data.title) }</li>
}
})
.collect_view(cx)
};
view! {
cx,
<ul>
{existing_todos}
{pending_todos}
</ul>
}
} }
} }
} </ErrorBoundary>
</Transition> </Transition>
</div> </div>
} }

View File

@ -28,6 +28,24 @@ use leptos_reactive::{
/// } /// }
/// # }); /// # });
/// ``` /// ```
///
/// ## Interaction with `<Suspense/>`
/// If you use this with a `<Suspense/>` or `<Transition/>` component, note that the
/// `<ErrorBoundary/>` should go inside the `<Suspense/>`, not the other way around,
/// if theres a chance that the `<ErrorBoundary/>` will begin in the error state.
/// This is a limitation of the current design of the two components and the way they
/// hydrate. Placing the `<ErrorBoundary/>` outside the `<Suspense/>` means that
/// it is rendered on the server without any knowledge of the suspended view, so it
/// will always be rendered on the server as if there were no errors, but might need
/// to be hydrated with errors, depending on the actual result.
///
/// ```rust,ignore
/// view! { cx,
/// <Suspense fallback=move || view! {cx, <p>"Loading..."</p> }>
/// <ErrorBoundary fallback=|cx, errors| view!{ cx, <ErrorTemplate errors=errors/>}>
/// {move || {
/// /* etc. */
/// ```
#[component] #[component]
pub fn ErrorBoundary<F, IV>( pub fn ErrorBoundary<F, IV>(
cx: Scope, cx: Scope,
@ -46,7 +64,25 @@ where
provide_context(cx, errors); provide_context(cx, errors);
// Run children so that they render and execute resources // Run children so that they render and execute resources
let children = children(cx).into_view(cx); let children = children(cx);
#[cfg(all(debug_assertions, feature = "hydrate"))]
{
use leptos_dom::View;
if children.nodes.iter().any(|child| {
matches!(child, View::Suspense(_, _))
|| matches!(child, View::Component(repr) if repr.name() == "Transition")
}) {
crate::debug_warn!("You are using a <Suspense/> or \
<Transition/> as the direct child of an <ErrorBoundary/>. To ensure correct \
hydration, these should be reorganized so that the <ErrorBoundary/> is a child \
of the <Suspense/> or <Transition/> instead: \n\
\nview! {{ cx,\
\n <Suspense fallback=todo!()>\n <ErrorBoundary fallback=todo!()>\n {{move || {{ /* etc. */")
}
}
let children = children.into_view(cx);
let errors_empty = create_memo(cx, move |_| errors.with(Errors::is_empty)); let errors_empty = create_memo(cx, move |_| errors.with(Errors::is_empty));
move || { move || {

View File

@ -221,6 +221,12 @@ impl ComponentRepr {
view_marker: None, view_marker: None,
} }
} }
#[cfg(any(debug_assertions, feature = "ssr"))]
/// Returns the name of the component.
pub fn name(&self) -> &str {
&self.name
}
} }
/// A user-defined `leptos` component. /// A user-defined `leptos` component.