From 3c6d8cb89159ec0712d530005044bf0dba41c41d Mon Sep 17 00:00:00 2001 From: freefire Date: Thu, 10 Sep 2026 05:04:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(ssr):=20=E5=B8=96=E5=AD=90=E9=A1=B5?= =?UTF-8?q?=E5=85=A8=E9=87=8F=20SSR=E2=80=94=E2=80=94=E4=BD=9C=E8=80=85?= =?UTF-8?q?=E5=8D=A1/=E7=9B=AE=E5=BD=95=E9=A6=96=E5=B8=A7=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E6=B8=B2=E6=9F=93=EF=BC=8C=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E5=86=97=E4=BD=99=E9=A6=96=E5=B1=8F=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 帖子快照携带作者主页资料与统计(authorProfile/authorStats), SSR 端与客户端预热均取 /api/users/:id;PostAuthorCard 首帧直接渲染, 不再挂载后请求,统计数字不再显示 — 占位 - MainLayout 首帧从已 seed 的帖子快照派生右栏 outline(作者卡+文章目录), 修复 outline 仅靠 effect 上报导致 SSR 不渲染右栏的问题 - 壳层首屏请求门控:useAuth 有种子跳过 /api/me、branding 缓存命中不重拉、 侧栏三个区块首屏用 seed 缓存、未读数首屏跳过即时请求(轮询/事件刷新保留) - serverDom(linkedom) 修复:DOMParser 片段包裹、属性顺序、SVG 自闭合展开 - 验证:首页与帖子页 hydration 后仅剩 pageview 上报,0 hydration 错误 --- frontend/renderer/+onRenderHtml.tsx | 4 +- frontend/src/components/PostAuthorCard.tsx | 17 ++- frontend/src/components/RightPanel.tsx | 7 +- frontend/src/hooks/useAuth.tsx | 8 +- frontend/src/hooks/useSiteBranding.ts | 6 +- frontend/src/layouts/MainLayout.tsx | 111 +++++++++++---- frontend/src/pages/PostDetailPage.tsx | 58 +++----- frontend/src/ssr/feedData.ts | 10 +- frontend/src/ssr/scopeTypes.ts | 5 +- frontend/src/ssr/serverDom.ts | 157 ++++++++++++++++++++- frontend/src/utils/authRedirect.ts | 7 +- frontend/src/utils/homeBoot.ts | 13 +- frontend/src/utils/prefetchRoute.ts | 6 +- 13 files changed, 322 insertions(+), 87 deletions(-) diff --git a/frontend/renderer/+onRenderHtml.tsx b/frontend/renderer/+onRenderHtml.tsx index a55f999..6b46351 100644 --- a/frontend/renderer/+onRenderHtml.tsx +++ b/frontend/renderer/+onRenderHtml.tsx @@ -36,10 +36,10 @@ async function onRenderHtml(pageContext: PageContextServer) { let pageHtml = ''; if (Page) { // SSR feed 页:per-request 作用域 + hydrate 同构变体;渲染失败降级空壳(客户端自行接管),不 500 - const scope = createRequestScope(createHomeStore()); - scope.hydrating = true; const { pathname, searchOriginal } = pageContext.urlParsed; const url = pathname + (searchOriginal ?? ''); + const scope = createRequestScope(createHomeStore(), url); + scope.hydrating = true; try { pageHtml = await runWithScope(scope, async () => { // 渲染前把 +data 取回的数据 seed 进请求作用域(缓存层按 scope 隔离) diff --git a/frontend/src/components/PostAuthorCard.tsx b/frontend/src/components/PostAuthorCard.tsx index e027f8e..502beed 100644 --- a/frontend/src/components/PostAuthorCard.tsx +++ b/frontend/src/components/PostAuthorCard.tsx @@ -15,6 +15,9 @@ interface Props { author?: User | null; publishedAt?: string; viewCount?: number; + /** SSR/预热快照携带的作者主页资料与统计:首帧直接渲染,避免挂载后再拉 /api/users/:id */ + initialProfile?: UserPublic | null; + initialStats?: UserActivityStats | null; } /** 帖子详情右栏:作者信息卡(私信 / 主页 / 统计) */ @@ -22,11 +25,13 @@ export default function PostAuthorCard({ author, publishedAt, viewCount, + initialProfile = null, + initialStats = null, }: Props) { const nav = useNavigate(); const { user: me } = useAuth(); - const [profile, setProfile] = useState(null); - const [stats, setStats] = useState(null); + const [profile, setProfile] = useState(initialProfile); + const [stats, setStats] = useState(initialStats); useEffect(() => { if (!author?.id) { @@ -34,6 +39,12 @@ export default function PostAuthorCard({ setStats(null); return; } + // 快照(SSR / 客户端预热 / 换帖)已带完整资料:同步即可,不再发请求 + if (initialProfile && initialStats) { + setProfile(initialProfile); + setStats(initialStats); + return; + } let cancelled = false; api.userProfile(author.id) .then((r) => { @@ -48,7 +59,7 @@ export default function PostAuthorCard({ setStats(null); }); return () => { cancelled = true; }; - }, [author?.id]); + }, [author?.id, initialProfile, initialStats]); if (!author?.id) { return ( diff --git a/frontend/src/components/RightPanel.tsx b/frontend/src/components/RightPanel.tsx index 8f28723..3558d10 100644 --- a/frontend/src/components/RightPanel.tsx +++ b/frontend/src/components/RightPanel.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { ListTree, MessageCircle, Tags, Link2, UserPlus } from 'lucide-react'; import { useLocation, useSearchParams, useNavigate } from 'react-router'; -import type { AsideWidget, RecentComment, RecentUser, TagCount, User, ForumStats, FriendLink } from '../api/types'; +import type { AsideWidget, RecentComment, RecentUser, TagCount, User, UserActivityStats, UserPublic, ForumStats, FriendLink } from '../api/types'; import type { PostHeading } from '../utils/postHeadings'; import { useSiteBranding } from '../hooks/useSiteBranding'; import { formatShortDateTime, formatTime } from '../utils/content'; @@ -20,6 +20,9 @@ export type PostDetailAside = { headings: PostHeading[]; scrollRoot?: HTMLElement | null; outlineTitle?: string; + /** SSR/预热快照携带的作者主页资料与统计(首帧直接渲染,缺省卡片自行拉取) */ + authorProfile?: UserPublic | null; + authorStats?: UserActivityStats | null; }; interface Props { @@ -237,6 +240,8 @@ export default function RightPanel({ author={postDetail.author} publishedAt={postDetail.publishedAt} viewCount={postDetail.viewCount} + initialProfile={postDetail.authorProfile ?? null} + initialStats={postDetail.authorStats ?? null} /> ) : null}
diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx index e7598e4..a8f7825 100644 --- a/frontend/src/hooks/useAuth.tsx +++ b/frontend/src/hooks/useAuth.tsx @@ -36,9 +36,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []); - // 有 SSR 种子时仍后台校验;无种子则首屏拉取 + // SSR +data / 冷启动预热已注入登录态种子(含已确认访客 null)时首屏不重复请求; + // 无种子(纯 SPA 且未预热)才首屏拉取。登录/退出均显式更新状态,不依赖 mount 校验。 // 不清空 auth 种子:StrictMode 重挂会再次跑 useState initializer,破坏性 clear 会导致 user 空窗闪动 - useEffect(() => { void refresh(); }, [refresh]); + useEffect(() => { + if (peekAuthSeed().hasSeed) return; + void refresh(); + }, [refresh]); const prevUserId = useRef('init'); useEffect(() => { diff --git a/frontend/src/hooks/useSiteBranding.ts b/frontend/src/hooks/useSiteBranding.ts index 1f57414..c3110d6 100644 --- a/frontend/src/hooks/useSiteBranding.ts +++ b/frontend/src/hooks/useSiteBranding.ts @@ -61,8 +61,10 @@ function fetchBranding(): Promise { preferCacheOnce = false; return Promise.resolve(cached); } + // SSR +data / 冷启动预热已 seed 品牌配置时首屏直接复用,不重复请求; + // 最新配置同步由显式刷新(refetchSiteBranding / ensureSiteBrandingLoaded force)触发 + if (cached) return Promise.resolve(cached); if (inflight) return inflight; - // 有 boot/缓存时首屏已可用;仍请求 API 以同步最新配置 inflight = api.siteBranding() .then(b => { cached = { ...DEFAULT_BRANDING, ...b }; @@ -137,6 +139,7 @@ export function useSiteBranding() { /** 保留当前画面,后台重拉品牌配置(下拉刷新等) */ export function refetchSiteBranding() { + cached = null; inflight = null; cacheEpoch += 1; listeners.forEach(fn => fn()); @@ -146,6 +149,7 @@ export function refetchSiteBranding() { export async function ensureSiteBrandingLoaded(opts?: { force?: boolean }): Promise { if (!opts?.force && cached) return cached; if (opts?.force) { + cached = null; inflight = null; preferCacheOnce = false; } diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index c1baa59..e4ca21e 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -12,13 +12,16 @@ import { useAuth } from '../hooks/useAuth'; import { useTheme, useMediaQuery } from '../hooks/useTheme'; import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y'; import { api } from '../api/client'; -import type { Board, RecentComment, RecentUser, ForumStats, TagCount, User } from '../api/types'; -import type { PostHeading } from '../utils/postHeadings'; +import type { Board, RecentComment, RecentUser, ForumStats, TagCount, User, UserActivityStats, UserPublic } from '../api/types'; +import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings'; +import { renderPostContentHtml } from '../utils/postContent'; +import { getSessionSnapshot } from '../utils/sessionPageCache'; +import { postDetailCacheKey, type PostDetailSnapshot } from '../utils/homeBoot'; import { getCachedBoards, getCachedStats, getCachedRecentComments, getCachedRecentUsers, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedRecentComments, setCachedRecentUsers, setCachedTags } from '../utils/layoutCache'; import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar'; import RightPanel from '../components/RightPanel'; import BackToTop from '../components/BackToTop'; -import { useForumLimits } from '../hooks/useForumLimits'; +import { getCachedForumLimits, useForumLimits } from '../hooks/useForumLimits'; import { resolveAsideWidgets } from '../utils/asideWidgets'; import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar'; import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; @@ -44,7 +47,7 @@ import { userPath } from '../utils/userPath'; import { parsePermalinkID } from '../utils/permalink'; import { ensureSitePagesLoaded } from '../hooks/useSitePages'; import { endHomeHydrate, isHomeHydrating } from '../utils/homeHydrate'; -import { getBootUnread } from '../utils/authBoot'; +import { getBootUnread, peekAuthSeed } from '../utils/authBoot'; export default function MainLayout() { const { user, loading: authLoading, logout } = useAuth(); @@ -73,14 +76,33 @@ export default function MainLayout() { const [notifyUnread, setNotifyUnread] = useState(0); const [tags, setTags] = useState(() => getCachedTags()); const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0); - const [postOutline, setPostOutline] = useState<{ - headings: PostHeading[]; - scrollRoot: HTMLElement | null; - title?: string; - author?: User | null; - publishedAt?: string; - viewCount?: number; - } | null>(null); + // SSR / hydrate 首帧:详情页快照已在渲染前 seed(onRenderHtml / onRenderClient), + // 直接从中派生右栏作者与目录——子组件的 setPostOutline 在 effect 中,SSR 不执行, + // 不走这一步作者卡/目录会整体退化为客户端挂载。首帧后的 SPA 导航仍由 effect 上报。 + const [postOutline, setPostOutline] = useState(() => { + const m = loc.pathname.match(/^\/post\/(\d+)/); + if (!m || /\/edit$/.test(loc.pathname)) return null; + const id = Number(m[1]); + if (!id) return null; + const snap = getSessionSnapshot(postDetailCacheKey(id)); + if (!snap?.post) return null; + const content = snap.post.content ?? ''; + const rendered = content.trim() + ? renderPostContentHtml(content, !!user, { + openLinksInNewTab: getCachedForumLimits()?.open_content_links_in_new_tab, + }) + : ''; + return { + headings: extractHeadingsFromHtml(rendered), + scrollRoot: null, + title: '文章目录', + author: snap.post.user ?? null, + publishedAt: snap.post.created_at, + viewCount: snap.post.view_count, + authorProfile: snap.authorProfile ?? null, + authorStats: snap.authorStats ?? null, + }; + }); const [asideOpen, setAsideOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [searchPanelOpen, setSearchPanelOpen] = useState(false); @@ -335,8 +357,15 @@ export default function MainLayout() { void transitionTo(nav, path); }, [nav, notifyUnread, dmUnread]); + // 仅首屏跳过一次:SSR/冷启动种子已带未读数(state 从 getBootUnread 初始化),不重复请求; + // 之后 user 变化(登录/退出)、事件刷新与 60s 轮询照常 + const unreadImmediateSkipped = useRef(false); useEffect(() => { - refreshUnreadMessages(); + if (!unreadImmediateSkipped.current && peekAuthSeed().hasSeed) { + unreadImmediateSkipped.current = true; + } else { + refreshUnreadMessages(); + } const onRefresh = () => refreshUnreadMessages(); window.addEventListener('messages-unread-refresh', onRefresh); const timer = window.setInterval(refreshUnreadMessages, 60_000); @@ -387,6 +416,13 @@ export default function MainLayout() { // 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签) useEffect(() => { if (isCompose || !showTagCloud) return; + // SSR +data / 冷启动预热已 seed 侧栏数据(含空数组)时首屏直接复用,不重复请求; + // 下拉强制刷新(layoutRefreshTick 递增)时仍重拉最新 + if (layoutRefreshTick === 0 && hasCachedAside()) { + setTags(getCachedTags()); + setTagsLoading(false); + return; + } let cancelled = false; // 有 session 缓存则静默刷新(软刷新不卸空白) if (getCachedTags().length === 0) setTagsLoading(true); @@ -407,6 +443,14 @@ export default function MainLayout() { const needRecentComments = needAsideData && showRecentComments; useEffect(() => { if (!needRecentComments) return; + // SSR +data / 冷启动预热已 seed:首屏同步缓存进 state,不重复请求; + // 下拉强制刷新(layoutRefreshTick 递增)时仍重拉最新 + if (layoutRefreshTick === 0 && hasCachedAside()) { + setRecentComments(getCachedRecentComments()); + asideEverLoaded.current = true; + setAsideLoading(false); + return; + } let cancelled = false; // 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动 if (!asideEverLoaded.current && !hasCachedAside()) { @@ -433,6 +477,12 @@ export default function MainLayout() { const needRecentUsers = needAsideData && showRecentUsers; useEffect(() => { if (!needRecentUsers) return; + if (layoutRefreshTick === 0 && hasCachedAside()) { + setRecentUsers(getCachedRecentUsers()); + asideEverLoaded.current = true; + setAsideLoading(false); + return; + } let cancelled = false; if (!asideEverLoaded.current && !hasCachedAside()) { setAsideLoading(true); @@ -503,14 +553,7 @@ export default function MainLayout() { const isArticleAside = isPostDetail || isSitePage; // 帖子有作者 →「作者与目录」;单页仅目录 const articleAsideLabel = isPostDetail ? '作者与目录' : '文章目录'; - const setPostOutlineSafe = useCallback((outline: { - headings: PostHeading[]; - scrollRoot: HTMLElement | null; - title?: string; - author?: User | null; - publishedAt?: string; - viewCount?: number; - } | null) => { + const setPostOutlineSafe = useCallback((outline: PostOutlineState) => { setPostOutline(outline); }, []); const layoutCtx = useMemo(() => ({ @@ -848,6 +891,8 @@ export default function MainLayout() { headings: postOutline?.headings ?? [], scrollRoot: postOutline?.scrollRoot ?? null, outlineTitle: postOutline?.title, + authorProfile: postOutline?.authorProfile ?? null, + authorStats: postOutline?.authorStats ?? null, } : null} /> )} @@ -967,6 +1012,8 @@ export default function MainLayout() { headings: postOutline?.headings ?? [], scrollRoot: postOutline?.scrollRoot ?? null, outlineTitle: postOutline?.title, + authorProfile: postOutline?.authorProfile ?? null, + authorStats: postOutline?.authorStats ?? null, } : null} />
@@ -979,6 +1026,19 @@ export default function MainLayout() { ); } +/** 详情页/单页上报到右栏的文章目录与作者信息 */ +export type PostOutlineState = { + headings: PostHeading[]; + scrollRoot: HTMLElement | null; + title?: string; + author?: User | null; + publishedAt?: string; + viewCount?: number; + /** SSR/预热快照携带的作者主页资料与统计(作者卡首帧直接渲染,缺省由卡片自行拉取) */ + authorProfile?: UserPublic | null; + authorStats?: UserActivityStats | null; +} | null; + export type LayoutCtx = { boardId: number; keyword: string; @@ -989,12 +1049,5 @@ export type LayoutCtx = { refreshBoards: () => void; isMobile: boolean; /** 详情页上报作者与目录,供右侧栏展示 */ - setPostOutline: (outline: { - headings: PostHeading[]; - scrollRoot: HTMLElement | null; - title?: string; - author?: User | null; - publishedAt?: string; - viewCount?: number; - } | null) => void; + setPostOutline: (outline: PostOutlineState) => void; }; diff --git a/frontend/src/pages/PostDetailPage.tsx b/frontend/src/pages/PostDetailPage.tsx index d7e15a5..18147ff 100644 --- a/frontend/src/pages/PostDetailPage.tsx +++ b/frontend/src/pages/PostDetailPage.tsx @@ -33,7 +33,7 @@ import { } from '@/components/ui/dialog'; import { notify } from '@/lib/notify'; import { api } from '../api/client'; -import type { PostItem, Comment, ReportReason, PollView, PostLotteryView } from '../api/types'; +import type { PostItem, Comment, PollView, PostLotteryView, ReportReason } from '../api/types'; import { REPORT_REASON_OPTIONS } from '../utils/report'; import CommentThreadList from '../components/CommentThreadList'; import CommentBox, { type CommentSubmitData } from '../components/CommentBox'; @@ -48,7 +48,7 @@ import ArticleOutline from '../components/ArticleOutline'; import { useAuth } from '../hooks/useAuth'; import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO'; import { getCachedSiteBranding } from '../hooks/useSiteBranding'; -import { formatDateTime, isTimeDiffSignificant } from '../utils/content'; +import { formatDateTime } from '../utils/content'; import { collectCommentSubtreeIds } from '../utils/comment'; import { loadMyCommentIds } from '../utils/guest'; import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache'; @@ -56,6 +56,7 @@ import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh'; import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache'; import { loginPath } from '../utils/authRedirect'; import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText'; +import { buildPostDetailSnapshot, postDetailCacheKey, type PostDetailSnapshot } from '../utils/homeBoot'; import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink'; import { useForumLimits } from '../hooks/useForumLimits'; import type { LayoutCtx } from '../layouts/MainLayout'; @@ -64,26 +65,6 @@ import { renderPostContentHtml } from '../utils/postContent'; import { InFlowSiteFooter } from '../components/SiteFooter'; import NotFoundPage from './NotFoundPage'; -type PostDetailSnapshot = { - post: PostItem; - comments: Comment[]; - poll: PollView | null; - lottery: PostLotteryView | null; - liked: boolean; - favorited: boolean; - canEdit: boolean; - isEdited: boolean; - editBlockReason: string; - editWindowHours: number; - bountyCanRefund: boolean; - bountyRefundBlockReason: string; - bountyEligibleReplyCount: number; -}; - -function postDetailCacheKey(id: number) { - return `post:${id}`; -} - /** 格式化剩余可编辑时间 */ function formatEditRemaining(createdAt: string, windowHours: number): string { if (windowHours <= 0) return ''; @@ -143,6 +124,8 @@ export default function PostDetailPage() { const [bountyCanRefund, setBountyCanRefund] = useState(initialSnap?.bountyCanRefund ?? true); const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState(initialSnap?.bountyRefundBlockReason ?? ''); const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(initialSnap?.bountyEligibleReplyCount ?? 0); + const [authorProfile, setAuthorProfile] = useState(initialSnap?.authorProfile ?? null); + const [authorStats, setAuthorStats] = useState(initialSnap?.authorStats ?? null); const commentSectionRef = useRef(null); const commentBoxRef = useRef(null); @@ -206,9 +189,11 @@ export default function PostDetailPage() { author: post.user ?? null, publishedAt: post.created_at, viewCount: post.view_count, + authorProfile, + authorStats, }); return () => setPostOutline(null); - }, [headings, loading, post, setPostOutline]); + }, [headings, loading, post, authorProfile, authorStats, setPostOutline]); const loadSeq = useRef(0); const detailPath = postPath(postId, limits); @@ -226,6 +211,8 @@ export default function PostDetailPage() { setBountyCanRefund(snap.bountyCanRefund); setBountyRefundBlockReason(snap.bountyRefundBlockReason); setBountyEligibleReplyCount(snap.bountyEligibleReplyCount); + setAuthorProfile(snap.authorProfile ?? null); + setAuthorStats(snap.authorStats ?? null); setComments(snap.comments); // POP(后退/前进)不干预,交给浏览器原生恢复; // PUSH 换帖 / 强制刷新是「换新内容」场景,文档滚动置顶 @@ -273,21 +260,11 @@ export default function PostDetailPage() { ]); if (seq !== loadSeq.current) return; const commentsList = Array.isArray(comm.comments) ? comm.comments : []; - const snap: PostDetailSnapshot = { - post: detail.post, - comments: commentsList, - poll: detail.poll ?? null, - lottery: detail.lottery ?? null, - liked: detail.liked, - favorited: detail.favorited, - canEdit: detail.can_edit ?? false, - isEdited: detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at), - editBlockReason: detail.edit_block_reason ?? '', - editWindowHours: detail.post_edit_window_hours ?? 0, - bountyCanRefund: detail.bounty_can_refund ?? true, - bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '', - bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0, - }; + // 作者主页资料一并拉取(右栏作者卡首帧完整;失败降级为卡片自行拉取) + const authorResp = detail.post.user_id + ? await api.userProfile(detail.post.user_id).catch(() => null) + : null; + const snap = buildPostDetailSnapshot(detail, commentsList, authorResp); setSessionSnapshot(postDetailCacheKey(postId), snap); applySnapshot(snap, { restoreScroll: false }); void refresh(); @@ -335,11 +312,14 @@ export default function PostDetailPage() { bountyCanRefund, bountyRefundBlockReason, bountyEligibleReplyCount, + authorProfile, + authorStats, + scrollTop: 0, }); }, [ post, comments, poll, lottery, liked, favorited, canEdit, isEdited, editBlockReason, editWindowHours, bountyCanRefund, bountyRefundBlockReason, - bountyEligibleReplyCount, postId, loading, + bountyEligibleReplyCount, authorProfile, authorStats, postId, loading, ]); const reloadComments = useCallback(async () => { diff --git a/frontend/src/ssr/feedData.ts b/frontend/src/ssr/feedData.ts index 2a7839a..b09ac6d 100644 --- a/frontend/src/ssr/feedData.ts +++ b/frontend/src/ssr/feedData.ts @@ -10,7 +10,7 @@ import { redirect, render } from 'vike/abort'; import type { PageContextServer } from 'vike/types'; import { serverApi } from '../api/serverClient'; -import type { Comment, PostDetailResponse } from '../api/types'; +import type { Comment, PostDetailResponse, UserActivityStats, UserPublic } from '../api/types'; import type { HomeBootPayload } from '../utils/homeBoot'; import { buildPostDetailSnapshot } from '../utils/homeBoot'; import { matchFeedSsrPath } from './feedPaths'; @@ -70,8 +70,14 @@ async function loadPostData(id: string, pathname: string, api: ApiCtx): Promise< }); } + // 作者主页资料(右栏作者卡首帧 SSR;失败降级为卡片自行拉取,不阻断渲染) + const authorId = detail.post.user_id; + const authorResp = authorId + ? await serverApi<{ user: UserPublic; stats: UserActivityStats }>(`/api/users/${authorId}`, api) + : null; + const comments = Array.isArray(commentsResp?.comments) ? commentsResp.comments : []; - const snapshot = buildPostDetailSnapshot(detail, comments); + const snapshot = buildPostDetailSnapshot(detail, comments, authorResp); const head = buildPostHead(detail.post, brand, boot?.limits ?? null, base); return { kind: 'post', boot: boot ?? undefined, snapshot, head }; } diff --git a/frontend/src/ssr/scopeTypes.ts b/frontend/src/ssr/scopeTypes.ts index 2b60abf..68ae443 100644 --- a/frontend/src/ssr/scopeTypes.ts +++ b/frontend/src/ssr/scopeTypes.ts @@ -10,6 +10,8 @@ import type { User, SiteBranding, ForumLimitsPublic, SitePageSummary } from '../ import type { HomeStoreState } from '../store/homeStore'; export type RequestScope = { + /** 本请求的路径 + 查询串(不含 hash,服务端不可见),供双端共享模块读取「当前 URL」 */ + url: string; /** layoutCache / sessionPageCache 等统一 KV(键与 sessionStorage 键一致) */ kv: Map; /** 本请求独立的 feed store(zustand 实例) */ @@ -24,8 +26,9 @@ export type RequestScope = { hydrating: boolean; }; -export function createRequestScope(homeStore: HomeStoreState): RequestScope { +export function createRequestScope(homeStore: HomeStoreState, url: string): RequestScope { return { + url, kv: new Map(), homeStore, auth: { hasSeed: false, user: null, unread: 0 }, diff --git a/frontend/src/ssr/serverDom.ts b/frontend/src/ssr/serverDom.ts index 9b7a603..97e4726 100644 --- a/frontend/src/ssr/serverDom.ts +++ b/frontend/src/ssr/serverDom.ts @@ -10,8 +10,106 @@ * * DOMPurify 在无 window 环境 isSupported=false,sanitize 原样返回—— * SSR 内容均来自 Go API(服务端已消毒),跳过浏览器端二次消毒是安全的。 + * + * 注意 1:linkedom 的 DOMParser 解析「片段 HTML」时不会构造 /, + * 首枚元素会被当作 documentElement、其余内容挂在文档根上,doc.body 恒为空; + * 而内容处理工具统一读 doc.body / 在文档内 querySelectorAll,SSR 会渲染出空正文。 + * ServerDOMParser 统一把片段包成完整文档,行为与浏览器 DOMParser 对齐。 + * + * 注意 2:linkedom 对运行时「新增」的属性采用头插(setAttribute 新属性、 + * classList.add 首次创建 class 时插到属性列表最前),浏览器是尾插; + * 而解析得到的属性两端都按源码顺序、已存在属性原地改值,行为一致。 + * 属性顺序差异会使 dangerouslySetInnerHTML 的 SSR/客户端字符串不一致, + * 触发 hydration mismatch(#418/#425)。这里在两个新增入口记录属性的调用 + * 顺序,并在 innerHTML/outerHTML 序列化读取前把「后加的属性」移回尾部, + * 使 linkedom 序列化结果与浏览器逐属性一致(重排幂等,可安全多次读取)。 + * + * 注意 3:linkedom 序列化时对无子节点的 SVG/MathML 外发内容元素输出 + * 自闭合(),而浏览器的 HTML 片段序列化一律输出显式闭合 + * (,仅 img/br 等 HTML void 元素省略尾标签)。序列化后 + * 把这类自闭合展开为显式闭合(文本节点已转义,裸 只可能是真实标签)。 + * 已知限制:linkedom 解析 HTML 时外发内容标签名也会被小写化(linearGradient + * → lineargradient);门控图标与现有数据仅使用全小写 SVG 标签,暂不处理。 */ -import { DOMParser as LinkedomDOMParser, HTMLElement as LinkedomHTMLElement, NodeFilter as LinkedomNodeFilter } from 'linkedom'; +import { + DOMParser as LinkedomDOMParser, + Element as LinkedomElement, + HTMLElement as LinkedomHTMLElement, + NodeFilter as LinkedomNodeFilter, +} from 'linkedom'; + +class ServerDOMParser { + parseFromString(markup: string, mimeType: string = 'text/html'): Document { + if (mimeType === 'text/html' && typeof markup === 'string' && !/]/i.test(markup)) { + markup = `${markup}`; + } + return new LinkedomDOMParser().parseFromString(markup, mimeType as 'text/html') as unknown as Document; + } +} + +/** 元素上记录「运行时新增」属性名的 expando 键(按调用顺序) */ +const ADDED_ATTRS = Symbol('ssrAddedAttrs'); +/** 重排期间抑制记录,避免 remove + setAttribute 重写产生重复登记 */ +let reordering = false; + +function recordAddedAttribute(el: Element, name: string): void { + const store = el as unknown as Record; + const list = store[ADDED_ATTRS]; + if (list) { + if (!list.includes(name)) list.push(name); + } else { + store[ADDED_ATTRS] = [name]; + } +} + +/** + * 序列化前归一化属性顺序:解析属性(源码顺序)在前、运行时新增属性按调用 + * 顺序在后——与浏览器尾插语义一致。linkedom 头插,重写时按期望顺序逆序写入。 + */ +function normalizeAttributeOrder(root: Element): void { + const elements: Element[] = [root, ...Array.from(root.querySelectorAll('*'))]; + for (const el of elements) { + const store = el as unknown as Record; + const added = store[ADDED_ATTRS]; + if (!added || added.length === 0) continue; + const current = el.getAttributeNames(); + if (current.length === 0) continue; + + const addedSet = new Set(added); + const kept = current.filter(n => !addedSet.has(n)); + const presentAdded = added.filter(n => current.includes(n)); + const desired = [...kept, ...presentAdded]; + if (desired.length === current.length && desired.every((n, i) => n === current[i])) { + continue; // 已是浏览器顺序(首次或重复读取) + } + + const values = new Map(current.map(n => [n, el.getAttribute(n) ?? ''])); + reordering = true; + try { + for (const n of current) el.removeAttribute(n); + for (let i = desired.length - 1; i >= 0; i--) { + el.setAttribute(desired[i], values.get(desired[i]) ?? ''); + } + } finally { + reordering = false; + } + } +} + +/** + * linkedom 对无子节点的外发内容(SVG/MathML)元素序列化为自闭合 , + * 浏览器的 HTML 片段序列化一律输出显式闭合 。读取 innerHTML/ + * outerHTML 后展开(文本节点中 < 已转义为 <,裸 只可能是真实标签)。 + */ +const FOREIGN_SELF_CLOSE = /<([a-zA-Z][\w:-]*)((?:\s[^<>"']*(?:"[^"]*"|'[^']*')[^<>"']*)*)?\s*\/>/g; + +function expandForeignSelfClose(html: string): string { + return html.replace( + FOREIGN_SELF_CLOSE, + (_match, name: string, attrs: string | undefined) => + `<${name}${(attrs ?? '').replace(/\s+$/, '')}>`, + ); +} let installed = false; @@ -20,14 +118,65 @@ export function installServerDom(): void { installed = true; const g = globalThis as unknown as Record; - const parser = new LinkedomDOMParser(); - const doc = parser.parseFromString('...', 'text/html'); + const parser = new ServerDOMParser(); + const doc = parser.parseFromString('', 'text/html'); - g.DOMParser ??= LinkedomDOMParser; + g.DOMParser ??= ServerDOMParser; g.NodeFilter ??= LinkedomNodeFilter; g.HTMLElement ??= LinkedomHTMLElement; // linkedom HTML 元素(含 unknown/custom 元素)统一继承 HTMLElement, // 可同时充当 instanceof Element / instanceof HTMLElement 的判定目标 g.Element ??= LinkedomHTMLElement; g.document ??= doc; + + installAttributeOrderPatch(doc); +} + +/** + * 补丁 linkedom 属性插入顺序(仅原型方法,安装一次即对所有解析文档生效)。 + */ +function installAttributeOrderPatch(doc: Document): void { + const elementProto = LinkedomElement.prototype as unknown as { + setAttribute(name: string, value: string): void; + }; + + // 1) setAttribute:新增属性登记调用顺序(已存在属性 linkedom/浏览器均原地改值) + const origSetAttribute = elementProto.setAttribute; + elementProto.setAttribute = function (this: Element, name: string, value: string): void { + if (!reordering && !this.hasAttribute(name)) recordAddedAttribute(this, name); + origSetAttribute.call(this, name, value); + }; + + // 2) DOMTokenList.add:class 属性的首次创建由此进入(className setter 也走 classList.add) + const probeEl = doc.createElement('div'); + const tokenList = probeEl.classList as unknown as Record; + const tokenProto = Object.getPrototypeOf(probeEl.classList) as { + add(this: unknown, ...tokens: string[]): void; + }; + const ownerSymbol = Object.getOwnPropertySymbols(tokenList).find( + s => tokenList[s] === probeEl, + ); + const origTokenAdd = tokenProto.add; + tokenProto.add = function (this: Record, ...tokens: string[]): void { + const owner = ownerSymbol ? this[ownerSymbol] : undefined; + if (!reordering && owner && !owner.hasAttribute('class')) recordAddedAttribute(owner, 'class'); + origTokenAdd.apply(this, tokens); + }; + + // 3) innerHTML / outerHTML 读取(序列化)前,把属性顺序归一为浏览器顺序 + const protoForDescriptors = LinkedomElement.prototype as unknown as Record; + (['innerHTML', 'outerHTML'] as const).forEach(key => { + const desc = Object.getOwnPropertyDescriptor(protoForDescriptors, key); + if (!desc?.get) return; + const origGet = desc.get as (this: Element) => string; + Object.defineProperty(protoForDescriptors, key, { + configurable: true, + enumerable: desc.enumerable, + get(this: Element) { + normalizeAttributeOrder(this); + return expandForeignSelfClose(origGet.call(this)); + }, + set: desc.set as ((this: Element, html: string) => void) | undefined, + }); + }); } diff --git a/frontend/src/utils/authRedirect.ts b/frontend/src/utils/authRedirect.ts index d606410..0ccc94b 100644 --- a/frontend/src/utils/authRedirect.ts +++ b/frontend/src/utils/authRedirect.ts @@ -1,3 +1,5 @@ +import { getScope } from '../ssr/scope'; + /** 构造带回跳的登录路径 */ export function loginPath(from?: string): string { const path = sanitizeReturnPath(from ?? currentPath()); @@ -36,7 +38,10 @@ export function navigateAfterAuth( } function currentPath(): string { - if (typeof window === 'undefined') return '/'; + if (typeof window === 'undefined') { + // SSR:hash 不随请求上送,取请求作用域中的 pathname + search + return getScope()?.url ?? '/'; + } return `${window.location.pathname}${window.location.search}${window.location.hash}`; } diff --git a/frontend/src/utils/homeBoot.ts b/frontend/src/utils/homeBoot.ts index 26cab25..34e46e1 100644 --- a/frontend/src/utils/homeBoot.ts +++ b/frontend/src/utils/homeBoot.ts @@ -15,6 +15,8 @@ import type { SitePageSummary, TagCount, User, + UserActivityStats, + UserPublic, } from '../api/types'; import { seedSiteBrandingCache } from '../hooks/useSiteBranding'; import { seedForumLimitsCache } from '../hooks/useForumLimits'; @@ -117,6 +119,9 @@ export type PostDetailSnapshot = { bountyCanRefund: boolean; bountyRefundBlockReason: string; bountyEligibleReplyCount: number; + /** 作者主页资料(右栏作者卡 SSR/预热首帧直接渲染,缺省由卡片自行拉取) */ + authorProfile: UserPublic | null; + authorStats: UserActivityStats | null; scrollTop: number; }; @@ -125,7 +130,11 @@ export function postDetailCacheKey(id: number): string { } /** 由帖子详情 API 响应构建会话快照(客户端 prefetch 与服务端 +data 共用) */ -export function buildPostDetailSnapshot(detail: PostDetailResponse, comments: Comment[]): PostDetailSnapshot { +export function buildPostDetailSnapshot( + detail: PostDetailResponse, + comments: Comment[], + author?: { user: UserPublic; stats?: UserActivityStats | null } | null, +): PostDetailSnapshot { return { post: detail.post, comments: Array.isArray(comments) ? comments : [], @@ -141,6 +150,8 @@ export function buildPostDetailSnapshot(detail: PostDetailResponse, comments: Co bountyCanRefund: detail.bounty_can_refund ?? true, bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '', bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0, + authorProfile: author?.user ?? null, + authorStats: author?.stats ?? null, scrollTop: 0, }; } diff --git a/frontend/src/utils/prefetchRoute.ts b/frontend/src/utils/prefetchRoute.ts index 60a8369..5c084b3 100644 --- a/frontend/src/utils/prefetchRoute.ts +++ b/frontend/src/utils/prefetchRoute.ts @@ -127,7 +127,11 @@ async function prefetchPost(id: number, force: boolean): Promise { api.post(id), api.comments(id, myIds), ]); - const snap = buildPostDetailSnapshot(detail, comm.comments); + // 作者主页资料一并预热(右栏作者卡首帧直接渲染;失败降级为卡片自行拉取) + const author = detail.post.user_id + ? await api.userProfile(detail.post.user_id).catch(() => null) + : null; + const snap = buildPostDetailSnapshot(detail, comm.comments, author); setSessionSnapshot(key, snap); }