Suspense 与错误边界
主线位置:两条主线都经过这里。更新主线上,
run_scope拿到组件的返回值之后先看它是不是Err;首屏主线上,服务端rebuild_in_place之后「等 suspense」「根错误边界里有错误就映射成状态码」这两步就是本页的机制。服务端怎么组合它们、怎么流式输出,链到 06 部分。
flowchart TB
runscope["run_scope 得到 Element"] --> isok{"Ok 还是 Err"}
isok -->|"Ok"| diffit["正常 diff"]
isok -->|"Err Suspended"| susp["任务改成挂起类型"]
isok -->|"Err Error"| thr["throw_error"]
susp --> addt["最近边界记下任务"]
addt --> fb["边界重跑 显示 fallback"]
susp --> fin["任务完成 remove_task"]
fin --> back["边界重跑 换回 children"]
thr --> ins["ErrorContext insert_error"]
ins --> eb["ErrorBoundary 重跑 handle_error"]
实验在原生 VirtualDom 上跑(依赖 dioxus =0.7.10、dioxus-core =0.7.10、dioxus-ssr =0.7.10、dioxus-html =0.7.10、tokio,平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建),HTML 用 dioxus_ssr::render(&dom) 取,指令用一个逐条打印的 WriteMutations 实现取(edit: 开头的行)。
Element 是一个 Result
lib.rs 里:
pub type Element = std::result::Result<VNode, RenderError>;
RenderError 只有两个变体:Error(CapturedError) 和 Suspended(SuspendedFuture)。组件里的 ? 能用,靠的是这个 From:
impl<E: Into<anyhow::Error>> From<E> for RenderError {
fn from(e: E) -> Self {
let anyhow_err = e.into();
if let Some(suspended) = anyhow_err.downcast_ref::<SuspendedFuture>() {
return Self::Suspended(suspended.clone());
}
if let Some(render_error) = anyhow_err.downcast_ref::<RenderError>() {
return render_error.clone();
}
Self::Error(CapturedError(Arc::new(anyhow_err)))
}
}
任何能转成 anyhow::Error 的错误都能 ?;转换时先试着 downcast,原本就是挂起或 RenderError 的保持原样,其余包成 CapturedError,也就是 pub struct CapturedError(pub Arc<anyhow::Error>)。Arc 让它可以 Clone;PartialEq 比的是 Arc::ptr_eq。0.6 的 CapturedError 还是一个带 Rc<dyn AnyError>、backtrace、出错 scope 和一个渲染用 VNode 的结构体,PR #4596(首次出现在 v0.7.0-rc.1,正式版 0.7.0)把它换成了现在这样(PR 说明:「rips out our anyhow::Error variant in favor of anyhow:Error itself」)。
组件返回 Err 时,LastRenderedNode::new 把它记成 Placeholder(VNode::placeholder(), err):这个组件在 DOM 里占一个占位注释,SSR 不输出任何东西。真正的处理在 handle_element_return,run_scope 每次跑完组件都调它:
fn handle_element_return(&self, node: &mut Element, scope: &Scope) {
match node {
Err(RenderError::Error(e)) => {
tracing::error!("Error while rendering component `{}`: {e}", scope.name);
self.runtime.throw_error(scope.id, e.clone());
}
Err(RenderError::Suspended(e)) => {
let task = e.task();
// Insert the task into the nearest suspense boundary if it exists
let boundary = scope.suspense_location();
let already_suspended = self
.runtime
.tasks
.borrow()
.get(task.id)
.expect("Suspended on a task that no longer exists")
.suspend(boundary.clone());
if !already_suspended {
tracing::trace!("Suspending {:?} on {:?}", scope.id, task);
// Add this task to the suspended tasks list of the boundary
if let SuspenseLocation::UnderSuspense(boundary) = &boundary {
boundary.add_suspended_task(e.clone());
}
self.runtime
.suspended_tasks
.set(self.runtime.suspended_tasks.get() + 1);
}
}
Ok(_) => {
// If the render was successful, we can move the render generation forward by one
scope.render_count.set(scope.render_count.get() + 1);
}
}
}
Element 在 0.5 里还是 Option<VNode>0.5.7 的 lib.rs 里是 pub type Element = Option<VNode>;,组件出错或挂起只能返回 None。PR #2365(「Suspense boundaries/out of order streaming/anyhow like error handling」)同时引入了 suspense 边界、乱序流式输出和这种 ? 式的错误处理,说明里列出的破坏性改动是:「This PR changes the Element type from Option<VNode> to Result<VNode> to make it easier to return errors early, and properly track the result of the component. Without this change, if you throw or suspend, handle it manually and then return None manually, dioxus will try to handle the suspense/error for you」。挂起也从「返回 None」改成了返回一个错误:「this now returns an error instead of None to avoid issues when the suspense is handled manually in the component」。这个 PR 首次出现在 0.6.0-alpha.0,正式版 0.6.0。
挂起:? 一个 SuspendedFuture
SuspendedFuture 只记两样:哪个任务(TaskId)、哪个 scope 发起的。造它的常见入口:
- 全局函数
suspend(task),直接返回Err(RenderError::Suspended(SuspendedFuture::new(task)))。 Resource::suspend:资源还在Pending/Paused/Stopped且任务没被暂停时,返回挂起错误,否则返回值的映射 signal。use_server_future:内部是use_resource,第一次运行时先poll_now一次,还是Pending就return Err(suspend(task).unwrap_err())。
handle_element_return 拿到挂起之后做三件事:
LocalTask::suspend把任务类型改成TaskType::Suspended { boundary }。任务本来是spawn出来的ClientOnly,改了类型之后才会在挂起期间被 poll(见下文)。返回值告诉调用方它是不是本来就挂起了,同一个任务第二次?不重复计数。- 组件位于某个边界之下(
SuspenseLocation::UnderSuspense)时,add_suspended_task把它加进边界的suspended_tasks: Vec<SuspendedFuture>,并needs_update边界所在的 scope。 Runtime.suspended_tasks: Cell<usize>加一。这是全局计数,VirtualDom::suspended_tasks_remaining()就是看它是否大于 0。
任务结束时 Runtime::remove_task 做相反的事:如果类型是 Suspended,全局计数减一,边界的 remove_suspended_task 把它移出列表并再次 needs_update 边界。任务 poll 到 Ready 时会走到这里,所属 scope 被卸载时也会。
SuspenseBoundary:先在后台建一遍
SuspenseBoundary 有一个组件函数,但函数体是 unreachable!("SuspenseBoundary should not be called directly")。创建组件节点时 create_component_node 先比较 props 的 TypeId,是 SuspenseBoundaryPropsWithOwner 就转去 SuspenseBoundaryProps::create;diff 时同样走它自己的 diff。它的 props 只有 fallback: Callback<SuspenseContext, Element> 和 children。
scope 在建立时记下自己处在哪种 SuspenseLocation(取 Runtime 的 suspense 栈顶),之后不再改变:
| 变体 | 谁 |
|---|---|
NotSuspended | 不在任何边界里 |
SuspenseBoundary(ctx) | 边界自己的 scope |
UnderSuspense(ctx) | 边界 children 里的组件 |
InSuspensePlaceholder(ctx) | 边界 fallback 里的组件 |
create 的核心:
// First always render the children in the background. Rendering the children may cause this boundary to suspend
suspense_context.under_suspense_boundary(&dom.runtime(), || {
children.create(dom, parent, None::<&mut M>);
});
// If there are suspended futures, render the fallback
let nodes_created = if !suspense_context.suspended_futures().is_empty() {
let (node, nodes_created) =
suspense_context.in_suspense_placeholder(&dom.runtime(), || {
// ...
suspense_context.set_suspended_nodes(children.as_vnode().clone());
let suspense_placeholder =
LastRenderedNode::new(props.fallback.call(suspense_context));
let nodes_created = suspense_placeholder.create(dom, parent, to);
(suspense_placeholder, nodes_created)
});
// ...
} else {
// Otherwise just render the children in the real dom
debug_assert!(children.mount.get().mounted());
let nodes_created = suspense_context
.under_suspense_boundary(&dom.runtime(), || children.create(dom, parent, to));
// ...
};
先用 to = None 把 children 整个建一遍:所有组件都跑了、scope 都建了,但不写任何指令(Mutations 讲过 None 和 NoOpMutations 的区别)。建的过程中有组件挂起,边界的 suspended_tasks 就非空,这时把 children 存进 suspended_nodes、调用 fallback、把 fallback 真正写出去;没有挂起,再用真正的 to 把 children 写出去。不预先跑一遍,就不知道该显示哪个。
用户的根组件外面本来就包着一个边界:RootScopeWrapper 渲染 SuspenseBoundary { fallback: 占位, ErrorBoundary { 根组件 } },所以 ScopeId(1) 是根 suspense 边界(ROOT_SUSPENSE_BOUNDARY),ScopeId(2) 是根错误边界(ROOT_ERROR_BOUNDARY),ScopeId(3) 才是用户的根组件(APP)。
实验:服务端式和客户端式
fn app() -> Element {
rsx! {
div {
"before "
SuspenseBoundary {
fallback: |_ctx: SuspenseContext| { log("fallback runs"); rsx! { span { "loading..." } } },
Slow {}
}
}
}
}
#[component]
fn Slow() -> Element {
let value = use_resource(|| async {
tokio::time::sleep(Duration::from_millis(30)).await;
42
});
log("Slow runs");
let v = value.suspend()?;
log(format!("Slow got {v}"));
rsx! { b { "value = {v}" } }
}
fn app_no_boundary() -> Element {
let value = use_resource(|| async {
tokio::time::sleep(Duration::from_millis(30)).await;
7
});
let v = value.suspend()?;
rsx! { "root value = {v}" }
}
第一段像服务端:rebuild_in_place、wait_for_suspense,前后各 SSR 一次。第二段像浏览器:rebuild 之后循环 wait_for_work(100 毫秒超时即停)+ render_immediate。第三段是没有自己的边界、根组件直接挂起:
== SSR style: rebuild_in_place + wait_for_suspense
Slow runs
fallback runs
fallback runs
suspended_tasks_remaining = true
ssr: <div>before <span>loading...</span></div>
fallback runs
Slow runs
Slow got 42
suspended_tasks_remaining = false
ssr: <div>before <b>value = 42</b></div>
== client style: rebuild + wait_for_work + render_immediate
Slow runs
fallback runs
edit: LoadTemplate index=0 id=1
fallback runs
edit: LoadTemplate index=0 id=2
edit: ReplacePlaceholder [1] m=1
edit: AppendChildren id=0 m=1
-- render_immediate
fallback runs
-- render_immediate
edit: CreatePlaceholder id=3
edit: ReplaceWith id=2 m=1
Slow runs
Slow got 42
edit: LoadTemplate index=0 id=2
edit: CreateTextNode "value = 42" id=4
edit: ReplacePlaceholder [0] m=1
edit: ReplaceWith id=3 m=1
== no SuspenseBoundary of our own
ssr: ""
ssr: "root value = 7"
逐段看:
Slow在rebuild里只跑了一次,fallback却跑了两次。我们的边界在根 suspense 边界的 children 里,根边界的create先用None建一遍 children(我们的边界create第一次,调fallback),没有挂起冒到根边界,再用真的to建一遍(create第二次,scope 已经存在,Slow不再跑,但fallback又调了一次)。客户端那段里两次fallback runs之间夹着div的LoadTemplate,就是第二遍才开始写指令。- 挂起时 SSR 输出的是 fallback,
suspended_tasks_remaining为true。wait_for_suspense之后计数归零,HTML 里是真内容。 - 客户端第一次
render_immediate没有指令:那是add_suspended_task发出的needs_update,边界重跑,fallback 没变。第二次是任务完成:Slow的资源写了值、remove_task又标脏了边界,按高度边界先跑,把 fallback 换成 children;此时Slow还没重跑,它上次的结果是Err,所以换上去的是一个占位注释(CreatePlaceholder id=3+ReplaceWith id=2);接着Slow重跑,占位再换成b。两步在同一次render_immediate里,渲染器一次应用。 - 根组件自己挂起时,接住它的是根 suspense 边界,fallback 是
VNode::placeholder(),SSR 出来是空字符串。
任务完成之后:边界的四种 diff
边界被标脏重跑时走 SuspenseBoundaryProps::diff,按两个条件分四支:之前有没有存着的 suspended_nodes、现在 suspended_futures() 是否非空:
| 之前已挂起 | 现在还挂起 | 做什么 |
|---|---|---|
| 是 | 是 | 在 InSuspensePlaceholder 里重算 fallback 并 diff(写指令);children 在后台 diff(不写) |
| 否 | 否 | 正常 diff children |
| 否 | 是 | 新挂起:算出 fallback,move_node_to_background 把正在显示的 children 换成 fallback,children 存进 suspended_nodes |
| 是 | 否 | 恢复:先在后台把存着的 children diff 到最新,再把 fallback replace 成 children,mark_suspense_resolved |
mark_suspense_resolved 把边界的 ScopeId 记进 VirtualDom.resolved_scopes,并运行 after_suspense_resolved 注册的回调。render_suspense_immediate 返回的就是这个列表(按高度排序),服务端流式渲染据此知道哪些边界可以发出去了,见 流式渲染。
fn app() -> Element {
rsx! {
SuspenseBoundary {
fallback: |_| { log("outer fallback runs"); rsx! { "outer loading" } },
SuspenseBoundary {
fallback: |_| { log("inner fallback runs"); rsx! { "inner loading" } },
Slow {}
}
}
}
}
Slow 和上面一样会挂起。rebuild_in_place() 之后打印一次 SSR。Slow runs 打印几次,inner fallback runs 打印几次,outer fallback runs 呢?
挂起期间谁在跑
只要 Runtime.suspended_tasks 大于 0,scope_should_render 就会检查 scope 是不是在一个正处于挂起状态的边界之下;是的话,create_scope 拿到的 to 被换成 None:组件照跑、子树照建,只是不写指令。
服务端用 wait_for_suspense 等:
pub async fn wait_for_suspense(&mut self) {
loop {
self.queue_events();
if !self.suspended_tasks_remaining() && !self.has_dirty_scopes() {
break;
}
self.wait_for_suspense_work().await;
self.render_suspense_immediate().await;
}
}
它和客户端的 wait_for_work + render_immediate 有三点不同:
- 任务:
wait_for_suspense_work和render_suspense_immediate取出任务后先问task_runs_during_suspense,只有Suspended和Isomorphic(spawn_isomorphic)两类会被 poll;普通spawn出来的ClientOnly任务被取出后直接跳过。 - scope:只重跑
should_run_during_suspense()为真的 scope,也就是位于某个边界之内、且这个边界没有被freeze的;其他被标脏的 scope 打一条warn:「Only nodes that are under a suspense boundary rerun during suspense」。 - 指令:重跑时传
None::<&mut NoOpMutations>,不产生指令。服务端只要最后的VNode树,HTML 由dioxus_ssr直接读树生成。
每处理 32 项工作还会 yield_now().await 一次,让出执行器。
实验:普通 spawn 在挂起期间不跑
#[component]
fn Slow() -> Element {
use_hook(|| {
spawn(async { log(" plain spawn task polled") });
});
let value = use_resource(|| async {
log(" resource future polled");
tokio::time::sleep(Duration::from_millis(10)).await;
1
});
let v = value.suspend()?;
rsx! { "v={v}" }
}
外面包一个 SuspenseBoundary,rebuild_in_place → wait_for_suspense → SSR,最后再给 wait_for_work 20 毫秒:
-- wait_for_suspense
resource future polled
-- done: v=1
-- wait_for_work (client style)
资源的 future 被 poll 了(它挂起过,类型变成了 Suspended);普通 spawn 的任务一次都没有被 poll,连之后的 wait_for_work 里也没有:它唯一一次唤醒(创建时那条 TaskNotified)在 wait_for_suspense 里被取出并跳过,没有放回队列,除非有人再唤醒它,它不会再被 poll。对服务端这正是想要的:VirtualDom 渲染完就丢掉,只在浏览器里有意义的任务不该在服务端执行。
问: 不调 .suspend()?,直接 match resource(),None 时显示「加载中」,不也一样吗?
答: 在浏览器里看起来一样,在服务端不一样。use_resource 的任务是普通 spawn 出来的,只有组件 ? 了它的 SuspendedFuture,handle_element_return 才把它改成 Suspended。不 suspend,任务就一直是 ClientOnly:wait_for_suspense 不 poll 它,suspended_tasks_remaining() 也不把它算进去,服务端会立即输出「加载中」那一版 HTML,数据要等浏览器里再请求一次。服务端要等数据、把数据放进水合数据里,必须走挂起,use_server_future 就是这么做的,见 水合。
错误边界
ErrorBoundary 是一个普通组件:
pub fn ErrorBoundary(props: ErrorBoundaryProps) -> Element {
let error_boundary = use_error_boundary_provider();
let errors = error_boundary.error();
let has_errors = errors.is_some();
// Drop errors before running user code that might borrow the error lock
drop(errors);
if has_errors {
(props.handle_error.0)(error_boundary.clone())
} else {
std::result::Result::Ok({
// ...
VNode::new(
None,
TEMPLATE,
Box::new([(props.children).into_dyn_node()]),
Default::default(),
)
})
}
}
use_error_boundary_provider 在第一次运行时 provide_context 一个 ErrorContext:
pub struct ErrorContext {
error: Rc<RefCell<Option<CapturedError>>>,
subscribers: Subscribers,
}
只存一个错误。error() 读值时把当前的 ReactiveContext(正在跑的 ErrorBoundary 自己)加进 subscribers;insert_error 替换掉旧错误、把订阅者逐个 mark_dirty;clear_errors 清空并同样标脏。这就是一个手写的小 signal:边界组件读了它,它变了边界就重跑。
错误从哪来:
- 渲染时
?:handle_element_return打一条tracing::error!,再runtime.throw_error(scope.id, e)。 Runtime::throw_error:从给定 scope 开始沿父链consume_context::<ErrorContext>(),找到最近的边界就insert_error;找不到只打一条tracing::error!「Tried to throw an error into an error boundary, but failed to locate a boundary」。用户组件都在根错误边界之下,所以总能找到一个。- 全局
throw_error:用当前 scope,可以在任务、回调里用。 - 事件回调返回
Result<()>(或async块返回Result<()>):SpawnIfAsync的实现在Err时调throw_error,见 事件。
ErrorContext 怎么造是可以替换的:use_error_boundary_provider 先找 CreateErrorBoundary context,没有才用 ErrorContext::new(None)。fullstack 在根上 provide_create_error_boundary 了 init_error_boundary,它造出的 ErrorContext 会把错误写进水合数据,浏览器端从同一位置读回来。
实验:渲染时的错误和回调里的错误
#[component]
fn Parse(input: String) -> Element {
let n: i32 = input.parse()?;
rsx! { span { "n = {n}" } }
}
fn bad_input() -> Element {
rsx! {
ErrorBoundary {
handle_error: |errors: ErrorContext| rsx! { p { "caught: {errors.error().unwrap()}" } },
Parse { input: "abc" }
}
}
}
fn default_handler() -> Element {
rsx! {
ErrorBoundary {
Parse { input: "abc" }
}
}
}
fn no_boundary() -> Element {
rsx! {
div { "header" }
Parse { input: "abc" }
}
}
三个应用各自 rebuild_in_place,SSR 一次,再 render_immediate(&mut NoOpMutations),再 SSR 一次:
bad_input, after rebuild: ""
bad_input, after render_immediate: "<p>caught: invalid digit found in string</p>"
default_handler, after rebuild: ""
default_handler, after render_immediate: "<div style=\"color:red;\"><pre>invalid digit found in string</pre></div>"
no_boundary, after rebuild: "<div>header</div>"
no_boundary, after render_immediate: "<div style=\"color:red;\"><pre>invalid digit found in string</pre></div>"
- 刚
rebuild完错误还没显示:出错的组件是占位,insert_error只是标脏了边界,边界要在下一轮渲染里重跑才轮到handle_error。服务端的wait_for_suspense会把这一轮跑掉(它在还有脏 scope 时不会退出)。 - 不写
handle_error用的是default_handler:红色div里每个错误一个pre。 - 没有自己的边界时,错误进了根错误边界,整个应用被替换成错误信息,
header也没了。
第二个程序里,ErrorBoundary 的 children 是一个正常的 Parse { input: "12" } 和一个按钮,按钮的回调返回 Result<()>、里面 "x".parse()?:
#[component]
fn Clicker() -> Element {
rsx! {
button {
onclick: move |_| -> Result<()> {
let n: i32 = "x".parse()?;
log(format!("unreachable {n}"));
Ok(())
},
"click"
}
}
}
== error thrown from an event handler
edit: LoadTemplate index=0 id=1
edit: CreateTextNode "n = 12" id=2
edit: ReplacePlaceholder [0] m=1
edit: LoadTemplate index=0 id=3
edit: NewEventListener click id=3
edit: AppendChildren id=0 m=2
ssr: <span>n = 12</span><button>click</button>
-- handle_event returned
handle_error runs, error = Some("invalid digit found in string")
edit: LoadTemplate index=0 id=4
edit: CreateTextNode "caught: invalid digit found in string" id=5
edit: ReplacePlaceholder [0] m=1
edit: Remove id=1
edit: ReplaceWith id=3 m=1
ssr: <p>caught: invalid digit found in string</p>
回调里的错误同样只是标脏;render_immediate 时边界重跑,handle_error 的结果整块替换掉边界原来的两个根(Remove id=1,ReplaceWith id=3)。
SSR 里怎么用这两个
fullstack 服务端对每个请求新建 VirtualDom,在根上换掉 CreateErrorBoundary,然后 rebuild_in_place();关闭流式时直接 wait_for_suspense().await,开启流式时循环 wait_for_suspense_work + render_suspense_immediate,直到路由提交了首块或者 suspended_tasks_remaining() 为假。之后它在 ScopeId::ROOT_ERROR_BOUNDARY 里 consume_context::<ErrorContext>().error(),有错误就按类型换算成响应状态码(HttpError、StatusCode、ServerFnError::ServerError 取其中的码,ParseRouteError 是 404)。还没就绪的边界先输出 fallback,resolved_scopes 报告哪些边界好了再补发。完整过程在 一次请求的渲染 和 流式渲染。
动脑筋答案
Slow runs
inner fallback runs
inner fallback runs
inner fallback runs
inner fallback runs
ssr: inner loading
Slow 一次,内层 fallback 四次,外层 fallback 零次。每个 SuspenseBoundary::create 都先用 None 建一遍 children,再决定用 fallback 还是用 children 真正写一遍;没有挂起时 children 会被 create 两次。根边界没挂起(内层接住了),它的两遍各让外层边界 create 一次;外层边界也没挂起,每次 create 又让内层边界 create 两次:2 × 2 = 4。内层每次 create 都看到自己挂起,都调一次 fallback。组件 scope 只在第一次 create 时建立并运行,之后的 create 复用它的 last_rendered_node,所以 Slow 只跑一次;外层边界从没挂起,它的 fallback 一次也不调。fallback 闭包因此不该有副作用。
小结
Element = Result<VNode, RenderError>,RenderError只有Error(CapturedError)和Suspended(SuspendedFuture);?经From<E: Into<anyhow::Error>>自动分流。0.5 的Element还是Option<VNode>,#2365(0.6.0-alpha.0)改成了Result。- 组件
?出挂起:任务类型改成Suspended,计入最近边界的suspended_tasks和全局计数,边界被标脏;组件本身在 DOM 里是占位。任务结束时remove_task反向操作并再次标脏边界。 SuspenseBoundary不是普通组件:create先用None在后台建 children,有挂起就写 fallback,否则写 children;diff按「之前挂起 / 现在挂起」分四支,恢复时mark_suspense_resolved记进resolved_scopes。fallback 会被调用多次。- 挂起期间
wait_for_suspense只 pollSuspended和Isomorphic任务、只重跑未冻结边界下的 scope、不产生指令;普通spawn的任务在这期间不跑。不suspend的资源服务端不会等。 ErrorBoundary提供一个只存一个错误的ErrorContext;渲染时?、throw_error、回调返回Err都沿父链找到它insert_error,边界下一轮渲染时换成handle_error的结果。根错误边界兜底,服务端从ROOT_ERROR_BOUNDARY读错误定状态码。