feat(ssr): 真 SSR 收尾——干净 hydration 与浏览器原生滚动恢复
- 修复 onRenderClient 引入 serverClient 导致浏览器 process is not defined: 抽出双端安全的 ssr/feedSeed.ts 作为唯一数据播种入口,serverClient 加 SSR 守卫 - 修复 React 19 Fizz Suspense Outlining:Suspense 边界从布局 Outlet 下移到 各懒加载路由,SSR 输出不再出现 <!--$?--> / $RB / template 探针标记 - 修复窄屏 React #418 hydration 致命错误:useMediaQuery 首帧恒 false, useLayoutEffect 绘制前同步真实值;主题改 cookie 存储供 SSR 直出, head 内联防闪烁/缩放脚本移除(缩放锁改为客户端 zoomGuard 模块) - 清理假 SSR 残留:删除 StaticFeedList 镜像组件、SSR 图标生成脚本、 探针与死代码;列表接口裁剪正文,仅下发 excerpt/first_image - Feed 与帖子详情改为窗口滚动(app-shell--doc-scroll):删除虚拟列表、 自研滚动快照与全局滚轮 hack;F5/前进后退滚动位置全部交给浏览器原生 历史恢复,HTML 响应头 no-cache 配合;锚点/楼层用 scrollIntoView + scroll-margin-top 适配 sticky 顶栏
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, startTransition as reactStartTransition } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react';
|
||||
import {
|
||||
useNavigate,
|
||||
useOutletContext,
|
||||
@@ -7,17 +7,22 @@ import {
|
||||
useParams,
|
||||
useNavigationType,
|
||||
} from 'react-router';
|
||||
import { Inbox, SearchX } from 'lucide-react';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import VirtualPostList from '../components/VirtualPostList';
|
||||
import StaticFeedList from '../components/StaticFeedList';
|
||||
import PostListItem from '../components/PostListItem';
|
||||
import FeedPagination from '../components/FeedPagination';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
import FeedHeader from '../components/FeedHeader';
|
||||
import FeedSearchFilters from '../components/search/FeedSearchFilters';
|
||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
|
||||
import { parseSearchFromUrl, usePostSearch, dispatchOpenPostSearch } from '../hooks/usePostSearch';
|
||||
import {
|
||||
navigateFeed,
|
||||
FEED_RESET_EVENT,
|
||||
@@ -29,7 +34,6 @@ import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
|
||||
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { startTransition, doneTransition } from '../utils/spaTransition';
|
||||
import { isHomeHydrating } from '../utils/homeHydrate';
|
||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
|
||||
@@ -49,7 +53,6 @@ type FeedHydrate = {
|
||||
posts: PostItem[];
|
||||
postTotal: number;
|
||||
page: number;
|
||||
scrollTop: number;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
@@ -69,11 +72,10 @@ function readHydrate(
|
||||
posts: cached.posts,
|
||||
postTotal: cached.postTotal,
|
||||
page: cached.page,
|
||||
scrollTop: cached.scrollTop,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
return { posts: [], postTotal: 0, page: 1, scrollTop: 0, loading: true };
|
||||
return { posts: [], postTotal: 0, page: 1, loading: true };
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -83,6 +85,7 @@ export default function HomePage() {
|
||||
const { id: boardRouteId } = useParams();
|
||||
const [params] = useSearchParams();
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const { user } = useAuth();
|
||||
const { branding } = useSiteBranding();
|
||||
const { limits, loading: limitsLoading } = useForumLimits();
|
||||
const postSearch = usePostSearch(limits);
|
||||
@@ -187,25 +190,14 @@ export default function HomePage() {
|
||||
titleOnly,
|
||||
});
|
||||
const [listPending, setListPending] = useState(false);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(
|
||||
initial.posts.length > 0 ? initial.scrollTop : null,
|
||||
);
|
||||
/** 翻页 / 切筛选 / 强制刷新后置顶信号(由布局 effect 消费) */
|
||||
const [listResetKey, setListResetKey] = useState(0);
|
||||
/** hydrate 首帧用静态列表;完成后升级 VirtualPostList */
|
||||
const [useVirtualList, setUseVirtualList] = useState(() => !isHomeHydrating());
|
||||
|
||||
useEffect(() => {
|
||||
if (useVirtualList) return;
|
||||
reactStartTransition(() => setUseVirtualList(true));
|
||||
}, [useVirtualList]);
|
||||
|
||||
const scrollTopRef = useRef(initial.scrollTop);
|
||||
const fetchSeqRef = useRef(0);
|
||||
const pageRef = useRef(initial.page);
|
||||
const cacheKeyRef = useRef(cacheKey);
|
||||
const viewKeyRef = useRef(view.cacheKey);
|
||||
const postsRef = useRef(posts);
|
||||
const scrollRafRef = useRef(0);
|
||||
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop) */
|
||||
const hydratedKeyRef = useRef<string | null>(null);
|
||||
/** 强制刷新已发起 loadFirst:消费 refreshFeed 后 effect 再跑时勿因空缓存重复请求 */
|
||||
@@ -223,8 +215,6 @@ export default function HomePage() {
|
||||
setPostTotal(next.postTotal);
|
||||
setPage(next.page);
|
||||
pageRef.current = next.page;
|
||||
scrollTopRef.current = next.scrollTop;
|
||||
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
|
||||
setLoading(next.loading);
|
||||
setListPending(false);
|
||||
setView(meta);
|
||||
@@ -254,10 +244,10 @@ export default function HomePage() {
|
||||
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||
const showPagination = totalPages > 1 && posts.length > 0;
|
||||
const hasMore = page < totalPages;
|
||||
// 首帧静态列表与 VirtualPostList 同规则:无更多页、无分页、有帖子且非加载中时显示结束提示
|
||||
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
|
||||
|
||||
const resetFeedView = useCallback(() => {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
setListResetKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
@@ -266,12 +256,12 @@ export default function HomePage() {
|
||||
resetFeedView();
|
||||
}, [resetFeedView]);
|
||||
|
||||
/** 把当前列表写入 Zustand(滚动位置用 ref,避免闭包过期) */
|
||||
/** 把当前列表写入 Zustand */
|
||||
const persistFeed = useCallback((
|
||||
nextPosts: PostItem[],
|
||||
nextTotal: number,
|
||||
nextPage: number,
|
||||
opts?: { scrollTop?: number; touchFetchTime?: boolean; key?: string },
|
||||
opts?: { touchFetchTime?: boolean; key?: string },
|
||||
) => {
|
||||
const key = opts?.key ?? cacheKeyRef.current;
|
||||
const prev = getHomeStoreState().getFeed(key);
|
||||
@@ -279,7 +269,6 @@ export default function HomePage() {
|
||||
posts: nextPosts,
|
||||
postTotal: nextTotal,
|
||||
page: nextPage,
|
||||
scrollTop: opts?.scrollTop ?? scrollTopRef.current,
|
||||
lastFetchTime: opts?.touchFetchTime === false
|
||||
? (prev?.lastFetchTime ?? Date.now())
|
||||
: Date.now(),
|
||||
@@ -309,10 +298,7 @@ export default function HomePage() {
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
const total = data.total ?? 0;
|
||||
const jumpTop = opts?.resetScroll || (keepView && requestKey !== viewKeyRef.current);
|
||||
const scrollTop = jumpTop ? 0 : scrollTopRef.current;
|
||||
if (jumpTop) {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
setListResetKey(k => k + 1);
|
||||
}
|
||||
setPosts(batch);
|
||||
@@ -320,7 +306,7 @@ export default function HomePage() {
|
||||
setPage(p);
|
||||
pageRef.current = p;
|
||||
setView({ cacheKey: requestKey, sort, boardId, keyword, tag, author, titleOnly });
|
||||
persistFeed(batch, total, p, { scrollTop, touchFetchTime: true, key: requestKey });
|
||||
persistFeed(batch, total, p, { touchFetchTime: true, key: requestKey });
|
||||
} catch (e: unknown) {
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
if (!silent) {
|
||||
@@ -352,8 +338,6 @@ export default function HomePage() {
|
||||
}, [fetchPage, postTotal, pageSize]);
|
||||
|
||||
const handleSelectPost = useCallback((id: number) => {
|
||||
// 离开前再写一次滚动,确保详情页返回可还原
|
||||
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
|
||||
openForumPost(nav, id, limits.open_posts_in_new_tab);
|
||||
}, [nav, limits.open_posts_in_new_tab]);
|
||||
|
||||
@@ -399,7 +383,6 @@ export default function HomePage() {
|
||||
posts: cached.posts,
|
||||
postTotal: cached.postTotal,
|
||||
page: cached.page,
|
||||
scrollTop: cached.scrollTop,
|
||||
loading: false,
|
||||
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
|
||||
} else {
|
||||
@@ -415,10 +398,6 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
hydratedKeyRef.current = cacheKey;
|
||||
if (postsRef.current.length === 0) {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
}
|
||||
loadFirst();
|
||||
}, [
|
||||
limitsLoading,
|
||||
@@ -466,7 +445,6 @@ export default function HomePage() {
|
||||
posts: warm.posts,
|
||||
postTotal: warm.postTotal,
|
||||
page: warm.page,
|
||||
scrollTop: 0,
|
||||
loading: false,
|
||||
}, { cacheKey: key, sort, boardId, keyword, tag, author, titleOnly });
|
||||
setListResetKey((k) => k + 1);
|
||||
@@ -495,20 +473,21 @@ export default function HomePage() {
|
||||
sort, boardId, keyword, tag, author, titleOnly,
|
||||
]);
|
||||
|
||||
// 卸载时取消未执行的 scroll rAF
|
||||
useEffect(() => () => {
|
||||
if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current);
|
||||
}, []);
|
||||
// 滚动位置完全交给浏览器原生历史恢复(feed 为文档滚动,见 .app-shell--doc-scroll):
|
||||
// - F5 整页刷新:SSR 输出静态 DOM + 响应头 no-cache,浏览器按历史记录恢复窗口滚动;
|
||||
// - SPA 前进/后退:列表 DOM 在 SSR/hydrate/客户端全程同构,浏览器同样自动恢复。
|
||||
// 仅翻页 / 切筛选 / 强制刷新这类「换新内容」场景主动置顶。
|
||||
useLayoutEffect(() => {
|
||||
if (listResetKey <= 0) return;
|
||||
window.scrollTo(0, 0);
|
||||
}, [listResetKey]);
|
||||
|
||||
const handleScrollTopChange = useCallback((top: number) => {
|
||||
scrollTopRef.current = top;
|
||||
// rAF 节流写入 store,避免每个 scroll 事件都触发订阅者
|
||||
if (scrollRafRef.current) return;
|
||||
scrollRafRef.current = requestAnimationFrame(() => {
|
||||
scrollRafRef.current = 0;
|
||||
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
|
||||
});
|
||||
}, []);
|
||||
// PUSH 进入新筛选(切板块 / 搜索 / 标签):文档滚动置顶;
|
||||
// POP(F5 初始载入、后退前进)不干预,交给浏览器原生恢复。
|
||||
useEffect(() => {
|
||||
if (navType === 'POP') return;
|
||||
window.scrollTo(0, 0);
|
||||
}, [cacheKey, navType]);
|
||||
|
||||
const handleSortChange = (next: FeedSort) => {
|
||||
const url = buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits });
|
||||
@@ -527,6 +506,32 @@ export default function HomePage() {
|
||||
const searchFilters = parseSearchFromUrl(location.pathname, params);
|
||||
const isSearchActive = !!(view.keyword || view.author);
|
||||
|
||||
// 空态(加载中且无列表时已在上方 return null,此处只处理真空态)
|
||||
const viewBoardName = ctx?.boards?.find(b => b.id === view.boardId)?.name || '';
|
||||
const noBoards = !ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0;
|
||||
const isSearchEmpty = posts.length === 0 && !loading
|
||||
&& (isSearchActive || !!view.tag || !!(view.keyword || '').trim());
|
||||
const composeTarget = view.boardId > 0 ? `/compose?board=${view.boardId}` : '/compose';
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const searchSummaryParts: string[] = [];
|
||||
const kw = (view.keyword || view.tag || '').trim();
|
||||
if (kw) searchSummaryParts.push(`关键词「${kw}」`);
|
||||
if (view.author?.trim()) searchSummaryParts.push(`作者「${view.author.trim()}」`);
|
||||
if (view.titleOnly) searchSummaryParts.push('仅标题');
|
||||
if (searchFilters.scopeBoardId) {
|
||||
const bn = ctx?.boards?.find(b => b.id === searchFilters.scopeBoardId)?.name || '';
|
||||
if (bn) searchSummaryParts.push(`板块「${bn}」`);
|
||||
}
|
||||
const emptyHint = noBoards
|
||||
? (isAdmin
|
||||
? '网站还没有任何板块。你可以创建第一个板块,给帖子们一个归属。'
|
||||
: '网站刚刚创建,管理员还没有添加板块。稍后再来看看吧。')
|
||||
: isSearchEmpty
|
||||
? `${searchSummaryParts.join(' · ')}${searchSummaryParts.length ? '。' : ''}换个关键词试试,或清除当前筛选条件。`
|
||||
: (view.boardId > 0
|
||||
? `板块「${viewBoardName}」还没有帖子。看看其他板块,或发表这个板块的第一帖。`
|
||||
: '成为第一个发帖的人,开始讨论吧。');
|
||||
|
||||
if (isInvalidBoardRoute || isMissingBoard) {
|
||||
return (
|
||||
<NotFoundPage
|
||||
@@ -546,25 +551,13 @@ export default function HomePage() {
|
||||
<div className="feed-panel">
|
||||
<div className="feed-top">
|
||||
<div className="feed-top__bar">
|
||||
{!useVirtualList ? (
|
||||
(view.keyword || view.tag || view.author) ? (
|
||||
<h1 className="feed-header-title">
|
||||
{view.tag
|
||||
? `#${view.tag}`
|
||||
: view.keyword
|
||||
? `搜索:${view.keyword}`
|
||||
: `作者:${view.author}`}
|
||||
</h1>
|
||||
) : null
|
||||
) : (
|
||||
<FeedHeader
|
||||
keyword={view.keyword}
|
||||
tag={view.tag}
|
||||
author={view.author}
|
||||
postTotal={postTotal}
|
||||
titleAs={isSiteHome ? 'h2' : 'h1'}
|
||||
/>
|
||||
)}
|
||||
<FeedHeader
|
||||
keyword={view.keyword}
|
||||
tag={view.tag}
|
||||
author={view.author}
|
||||
postTotal={postTotal}
|
||||
titleAs={isSiteHome ? 'h2' : 'h1'}
|
||||
/>
|
||||
{showSortBar && (
|
||||
<FeedSortBar
|
||||
value={view.sort}
|
||||
@@ -583,36 +576,79 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!useVirtualList ? (
|
||||
<StaticFeedList posts={posts} sort={view.sort} boardId={view.boardId} />
|
||||
) : (
|
||||
<VirtualPostList
|
||||
posts={posts}
|
||||
sort={view.sort}
|
||||
loading={listPending ? false : (loading || limitsLoading)}
|
||||
hasMore={hasMore}
|
||||
showPagination={showPagination}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
postTotal={postTotal}
|
||||
onPageChange={goToPage}
|
||||
onSelect={handleSelectPost}
|
||||
restoreScrollTop={restoreScrollTop}
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={handleScrollTopChange}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
keyword={view.keyword || view.tag || view.author}
|
||||
isSearchMode={!!(view.keyword || view.author)}
|
||||
searchKeyword={view.keyword}
|
||||
searchAuthor={view.author}
|
||||
searchTitleOnly={view.titleOnly}
|
||||
searchScopeBoardId={searchFilters.scopeBoardId}
|
||||
onClearSearch={postSearch.clearSearch}
|
||||
boardId={view.boardId}
|
||||
boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''}
|
||||
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
|
||||
/>
|
||||
)}
|
||||
{/*
|
||||
列表始终静态流式渲染:SSR 输出与 hydration 后 DOM 完全一致(不包 Suspense、
|
||||
不切虚拟列表),使浏览器原生 scroll restoration 在 F5 后直接生效,
|
||||
也避免「JS 执行后重渲染内容区」被 SEO 工具误判为 CSR。
|
||||
*/}
|
||||
<div className="post-list-scroll post-list-scroll--ssr">
|
||||
{posts.length === 0 ? (
|
||||
<div className="empty-feed" role="status">
|
||||
{isSearchEmpty
|
||||
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
: <Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />}
|
||||
<p>{noBoards ? '暂无板块' : isSearchEmpty ? '没有匹配的帖子' : '暂无帖子'}</p>
|
||||
<p className="empty-feed-hint">{emptyHint}</p>
|
||||
<div className="empty-feed-actions">
|
||||
{noBoards ? (
|
||||
isAdmin ? (
|
||||
<Button variant="default" onClick={() => nav('/admin#boards')}>去创建板块</Button>
|
||||
) : (
|
||||
<Button variant="ghost" onClick={() => nav('/')}>刷新看看</Button>
|
||||
)
|
||||
) : isSearchEmpty ? (
|
||||
<>
|
||||
<Button variant="default" onClick={() => dispatchOpenPostSearch()}>修改搜索</Button>
|
||||
<Button variant="ghost" onClick={postSearch.clearSearch}>清除筛选条件</Button>
|
||||
<Button variant="ghost" onClick={() => nav('/')}>返回全部帖子</Button>
|
||||
</>
|
||||
) : view.boardId > 0 ? (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => nav('/')}>看看其他板块</Button>
|
||||
{user ? (
|
||||
<Button variant="default" onClick={() => nav(composeTarget)}>发第一帖</Button>
|
||||
) : (
|
||||
<Button variant="default" onClick={() => nav(loginPath(composeTarget))}>登录后发帖</Button>
|
||||
)}
|
||||
</>
|
||||
) : user ? (
|
||||
<Button variant="default" onClick={() => nav(composeTarget)}>发第一帖</Button>
|
||||
) : (
|
||||
<Button variant="default" onClick={() => nav(loginPath(composeTarget))}>登录后发帖</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="content-surface content-surface--ssr">
|
||||
{posts.map((post) => (
|
||||
<PostListItem
|
||||
key={post.id}
|
||||
post={post}
|
||||
sort={view.sort}
|
||||
boardId={view.boardId}
|
||||
onSelect={handleSelectPost}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{showPagination && (
|
||||
<div className="feed-list-footer feed-list-footer--pagination">
|
||||
<FeedPagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
postTotal={postTotal}
|
||||
loading={loading}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showEnd && (
|
||||
<div className="feed-list-footer feed-list-footer--end">— 已加载全部 —</div>
|
||||
)}
|
||||
<InFlowSiteFooter />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user