feat(ssr): 帖子页全量 SSR——作者卡/目录首帧服务端渲染,消除冗余首屏请求
- 帖子快照携带作者主页资料与统计(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 错误
This commit is contained in:
@@ -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 隔离)
|
||||
|
||||
@@ -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<UserPublic | null>(null);
|
||||
const [stats, setStats] = useState<UserActivityStats | null>(null);
|
||||
const [profile, setProfile] = useState<UserPublic | null>(initialProfile);
|
||||
const [stats, setStats] = useState<UserActivityStats | null>(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 (
|
||||
|
||||
@@ -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}
|
||||
<div className="widget-card widget-card--outline">
|
||||
|
||||
@@ -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<number | null | 'init'>('init');
|
||||
useEffect(() => {
|
||||
|
||||
@@ -61,8 +61,10 @@ function fetchBranding(): Promise<SiteBranding> {
|
||||
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<SiteBranding> {
|
||||
if (!opts?.force && cached) return cached;
|
||||
if (opts?.force) {
|
||||
cached = null;
|
||||
inflight = null;
|
||||
preferCacheOnce = false;
|
||||
}
|
||||
|
||||
@@ -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<TagCount[]>(() => 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<PostOutlineState>(() => {
|
||||
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<PostDetailSnapshot>(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<LayoutCtx>(() => ({
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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<PostDetailSnapshot['authorProfile']>(initialSnap?.authorProfile ?? null);
|
||||
const [authorStats, setAuthorStats] = useState<PostDetailSnapshot['authorStats']>(initialSnap?.authorStats ?? null);
|
||||
|
||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||
const commentBoxRef = useRef<HTMLDivElement>(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 () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
/** 本请求独立的 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 },
|
||||
|
||||
@@ -10,8 +10,106 @@
|
||||
*
|
||||
* DOMPurify 在无 window 环境 isSupported=false,sanitize 原样返回——
|
||||
* SSR 内容均来自 Go API(服务端已消毒),跳过浏览器端二次消毒是安全的。
|
||||
*
|
||||
* 注意 1:linkedom 的 DOMParser 解析「片段 HTML」时不会构造 <html>/<body>,
|
||||
* 首枚元素会被当作 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 外发内容元素输出
|
||||
* 自闭合(<path …/>),而浏览器的 HTML 片段序列化一律输出显式闭合
|
||||
* (<path …></path>,仅 img/br 等 HTML void 元素省略尾标签)。序列化后
|
||||
* 把这类自闭合展开为显式闭合(文本节点已转义,裸 <tag/> 只可能是真实标签)。
|
||||
* 已知限制: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' && !/<html[\s>]/i.test(markup)) {
|
||||
markup = `<!DOCTYPE html><html><head></head><body>${markup}</body></html>`;
|
||||
}
|
||||
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<symbol, string[] | undefined>;
|
||||
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<symbol, string[] | undefined>;
|
||||
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)元素序列化为自闭合 <tag …/>,
|
||||
* 浏览器的 HTML 片段序列化一律输出显式闭合 <tag …></tag>。读取 innerHTML/
|
||||
* outerHTML 后展开(文本节点中 < 已转义为 <,裸 <tag/> 只可能是真实标签)。
|
||||
*/
|
||||
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+$/, '')}></${name}>`,
|
||||
);
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
@@ -20,14 +118,65 @@ export function installServerDom(): void {
|
||||
installed = true;
|
||||
|
||||
const g = globalThis as unknown as Record<string, unknown>;
|
||||
const parser = new LinkedomDOMParser();
|
||||
const doc = parser.parseFromString('...', 'text/html');
|
||||
const parser = new ServerDOMParser();
|
||||
const doc = parser.parseFromString('<!DOCTYPE html><html><head></head><body></body></html>', '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<symbol, unknown>;
|
||||
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<symbol, Element | undefined>, ...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<string, unknown>;
|
||||
(['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,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,7 +127,11 @@ async function prefetchPost(id: number, force: boolean): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user