Rust in Everything
基于 dioxus 0.7.10

Unable to retrieve the hook that was initialized at this index

主线位置:更新主线里 run_scope 调组件函数的那一步。每个 use_* 最后都落到 Scope::use_hook,它只按「第几次调用」取值。序号和类型怎么决定取到什么,以及条件 hook 的三种基本情况,见 Scope 与 hooks 的「use_hook:只认序号」;本页讲循环和早返回,以及怎么在运行前查出来。

flowchart TB
  hk["第 i 次调用 use_*"] --> has{"第 i 格存在?"}
  has -->|否| push["追加新 hook"]
  has -->|是| ty{"类型对得上?"}
  ty -->|是| ret["返回这一格"]
  ty -->|否| hp{"debug 且热补丁中?"}
  hp -->|是| over["覆盖这一格"]
  hp -->|否| pan["panic,组件变错误"]

本页实验在 M2 Max、macOS 27、rustc 1.99.0 上运行,依赖 dioxus = "=0.7.10",原生 VirtualDom;dx check 用的是 dx 0.7.9(git diff v0.7.9 v0.7.10 不涉及 packages/cli/src/check/)。

现象

pit1-exp/src/bin/hook_loop.rs。第一个组件给每个标签建一个「展开」开关,用的是循环里的 use_signal;第二个在两个 hook 之间早返回:

// one "expanded" flag per tag, created with a hook inside the loop
fn tag_list() -> Element {
  let tags = TAGS.with(|t| t.borrow().clone());
  let mut flags = Vec::new();
  for _tag in &tags {
    flags.push(use_signal(|| false));
  }
  let filter = use_signal(|| String::from("all"));
  rsx! { "{tags.len()} tags, {flags.len()} flags, filter = {filter}" }
}

// an early return before the second hook
fn early_return() -> Element {
  let count = use_signal(|| 1);
  if EMPTY.get() {
    return rsx! { "empty" };
  }
  let label = use_signal(|| String::from("items"));
  rsx! { "{count} {label}" }
}

tag_list 先用 2 个标签渲染,再改成 1 个;early_return 在 EMPTY 为真、假之间来回切。每次改完 mark_dirty(ScopeId::APP),render_immediate 两次(第二次让错误边界渲染出来)。debug:

== hook in a loop
render 1 (2 tags)            2 tags, 2 flags, filter = all

thread 'main' (2102883) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-core-0.7.10/src/scope_context.rs:387:9:
Unable to retrieve the hook that was initialized at this index.
                    Consult the `rules of hooks` to understand how to use hooks properly.

                    You likely used the hook in a conditional. Hooks rely on consistent ordering between renders.
                    Functions prefixed with "use" should never be called conditionally.

                    Help: Run `dx check` to look for check for some common hook errors.
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
render 2 (1 tag)             <div style="color:red;"><pre>Encountered panic: Any { .. }</pre></div>
== early return
render 1                     1 items
render 2 (empty = true)      empty
render 3 (empty = false)     1 items
render 4 (empty = true)      empty
render 5 (empty = false)     1 items

release 除线程号外逐行相同。

  • 循环:第一次渲染的 hook 表是 [bool, bool, String]。标签减到 1 个,第二次渲染里 filter 是第 2 次调用,拿到第 1 格的 Signal<bool>,类型不对,panic。panic 被组件外的 catch_unwind 接住,整页变成根错误边界的 Encountered panic。标签变多也一样:第 2 格原来是 Signal<String>,现在要 Signal<bool>。如果开关和 filter 恰好是同一类型,不会 panic,filter 静默地拿到某个标签的开关值,这比 panic 更难查(Scope 与 hooks 的情况 B)。
  • 早返回:完全正常。早返回只会让本次渲染少调用末尾的几个 hook,前面的序号不变;之后再走到后面,序号超出表长就是追加,再之后就是取回同一格。let v = res.suspend()?; 这种 ? 也是早返回,放在其他 hook 之后同样没问题。

出处

panic 在 Scope::push_hook_value 的最后一个分支(第 387 行):序号在表内、downcast_ref 失败、又不在热补丁下。取值入口是 Scope::use_hook,它不记 hook 的名字和调用位置,所以 panic 只能说「这个序号」,说不出是哪一行。这也是为什么 panic 位置在 scope_context.rs 而不在你的代码里。

panic 文字里建议的 dx check 是 CLI 里一个基于 syn 的语法检查(packages/cli/src/check/mod.rs)。visit_expr_call 找名字以 use_ 开头的函数调用(is_hook_ident),沿着当前所在的语法结构往外看:在 if、match、for、while、loop、闭包、async 块里就报;所在函数返回 Element 算组件、名字以 use_ 开头算 hook,两者都不是就报「outside component or hook」。它不展开宏,rsx! 里的东西看不见。

把各种写法放进一个文件试(src/bin/hook_check.rs,只给 dx check 看):

fn in_if(show: bool) -> Element {
  if show {
    let _a = use_signal(|| 0);
  }
  rsx! {}
}

fn in_match(n: u8) -> Element {
  match n {
    0 => {
      let _a = use_signal(|| 0);
    }
    _ => {}
  }
  rsx! {}
}

fn in_closure() -> Element {
  rsx! {
    button { onclick: move |_| { let _a = use_signal(|| 0); }, "x" }
  }
}

fn after_early_return(empty: bool) -> Element {
  if empty {
    return rsx! {};
  }
  let _a = use_signal(|| 0);
  rsx! {}
}

// not named use_*, so it does not look like a hook
fn make_state() -> Signal<i32> {
  use_signal(|| 0)
}

fn helper_in_if(show: bool) -> Element {
  if show {
    let _a = make_state();
  }
  rsx! {}
}

dx check --bin hook_check -f src/bin/hook_check.rs(去掉了开头一行 Waiting for cargo-metadata 和 dx 与 dioxus 版本不一致的三行 ERROR,以及颜色):

  1.66s  INFO error: hook called conditionally: `use_signal` (inside `if`)
  --> src/bin/hook_check.rs:6:14
  |
6 |     let _a = use_signal(|| 0);
  |              ^^^^^^^^^^
  |
  = note: `if show { … }` is the conditional

error: hook called conditionally: `use_signal` (inside `match`)
  --> src/bin/hook_check.rs:14:16
   |
14 |       let _a = use_signal(|| 0);
   |                ^^^^^^^^^^
   |
   = note: `match n { … }` is the conditional

error: hook called outside component or hook: `use_signal`
  --> src/bin/hook_check.rs:37:3
   |
37 |   use_signal(|| 0)
   |   ^^^^^^^^^^


ERROR dx check: error checking file

Caused by:
    3 issues found.
  • if、match 抓到了;早返回没报,和上面的运行结果一致。
  • onclick 闭包里的 use_signal 没报:它在 rsx! 里。
  • make_state 被报成「outside component or hook」,但真正有问题的 helper_in_if 没报:检查只看名字,make_state() 不像 hook,它在 if 里也就不算条件 hook。给这类辅助函数起 use_ 开头的名字,两处都会按规则检查。

对 hook_loop.rs 跑,报的是循环:

  0.56s  INFO error: hook called in a loop: `use_signal` (inside `for` loop)
  --> src/bin/hook_loop.rs:16:16
   |
16 |     flags.push(use_signal(|| false));
   |                ^^^^^^^^^^
   |
   = note: `for _tag in &tags { … }` is the loop

修法

「每一项一份状态」交给子组件:子组件的 hook 在子组件自己的 scope 里,有几项就有几个 scope,hook 表各管各的。src/bin/hook_loop_fix.rs:

// each tag gets its own component, and the flag is that component's hook
#[component]
fn TagRow(tag: &'static str) -> Element {
  let open = use_signal(|| false);
  rsx! { "[{tag} open={open}] " }
}

fn tag_list() -> Element {
  let tags = TAGS.with(|t| t.borrow().clone());
  let filter = use_signal(|| String::from("all"));
  rsx! {
    for tag in tags {
      TagRow { key: "{tag}", tag }
    }
    "filter = {filter}"
  }
}

2 个、1 个、3 个标签依次渲染:

render 1 (2 tags)  [rust open=false] [web open=false] filter = all
render 2 (1 tag)   [rust open=false] filter = all
render 3 (3 tags)  [rust open=false] [web open=false] [wasm open=false] filter = all

dx check --bin hook_loop_fix -f src/bin/hook_loop_fix.rs 输出 No issues found.,退出码 0;对原来的 hook_loop.rs 退出码是 1。

其他写法的改法:

  • 条件 hook:hook 总是调用,条件放在用它的地方(let s = use_signal(..); if show { .. s .. }),或者把条件分支整个做成子组件,用 if show { Child {} } 控制挂载。
  • 一个 signal 装整组状态:use_signal(HashSet::<TagId>::new) 存展开的标签,比每项一个 signal 更容易和数据保持一致。子组件写法要给列表加 key,否则状态按位置走(本章第 6 篇)。

怎么确认没中招

  • 在 CI 里跑 dx check,用退出码判断。不带 -f 时它读项目里每个 .rs 文件(按 .gitignore 过滤,不管模块是否被引用),在本实验 crate 上一次报出了上面两个文件的 4 处。它能抓 if / match / 循环 / 普通闭包 / async 块里的 use_* 调用,以及在非组件、非 use_ 函数里调 hook。
  • 它抓不到的:rsx! 里的闭包、不以 use_ 开头的辅助函数被条件调用。这些靠代码审查:hook 只在组件函数体最外层的语句里调用,在任何 if / for / match / 闭包之外。
  • 运行时,类型不同的错位 panic 原文就是标题这句,位置固定是 scope_context.rs:387;类型相同的错位没有任何信号,只能靠上面两条预防。

小结

  • hook 按调用序号取值,只校验类型。循环和条件让序号漂移:类型不同 panic、整页被错误边界替换,类型相同静默串值。
  • 早返回(含 ?)只截掉末尾的 hook,不违反规则。
  • dx check 是基于语法的检查:能抓 if / match / 循环 / 闭包里的 use_*,看不进 rsx!,只认 use_ 前缀。
  • 每项一份状态用子组件(加 key)或一个装整组状态的 signal。

💬 关联讨论

发起讨论
加载中...