yggdrasil/hooks/query.rs
1//! 通用客户端数据加载 hook。
2//!
3//! 封装 `Effect + spawn_local + 多个 RwSignal` 的三件套,统一 loading / data / error
4//! 三态与请求世代号竞态保护。后台 CSR 页面用这套;公共 SSR 页面仍用路由
5//! `LazyRoute::data()` 里创建的 `Resource`(带 Suspense 的真 SSR 预取),二者职责区分明确。
6//!
7//! 当前只提供 `use_paginated`(分页列表)。system tab 的"load_once + 定时轮询"形态、
8//! dashboard 的多源聚合形态保持手写——强行套 hook 反而更复杂。
9
10use leptos::prelude::*;
11
12/// 分页列表加载状态。
13///
14/// 各字段是独立 `RwSignal`(`Copy`),调用方可按名读取、传给子组件,
15/// 或对 `items` 做 `update` 乐观更新。
16pub struct PaginatedState<T: Send + Sync + 'static> {
17 /// 是否正在加载(初始为 `true`;服务端不发请求,保持 `true`,与水合首帧一致)。
18 pub loading: RwSignal<bool>,
19 /// 当前页数据。
20 pub items: RwSignal<Vec<T>>,
21 /// 总条数(用于分页器)。
22 pub total: RwSignal<i64>,
23 /// 最近一次加载错误(成功时为 None)。
24 pub error: RwSignal<Option<String>>,
25}
26
27// 手写而非 derive:derive 会给 `T` 加上多余的 `Clone`/`Copy` 约束。
28impl<T: Send + Sync + 'static> Clone for PaginatedState<T> {
29 fn clone(&self) -> Self {
30 *self
31 }
32}
33
34impl<T: Send + Sync + 'static> Copy for PaginatedState<T> {}
35
36/// 分页列表加载 hook。
37///
38/// - `page`:返回当前页码的闭包,在 Effect 内调用,其读取的信号被追踪
39/// (变化时自动重新加载)。
40/// - `per_page`:每页条数。
41/// - `fetch`:接收 `(page, per_page)`,返回 `Future<Output = Result<(Vec<T>, i64), E>>`。
42/// `E: Display` 用于把错误写入 `error` 信号;`ApiError` 请先经
43/// `crate::utils::error::failure_message` 映射成可读文案(UI 不得直接展示
44/// `err.to_string()`)。`fetch` 在 Effect 内**同步**调用(返回 Future 之前的部分),
45/// 因此构造请求参数时读取的信号(筛选条件等)同样被追踪;`async` 块内的读取不追踪。
46/// `fetch` 与 `page` 都要求 `Send + Sync + 'static`,捕获的信号都是 `Copy` 句柄,
47/// 满足该要求;Future 本身在浏览器本地执行,无需 `Send`。
48///
49/// 仅在浏览器(Effect 运行时)发起请求,服务端不请求;快速翻页/切筛选时,
50/// 只有最后一次请求的响应能落地。
51///
52/// # 例
53///
54/// ```ignore
55/// let state = use_paginated(
56/// move || current_page.get(),
57/// POSTS_PER_PAGE,
58/// move |p, pp| {
59/// let query = build_query(); // 同步读信号:被追踪
60/// async move {
61/// list_posts(p, pp, query).await
62/// .map(|r| (r.posts, r.total))
63/// .map_err(|e| failure_message("加载文章", &e))
64/// }
65/// },
66/// );
67/// let (posts, loading) = (state.items, state.loading);
68/// ```
69pub fn use_paginated<T, P, F, Fut, E>(page: P, per_page: i32, fetch: F) -> PaginatedState<T>
70where
71 T: Clone + Send + Sync + 'static,
72 P: Fn() -> i32 + Send + Sync + 'static,
73 F: Fn(i32, i32) -> Fut + Send + Sync + 'static,
74 Fut: std::future::Future<Output = Result<(Vec<T>, i64), E>> + 'static,
75 E: std::fmt::Display + 'static,
76{
77 let loading = RwSignal::new(true);
78 let items = RwSignal::new(Vec::new());
79 let total = RwSignal::new(0_i64);
80 let error: RwSignal<Option<String>> = RwSignal::new(None);
81 // 请求世代号:快速翻页/切筛选时,只有最后一次请求的响应能落地。
82 let generation = StoredValue::new(0_u64);
83
84 Effect::new(move |_| {
85 let p = page();
86 let future = fetch(p, per_page);
87 let Some(id) = generation.try_update_value(|g| {
88 *g = g.wrapping_add(1);
89 *g
90 }) else {
91 return;
92 };
93 loading.set(true);
94 error.set(None);
95 leptos::task::spawn_local(async move {
96 let result = future.await;
97 // 过期响应(或组件已卸载)直接丢弃,不写任何信号(含 loading)。
98 if generation.try_get_value() != Some(id) {
99 return;
100 }
101 match result {
102 Ok((list, t)) => {
103 items.set(list);
104 total.set(t);
105 }
106 Err(e) => error.set(Some(e.to_string())),
107 }
108 loading.set(false);
109 });
110 });
111
112 PaginatedState {
113 loading,
114 items,
115 total,
116 error,
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use leptos::reactive::owner::Owner;
124
125 fn assert_copy<S: Copy>() {}
126
127 #[test]
128 fn state_is_copy_even_for_non_copy_items() {
129 assert_copy::<PaginatedState<String>>();
130 }
131
132 #[test]
133 fn server_side_keeps_initial_loading_state_without_fetching() {
134 Owner::new().with(|| {
135 let state = use_paginated(
136 || 1,
137 20,
138 |_, _| async {
139 panic!("服务端不应发起请求") as Result<(Vec<String>, i64), String>
140 },
141 );
142 assert!(state.loading.get_untracked());
143 assert!(state.items.get_untracked().is_empty());
144 assert_eq!(state.total.get_untracked(), 0);
145 assert!(state.error.get_untracked().is_none());
146 });
147 }
148}