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>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,6 @@ import { loadMyCommentIds } from '../utils/guest';
|
||||
import { clearAllFeedCache, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
|
||||
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
|
||||
import { deleteSessionSnapshot, getSessionSnapshot, setSessionSnapshot } from '../utils/sessionPageCache';
|
||||
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
||||
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
|
||||
@@ -79,7 +78,6 @@ type PostDetailSnapshot = {
|
||||
bountyCanRefund: boolean;
|
||||
bountyRefundBlockReason: string;
|
||||
bountyEligibleReplyCount: number;
|
||||
scrollTop: number;
|
||||
};
|
||||
|
||||
function postDetailCacheKey(id: number) {
|
||||
@@ -145,24 +143,15 @@ 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 [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => {
|
||||
if (!initialSnap) return null;
|
||||
if (navType === 'POP' && !location.hash) return initialSnap.scrollTop;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||
const commentBoxRef = useRef<HTMLDivElement>(null);
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const postRef = useRef<PostItem | null>(null);
|
||||
const scrollTopRef = useRef(
|
||||
initialSnap && navType === 'POP' && !location.hash ? initialSnap.scrollTop : 0,
|
||||
);
|
||||
postRef.current = post;
|
||||
|
||||
useGlobalWheelScroll(pageRef, !loading && !!post);
|
||||
// 滚动位置完全交给浏览器原生历史恢复(详情页为文档滚动,见 .app-shell--doc-scroll),
|
||||
// 会话快照只缓存数据让 POP 后退时内容即时渲染、文档即刻够高,原生恢复才有正确落点。
|
||||
|
||||
// SPA 内跳转时纠正非规范伪静态路径
|
||||
useEffect(() => {
|
||||
@@ -212,7 +201,7 @@ export default function PostDetailPage() {
|
||||
}
|
||||
setPostOutline({
|
||||
headings,
|
||||
scrollRoot: pageRef.current,
|
||||
scrollRoot: null,
|
||||
title: '文章目录',
|
||||
author: post.user ?? null,
|
||||
publishedAt: post.created_at,
|
||||
@@ -238,15 +227,9 @@ export default function PostDetailPage() {
|
||||
setBountyRefundBlockReason(snap.bountyRefundBlockReason);
|
||||
setBountyEligibleReplyCount(snap.bountyEligibleReplyCount);
|
||||
setComments(snap.comments);
|
||||
if (opts?.restoreScroll) {
|
||||
scrollTopRef.current = snap.scrollTop;
|
||||
setRestoreScrollTop(snap.scrollTop);
|
||||
} else {
|
||||
scrollTopRef.current = 0;
|
||||
// 同组件换帖时容器可能仍停在上一帖位置;初次进入已是 0 则不动,避免覆盖 #floor-N
|
||||
const el = pageRef.current;
|
||||
if (el && el.scrollTop !== 0) setRestoreScrollTop(0);
|
||||
}
|
||||
// POP(后退/前进)不干预,交给浏览器原生恢复;
|
||||
// PUSH 换帖 / 强制刷新是「换新内容」场景,文档滚动置顶
|
||||
if (!opts?.restoreScroll) window.scrollTo(0, 0);
|
||||
}, []);
|
||||
|
||||
const loadPostRef = useRef<(mode: 'auto' | 'force') => void>(() => {});
|
||||
@@ -304,15 +287,9 @@ export default function PostDetailPage() {
|
||||
bountyCanRefund: detail.bounty_can_refund ?? true,
|
||||
bountyRefundBlockReason: detail.bounty_refund_block_reason ?? '',
|
||||
bountyEligibleReplyCount: detail.bounty_eligible_reply_count ?? 0,
|
||||
scrollTop: 0,
|
||||
};
|
||||
setSessionSnapshot(postDetailCacheKey(postId), snap);
|
||||
applySnapshot(snap, { restoreScroll: false });
|
||||
if (mode === 'force') {
|
||||
const el = pageRef.current;
|
||||
if (el) el.scrollTop = 0;
|
||||
scrollTopRef.current = 0;
|
||||
}
|
||||
void refresh();
|
||||
} catch {
|
||||
if (seq !== loadSeq.current) return;
|
||||
@@ -342,23 +319,6 @@ export default function PostDetailPage() {
|
||||
};
|
||||
}, [postId, applySnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = pageRef.current;
|
||||
if (!el || loading || !post) return;
|
||||
const onScroll = () => {
|
||||
scrollTopRef.current = el.scrollTop;
|
||||
};
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [loading, post]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (restoreScrollTop == null || !pageRef.current || loading || !post) return;
|
||||
pageRef.current.scrollTop = restoreScrollTop;
|
||||
scrollTopRef.current = restoreScrollTop;
|
||||
setRestoreScrollTop(null);
|
||||
}, [restoreScrollTop, loading, post]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!post || post.id !== postId || loading) return;
|
||||
setSessionSnapshot(postDetailCacheKey(post.id), {
|
||||
@@ -375,7 +335,6 @@ export default function PostDetailPage() {
|
||||
bountyCanRefund,
|
||||
bountyRefundBlockReason,
|
||||
bountyEligibleReplyCount,
|
||||
scrollTop: scrollTopRef.current,
|
||||
});
|
||||
}, [
|
||||
post, comments, poll, lottery, liked, favorited, canEdit, isEdited,
|
||||
@@ -383,14 +342,6 @@ export default function PostDetailPage() {
|
||||
bountyEligibleReplyCount, postId, loading,
|
||||
]);
|
||||
|
||||
useEffect(() => () => {
|
||||
const p = postRef.current;
|
||||
if (!p) return;
|
||||
const prev = getSessionSnapshot<PostDetailSnapshot>(postDetailCacheKey(p.id));
|
||||
if (!prev) return;
|
||||
setSessionSnapshot(postDetailCacheKey(p.id), { ...prev, scrollTop: scrollTopRef.current });
|
||||
}, []);
|
||||
|
||||
const reloadComments = useCallback(async () => {
|
||||
const myIds = user ? [] : loadMyCommentIds();
|
||||
const comm = await api.comments(postId, myIds);
|
||||
@@ -460,102 +411,55 @@ export default function PostDetailPage() {
|
||||
return () => root.removeEventListener('focusout', onFocusOut);
|
||||
}, [isMobile, composerOpen]);
|
||||
|
||||
/**
|
||||
* 详情页为窗口滚动:scroll-margin-top 预留 sticky 顶栏空间。
|
||||
* 首屏图片/字体加载会持续改变布局,落点需短暂校正;用户手动滚动立即让位。
|
||||
*/
|
||||
const scrollAnchorIntoView = useCallback((el: HTMLElement) => {
|
||||
el.scrollIntoView({ block: 'start' });
|
||||
|
||||
let interrupted = false;
|
||||
const yieldToUser = () => { interrupted = true; };
|
||||
window.addEventListener('wheel', yieldToUser, { passive: true });
|
||||
window.addEventListener('touchmove', yieldToUser, { passive: true });
|
||||
|
||||
const start = Date.now();
|
||||
(function correct() {
|
||||
window.removeEventListener('wheel', yieldToUser);
|
||||
window.removeEventListener('touchmove', yieldToUser);
|
||||
if (interrupted || !el.isConnected || Date.now() - start > 1500) return;
|
||||
const margin = parseFloat(getComputedStyle(el).scrollMarginTop) || 0;
|
||||
if (Math.abs(el.getBoundingClientRect().top - margin) > 4) {
|
||||
el.scrollIntoView({ block: 'start' });
|
||||
}
|
||||
window.addEventListener('wheel', yieldToUser, { passive: true });
|
||||
window.addEventListener('touchmove', yieldToUser, { passive: true });
|
||||
window.setTimeout(correct, 120);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const jumpToFloor = useCallback((floor: number) => {
|
||||
const el = document.getElementById(`floor-${floor}`);
|
||||
if (!el) return false;
|
||||
|
||||
const root = pageRef.current;
|
||||
if (!root) return false;
|
||||
|
||||
// 直接计算目标滚动位置
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const relativeTop = elRect.top - rootRect.top;
|
||||
const initialMaxScrollTop = root.scrollHeight - root.clientHeight;
|
||||
const targetTop = root.scrollTop + relativeTop - 24;
|
||||
|
||||
// 如果 relativeTop 非常异常(如 NaN 或 Infinity),说明布局可能还没完成
|
||||
if (!isFinite(targetTop) || targetTop < -500) return false;
|
||||
|
||||
const clampedTop = Math.max(0, Math.min(targetTop, initialMaxScrollTop));
|
||||
|
||||
// 直接设置滚动位置(不使用平滑滚动,避免动画干扰)
|
||||
root.scrollTop = clampedTop;
|
||||
|
||||
// 验证滚动结果,如果不准确则再次调整
|
||||
// 注意:使用动态计算的 currentMaxScrollTop,因为评论可能在之后才渲染完成
|
||||
const verifyAndFix = () => {
|
||||
const currentElRect = el.getBoundingClientRect();
|
||||
const currentRootRect = root.getBoundingClientRect();
|
||||
const currentRelativeTop = currentElRect.top - currentRootRect.top;
|
||||
const currentMaxScrollTop = root.scrollHeight - root.clientHeight;
|
||||
|
||||
// 如果元素在视口内但位置不准确,进行修正
|
||||
if (root.scrollTop > 0 && Math.abs(currentRelativeTop - 24) > 10) {
|
||||
const correction = currentRelativeTop - 24;
|
||||
const newTarget = Math.max(0, Math.min(root.scrollTop + correction, currentMaxScrollTop));
|
||||
root.scrollTop = newTarget;
|
||||
}
|
||||
};
|
||||
|
||||
// 在多个时间点验证和调整
|
||||
requestAnimationFrame(verifyAndFix);
|
||||
setTimeout(verifyAndFix, 50);
|
||||
setTimeout(verifyAndFix, 150);
|
||||
scrollAnchorIntoView(el);
|
||||
|
||||
setHighlightFloor(floor);
|
||||
clearTimeout(highlightTimer.current);
|
||||
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
||||
return true;
|
||||
}, []);
|
||||
}, [scrollAnchorIntoView]);
|
||||
|
||||
/** 正文标题锚点:滚动容器是 pageRef,原生 hash 定位无效,需手动滚 */
|
||||
const jumpToHeadingHash = useCallback((hash: string, smooth = false) => {
|
||||
/** 正文标题锚点:等正文进 DOM 后由调用方重试 */
|
||||
const jumpToHeadingHash = useCallback((hash: string) => {
|
||||
const id = decodeURIComponent((hash || '').replace(/^#/, '')).trim();
|
||||
if (!id || /^floor-\d+$/.test(id)) return false;
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return false;
|
||||
|
||||
const root = pageRef.current;
|
||||
if (!root) return false;
|
||||
|
||||
// 直接计算目标滚动位置
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const relativeTop = elRect.top - rootRect.top;
|
||||
const initialMaxScrollTop = root.scrollHeight - root.clientHeight;
|
||||
const targetTop = root.scrollTop + relativeTop - 12;
|
||||
|
||||
// 如果 relativeTop 非常异常(如 NaN 或 Infinity),说明布局可能还没完成
|
||||
if (!isFinite(targetTop) || targetTop < -500) return false;
|
||||
|
||||
const clampedTop = Math.max(0, Math.min(targetTop, initialMaxScrollTop));
|
||||
|
||||
// 直接设置滚动位置
|
||||
root.scrollTop = clampedTop;
|
||||
|
||||
// 验证滚动结果,如果不准确则再次调整
|
||||
const verifyAndFix = () => {
|
||||
const currentElRect = el.getBoundingClientRect();
|
||||
const currentRootRect = root.getBoundingClientRect();
|
||||
const currentRelativeTop = currentElRect.top - currentRootRect.top;
|
||||
const currentMaxScrollTop = root.scrollHeight - root.clientHeight;
|
||||
|
||||
if (root.scrollTop > 0 && Math.abs(currentRelativeTop - 12) > 10) {
|
||||
const correction = currentRelativeTop - 12;
|
||||
const newTarget = Math.max(0, Math.min(root.scrollTop + correction, currentMaxScrollTop));
|
||||
root.scrollTop = newTarget;
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(verifyAndFix);
|
||||
if (smooth) {
|
||||
setTimeout(verifyAndFix, 50);
|
||||
setTimeout(verifyAndFix, 150);
|
||||
}
|
||||
|
||||
scrollAnchorIntoView(el);
|
||||
return true;
|
||||
}, []);
|
||||
}, [scrollAnchorIntoView]);
|
||||
|
||||
// 从 #floor-N 定位到对应评论(右栏最新评论等入口);等评论进 DOM 后重试,避免首屏 hash 失效
|
||||
useEffect(() => {
|
||||
@@ -597,7 +501,7 @@ export default function PostDetailPage() {
|
||||
|
||||
const tryJump = () => {
|
||||
if (cancelled) return;
|
||||
if (jumpToHeadingHash(hash, false)) return;
|
||||
if (jumpToHeadingHash(hash)) return;
|
||||
attempts += 1;
|
||||
if (attempts < 30) {
|
||||
timer = window.setTimeout(tryJump, 50);
|
||||
@@ -946,7 +850,7 @@ export default function PostDetailPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="page-wrap post-detail-page" ref={pageRef}>
|
||||
<article className="page-wrap post-detail-page">
|
||||
<div className="post-detail-header">
|
||||
<div className="post-detail-nav">
|
||||
<div className="post-detail-nav-left">
|
||||
@@ -1083,7 +987,6 @@ export default function PostDetailPage() {
|
||||
<summary>文章目录({headings.length})</summary>
|
||||
<ArticleOutline
|
||||
headings={headings}
|
||||
scrollRoot={pageRef.current}
|
||||
title="目录"
|
||||
/>
|
||||
</details>
|
||||
|
||||
Reference in New Issue
Block a user