Skip to main content

yggdrasil/bridges/
library.rs

1//! 按组件需求加载浏览器库,资源完成后才允许构造 wasm-bindgen Options/实例。
2//!
3//! 加载入口是 yggdrasil-core 挂在 window 上的 `__loadBrowserLibrary(name)`
4//! (按库名注入脚本与样式、去重并返回 Promise)。
5
6use leptos::prelude::*;
7
8#[cfg(target_arch = "wasm32")]
9#[wasm_bindgen::prelude::wasm_bindgen]
10extern "C" {
11    #[wasm_bindgen(js_namespace = window, js_name = __loadBrowserLibrary, catch)]
12    async fn load_browser_library(
13        name: &str,
14    ) -> Result<wasm_bindgen::JsValue, wasm_bindgen::JsValue>;
15}
16
17/// 浏览器库加载状态句柄。
18///
19/// `state`:`None` 加载中(SSR 恒为 None),`Some(Ok(true))` 就绪,
20/// `Some(Ok(false))` 未启用,`Some(Err(_))` 加载失败。
21///
22/// 不用 `LocalResource`:Resource 在 `<Suspense>` 内被读取时会登记为挂起项,
23/// 编辑器按需启用(聚焦表单)触发重新加载时,外层 Suspense 会退回 fallback 并把
24/// 评论区整段移出再插回 DOM,正在输入的编辑器因此失焦。这里用 Effect + spawn_local
25/// 直接写普通信号,加载过程对 Suspense 不可见。
26#[derive(Clone, Copy)]
27pub struct LibraryResource {
28    reload: RwSignal<u32>,
29    state: RwSignal<Option<Result<bool, String>>>,
30}
31
32impl LibraryResource {
33    /// 重新加载(加载失败后的"重新加载"按钮)。
34    pub fn restart(&self) {
35        self.reload.update(|v| *v = v.wrapping_add(1));
36    }
37
38    /// 当前加载状态(响应式读取)。
39    pub fn read_state(&self) -> Option<Result<bool, String>> {
40        self.state.get()
41    }
42
43    /// 库是否已就绪(非响应式读取,供事件处理器使用)。
44    pub fn ready_untracked(&self) -> bool {
45        self.state
46            .with_untracked(|state| matches!(state, Some(Ok(true))))
47    }
48}
49
50/// 可按需加载的浏览器库,与 `libs/yggdrasil-core/src/browser-library.ts` 的 `BrowserLibrary` 对应。
51#[derive(Clone, Copy, PartialEq, Eq)]
52pub enum BrowserLibrary {
53    Tiptap,
54    CodeMirror,
55    Xterm,
56}
57
58impl BrowserLibrary {
59    /// 传给 `__loadBrowserLibrary` 的库名。
60    pub const fn name(self) -> &'static str {
61        match self {
62            Self::Tiptap => "tiptap",
63            Self::CodeMirror => "codemirror",
64            Self::Xterm => "xterm",
65        }
66    }
67}
68
69/// enabled 可订阅组件状态(Effect 内读取,变化时重新加载);
70/// 组件卸载后 `try_set` 失败即丢弃迟到的加载结果,不会挂载旧组件。
71pub fn use_browser_library(
72    library: BrowserLibrary,
73    enabled: impl Fn() -> bool + Send + Sync + 'static,
74) -> LibraryResource {
75    let reload = RwSignal::new(0u32);
76    let state = RwSignal::new(None);
77    // 每次(重新)加载递增世代号,只接受最新一次请求的结果。
78    let generation = StoredValue::new(0u64);
79    // Effect 只在浏览器执行。
80    Effect::new(move |_| {
81        reload.track();
82        let enabled = enabled();
83        let current = generation.try_update_value(|g| {
84            *g += 1;
85            *g
86        });
87        let Some(current) = current else { return };
88        #[cfg(target_arch = "wasm32")]
89        {
90            if !enabled {
91                state.set(Some(Ok(false)));
92                return;
93            }
94            state.set(None);
95            leptos::task::spawn_local(async move {
96                let result = load_browser_library(library.name())
97                    .await
98                    .map(|_| true)
99                    .map_err(|error| {
100                        web_sys::console::error_1(&error);
101                        "加载失败,请检查网络后重试。".to_string()
102                    });
103                if generation.try_get_value() == Some(current) {
104                    let _ = state.try_set(Some(result));
105                }
106            });
107        }
108        #[cfg(not(target_arch = "wasm32"))]
109        let _ = (library, enabled, current);
110    });
111    LibraryResource { reload, state }
112}
113
114/// 库是否已加载就绪(响应式读取)。
115pub fn library_ready(library: LibraryResource) -> bool {
116    library.state.with(|state| matches!(state, Some(Ok(true))))
117}
118
119/// 加载失败时的提示条 + 重新加载按钮。
120#[component]
121pub fn LibraryLoadError(library: LibraryResource) -> impl IntoView {
122    move || {
123        library
124            .read_state()
125            .and_then(|result| result.err())
126            .map(|message| {
127                view! {
128                    <div role="alert" class="flex items-center gap-3 p-3 text-sm text-red-500 dark:text-red-400">
129                        <span>{message}</span>
130                        <button type="button" class="underline underline-offset-4" on:click=move |_| library.restart()>
131                            "重新加载"
132                        </button>
133                    </div>
134                }
135            })
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use leptos::reactive::owner::Owner;
143
144    #[test]
145    fn server_side_state_stays_loading_and_not_ready() {
146        Owner::new().with(|| {
147            let library = use_browser_library(BrowserLibrary::Tiptap, || true);
148            assert!(library.read_state().is_none());
149            assert!(!library.ready_untracked());
150            assert!(!library_ready(library));
151        });
152    }
153
154    #[test]
155    fn load_error_renders_alert_with_retry_button() {
156        Owner::new().with(|| {
157            let library = use_browser_library(BrowserLibrary::Tiptap, || true);
158            library
159                .state
160                .set(Some(Err("加载失败,请检查网络后重试。".to_string())));
161            let html = view! { <LibraryLoadError library/> }.to_html();
162            assert!(html.contains(r#"role="alert""#), "{html}");
163            assert!(html.contains("加载失败,请检查网络后重试。"), "{html}");
164            assert!(html.contains("重新加载"), "{html}");
165        });
166    }
167
168    #[test]
169    fn ready_library_renders_nothing() {
170        Owner::new().with(|| {
171            let library = use_browser_library(BrowserLibrary::Tiptap, || true);
172            library.state.set(Some(Ok(true)));
173            assert!(library_ready(library));
174            let html = view! { <LibraryLoadError library/> }.to_html();
175            assert!(!html.contains("alert"), "{html}");
176        });
177    }
178}