Skip to main content

yggdrasil/bridges/
navigation.rs

1//! 浏览器导航协调器桥接;历史记录中的页面状态只保存在当前文档内存中。
2//!
3//! 协调器是 `window.__routeTransitions`(`libs/yggdrasil-core/src/route-transitions.ts`),
4//! 它拥有 history 与视图过渡;这里是它的 Rust 封装。`push/replace/back/forward/external`
5//! 返回协调器是否存在(`false` 时调用方自行回退到浏览器原生导航),服务端恒为 `false`。
6
7use serde::{de::DeserializeOwned, Serialize};
8
9/// 请求站内跳转(视图过渡后写入 history)。协调器缺失返回 `false`。
10pub fn push(route: &str) -> bool {
11    #[cfg(target_arch = "wasm32")]
12    if let Some(module) = wasm::get_module() {
13        module.push(route);
14        return true;
15    }
16    let _ = route;
17    false
18}
19
20/// 请求站内替换跳转。协调器缺失返回 `false`。
21pub fn replace(route: &str) -> bool {
22    #[cfg(target_arch = "wasm32")]
23    if let Some(module) = wasm::get_module() {
24        module.replace(route);
25        return true;
26    }
27    let _ = route;
28    false
29}
30
31/// 历史记录后退。协调器缺失返回 `false`。
32pub fn back() -> bool {
33    #[cfg(target_arch = "wasm32")]
34    if let Some(module) = wasm::get_module() {
35        module.back();
36        return true;
37    }
38    false
39}
40
41/// 历史记录前进。协调器缺失返回 `false`。
42pub fn forward() -> bool {
43    #[cfg(target_arch = "wasm32")]
44    if let Some(module) = wasm::get_module() {
45        module.forward();
46        return true;
47    }
48    false
49}
50
51/// 整页跳转到站外或非 SPA 地址。协调器缺失或跳转失败返回 `false`。
52pub fn external(url: &str) -> bool {
53    #[cfg(target_arch = "wasm32")]
54    if let Some(module) = wasm::get_module() {
55        return module.external(url);
56    }
57    let _ = url;
58    false
59}
60
61/// 连接协调器:之后它每次发布新路由都会调用 `notify`;悬停、聚焦、触屏按下站内链接时
62/// 调用 `prefetch(route, with_data)`。协调器缺失或连接失败返回 `false`。
63///
64/// JS 同时持有两个闭包的函数引用;调用方必须保持两个 `Closure` 存活到 [`disconnect`] 之后。
65#[cfg(target_arch = "wasm32")]
66pub fn connect(
67    notify: &wasm_bindgen::prelude::Closure<dyn FnMut()>,
68    prefetch: &wasm_bindgen::prelude::Closure<dyn FnMut(String, bool)>,
69) -> bool {
70    let Some(module) = wasm::get_module() else {
71        return false;
72    };
73    if module.connect(notify, None, prefetch).is_err() {
74        let _ = module.disconnect();
75        return false;
76    }
77    true
78}
79
80/// 断开协调器,释放它持有的事件监听与历史记录状态。
81#[cfg(target_arch = "wasm32")]
82pub fn disconnect() {
83    if let Some(module) = wasm::get_module() {
84        let _ = module.disconnect();
85    }
86}
87
88/// 读取当前历史记录保存的页面状态。SSR 和资源加载失败时没有客户端状态。
89pub fn read_state<T: DeserializeOwned>(key: &str) -> Option<T> {
90    #[cfg(target_arch = "wasm32")]
91    {
92        let value = wasm::get_module()?.read_state(key)?;
93        serde_json::from_slice(value.as_bytes()).ok()
94    }
95    #[cfg(not(target_arch = "wasm32"))]
96    {
97        let _ = key;
98        None
99    }
100}
101
102/// 写入挂载时捕获的历史记录,避免卸载后的异步回调覆盖新页面状态。
103pub fn write_state<T: Serialize>(entry_id: &str, key: &str, value: &T) {
104    #[cfg(target_arch = "wasm32")]
105    if let (Some(module), Ok(value)) = (wasm::get_module(), serde_json::to_string(value)) {
106        module.write_state(entry_id, key, &value);
107    }
108    #[cfg(not(target_arch = "wasm32"))]
109    let _ = (entry_id, key, value);
110}
111
112/// 页面挂载时捕获的历史记录标识。服务端返回空字符串。
113pub fn entry_id() -> String {
114    #[cfg(target_arch = "wasm32")]
115    if let Some(module) = wasm::get_module() {
116        return module.entry_id();
117    }
118    String::new()
119}
120
121/// 退出登录时清理后台历史记录中的页面状态。
122pub fn clear_admin_state() {
123    #[cfg(target_arch = "wasm32")]
124    if let Some(module) = wasm::get_module() {
125        module.clear_admin_state();
126    }
127}
128
129/// 与本轮路由 DOM 一起渲染的导航编号。
130pub fn navigation_id() -> u32 {
131    #[cfg(target_arch = "wasm32")]
132    if let Some(module) = wasm::get_module() {
133        return module.navigation_id();
134    }
135    0
136}
137
138/// 布局 effect 在实际 DOM 提交后通知 JS;JS 再核对编号和 DOM 标记。
139pub fn rendered(id: u32, route: &str) {
140    #[cfg(target_arch = "wasm32")]
141    if let Some(module) = wasm::get_module() {
142        module.rendered(id, route);
143    }
144    #[cfg(not(target_arch = "wasm32"))]
145    let _ = (id, route);
146}
147
148/// 协调器是否处于分包迟到的挂起状态(`<html>` 带 `is-route-pending`)。服务端恒为 `false`。
149///
150/// 迟到的新页面挂载后,组件 Effect(微任务)先于 `RouteMarker` 用 `set_timeout(0)`
151/// 触发的 [`rendered`] 运行,协调器在 `rendered` 里才撤类,所以 Effect 内读到的仍为 `true`。
152pub fn route_pending() -> bool {
153    #[cfg(target_arch = "wasm32")]
154    {
155        leptos::prelude::document()
156            .document_element()
157            .is_some_and(|root| root.class_list().contains("is-route-pending"))
158    }
159    #[cfg(not(target_arch = "wasm32"))]
160    {
161        false
162    }
163}
164
165#[cfg(target_arch = "wasm32")]
166pub mod wasm {
167    use wasm_bindgen::{prelude::*, JsCast};
168
169    #[wasm_bindgen]
170    extern "C" {
171        /// `window.__routeTransitions` 是对象字面量,通过 Reflect 读取。
172        pub type NavigationModule;
173
174        #[wasm_bindgen(method, catch)]
175        pub fn connect(
176            this: &NavigationModule,
177            notify: &Closure<dyn FnMut()>,
178            prefix: Option<&str>,
179            prefetch: &Closure<dyn FnMut(String, bool)>,
180        ) -> Result<(), JsValue>;
181
182        #[wasm_bindgen(method, catch)]
183        pub fn disconnect(this: &NavigationModule) -> Result<(), JsValue>;
184
185        #[wasm_bindgen(method, js_name = currentRoute)]
186        pub fn current_route(this: &NavigationModule) -> String;
187
188        #[wasm_bindgen(method, js_name = currentPrefix)]
189        pub fn current_prefix(this: &NavigationModule) -> Option<String>;
190
191        #[wasm_bindgen(method, js_name = navigationId)]
192        pub fn navigation_id(this: &NavigationModule) -> u32;
193
194        #[wasm_bindgen(method)]
195        pub fn push(this: &NavigationModule, route: &str);
196
197        #[wasm_bindgen(method)]
198        pub fn replace(this: &NavigationModule, route: &str);
199
200        #[wasm_bindgen(method)]
201        pub fn back(this: &NavigationModule);
202
203        #[wasm_bindgen(method)]
204        pub fn forward(this: &NavigationModule);
205
206        #[wasm_bindgen(method)]
207        pub fn external(this: &NavigationModule, url: &str) -> bool;
208
209        #[wasm_bindgen(method)]
210        pub fn rendered(this: &NavigationModule, id: u32, route: &str);
211
212        #[wasm_bindgen(method, js_name = readState)]
213        pub fn read_state(this: &NavigationModule, key: &str) -> Option<String>;
214
215        #[wasm_bindgen(method, js_name = writeState)]
216        pub fn write_state(this: &NavigationModule, entry_id: &str, key: &str, value: &str);
217
218        #[wasm_bindgen(method, js_name = entryId)]
219        pub fn entry_id(this: &NavigationModule) -> String;
220
221        #[wasm_bindgen(method, js_name = clearAdminState)]
222        pub fn clear_admin_state(this: &NavigationModule);
223
224        #[wasm_bindgen(method, js_name = setLeaveGuard)]
225        pub fn set_leave_guard(
226            this: &NavigationModule,
227            check: &Closure<dyn FnMut() -> bool>,
228        ) -> u32;
229
230        #[wasm_bindgen(method, js_name = clearLeaveGuard)]
231        pub fn clear_leave_guard(this: &NavigationModule, id: u32);
232    }
233
234    pub fn get_module() -> Option<NavigationModule> {
235        let window = web_sys::window()?;
236        let value = js_sys::Reflect::get(&window, &"__routeTransitions".into()).ok()?;
237        if value.is_null() || value.is_undefined() {
238            return None;
239        }
240        let connect = js_sys::Reflect::get(&value, &"connect".into()).ok()?;
241        connect
242            .is_function()
243            .then(|| value.unchecked_into::<NavigationModule>())
244    }
245}