Skip to main content

yggdrasil/hooks/
ssr_resource.rs

1//! SSR 数据资源。
2//!
3//! # 路由数据:[`route_data`][crate::hooks::ssr_resource::route_data]
4//! 懒加载路由的 `LazyRoute::data()` 编译进主包(SSR 会多次执行 `view()`,资源只能在
5//! `data()` 里建)。若在那里直接建 `Resource<Result<T, ApiError>>`,每个路由的数据
6//! 类型、反序列化代码与 server fn 客户端都会进主包,所有页面首屏一起付费。
7//!
8//! [`route_data`][crate::hooks::ssr_resource::route_data] 把资源的值类型擦除成 JSON 文本([`RawJson`][crate::hooks::ssr_resource::RawJson],所有路由共用同一份
9//! `Resource` 实例化),取数函数由调用方写成 `#[leptos::lazy]` 函数、返回 [`PageJson<T>`][crate::hooks::ssr_resource::PageJson]:
10//! 服务端直接调用并编码,客户端首次取数时才加载它所在的分包。`view()` 里
11//! `data.await` 得到 `Result<T, ApiError>`,解码代码随视图分包加载。SSR 的视图直接
12//! 克隆服务端的取数结果,JSON 文本只用于水合;正文这类 SSR 已渲染进 HTML 的大字段可用
13//! [`PageJson::with_hydration`][crate::hooks::ssr_resource::PageJson::with_hydration]
14//! 在水合数据里换成占位。
15//!
16//! ```ignore
17//! #[leptos::lazy]
18//! async fn fetch_post(slug: String) -> PageJson<PublicPostResponse> {
19//!     PageJson::new(get_public_post_by_slug(slug).await)
20//! }
21//! // data():  post: route_data(move || slug.get(), fetch_post),
22//! // view():  match post.await { Ok(..) => .., Err(..) => .. }
23//! ```
24//!
25//! # 悬停预取:[`prefetch_route_data`][crate::hooks::ssr_resource::prefetch_route_data]
26//! 悬停、聚焦、触屏按下站内链接时,`route_prefetch` 调用各路由的 `prefetch_data` 提前请求
27//! 页面数据,结果存进浏览器端的小缓存;之后导航到该页,[`route_data`][crate::hooks::ssr_resource::route_data] 的取数闭包先查缓存,
28//! 命中就直接复用进行中或已完成的请求。
29//!
30//! - **键** = 取数函数的类型(`TypeId::of::<F>()`)+ `source` 的值。具名函数项与 [`unit()`][crate::hooks::ssr_resource::unit]
31//!   返回的闭包类型都是唯一的,`data()` 与预取两边传同一个函数就是同一个键;闭包字面量
32//!   每处类型都不同,**永远不会命中**,只是不预取,不会取错数据。
33//! - **一次性**:命中即取走。完成后保留 10 秒,过期丢弃;进行中的请求不按 TTL 过期。
34//! - **出错不缓存**:错误结果只交给已经在等的消费者,之后的导航重新请求。
35//! - 只用于不依赖会话的公开数据;服务端不保留任何跨请求状态(非 wasm 为空操作)。
36//!
37//! # 主包内的数据
38//! 主包组件自己的数据(页脚)同样走 [`route_data`][crate::hooks::ssr_resource::route_data]。
39//! [`JsonSliceCodec`][crate::hooks::ssr_resource::JsonSliceCodec] 给 `share_meta` 的
40//! `SharedValue` 用:水合解码与 server fn 共用 `serde_json::from_slice` 的实例化(默认
41//! `JsonSerdeCodec` 用 `from_str`,同一类型会多出一份 `StrRead` 反序列化代码)。
42
43use std::future::{Future, IntoFuture};
44use std::marker::PhantomData;
45use std::pin::Pin;
46use std::sync::Arc;
47
48use std::any::TypeId;
49
50use leptos::prelude::*;
51use leptos::server::codee::{Decoder, Encoder};
52use serde::{de::DeserializeOwned, Serialize};
53
54use crate::api::error::ApiError;
55
56/// `Resource` 的 JSON 编解码器:编码 `serde_json::to_string`,解码 `serde_json::from_slice`。
57pub struct JsonSliceCodec;
58
59impl<T: Serialize> Encoder<T> for JsonSliceCodec {
60    type Error = serde_json::Error;
61    type Encoded = String;
62
63    fn encode(val: &T) -> Result<Self::Encoded, Self::Error> {
64        serde_json::to_string(val)
65    }
66}
67
68impl<T: DeserializeOwned> Decoder<T> for JsonSliceCodec {
69    type Error = serde_json::Error;
70    type Encoded = str;
71
72    fn decode(val: &Self::Encoded) -> Result<T, Self::Error> {
73        serde_json::from_slice(val.as_bytes())
74    }
75}
76
77/// 路由数据资源的统一值类型:`Result<T, ApiError>` 的 JSON 文本,也就是写进水合数据的内容。
78/// SSR 时还带着取数结果本身,视图直接克隆它,不再把刚编码的文本解析回来。
79#[derive(Clone)]
80pub struct RawJson {
81    text: String,
82    #[cfg(not(target_arch = "wasm32"))]
83    value: Option<Arc<dyn std::any::Any + Send + Sync>>,
84}
85
86impl RawJson {
87    fn from_text(text: String) -> Self {
88        Self {
89            text,
90            #[cfg(not(target_arch = "wasm32"))]
91            value: None,
92        }
93    }
94}
95
96impl PartialEq for RawJson {
97    fn eq(&self, other: &Self) -> bool {
98        self.text == other.text
99    }
100}
101
102impl std::fmt::Debug for RawJson {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_tuple("RawJson").field(&self.text).finish()
105    }
106}
107
108/// [`RawJson`] 的文本原样作为资源的序列化形式,不再二次编码。
109pub struct RawJsonCodec;
110
111impl Encoder<RawJson> for RawJsonCodec {
112    type Error = std::convert::Infallible;
113    type Encoded = String;
114
115    fn encode(val: &RawJson) -> Result<Self::Encoded, Self::Error> {
116        Ok(val.text.clone())
117    }
118}
119
120impl Decoder<RawJson> for RawJsonCodec {
121    type Error = std::convert::Infallible;
122    type Encoded = str;
123
124    fn decode(val: &Self::Encoded) -> Result<RawJson, Self::Error> {
125        Ok(RawJson::from_text(val.to_owned()))
126    }
127}
128
129/// 路由取数函数(`#[leptos::lazy]`)的返回值:已编码的 `Result<T, ApiError>`。
130pub struct PageJson<T> {
131    raw: RawJson,
132    _type: PhantomData<fn() -> T>,
133}
134
135fn encode_page_json<T: Serialize>(result: &Result<T, ApiError>) -> String {
136    serde_json::to_string(result).unwrap_or_else(|err| {
137        let failed: Result<(), ApiError> = Err(ApiError::Framework(ServerFnError::Serialization(
138            err.to_string(),
139        )));
140        serde_json::to_string(&failed).expect("ApiError 可序列化")
141    })
142}
143
144impl<T: Serialize + Send + Sync + 'static> PageJson<T> {
145    /// 编码取数结果。
146    pub fn new(result: Result<T, ApiError>) -> Self {
147        let text = encode_page_json(&result);
148        Self::with_text(text, result)
149    }
150
151    /// 同 [`PageJson::new`],但水合数据写入 `hydration(&值)`:SSR 已渲染进 HTML 的大字段可以
152    /// 换成占位。客户端水合时用这份值构建视图,必须与 SSR 得到同样的组件树与 DOM 结构
153    /// (`inner_html` 水合时不改写服务端 DOM)。SSR 视图仍用完整值;客户端取数(SPA 导航)
154    /// 不经水合,照旧编码完整结果。
155    pub fn with_hydration(result: Result<T, ApiError>, hydration: impl FnOnce(&T) -> T) -> Self {
156        #[cfg(not(target_arch = "wasm32"))]
157        let text = match &result {
158            Ok(value) => encode_page_json(&Ok::<T, ApiError>(hydration(value))),
159            Err(_) => encode_page_json(&result),
160        };
161        #[cfg(target_arch = "wasm32")]
162        let text = {
163            let _ = hydration;
164            encode_page_json(&result)
165        };
166        Self::with_text(text, result)
167    }
168
169    fn with_text(text: String, result: Result<T, ApiError>) -> Self {
170        #[cfg(target_arch = "wasm32")]
171        let _ = result;
172        Self {
173            raw: RawJson {
174                text,
175                #[cfg(not(target_arch = "wasm32"))]
176                value: Some(Arc::new(result)),
177            },
178            _type: PhantomData,
179        }
180    }
181}
182
183/// 路由数据句柄:在 `data()` 里由 [`route_data`] 创建,`view()` 里 `.await` 得到结果。
184pub struct RouteData<T> {
185    raw: Resource<RawJson, RawJsonCodec>,
186    _type: PhantomData<fn() -> T>,
187}
188
189impl<T> Clone for RouteData<T> {
190    fn clone(&self) -> Self {
191        *self
192    }
193}
194
195impl<T> Copy for RouteData<T> {}
196
197impl<T: DeserializeOwned + Clone + Send + Sync + 'static> IntoFuture for RouteData<T> {
198    type Output = Result<T, ApiError>;
199    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
200
201    fn into_future(self) -> Self::IntoFuture {
202        let raw = self.raw;
203        Box::pin(async move { resolve_page_json(&raw.await) })
204    }
205}
206
207/// SSR 直接克隆取数结果;水合与客户端取数解析 JSON 文本。
208fn resolve_page_json<T: DeserializeOwned + Clone + 'static>(raw: &RawJson) -> Result<T, ApiError> {
209    #[cfg(not(target_arch = "wasm32"))]
210    if let Some(result) = raw
211        .value
212        .as_deref()
213        .and_then(|value| value.downcast_ref::<Result<T, ApiError>>())
214    {
215        return result.clone();
216    }
217    serde_json::from_slice::<Result<T, ApiError>>(raw.text.as_bytes()).unwrap_or_else(|err| {
218        Err(ApiError::Framework(ServerFnError::Deserialization(
219            err.to_string(),
220        )))
221    })
222}
223
224/// 把无参取数函数包成 `Fn(())`,与 `route_data(|| (), unit(f))` 搭配。
225/// 返回的闭包类型只取决于 `f` 的函数项类型,`data()` 与悬停预取得到同一个预取键。
226pub fn unit<F, Fut>(fetcher: F) -> impl Fn(()) -> Fut + Send + Sync + Copy + 'static
227where
228    F: Fn() -> Fut + Send + Sync + Copy + 'static,
229{
230    move |()| fetcher()
231}
232
233/// 创建路由数据(语义同 `Resource::new`:服务端执行 `fetcher` 并把结果写进 HTML,
234/// 客户端水合直接复用;`source` 变化即重新请求)。`fetcher` 应是 `#[leptos::lazy]`
235/// 函数,主包里只留下这一份与数据类型无关的资源代码。
236///
237/// 悬停预取按 `fetcher` 的类型加 `source` 的值查缓存:公开页面传具名函数项,
238/// 无参函数用 [`unit()`] 包装;闭包字面量不会命中预取(见模块文档)。
239///
240/// SSR 时同一请求内 (fetcher 类型, source) 相同的调用复用同一个资源(见 `RouteDataRegistry`)。
241#[track_caller]
242pub fn route_data<S, T, F, Fut>(
243    source: impl Fn() -> S + Send + Sync + 'static,
244    fetcher: F,
245) -> RouteData<T>
246where
247    S: PartialEq + Clone + Send + Sync + 'static,
248    F: Fn(S) -> Fut + Send + Sync + 'static,
249    Fut: Future<Output = PageJson<T>> + Send + 'static,
250{
251    let source: ErasedSource = Arc::new(move || ErasedKey(Arc::new(source())));
252    let fetch: ErasedFetch = Arc::new(move |key: ErasedKey| {
253        let source: S = key.downcast::<S>();
254        #[cfg(target_arch = "wasm32")]
255        {
256            let pending = match PREFETCH.take(TypeId::of::<F>(), &source, now_ms()) {
257                Some(hit) => Ok(hit),
258                None => Err(fetcher(source)),
259            };
260            Box::pin(async move {
261                match pending {
262                    Ok(hit) => hit.await,
263                    Err(fut) => fut.await.raw,
264                }
265            }) as RawFuture
266        }
267        #[cfg(not(target_arch = "wasm32"))]
268        {
269            let fut = fetcher(source);
270            Box::pin(async move { fut.await.raw }) as RawFuture
271        }
272    });
273    #[cfg(not(target_arch = "wasm32"))]
274    if let Some(registry) = use_context::<RouteDataRegistry>() {
275        let key = untrack(|| source());
276        let mut entries = registry.0.lock().unwrap_or_else(|e| e.into_inner());
277        let slot = entries
278            .iter()
279            .position(|(tid, k, _)| *tid == TypeId::of::<F>() && *k == key);
280        let raw = match slot {
281            Some(i) if !entries[i].2.is_disposed() => entries[i].2,
282            // 资源建在已丢弃的 owner 下:换一份新的。
283            Some(i) => {
284                let raw = erased_route_resource(source, fetch);
285                entries[i].2 = raw;
286                raw
287            }
288            None => {
289                let raw = erased_route_resource(source, fetch);
290                entries.push((TypeId::of::<F>(), key, raw));
291                raw
292            }
293        };
294        return RouteData {
295            raw,
296            _type: PhantomData,
297        };
298    }
299    RouteData {
300        raw: erased_route_resource(source, fetch),
301        _type: PhantomData,
302    }
303}
304
305/// SSR:本次请求已创建的路由数据资源,键为(取数函数类型, source 值)。
306///
307/// Suspense 在 SSR 时会遍历子树三次(两次 dry_resolve、一次 resolve),`<Outlet/>` 每次都会重新选择视图;
308/// `Lazy::choose` 只能取走一次预载数据,之后每次都重新调用 `LazyRoute::data()`。
309/// 不复用的话,同一份数据会被取三次、写进 HTML 三次,而客户端水合只会创建并读取第一份。
310#[cfg(not(target_arch = "wasm32"))]
311#[derive(Clone, Default)]
312struct RouteDataRegistry(Arc<std::sync::Mutex<Vec<RegistryEntry>>>);
313
314/// 登记表条目:(取数函数类型, source 值, 资源)。
315#[cfg(not(target_arch = "wasm32"))]
316type RegistryEntry = (TypeId, ErasedKey, Resource<RawJson, RawJsonCodec>);
317
318/// 在应用根部调用:SSR 时为本次请求建立路由数据登记表(见 [`route_data`])。浏览器端不做任何事。
319pub fn provide_route_data_registry() {
320    #[cfg(not(target_arch = "wasm32"))]
321    provide_context(RouteDataRegistry::default());
322}
323
324/// 路由数据键:擦除 `source` 的具体类型,所有路由共用一份 `Resource` 实例化。
325trait DynKey: Send + Sync {
326    fn as_any(&self) -> &dyn std::any::Any;
327    fn eq_dyn(&self, other: &dyn DynKey) -> bool;
328}
329
330impl<S: PartialEq + Send + Sync + 'static> DynKey for S {
331    fn as_any(&self) -> &dyn std::any::Any {
332        self
333    }
334
335    fn eq_dyn(&self, other: &dyn DynKey) -> bool {
336        other.as_any().downcast_ref::<S>() == Some(self)
337    }
338}
339
340#[derive(Clone)]
341struct ErasedKey(Arc<dyn DynKey>);
342
343impl ErasedKey {
344    /// 还原 `source` 的值:键只由同一次 [`route_data`] 的 `source` 产生、交给同一个取数闭包,
345    /// 类型必然一致。
346    fn downcast<S: Clone + 'static>(&self) -> S {
347        self.0
348            .as_any()
349            .downcast_ref::<S>()
350            .expect("路由数据键类型一致")
351            .clone()
352    }
353}
354
355impl PartialEq for ErasedKey {
356    fn eq(&self, other: &Self) -> bool {
357        self.0.eq_dyn(&*other.0)
358    }
359}
360
361type RawFuture = Pin<Box<dyn Future<Output = RawJson> + Send>>;
362type ErasedSource = Arc<dyn Fn() -> ErasedKey + Send + Sync>;
363type ErasedFetch = Arc<dyn Fn(ErasedKey) -> RawFuture + Send + Sync>;
364
365/// 唯一一处 `Resource<RawJson, _>` 实例化:`source`、`fetch` 都是 trait 对象。
366#[track_caller]
367#[inline(never)]
368fn erased_route_resource(
369    source: ErasedSource,
370    fetch: ErasedFetch,
371) -> Resource<RawJson, RawJsonCodec> {
372    Resource::new_with_options(move || source(), move |key| fetch(key), false)
373}
374
375/// 悬停预取一份路由数据:键与 [`route_data`] 相同(`fetcher` 的类型 + `source`)。
376/// 之后导航到该页时 `route_data` 直接复用结果。只用于不依赖会话的公开数据。
377pub fn prefetch_route_data<S, T, F, Fut>(source: S, fetcher: F)
378where
379    S: PartialEq + Clone + Send + 'static,
380    F: Fn(S) -> Fut + 'static,
381    Fut: Future<Output = PageJson<T>> + Send + 'static,
382{
383    #[cfg(target_arch = "wasm32")]
384    {
385        let Some(id) = PREFETCH.begin(TypeId::of::<F>(), source.clone(), now_ms()) else {
386            return;
387        };
388        let fut = fetcher(source);
389        leptos::task::spawn_local(async move {
390            let raw = fut.await.raw;
391            PREFETCH.complete(id, raw, now_ms());
392        });
393    }
394    #[cfg(not(target_arch = "wasm32"))]
395    let _ = (source, fetcher);
396}
397
398#[cfg(target_arch = "wasm32")]
399static PREFETCH: prefetch::Store = prefetch::Store::new();
400
401#[cfg(target_arch = "wasm32")]
402fn now_ms() -> f64 {
403    js_sys::Date::now()
404}
405
406/// 预取缓存的存储结构;只用 std,时间由调用方传入,便于单元测试。
407#[cfg(any(target_arch = "wasm32", test))]
408mod prefetch {
409    use super::RawJson;
410    use std::any::{Any, TypeId};
411    use std::future::Future;
412    use std::pin::Pin;
413    use std::sync::{Arc, Mutex};
414    use std::task::{Context, Poll, Waker};
415
416    /// 完成后保留多久(毫秒)。
417    pub(super) const TTL_MS: f64 = 10_000.0;
418    pub(super) const CAPACITY: usize = 24;
419
420    enum Slot {
421        Pending(Vec<Waker>),
422        Ready(RawJson),
423    }
424
425    struct Entry {
426        id: u64,
427        fetcher: TypeId,
428        source: Box<dyn Any + Send>,
429        /// `None` = 进行中。
430        done_at: Option<f64>,
431        /// 已被消费者取走:不再参与去重,完成后从列表删除。
432        taken: bool,
433        slot: Arc<Mutex<Slot>>,
434    }
435
436    impl Entry {
437        fn matches<S: PartialEq + 'static>(&self, fetcher: TypeId, source: &S) -> bool {
438            !self.taken
439                && self.fetcher == fetcher
440                && self.source.downcast_ref::<S>().is_some_and(|s| s == source)
441        }
442
443        fn expired(&self, now: f64) -> bool {
444            self.done_at.is_some_and(|t| now - t > TTL_MS)
445        }
446    }
447
448    struct Inner {
449        next_id: u64,
450        entries: Vec<Entry>,
451    }
452
453    pub(super) struct Store {
454        inner: Mutex<Inner>,
455    }
456
457    impl Store {
458        pub(super) const fn new() -> Self {
459            Self {
460                inner: Mutex::new(Inner {
461                    next_id: 0,
462                    entries: Vec::new(),
463                }),
464            }
465        }
466
467        fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
468            self.inner.lock().unwrap_or_else(|e| e.into_inner())
469        }
470
471        /// 已有同键的进行中条目或未过期的已完成条目时返回 `None`(不重复请求);
472        /// 否则清理过期条目、超出容量时淘汰最早的已完成条目,登记进行中条目并返回 id。
473        /// 容量全被进行中的请求占满时同样返回 `None`:淘汰进行中条目会让已在等待的
474        /// 消费者永远等不到结果。
475        pub(super) fn begin<S: PartialEq + Send + 'static>(
476            &self,
477            fetcher: TypeId,
478            source: S,
479            now: f64,
480        ) -> Option<u64> {
481            let mut inner = self.lock();
482            inner.entries.retain(|e| !e.expired(now));
483            if inner.entries.iter().any(|e| e.matches(fetcher, &source)) {
484                return None;
485            }
486            if inner.entries.len() >= CAPACITY {
487                let oldest_done = inner.entries.iter().position(|e| e.done_at.is_some())?;
488                inner.entries.remove(oldest_done);
489            }
490            let id = inner.next_id;
491            inner.next_id += 1;
492            inner.entries.push(Entry {
493                id,
494                fetcher,
495                source: Box::new(source),
496                done_at: None,
497                taken: false,
498                slot: Arc::new(Mutex::new(Slot::Pending(Vec::new()))),
499            });
500            Some(id)
501        }
502
503        /// 写入结果并唤醒全部等待者。结果是错误([`is_err_json`])或条目已被取走时从列表
504        /// 删掉条目:已经在等的消费者照样拿到这次结果,之后的导航重新请求。
505        pub(super) fn complete(&self, id: u64, raw: RawJson, now: f64) {
506            let slot = {
507                let mut inner = self.lock();
508                let Some(index) = inner.entries.iter().position(|e| e.id == id) else {
509                    return;
510                };
511                let entry = &mut inner.entries[index];
512                if entry.taken || is_err_json(&raw) {
513                    inner.entries.remove(index).slot
514                } else {
515                    entry.done_at = Some(now);
516                    entry.slot.clone()
517                }
518            };
519            let wakers = {
520                let mut slot = slot.lock().unwrap_or_else(|e| e.into_inner());
521                match std::mem::replace(&mut *slot, Slot::Ready(raw)) {
522                    Slot::Pending(wakers) => wakers,
523                    Slot::Ready(_) => Vec::new(),
524                }
525            };
526            for waker in wakers {
527                waker.wake();
528            }
529        }
530
531        /// 取走同键条目(一次性):进行中的直接加入等待;已完成且超过 TTL 的删掉并返回 `None`。
532        pub(super) fn take<S: PartialEq + 'static>(
533            &self,
534            fetcher: TypeId,
535            source: &S,
536            now: f64,
537        ) -> Option<Prefetched> {
538            let mut inner = self.lock();
539            let index = inner
540                .entries
541                .iter()
542                .position(|e| e.matches(fetcher, source))?;
543            if inner.entries[index].expired(now) {
544                inner.entries.remove(index);
545                return None;
546            }
547            if inner.entries[index].done_at.is_some() {
548                return Some(Prefetched(inner.entries.remove(index).slot));
549            }
550            let entry = &mut inner.entries[index];
551            entry.taken = true;
552            Some(Prefetched(entry.slot.clone()))
553        }
554    }
555
556    /// 预取结果的等待句柄。`Arc<Mutex<_>>` 内容都是 `Send`,满足 `Resource` 对 future 的 `Send` 约束。
557    pub(super) struct Prefetched(Arc<Mutex<Slot>>);
558
559    impl Future for Prefetched {
560        type Output = RawJson;
561
562        fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<RawJson> {
563            let mut slot = self.0.lock().unwrap_or_else(|e| e.into_inner());
564            match &mut *slot {
565                Slot::Ready(raw) => Poll::Ready(raw.clone()),
566                Slot::Pending(wakers) => {
567                    if !wakers.iter().any(|w| w.will_wake(cx.waker())) {
568                        wakers.push(cx.waker().clone());
569                    }
570                    Poll::Pending
571                }
572            }
573        }
574    }
575
576    /// 错误结果的判定不反序列化具体类型,用编码格式:`PageJson::new` 编码的是
577    /// `Result<T, ApiError>`,外部标签格式,错误一定以 `{"Err"` 开头。
578    pub(super) fn is_err_json(raw: &RawJson) -> bool {
579        raw.text.starts_with("{\"Err\"")
580    }
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::utils::error::is_not_found;
587
588    #[derive(Debug, Clone, PartialEq, Serialize, serde::Deserialize)]
589    struct Sample {
590        id: i32,
591        title: String,
592    }
593
594    #[test]
595    fn json_slice_codec_round_trips() {
596        let value = Sample {
597            id: 7,
598            title: "中文标题 \"quoted\"".into(),
599        };
600        let encoded = <JsonSliceCodec as Encoder<Sample>>::encode(&value).unwrap();
601        let decoded: Sample = <JsonSliceCodec as Decoder<Sample>>::decode(&encoded).unwrap();
602        assert_eq!(decoded, value);
603    }
604
605    /// 路由数据经 SSR 序列化(RawJsonCodec)往返后解码出原始结果,错误保留 404 语义。
606    #[test]
607    fn page_json_survives_resource_round_trip() {
608        let through_resource = |page: PageJson<Sample>| {
609            let wire = <RawJsonCodec as Encoder<RawJson>>::encode(&page.raw).unwrap();
610            <RawJsonCodec as Decoder<RawJson>>::decode(&wire).unwrap()
611        };
612
613        let ok = through_resource(PageJson::new(Ok(Sample {
614            id: 1,
615            title: "标题".into(),
616        })));
617        assert_eq!(
618            resolve_page_json::<Sample>(&ok).unwrap(),
619            Sample {
620                id: 1,
621                title: "标题".into()
622            }
623        );
624
625        let missing = through_resource(PageJson::new(Err(ApiError::NotFound("文章不存在".into()))));
626        let error = resolve_page_json::<Sample>(&missing).unwrap_err();
627        assert!(matches!(&error, ApiError::NotFound(message) if message == "文章不存在"));
628        assert!(is_not_found(&error));
629    }
630
631    /// 编码的类型与解码期望不符时给出反序列化错误,而不是 panic。
632    #[test]
633    fn mismatched_page_json_is_a_deserialization_error() {
634        let page = PageJson::new(Ok("not a sample".to_string()));
635        assert!(matches!(
636            resolve_page_json::<Sample>(&page.raw),
637            Err(ApiError::Framework(ServerFnError::Deserialization(_)))
638        ));
639    }
640
641    /// 水合形态只进水合数据:SSR 视图拿到完整值,经资源序列化往返后得到瘦身值;错误原样。
642    #[test]
643    fn hydration_form_only_reaches_the_wire() {
644        let full = Sample {
645            id: 2,
646            title: "很长的正文".repeat(100),
647        };
648        let slim = |sample: &Sample| Sample {
649            id: sample.id,
650            title: String::new(),
651        };
652        let page = PageJson::with_hydration(Ok(full.clone()), slim);
653        assert_eq!(resolve_page_json::<Sample>(&page.raw).unwrap(), full);
654        let wire = <RawJsonCodec as Encoder<RawJson>>::encode(&page.raw).unwrap();
655        let hydrated = <RawJsonCodec as Decoder<RawJson>>::decode(&wire).unwrap();
656        assert_eq!(resolve_page_json::<Sample>(&hydrated).unwrap(), slim(&full));
657
658        let missing = PageJson::with_hydration(Err(ApiError::NotFound("没有".into())), slim);
659        let wire = <RawJsonCodec as Encoder<RawJson>>::encode(&missing.raw).unwrap();
660        let hydrated = <RawJsonCodec as Decoder<RawJson>>::decode(&wire).unwrap();
661        assert!(is_not_found(
662            &resolve_page_json::<Sample>(&hydrated).unwrap_err()
663        ));
664    }
665
666    async fn fetch_a() -> PageJson<u8> {
667        PageJson::new(Ok(1))
668    }
669
670    async fn fetch_b() -> PageJson<u8> {
671        PageJson::new(Ok(2))
672    }
673
674    fn key_of<F: 'static>(_: &F) -> std::any::TypeId {
675        std::any::TypeId::of::<F>()
676    }
677
678    /// 预取键由取数函数的类型决定:同一个函数经 `unit` 得到同一个键,不同函数不同。
679    #[test]
680    fn unit_keys_follow_the_function() {
681        assert_eq!(key_of(&unit(fetch_a)), key_of(&unit(fetch_a)));
682        assert_ne!(key_of(&unit(fetch_a)), key_of(&unit(fetch_b)));
683    }
684
685    mod store {
686        use super::*;
687        use crate::hooks::ssr_resource::prefetch::{
688            is_err_json, Prefetched, Store, CAPACITY, TTL_MS,
689        };
690        use std::any::TypeId;
691        use std::task::{Context, Poll, Waker};
692
693        const KEY: fn() -> TypeId = TypeId::of::<u8>;
694        const OTHER_KEY: fn() -> TypeId = TypeId::of::<u16>;
695
696        fn ok(n: u8) -> RawJson {
697            PageJson::new(Ok(n)).raw
698        }
699
700        fn poll(hit: &mut Prefetched) -> Poll<RawJson> {
701            Pin::new(hit).poll(&mut Context::from_waker(Waker::noop()))
702        }
703
704        #[test]
705        fn begin_deduplicates_by_key_and_source() {
706            let store = Store::new();
707            assert!(store.begin(KEY(), 1, 0.0).is_some());
708            assert!(store.begin(KEY(), 1, 0.0).is_none());
709            assert!(store.begin(KEY(), 2, 0.0).is_some());
710            assert!(store.begin(OTHER_KEY(), 1, 0.0).is_some());
711        }
712
713        #[test]
714        fn pending_entry_resolves_for_a_waiting_consumer() {
715            let store = Store::new();
716            let id = store.begin(KEY(), 1, 0.0).unwrap();
717            let mut hit = store.take(KEY(), &1, 5.0).unwrap();
718            assert!(poll(&mut hit).is_pending());
719            store.complete(id, ok(7), 10.0);
720            assert_eq!(poll(&mut hit), Poll::Ready(ok(7)));
721            // 取走的条目完成后不再留在列表里:同键可以重新请求。
722            assert!(store.take(KEY(), &1, 11.0).is_none());
723            assert!(store.begin(KEY(), 1, 11.0).is_some());
724        }
725
726        #[test]
727        fn taken_pending_entry_no_longer_deduplicates() {
728            let store = Store::new();
729            store.begin(KEY(), 1, 0.0).unwrap();
730            store.take(KEY(), &1, 0.0).unwrap();
731            assert!(store.begin(KEY(), 1, 0.0).is_some());
732        }
733
734        #[test]
735        fn completed_entry_is_consumed_once() {
736            let store = Store::new();
737            let id = store.begin(KEY(), 1, 0.0).unwrap();
738            store.complete(id, ok(3), 1.0);
739            let mut hit = store.take(KEY(), &1, 2.0).unwrap();
740            assert_eq!(poll(&mut hit), Poll::Ready(ok(3)));
741            assert!(store.take(KEY(), &1, 2.0).is_none());
742        }
743
744        #[test]
745        fn completed_entry_expires_after_ttl_but_pending_does_not() {
746            let store = Store::new();
747            let fresh = store.begin(KEY(), 1, 0.0).unwrap();
748            store.complete(fresh, ok(1), 100.0);
749            assert!(store.take(KEY(), &1, 100.0 + TTL_MS - 1.0).is_some());
750
751            let stale = store.begin(KEY(), 2, 0.0).unwrap();
752            store.complete(stale, ok(2), 100.0);
753            assert!(store.take(KEY(), &2, 100.0 + TTL_MS + 1.0).is_none());
754            assert!(store.begin(KEY(), 2, 100.0 + TTL_MS + 1.0).is_some());
755
756            store.begin(KEY(), 3, 0.0).unwrap();
757            assert!(store.take(KEY(), &3, TTL_MS * 10.0).is_some());
758        }
759
760        #[test]
761        fn error_results_are_not_cached_but_reach_waiters() {
762            let store = Store::new();
763            let id = store.begin(KEY(), 1, 0.0).unwrap();
764            let mut waiter = store.take(KEY(), &1, 0.0).unwrap();
765            let id2 = store.begin(KEY(), 2, 0.0).unwrap();
766            let mut early = store.take(KEY(), &2, 0.0).unwrap();
767            let failure = PageJson::<u8>::new(Err(ApiError::Internal("失败".into()))).raw;
768            store.complete(id, failure.clone(), 1.0);
769            assert_eq!(poll(&mut waiter), Poll::Ready(failure.clone()));
770            store.complete(id2, failure.clone(), 1.0);
771            assert_eq!(poll(&mut early), Poll::Ready(failure));
772            assert!(store.take(KEY(), &1, 2.0).is_none());
773            assert!(store.begin(KEY(), 1, 2.0).is_some());
774
775            // 未被取走的条目收到错误后也直接删除。
776            let id3 = store.begin(KEY(), 3, 0.0).unwrap();
777            store.complete(
778                id3,
779                PageJson::<u8>::new(Err(ApiError::Internal("失败".into()))).raw,
780                1.0,
781            );
782            assert!(store.take(KEY(), &3, 2.0).is_none());
783        }
784
785        #[test]
786        fn capacity_evicts_the_oldest_completed_entry() {
787            let store = Store::new();
788            for source in 0..=CAPACITY as i32 {
789                let id = store.begin(KEY(), source, 0.0).unwrap();
790                store.complete(id, ok(1), 0.0);
791            }
792            assert!(store.take(KEY(), &0, 1.0).is_none());
793            assert!(store.take(KEY(), &1, 1.0).is_some());
794            assert!(store.take(KEY(), &(CAPACITY as i32), 1.0).is_some());
795        }
796
797        #[test]
798        fn capacity_full_of_pending_requests_refuses_new_ones() {
799            let store = Store::new();
800            for source in 0..CAPACITY as i32 {
801                assert!(store.begin(KEY(), source, 0.0).is_some());
802            }
803            assert!(store.begin(KEY(), CAPACITY as i32, 0.0).is_none());
804        }
805
806        /// `is_err_json` 依赖 `PageJson` 的编码格式:错误必以 `{"Err"` 开头。
807        #[test]
808        fn error_encoding_is_recognised() {
809            assert!(is_err_json(
810                &PageJson::<u8>::new(Err(ApiError::Internal("失败".into()))).raw
811            ));
812            assert!(!is_err_json(&PageJson::new(Ok(1u8)).raw));
813        }
814    }
815}