Skip to main content

yggdrasil/
navigation.rs

1//! 站内导航:JS 协调器与 Leptos 路由器之间的握手。
2//!
3//! JS 协调器 `window.__routeTransitions`(`libs/yggdrasil-core/src/route-transitions.ts`)
4//! 拥有 history 与视图过渡:它拦截站内链接点击与 `popstate`,在视图过渡的 update 回调里
5//! 写入 history,然后通知 Rust;Leptos 的 `BrowserUrl` 只负责“读 URL → 渲染”。
6//!
7//! - [`NavigationBridge`]:连接协调器,收到通知后把地址栏 URL 写入 `BrowserUrl` 的 URL 信号。
8//! - [`RouteMarker`]:包住路由出口,路由内容提交到 DOM 后通知协调器“已渲染”,并提供
9//!   [`use_rendered_route`] 所需的“已提交路由”。
10//! - [`RouteProgress`]:路由切换较慢时在页面顶端显示进度条。
11//! - [`push`] / [`replace`] / [`back`] / [`forward`] / [`external`]:程序化导航的唯一入口,
12//!   **禁止**使用 `leptos_router::hooks::use_navigate` 与 `<A>`。
13//!
14//! 悬停、聚焦、触屏按下站内链接时,协调器回调 `NavigationBridge` 交给它的预取闭包,
15//! 由 [`crate::route_prefetch`] 提前下载目标页面的分包。
16//!
17//! 站内链接一律是普通 `<a href>`;指向非 SPA 路由的链接(`/feed.xml`、`/doc/...`、
18//! `/uploads/...` 等)必须带 `rel="external"`(或 `download`/`target`)。
19
20use leptos::html;
21use leptos::prelude::*;
22use leptos_router::hooks::use_location;
23use std::sync::atomic::AtomicBool;
24#[cfg(target_arch = "wasm32")]
25use std::sync::atomic::Ordering;
26
27/// 协调器是否已连接(仅浏览器端 `NavigationBridge` 在整棵树水合完毕后置真)。
28#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
29static CONNECTED: AtomicBool = AtomicBool::new(false);
30
31/// 站内跳转(新增 history 记录)。协调器未连接时退化为整页跳转;服务端为空操作。
32pub fn push(route: &str) {
33    #[cfg(target_arch = "wasm32")]
34    {
35        if CONNECTED.load(Ordering::Acquire) && crate::bridges::navigation::push(route) {
36            return;
37        }
38        if let Some(window) = web_sys::window() {
39            let _ = window.location().assign(route);
40        }
41    }
42    #[cfg(not(target_arch = "wasm32"))]
43    let _ = route;
44}
45
46/// 站内跳转(替换当前 history 记录)。协调器未连接时退化为 `location.replace`。
47pub fn replace(route: &str) {
48    #[cfg(target_arch = "wasm32")]
49    {
50        if CONNECTED.load(Ordering::Acquire) && crate::bridges::navigation::replace(route) {
51            return;
52        }
53        if let Some(window) = web_sys::window() {
54            let _ = window.location().replace(route);
55        }
56    }
57    #[cfg(not(target_arch = "wasm32"))]
58    let _ = route;
59}
60
61/// 历史记录后退。协调器缺失时用 `history.back()`。
62pub fn back() {
63    #[cfg(target_arch = "wasm32")]
64    {
65        if CONNECTED.load(Ordering::Acquire) && crate::bridges::navigation::back() {
66            return;
67        }
68        if let Some(history) = web_sys::window().and_then(|window| window.history().ok()) {
69            let _ = history.back();
70        }
71    }
72}
73
74/// 历史记录前进。协调器缺失时用 `history.forward()`。
75pub fn forward() {
76    #[cfg(target_arch = "wasm32")]
77    {
78        if CONNECTED.load(Ordering::Acquire) && crate::bridges::navigation::forward() {
79            return;
80        }
81        if let Some(history) = web_sys::window().and_then(|window| window.history().ok()) {
82            let _ = history.forward();
83        }
84    }
85}
86
87/// 整页跳转到站外或非 SPA 地址。协调器缺失时用 `location.assign`。
88pub fn external(url: &str) {
89    #[cfg(target_arch = "wasm32")]
90    {
91        if CONNECTED.load(Ordering::Acquire) && crate::bridges::navigation::external(url) {
92            return;
93        }
94        if let Some(window) = web_sys::window() {
95            let _ = window.location().assign(url);
96        }
97    }
98    #[cfg(not(target_arch = "wasm32"))]
99    let _ = url;
100}
101
102/// 连接 JS 协调器;放在 `<Router>` 内、`<Routes>` 前,不渲染 DOM。
103///
104/// 整棵树(含懒加载路由分包)水合完毕后才连接,并随即在 `<html>` 上置 `data-hydrated`
105/// (浏览器回归脚本与性能测试等它出现再操作)。水合期间路由器换页会让后续节点对不上
106/// SSR 输出,水合 panic 后整个 WASM 失效、站内链接全部点不动;未连接时协调器把站内
107/// 链接点击交还浏览器整页跳转(见 `route-transitions.ts` 的 `onClick`)。
108///
109/// 连接后协调器每次发布新路由(视图过渡的 update 回调里)都会通知这里,
110/// 这里读取已被 JS 写好的地址栏并设置 `BrowserUrl` 的 URL 信号,路由器随即渲染新页面。
111#[component]
112pub fn NavigationBridge() -> impl IntoView {
113    #[cfg(feature = "hydrate")]
114    {
115        use crate::context::UserContext;
116        use crate::models::user::UserRole;
117        use leptos_router::location::{BrowserUrl, LocationProvider};
118        use wasm_bindgen::prelude::Closure;
119
120        let browser_url = use_context::<BrowserUrl>();
121        let shared = Owner::current_shared_context();
122        // JS 持有函数引用:闭包必须活到 disconnect 之后,存进本地存储并在卸载时释放。
123        let notify = StoredValue::new_local(None::<Closure<dyn FnMut()>>);
124        let prefetch = StoredValue::new_local(None::<Closure<dyn FnMut(String, bool)>>);
125        let user = use_context::<UserContext>();
126        Effect::new(move |_| {
127            let browser_url = browser_url.clone();
128            let shared = shared.clone();
129            leptos::task::spawn_local(async move {
130                // Effect 在懒加载路由分包水合前就会运行,必须等 during_hydration 回落。
131                while shared.as_ref().is_some_and(|sc| sc.during_hydration()) {
132                    crate::utils::time::sleep_ms(10).await;
133                }
134                // 等待期间组件已卸载:不再连接。
135                if notify.try_with_value(|_| ()).is_none() {
136                    return;
137                }
138                if let Some(browser_url) = browser_url {
139                    let closure = Closure::wrap(Box::new(move || {
140                        if let Ok(url) = BrowserUrl::current() {
141                            browser_url.as_url().set(url);
142                        }
143                    }) as Box<dyn FnMut()>);
144                    let prefetch_closure =
145                        Closure::wrap(Box::new(move |route: String, with_data: bool| {
146                            let is_admin = user.is_some_and(|ctx| {
147                                ctx.user
148                                    .try_with_untracked(|u| {
149                                        u.as_ref().is_some_and(|u| u.role == UserRole::Admin)
150                                    })
151                                    .unwrap_or(false)
152                            });
153                            crate::route_prefetch::prefetch(&route, with_data, is_admin);
154                        }) as Box<dyn FnMut(String, bool)>);
155                    if crate::bridges::navigation::connect(&closure, &prefetch_closure) {
156                        CONNECTED.store(true, Ordering::Release);
157                        notify.set_value(Some(closure));
158                        prefetch.set_value(Some(prefetch_closure));
159                    }
160                }
161                if let Some(root) = document().document_element() {
162                    let _ = root.set_attribute("data-hydrated", "true");
163                }
164            });
165        });
166        on_cleanup(move || {
167            if CONNECTED.swap(false, Ordering::AcqRel) {
168                crate::bridges::navigation::disconnect();
169            }
170        });
171    }
172}
173
174/// 当前路由的“路径 + 查询”字符串,作为视图过渡协调器核对 DOM 的路由标记。
175fn route_key(pathname: &str, search: &str) -> String {
176    if search.is_empty() {
177        pathname.to_owned()
178    } else {
179        format!("{pathname}?{search}")
180    }
181}
182
183/// 一条路由地址:路径(不含查询串)与查询串(不含 `?`)。
184#[derive(Clone, Debug, Default, PartialEq, Eq)]
185pub struct RenderedRoute {
186    pub pathname: String,
187    pub search: String,
188}
189
190impl RenderedRoute {
191    /// 路径加查询串,如 `/search?q=rust`。
192    pub fn key(&self) -> String {
193        route_key(&self.pathname, &self.search)
194    }
195}
196
197/// 已提交到 DOM 的路由,由 [`RouteMarker`] 提供。
198#[derive(Clone, Copy)]
199struct CommittedRoute(ReadSignal<RenderedRoute>);
200
201/// 布局外壳应跟随的路由:当前显示在页面上的那一个,而不是地址栏。
202///
203/// 懒加载路由切换时,地址栏先变,路由器在新页面的分包与数据就绪前继续显示旧页面;
204/// 外壳(导航激活项、主区宽度等)若直接读 `use_location()`,会先切到新页面的样子,
205/// 把旧页面套进错误的版式。这里返回的值在新页面提交到 DOM 后才更新。
206///
207/// 组件首次计算取地址栏:在路由切换中新建的布局本身就属于即将显示的新页面。
208/// 须在 [`RouteMarker`] 内使用。
209pub fn use_rendered_route() -> Memo<RenderedRoute> {
210    let location = use_location();
211    let CommittedRoute(committed) = expect_context::<CommittedRoute>();
212    Memo::new(move |previous: Option<&RenderedRoute>| {
213        let committed = committed.get();
214        if previous.is_some() {
215            committed
216        } else {
217            RenderedRoute {
218                pathname: location.pathname.get_untracked(),
219                search: location.search.get_untracked(),
220            }
221        }
222    })
223}
224
225/// 路由出口外壳:渲染 `data-vt-route`/`data-vt-navigation` 标记,并在路由内容提交后通知协调器。
226///
227/// 标记用 `set_attribute` 直接写入(不经响应式绑定),保证调用 `rendered` 时 DOM 已带正确值;
228/// `is_routing` 为真(路由器正在切换)时不通知,回落后由 Effect 重新触发。
229/// 同一时机更新 [`use_rendered_route`] 读取的已提交路由。
230#[component]
231pub fn RouteMarker(is_routing: ReadSignal<bool>, children: Children) -> impl IntoView {
232    let location = use_location();
233    let pathname = location.pathname;
234    let search = location.search;
235    let first_route = RenderedRoute {
236        pathname: pathname.get_untracked(),
237        search: search.get_untracked(),
238    };
239    let first_key = first_route.key();
240    let committed = RwSignal::new(first_route);
241    provide_context(CommittedRoute(committed.read_only()));
242    let marker = NodeRef::<html::Div>::new();
243
244    #[cfg(feature = "hydrate")]
245    Effect::new(move |was_routing: Option<bool>| {
246        let route = RenderedRoute {
247            pathname: pathname.get(),
248            search: search.get(),
249        };
250        if is_routing.get() {
251            return true;
252        }
253        let commit = move |route: &RenderedRoute| {
254            if committed.with_untracked(|current| current != route) {
255                committed.set(route.clone());
256            }
257        };
258        // 路由器刚把新页面挂进 DOM:在同一轮微任务里同步外壳,赶在浏览器绘制之前,
259        // 不让新页面以旧版式露出一帧。
260        if was_routing == Some(true) {
261            commit(&route);
262        }
263        // 不用 requestAnimationFrame:视图过渡进行时浏览器暂停渲染,rAF 要等过渡回调结束才触发,
264        // 而协调器正等着这次通知来结束回调(互相等待,只能靠超时兜底)。定时器不受影响。
265        set_timeout(
266            move || {
267                // URL 已变、路由器还没来得及把 is_routing 置真的窗口内放弃本次通知。
268                if is_routing.get_untracked() {
269                    return;
270                }
271                // 不经路由器切换的地址变化(仅查询串变化、未匹配路由)在这里提交。
272                commit(&route);
273                let Some(element) = marker.get_untracked() else {
274                    return;
275                };
276                let key = route.key();
277                let id = crate::bridges::navigation::navigation_id();
278                let _ = element.set_attribute("data-vt-route", &key);
279                let _ = element.set_attribute("data-vt-navigation", &id.to_string());
280                crate::bridges::navigation::rendered(id, &key);
281            },
282            std::time::Duration::ZERO,
283        );
284        false
285    });
286    #[cfg(not(feature = "hydrate"))]
287    let _ = is_routing;
288
289    view! {
290        <div
291            style="display: contents"
292            data-vt-route=first_key
293            data-vt-navigation="0"
294            node_ref=marker
295        >
296            {children()}
297        </div>
298    }
299}
300
301/// 路由切换超过这个时长仍未完成才显示进度条;与协调器的视图过渡等待时长(`WAIT_MS`)一致,
302/// 过渡期间浏览器暂停渲染,早于它显示也看不到。
303#[cfg(feature = "hydrate")]
304const ROUTE_PROGRESS_DELAY: std::time::Duration = std::time::Duration::from_millis(300);
305
306/// 路由切换进度条:新页面的分包或数据加载较慢时,在页面顶端显示一条往复滑动的细线,
307/// 表明点击已生效。快速切换(不超过 300ms)不显示。
308#[component]
309pub fn RouteProgress(is_routing: ReadSignal<bool>) -> impl IntoView {
310    let active = RwSignal::new(false);
311
312    #[cfg(feature = "hydrate")]
313    {
314        // 每次切换换一个世代号,过期的计时器不再点亮进度条。
315        let generation = StoredValue::new(0_u64);
316        Effect::new(move |_| {
317            let routing = is_routing.get();
318            generation.update_value(|g| *g += 1);
319            if !routing {
320                active.set(false);
321                return;
322            }
323            let id = generation.get_value();
324            set_timeout(
325                move || {
326                    if generation.get_value() == id && is_routing.get_untracked() {
327                        active.set(true);
328                    }
329                },
330                ROUTE_PROGRESS_DELAY,
331            );
332        });
333    }
334    #[cfg(not(feature = "hydrate"))]
335    let _ = is_routing;
336
337    view! {
338        <div class="route-progress" data-active=move || active.get().then_some("") aria-hidden="true">
339            <span></span>
340        </div>
341    }
342}