Skip to main content

repose_core/
error.rs

1//! Stateful error boundary.
2//!
3//! [`ErrorBoundary`] catches panics thrown from its content closure (reliable on
4//! native) and also supports a WASM-safe failure path via [`throw_boundary`]:
5//! panics abort on `wasm32-unknown-unknown` builds compiled with
6//! `panic = "abort"`, so `catch_unwind` is best-effort there.
7//!
8//! The boundary is *sticky*: once a fallback is shown it stays until the `reset`
9//! callback fires. This prevents a panicking leaf from re-panicking every frame
10//! and means the Reset button lives in the fallback, not the failing content.
11
12#![allow(non_snake_case)]
13
14use crate::{View, remember_state, request_frame};
15use std::cell::RefCell;
16use std::panic::{AssertUnwindSafe, catch_unwind};
17use std::rc::Rc;
18
19/// Details about a boundary trip, passed to the fallback.
20#[derive(Clone, Debug)]
21pub struct ErrorInfo {
22    pub message: String,
23    pub component: String,
24}
25
26thread_local! {
27    /// Pending explicit throw (WASM-safe path). Cleared before each content run.
28    static BOUNDARY_THROW: RefCell<Option<ErrorInfo>> = const { RefCell::new(None) };
29}
30
31/// Call from inside a boundary's content to trip the fallback without `panic!`.
32/// This is the WASM-safe path.
33pub fn throw_boundary(message: impl Into<String>) {
34    BOUNDARY_THROW.with(|t| {
35        *t.borrow_mut() = Some(ErrorInfo {
36            message: message.into(),
37            component: "Unknown".into(),
38        });
39    });
40}
41
42fn take_throw() -> Option<ErrorInfo> {
43    BOUNDARY_THROW.with(|t| t.borrow_mut().take())
44}
45
46/// Render `fallback` when `content` panics (native) or calls [`throw_boundary`],
47/// and stay in fallback until `reset()` runs. `fallback(info, reset)` receives
48/// the error and a closure that clears the error and re-enters `content`.
49///
50/// Prefer Result-style / [`throw_boundary`] over `panic!` on WASM targets.
51pub fn ErrorBoundary(
52    fallback: impl Fn(ErrorInfo, Rc<dyn Fn()>) -> View + 'static,
53    content: impl Fn() -> View + 'static,
54) -> View {
55    // Sticky error + generation so Reset can force content re-entry.
56    let error: Rc<RefCell<Option<ErrorInfo>>> = remember_state(|| None);
57    let generation = remember_state(|| 0u64);
58
59    let reset = {
60        let error = error.clone();
61        let generation = generation.clone();
62        Rc::new(move || {
63            *error.borrow_mut() = None;
64            *generation.borrow_mut() += 1;
65            request_frame();
66        }) as Rc<dyn Fn()>
67    };
68
69    if let Some(info) = error.borrow().clone() {
70        return fallback(info, reset);
71    }
72
73    // Read generation so recomposition after reset is forced even if other inputs match.
74    let _g = *generation.borrow();
75
76    BOUNDARY_THROW.with(|t| *t.borrow_mut() = None);
77
78    let result = catch_unwind(AssertUnwindSafe(|| content()));
79
80    // Prefer explicit throw_boundary over panic.
81    if let Some(info) = take_throw() {
82        *error.borrow_mut() = Some(info.clone());
83        request_frame();
84        return fallback(info, reset);
85    }
86
87    match result {
88        Ok(view) => view,
89        Err(err) => {
90            let message = if let Some(s) = err.downcast_ref::<String>() {
91                s.clone()
92            } else if let Some(s) = err.downcast_ref::<&str>() {
93                (*s).to_string()
94            } else {
95                "Unknown panic".to_string()
96            };
97            let info = ErrorInfo {
98                message,
99                component: "Unknown".into(),
100            };
101            *error.borrow_mut() = Some(info.clone());
102            request_frame();
103            fallback(info, reset)
104        }
105    }
106}