Failed to borrow because the value was dropped
主线位置:更新主线里「
run_scope重跑组件」那一步。组件读 signal 时,值所在的格子已经随另一个 scope 的Owner被回收。格子、代数和Owner的机制见 generational-box,本页只讲最常见的成因:signal 被交给了比创建它的组件活得更久的地方。
flowchart TB child["子组件 use_signal"] --> owner["值属于子组件的 Owner"] child --> up["句柄 push 进父组件的列表"] rm["子组件卸载"] --> drop["Owner drop,格子回收"] up --> read["父组件重跑时读列表"] drop --> read read --> err["Dropped,渲染 panic"] err --> eb["根错误边界替换整页"]
本页实验在 M2 Max、macOS 27、rustc 1.99.0 上运行,依赖 dioxus = "=0.7.10",原生 VirtualDom,点击用 Runtime::handle_event 派发(做法同事件)。
现象
dioxus-signals 自带一个反例,就在 copy_value_hoisted 警告的帮助文字里(docs/hoist/error.rs):每个 Counter 子组件建一个 signal,挂载时把它 push 进父组件的 counts,父组件把整个列表打印出来。原样抄进 pit1-exp/src/bin/value_dropped.rs:
#[component]
fn Counters() -> Element {
let counts = use_signal(Vec::new);
let mut children = use_signal(|| 0);
rsx! {
button { onclick: move |_| children += 1, "Add child" }
button { onclick: move |_| children -= 1, "Remove child" }
// A signal from a child is read or written to in a parent scope
"{counts:?}"
for _ in 0..children() {
Counter { counts }
}
}
}
#[component]
fn Counter(mut counts: Signal<Vec<Signal<i32>>>) -> Element {
let mut signal_owned_by_child = use_signal(|| 0);
use_hook(|| counts.push(signal_owned_by_child));
rsx! {
button { onclick: move |_| signal_owned_by_child += 1, "{signal_owned_by_child}" }
}
}
main 依次点「Add child」「Add child」「Remove child」「Add child」,每次点完把 VirtualDom 跑到空闲,打印 dioxus_ssr::render 的结果。debug:
after add <button>Add child</button><button>Remove child</button>[0]<button>0</button>
after add <button>Add child</button><button>Remove child</button>[0, 0]<button>0</button><button>0</button>
after remove <button>Add child</button><button>Remove child</button>[0, 0]<button>0</button>
thread 'main' (2009045) panicked at ~/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/fmt/mod.rs:2882:1:
called `Result::unwrap()` on an `Err` value: Dropped(ValueDroppedError { created_at: Location { file: "src/bin/value_dropped.rs", line: 23, column: 35 } })
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
after add <div style="color:red;"><pre>Encountered panic: Any { .. }</pre></div>
after (next pass) <div style="color:red;"><pre>Encountered panic: Any { .. }</pre></div>
release 只有 panic 那一行不同:
called `Result::unwrap()` on an `Err` value: Dropped(ValueDroppedError)
- 「Remove child」之后页面还是
[0, 0]:父组件先重跑(它读了children),重跑时第二个子组件还活着;diff 之后子组件才被删掉,它的 signal 随之回收。列表里那个句柄这时已经指向一个空格子,只是还没人读。 - 下一次父组件因为任何原因重跑(这里是再点一次「Add child」),
"{counts:?}"逐个格式化列表里的 signal,读到已回收的那一个,panic。 - panic 发生在组件函数里,被
catch_unwind接住,变成错误交给根上的默认错误边界:整页被换成一句Encountered panic。没有自定义错误边界的应用,用户看到的就是这个。 - panic 位置落在标准库的
fmt/mod.rs,对排查没有用。有用的是 debug 下的created_at:第 23 行第 35 列,Counter里的use_signal。它告诉你值是谁建的,该去看谁的生命周期。
这种写法常见于「子组件向父组件注册自己的状态」:表单字段注册到表单、标签页注册到标签栏、列表项把选中状态交给列表。只要子组件可能比读它的那一方先卸载,迟早会碰上。
出处
use_signal 把值放进当前 scope 的 Owner(use_signal → Signal::new_with_caller → current_owner()),scope 被删时 Owner 被 drop,格子代数加一。之后任何持有旧句柄的读,在 ValueDroppedError 里结束;read() 是 try_read 再 unwrap,所以是一条 panic。完整的过程见 generational-box 的「读一个已回收的值」和「在 dioxus 里:Owner 挂在 scope 上」。
panic 变成错误页面的那一步在 VProps::render:catch_unwind 接住组件函数里的 panic,包成 CapturedPanic。紧挨着的注释写着 wasm 上「we can't even capture the panic」,所以浏览器里不要指望这层兜底;事件回调里的 panic 原生平台上也没人接(见 Signal 的「借用冲突」)。
dioxus 在出错之前其实打过招呼。debug 构建里每次在「不是创建者、也不是创建者后代」的 scope 里读写一个 CopyValue,copy_value_hoisted 都打一条 tracing::warn!。同一个程序装上只打印 WARN 的订阅者再跑,按行首过滤后(每条警告后面还有十几行帮助文字和两段示例代码,这里截到 200 个字符):
WARN dioxus_signals::warnings::__copy_value_hoisted: A Copy Value created in ScopeId(5, "value_dropped::Counter") (at src/bin/value_dropped.rs:23:35). It will be dropped when that scope is dropped, b
WARN dioxus_signals::warnings::__copy_value_hoisted: A Copy Value created in ScopeId(5, "value_dropped::Counter") (at src/bin/value_dropped.rs:23:35). It will be dropped when that scope is dropped, b
after add <button>Add child</button><button>Remove child</button>[0]<button>0</button>
WARN dioxus_signals::warnings::__copy_value_hoisted: A Copy Value created in ScopeId(5, "value_dropped::Counter") (at src/bin/value_dropped.rs:23:35). It will be dropped when that scope is dropped, b
...
after remove <button>Add child</button><button>Remove child</button>[0, 0]<button>0</button>
WARN dioxus_signals::warnings::__copy_value_hoisted: A Copy Value created in ScopeId(5, "value_dropped::Counter") (at src/bin/value_dropped.rs:23:35). It will be dropped when that scope is dropped, b
WARN dioxus_signals::warnings::__copy_value_hoisted: A Copy Value created in ScopeId(6) (at src/bin/value_dropped.rs:23:35). It will be dropped when that scope is dropped, but it was used in ScopeId(
thread 'main' (2018627) panicked at ~/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/fmt/mod.rs:2882:1:
called `Result::unwrap()` on an `Err` value: Dropped(ValueDroppedError { created_at: Location { file: "src/bin/value_dropped.rs", line: 23, column: 35 } })
ERROR dioxus_core::scope_arena: Error while rendering component `value_dropped::Counters`: Encountered panic: Any { .. }
第一次点击就开始警告,比 panic 早了三步。最后一条里 ScopeId(6) 后面没有组件名,说明那个 scope 已经删了。警告用 warnings crate 的 #[warning] 包着,release 下不出现。
修法
警告的帮助文字附了修好的版本(docs/hoist/fixed_list.rs):值放在父组件里,子组件只拿下标去读写父组件的 signal。另一个思路是保留原来的结构,但让子组件把 signal 建在列表所在的 scope 里:Signal::new_in_scope 收一个 ScopeId,值进那个 scope 的 Owner,origin_scope 能拿到 counts 属于哪个 scope。src/bin/value_dropped_fix.rs:
// A: dioxus-signals/docs/hoist/fixed_list.rs, unchanged: the parent owns plain values
#[component]
fn CountersA() -> Element {
let mut counts = use_signal(Vec::new);
rsx! {
button { onclick: move |_| counts.write().push(0), "Add child" }
button { onclick: move |_| { counts.write().pop(); }, "Remove child" }
"{counts:?}"
for index in 0..counts.len() {
CounterA { index, counts }
}
}
}
#[component]
fn CounterA(index: usize, mut counts: Signal<Vec<i32>>) -> Element {
rsx! {
button { onclick: move |_| counts.write()[index] += 1, "{counts.read()[index]}" }
}
}
// B: keep the broken shape, but create the child's signal in the list's owner scope
#[component]
fn CounterB(mut counts: Signal<Vec<Signal<i32>>>) -> Element {
let mut sig = use_hook(|| Signal::new_in_scope(0, counts.origin_scope()));
use_hook(|| counts.push(sig));
rsx! {
button { onclick: move |_| sig += 1, "{sig}" }
}
}
// CountersB is the broken Counters with Counter replaced by CounterB
同样的四次点击,debug,装着 WARN 订阅者:
== A
after add <button>Add child</button><button>Remove child</button>[0]<button>0</button>
after add <button>Add child</button><button>Remove child</button>[0, 0]<button>0</button><button>0</button>
after remove <button>Add child</button><button>Remove child</button>[0]<button>0</button>
after add <button>Add child</button><button>Remove child</button>[0, 0]<button>0</button><button>0</button>
== B
after add <button>Add child</button><button>Remove child</button>[0]<button>0</button>
after add <button>Add child</button><button>Remove child</button>[0, 0]<button>0</button><button>0</button>
after remove <button>Add child</button><button>Remove child</button>[0, 0]<button>0</button>
after add <button>Add child</button><button>Remove child</button>[0, 0, 0]<button>0</button><button>0</button>
两种都没有 panic,也没有一条警告。但 B 的结果是错的:子组件卸载时没人把它的句柄从列表里拿掉,列表只增不减,最后有 3 项、页面上只有 2 个子组件;那些值要等父组件卸载才释放。new_in_scope 解决的是「谁拥有这个值」,不解决「列表和子组件不同步」。A 里列表本身就是唯一的数据,「Remove child」改的是数据,子组件跟着数据走,没有这类问题。
所以优先用 A:状态放在所有读写它的组件的共同祖先里,往下传 signal(或下标、ReadSignal),不往上传。往上传不可避免时(比如第三方组件只能自己建状态),再用 new_in_scope 并自己在子组件的 use_drop 里把句柄移除。
怎么确认没中招
- 跑 debug 构建,看日志里有没有
copy_value_hoisted。dioxus::launch在默认的loggerfeature 下调dioxus_logger::initialize_default,debug 构建的级别是DEBUG,所以不用自己装订阅者:web 上在浏览器控制台,服务端在终端。这条警告报的是「可能」,第一次点击就会出现,不必等到真的出错。 - 搜「把 signal 放进父组件状态」的写法:
Signal<Vec<Signal<_>>>、Signal<HashMap<_, Signal<_>>>、use_hook(|| parent.push(..))、子组件里provide_root_context/ 写GlobalSignal的 signal 句柄。 - 已经出错时,读 panic 里的
created_at(只在 debug 有):它指向建值的那一行,不是读值的那一行。读的位置看警告里it was used in ScopeId(..) (at ..)那一段。
小结
- signal 属于创建它的 scope;scope 卸载,值被回收。把句柄交给活得更久的组件,等于留了一个悬空引用。
- 出错时机比原因晚:子组件删掉之后,要等别人再读才 panic;组件函数里的 panic 被错误边界接住,默认是整页
Encountered panic,wasm 上连这层也不可靠。 - debug 构建的
copy_value_hoisted警告在第一次越级读写时就出现,release 什么都没有。 - 修法:状态放进共同祖先,往下传。
Signal::new_in_scope能止住 panic,但数据和组件的对应关系要自己维护。