fix(editor): 修复 SSR 直接访问时可运行代码块不挂载 CodeMirror

CodeRunner 原用 use_hook(|| format!("code-runner-{}", now_pseudo_unique()))
生成容器 id,后缀依赖 now_millis() 时间戳。Dioxus hydration 不传递
use_hook 状态——SSR 渲染与客户端 hydration 各执行一次 use_hook,时间戳
(以及顺带验证过的 ScopeId)两端不同,导致 CodeMirror create() 在 hydration
时用新 id 去 getElementById,找不到 SSR 渲染的容器(id 是旧值),返回 null
不挂载。路由跳转是纯客户端渲染,use_hook 只跑一次 id 一致,故正常。

改为由父组件传入确定性的 instance_id(PostContent 的片段索引 i,来自纯函数
split_content_fragments 对同一 content_html 的解析,SSR 与 hydration 一致),
container_id 直接派生自该 prop,不进 use_hook。admin 试运行页单实例固定 0。

Playwright(release 构建)验证:SSR 直接访问与路由跳转 .cm-editor 均为 1。
This commit is contained in:
xfy 2026-07-08 10:56:35 +08:00
parent 8f6c864f22
commit b95777e58d
4 changed files with 18 additions and 13 deletions

2
.gitignore vendored
View File

@ -32,3 +32,5 @@ profile.json.gz
# FreeBSD cross-compile sysroot (extracted from base.txz, machine-local) # FreeBSD cross-compile sysroot (extracted from base.txz, machine-local)
.freebsd-sysroot/ .freebsd-sysroot/
.zcode/

View File

@ -24,6 +24,11 @@ const MAX_POLLS: u32 = 240; // 500ms * 240 = 120s 上限
/// - `source`:初始源码(首次挂载用于初始化编辑器;之后编辑器内容是唯一真源)。 /// - `source`:初始源码(首次挂载用于初始化编辑器;之后编辑器内容是唯一真源)。
/// - `language`语言标识python / node 等)。 /// - `language`语言标识python / node 等)。
/// - `overrides`:可选资源限制覆盖。 /// - `overrides`:可选资源限制覆盖。
/// - `instance_id`:实例在父级片段序列中的索引,用作 CodeMirror 容器 id 后缀。
/// 必须是 **SSR/hydration 确定性**的值(如父组件 `for (i, ..)` 的索引 `i`)——
/// Dioxus hydration 不传递 use_hook 状态,任何在 use_hook 内基于运行时状态
/// (时间戳 / 随机 / ScopeId生成的 id在 SSR 与 hydration 两端会不一致,
/// 导致 CodeMirror `create()` 在 hydration 时找不到 SSR 渲染的容器元素。
/// ///
/// `mut` 信号仅在 WASM 的 spawn 闭包内被 `.set()`server 构建会触发 unused_mut /// `mut` 信号仅在 WASM 的 spawn 闭包内被 `.set()`server 构建会触发 unused_mut
/// 故按项目惯例加 `cfg_attr` 放行(参见 AGENTS.md「mut bindings needed only on WASM」 /// 故按项目惯例加 `cfg_attr` 放行(参见 AGENTS.md「mut bindings needed only on WASM」
@ -33,6 +38,7 @@ pub fn CodeRunner(
source: String, source: String,
language: String, language: String,
overrides: Option<ResourceLimits>, overrides: Option<ResourceLimits>,
instance_id: usize,
) -> Element { ) -> Element {
let mut running = use_signal(|| false); let mut running = use_signal(|| false);
let mut stage = use_signal(String::new); let mut stage = use_signal(String::new);
@ -43,9 +49,10 @@ pub fn CodeRunner(
// 编辑器内容的唯一真源;初始化为 prop 值。 // 编辑器内容的唯一真源;初始化为 prop 值。
let mut source_signal = use_signal(|| source.clone()); let mut source_signal = use_signal(|| source.clone());
// 为每个实例生成稳定的容器 idCodeMirror 容器,由本组件在 WASM 端挂载)。 // CodeMirror 容器 id直接由确定性 prop 派生(不进 use_hook
// use_hook 保证只算一次;后缀用时间戳+原子计数避免同页多实例 id 冲突。 // instance_id 由父组件从纯函数片段解析的索引传入SSR 与 hydration 同一 content_html
let container_id = use_hook(|| format!("code-runner-{}", now_pseudo_unique())); // → 同一片段序列 → 同一索引 → 同一 id故 hydration 时 create() 能找到 SSR 渲染的容器。
let container_id = format!("code-runner-{instance_id}");
// —— CodeMirror 挂载(仅 WASM—— // —— CodeMirror 挂载(仅 WASM——
// 范式镜像 src/pages/admin/system.rs 的 SQL 控制台与 src/pages/admin/write.rs 的 Tiptap。 // 范式镜像 src/pages/admin/system.rs 的 SQL 控制台与 src/pages/admin/write.rs 的 Tiptap。
@ -282,12 +289,3 @@ fn status_label(status: &ExecStatus) -> String {
ExecStatus::RateLimited => "请求过频".to_string(), ExecStatus::RateLimited => "请求过频".to_string(),
} }
} }
/// 生成一个伪唯一后缀(基于时间戳 + 计数器),用于容器 id。
/// 非安全用途,仅避免同页多实例 id 冲突。
fn now_pseudo_unique() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{}-{}", crate::utils::time::now_millis(), n)
}

View File

@ -162,6 +162,10 @@ pub fn PostContent(content_html: String) -> Element {
source: source.clone(), source: source.clone(),
language: lang.clone(), language: lang.clone(),
overrides: overrides.clone(), overrides: overrides.clone(),
// i 是片段序列中的确定性索引(来自纯函数 split_content_fragments
// 对同一 content_html 的解析SSR 与 hydration 一致,用作容器
// id 后缀保证 hydration 时 CodeMirror 能找到 SSR 渲染的容器。
instance_id: i,
} }
}, },
}} }}

View File

@ -113,11 +113,12 @@ pub fn Runner() -> Element {
} }
} }
// 运行器 // 运行器admin 试运行页单实例、纯客户端渲染instance_id 固定 0 即可)
CodeRunner { CodeRunner {
source: source(), source: source(),
language: lang(), language: lang(),
overrides: overrides, overrides: overrides,
instance_id: 0,
} }
} }
} }