yggdrasil/src/pages/post_detail.rs
xfy 79978aa4a4 fix(post): 修复上/下一篇导航后文章内容不更新
点击文章底部的上一篇/下一篇后,URL 正确变化为 /post/<new-slug>,但页面
内容仍停留在旧文章,只有刷新浏览器才生效。

根因:use_server_future(内部即 use_resource)依赖 ReactiveContext 追踪
闭包内读取的 signal 来决定重跑时机。但 PostDetail 的 slug 是路由宏注入的
普通 String prop,move 进闭包后成为冻结快照,读取它不建立任何反应式订阅。
同一 Route::PostDetail 变体间的导航(/post/a -> /post/b)会复用组件实例、
仅更新 props,而 use_server_future 的 task 只在首次 hook 时创建一次,因此
永远不会重跑。

此前 commit 225bb24 把 slug_signal 镜像 + render 期 set 改成直接读 prop,
消除了反模式,但也丢掉了唯一能触发重跑的订阅源(slug_signal 的读取),
反而引入了这个 bug。

修复:在闭包内通过 dioxus::router::router().current::<Route>() 读取当前
slug。current() 内部调用 subscribe_to_current_context(),在 use_server_future
的 ReactiveContext 中注册订阅,路由变化即触发重跑。render body 保持纯净。

验证(headless chromium,本地需设 APP_BASE_URL 绕过 CSRF 403):
- 修复前:点 Next 后 URL 变 /post/1783580058,标题仍为旧文章(复现 bug)
- 修复后:URL 变,标题随之变为新文章
2026-07-09 15:34:25 +08:00

128 lines
6.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 文章详情页面模块。
//!
//! 对应路由 `/post/:slug`。
//!
//! 数据获取:通过 `use_server_future` 调用 `get_post_by_slug` server function
//! 根据 URL 中的 slug 获取单篇文章详情(含正文 HTML、目录、封面及上下篇导航
//!
//! # 反应式取数的关键
//! Dioxus 0.7 的 `use_server_future`(内部即 `use_resource`)只在闭包内读取的
//! **signal** 变化时才会重跑 future——它通过 `ReactiveContext` 追踪闭包执行期间的
//! 订阅。但本组件的 `slug` 是路由宏注入的普通 `String` prop被 `move` 进闭包后
//! 成了冻结快照,读取它不会建立订阅。因此上/下一篇导航(同一路由变体间的 slug
//! 变化)会复用组件实例、更新 props却无法触发 future 重跑——表现为「URL 变了
//! 但内容不变,刷新才生效」。
//!
//! 修复:在闭包内通过 `router().current::<Route>()` 读取当前 slug。`current()`
//! 内部调用 `subscribe_to_current_context()`,在 `use_server_future` 的
//! ReactiveContext 中注册订阅路由变化时订阅触发future 自动重跑。
//! 在 `wasm32` 目标下server function 的函数体被替换为向服务端端点发起 HTTP POST 请求的客户端存根;
//! 实际的数据库访问逻辑仅在 `feature = "server"` 启用时运行。
use dioxus::prelude::*;
use dioxus::router::components::Link;
use crate::api::posts::{get_post_by_slug, SinglePostResponse};
use crate::components::post::post_content::PostContent;
use crate::components::post::post_cover::PostCover;
use crate::components::post::post_footer::PostFooter;
use crate::components::post::post_header::PostHeader;
use crate::components::post::post_toc::PostToc;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::skeletons::post_detail_skeleton::PostDetailSkeleton;
use crate::router::Route;
/// 文章详情页面组件,对应路由 `/post/:slug`。
///
/// 根据 slug 异步获取文章,渲染文章头部、封面、目录、正文、页脚及评论区;
/// 若文章不存在或加载失败,则展示对应的提示页面。
#[component]
pub fn PostDetail(slug: String) -> Element {
// 取得路由上下文句柄(不订阅组件层渲染,仅在闭包内按需订阅)。
// 见模块文档必须在闭包内读取路由状态才能建立反应式订阅future 才会在
// slug 变化(上/下一篇导航)时重跑。`slug` prop 本身是冻结的 String 快照,
// 不能作为依赖。
let router = dioxus::router::router();
let post = use_server_future(move || {
// 在闭包内读取当前 slugcurrent() 内部会 subscribe_to_current_context()
// 把订阅注册到 use_server_future 的 ReactiveContext路由变化即重跑。
let current_slug = match router.current::<Route>() {
Route::PostDetail { slug } => slug,
// 组件卸载/路由切走的瞬间可能命中其它变体,退回用 prop 值兜底。
_ => slug.clone(),
};
get_post_by_slug(current_slug)
})?;
// 将结果映射为更直观的 Ok(post) / Err("not_found") / Err("error") 三种状态。
let post_data = post.read().as_ref().map(|r| match r {
Ok(SinglePostResponse { post: Some(post) }) => Ok(post.clone()),
Ok(SinglePostResponse { post: None }) => Err("not_found"),
Err(_) => Err("error"),
});
match post_data {
Some(Ok(post)) => {
rsx! {
article { class: "post-single animate-page-enter", key: "{post.slug}",
PostHeader { post: post.clone() }
// 如果文章设置了封面图,则渲染封面组件。
if let Some(cover) = &post.cover_image {
PostCover { src: cover.clone() }
}
// 如果文章生成了目录 HTML则渲染目录组件。
if let Some(toc) = &post.toc_html {
PostToc { toc_html: toc.clone() }
}
PostContent { content_html: post.content_html.clone().unwrap_or_default() }
PostFooter { post: post.clone() }
// 仅对已发布文章展示评论区域,使用 SuspenseBoundary 处理加载状态。
if post.status == crate::models::post::PostStatus::Published {
div { class: "mt-12 border-t border-gray-200 dark:border-gray-700 pt-8",
SuspenseBoundary {
fallback: move |_| rsx! {
DelayedSkeleton {
crate::components::skeletons::comment_skeleton::CommentListSkeleton {}
}
},
crate::components::comments::section::CommentSection { post_id: post.id }
}
}
}
}
}
}
Some(Err("not_found")) => {
rsx! {
div { class: "text-center py-20",
h2 { class: "text-2xl font-bold text-paper-primary mb-4", "文章不存在" }
p { class: "text-paper-secondary mb-6",
"这篇文章可能已被删除或移动。"
}
Link {
class: "px-6 py-2 bg-paper-primary text-paper-theme rounded-full font-medium hover:opacity-80 transition-opacity",
to: Route::Home {},
"返回首页"
}
}
}
}
Some(Err("error")) => {
rsx! {
div { class: "text-center text-red-500 dark:text-red-400 py-20", "加载失败" }
}
}
_ => {
rsx! {
DelayedSkeleton { PostDetailSkeleton {} }
}
}
}
}