fix: 软刷新齐套前保留旧画面,避免一点击就卸光

Logo/下拉静默预热后一次覆盖;commit 不再先 reset/loading;顺带会话预取与可配置 Feed 排序标签。
This commit is contained in:
2026-09-01 04:56:10 +08:00
parent ba60a9b1c4
commit 9e134916a4
47 changed files with 2446 additions and 636 deletions

View File

@@ -14,19 +14,20 @@ import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList';
import FeedHeader from '../components/FeedHeader';
import FeedSearchFilters from '../components/search/FeedSearchFilters';
import FeedPageSkeleton from '../components/FeedPageSkeleton';
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
import { useForumLimits } from '../hooks/useForumLimits';
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
import {
clearAllFeedCache,
navigateFeed,
FEED_RESET_EVENT,
FEED_PULL_REFRESH_EVENT,
type FeedNavState,
} from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { enabledFeedSortTabs, getDefaultFeedSort } from '../utils/feedSortTabs';
import { openForumPost } from '../utils/openPost';
import { startTransition, doneTransition } from '../utils/spaTransition';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
@@ -95,7 +96,8 @@ export default function HomePage() {
const tag = params.get('tag') || '';
const author = params.get('author') || '';
const titleOnly = params.get('title_only') === '1';
const sort = parseFeedSort(params.get('sort'));
const rawSort = params.get('sort');
const sort = parseFeedSort(rawSort, limits.feed_sort_tabs);
const board = (ctx?.boards ?? []).find(b => b.id === boardId);
const isSiteHome = !boardId && !keyword && !tag && !author;
const siteIntro = siteMetaDescription(branding);
@@ -135,6 +137,27 @@ export default function HomePage() {
}
}, [queryBoardId, boardId, boardRouteId, params, nav, limits, location.pathname, location.search]);
// URL 上的 sort 已被后台关闭时,纠正为默认排序
useEffect(() => {
if (limitsLoading) return;
if (rawSort !== 'latest' && rawSort !== 'hot' && rawSort !== 'reply') return;
const stillOn = enabledFeedSortTabs(limits.feed_sort_tabs).some(t => t.id === rawSort);
if (stillOn) return;
const def = getDefaultFeedSort(limits.feed_sort_tabs);
nav(buildHomeUrl(boardId, def, { keyword, tag, author, titleOnly, permalink: limits }), { replace: true });
}, [
limitsLoading,
limits.feed_sort_tabs,
limits,
rawSort,
boardId,
keyword,
tag,
author,
titleOnly,
nav,
]);
const cacheKey = useMemo(
() => feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly }),
[boardId, keyword, sort, tag, author, titleOnly],
@@ -151,25 +174,40 @@ export default function HomePage() {
const [postTotal, setPostTotal] = useState(initial.postTotal);
const [page, setPage] = useState(initial.page);
const [loading, setLoading] = useState(initial.loading);
/** 画面上已提交的筛选URL 已变但数据未到时仍画上一份) */
const [view, setView] = useState({
cacheKey,
sort,
boardId,
keyword,
tag,
author,
titleOnly,
});
const [listPending, setListPending] = useState(false);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(
initial.posts.length > 0 ? initial.scrollTop : null,
);
const [listResetKey, setListResetKey] = useState(0);
const scrollTopRef = useRef(initial.scrollTop);
const loadingRef = useRef(false);
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);
pageRef.current = page;
cacheKeyRef.current = cacheKey;
viewKeyRef.current = view.cacheKey;
postsRef.current = posts;
// 筛选键切换:用新键的缓存重置本地 stateuseMemo initial 不会自动 setState
useEffect(() => {
const next = readHydrate(boardId, keyword, sort, tag, author, titleOnly);
hydratedKeyRef.current = null;
const commitDisplayed = useCallback((
next: FeedHydrate,
meta: { cacheKey: string; sort: FeedSort; boardId: number; keyword: string; tag: string; author: string; titleOnly: boolean },
) => {
setPosts(next.posts);
setPostTotal(next.postTotal);
setPage(next.page);
@@ -177,7 +215,30 @@ export default function HomePage() {
scrollTopRef.current = next.scrollTop;
setRestoreScrollTop(next.posts.length > 0 ? next.scrollTop : null);
setLoading(next.loading);
}, [cacheKey, boardId, keyword, sort, tag, author, titleOnly]);
setListPending(false);
setView(meta);
}, []);
// 筛选键切换:有快照则立刻换页;否则保留当前画面等请求结束
useEffect(() => {
if ((location.state as FeedNavState | null)?.refreshFeed && navType !== 'POP') {
hydratedKeyRef.current = null;
return;
}
const next = readHydrate(boardId, keyword, sort, tag, author, titleOnly);
if (next.posts.length > 0) {
hydratedKeyRef.current = cacheKey;
commitDisplayed(next, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
return;
}
hydratedKeyRef.current = null;
if (postsRef.current.length > 0) {
setListPending(true);
setLoading(false);
return;
}
commitDisplayed(next, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
}, [cacheKey, boardId, keyword, sort, tag, author, titleOnly, commitDisplayed, location.state, navType]);
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
const showPagination = totalPages > 1 && posts.length > 0;
@@ -189,9 +250,8 @@ export default function HomePage() {
setListResetKey(k => k + 1);
}, []);
/** 强制刷新:清空全部 Feed 缓存并滚回顶部 */
/** 同筛选强制刷新:只复位滚动,保留旧列表直到 loadFirst 覆盖 */
const beginFeedRefresh = useCallback(() => {
clearAllFeedCache();
resetFeedView();
}, [resetFeedView]);
@@ -200,9 +260,9 @@ export default function HomePage() {
nextPosts: PostItem[],
nextTotal: number,
nextPage: number,
opts?: { scrollTop?: number; touchFetchTime?: boolean },
opts?: { scrollTop?: number; touchFetchTime?: boolean; key?: string },
) => {
const key = cacheKeyRef.current;
const key = opts?.key ?? cacheKeyRef.current;
const prev = getHomeStoreState().getFeed(key);
getHomeStoreState().setFeed(key, {
posts: nextPosts,
@@ -215,12 +275,14 @@ export default function HomePage() {
});
}, []);
const fetchPage = useCallback(async (p: number, opts?: { silent?: boolean }) => {
if (loadingRef.current) return;
loadingRef.current = true;
const fetchPage = useCallback(async (p: number, opts?: { silent?: boolean; resetScroll?: boolean }) => {
const seq = ++fetchSeqRef.current;
const requestKey = cacheKeyRef.current;
const silent = !!opts?.silent;
// 静默刷新:保留现有列表,不展示加载骨架
if (!silent) setLoading(true);
const keepView = postsRef.current.length > 0;
// 有旧列表时不卸页,只标 pending
if (!silent && !keepView) setLoading(true);
if (!silent && keepView) setListPending(true);
try {
const data = await api.posts({
page: p,
@@ -232,42 +294,53 @@ export default function HomePage() {
title_only: !tag && titleOnly ? '1' : '',
sort,
});
if (seq !== fetchSeqRef.current) return;
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);
setPostTotal(total);
setPage(p);
pageRef.current = p;
persistFeed(batch, total, p, { touchFetchTime: true });
setView({ cacheKey: requestKey, sort, boardId, keyword, tag, author, titleOnly });
persistFeed(batch, total, p, { scrollTop, touchFetchTime: true, key: requestKey });
} catch (e: unknown) {
if (seq !== fetchSeqRef.current) return;
if (!silent) {
notify.error(e instanceof Error ? e.message : '加载失败');
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
if (!keepView) {
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
}
}
} finally {
loadingRef.current = false;
setLoading(false);
if (seq === fetchSeqRef.current) {
setLoading(false);
setListPending(false);
}
}
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]);
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
const goToPage = useCallback((p: number) => {
if (loadingRef.current) return;
const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
if (p < 1 || p > maxPage) return;
if (p === pageRef.current) return;
resetFeedView();
getHomeStoreState().patchScroll(cacheKeyRef.current, 0);
fetchPage(p);
}, [fetchPage, postTotal, pageSize, resetFeedView]);
void fetchPage(p, { resetScroll: true });
}, [fetchPage, postTotal, pageSize]);
const handleSelectPost = useCallback((id: number) => {
// 离开前再写一次滚动,确保详情页返回可还原
getHomeStoreState().patchScroll(cacheKeyRef.current, scrollTopRef.current);
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
openForumPost(nav, id, limits.open_posts_in_new_tab);
}, [nav, limits.open_posts_in_new_tab]);
@@ -278,14 +351,26 @@ export default function HomePage() {
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
// 浏览器后退/前进POP忽略 history 上残留的 refreshFeed避免误清空缓存
if (forceRefresh && navType !== 'POP') {
fetchSeqRef.current += 1;
hydratedKeyRef.current = cacheKey;
beginFeedRefresh();
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
setLoading(true);
loadFirst();
// 预取已写入 store整页替换不卸成骨架
resetFeedView();
const warm = getHomeStoreState().getFeed(cacheKey);
if (warm && warm.posts.length > 0) {
commitDisplayed({
posts: warm.posts,
postTotal: warm.postTotal,
page: warm.page,
scrollTop: 0,
loading: false,
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
} else {
// 预取失败时保留旧列表并重拉
setListPending(postsRef.current.length > 0);
if (postsRef.current.length === 0) setLoading(true);
setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly });
loadFirst();
}
// 消费后清掉 state防止该 history 条目永远带着刷新标记
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
return;
@@ -300,26 +385,31 @@ export default function HomePage() {
if (cached && cached.posts.length > 0) {
const needRestore = hydratedKeyRef.current !== cacheKey;
hydratedKeyRef.current = cacheKey;
setPosts(cached.posts);
setPostTotal(cached.postTotal);
setPage(cached.page);
pageRef.current = cached.page;
setLoading(false);
// 仅在「首次进入该筛选」时恢复滚动,避免 limits/pageSize 变化导致 effect 重跑时打断用户滚动
if (needRestore) {
setRestoreScrollTop(cached.scrollTop);
scrollTopRef.current = cached.scrollTop;
}
// 超过 TTL后台静默刷新不重置滚动
if (getHomeStoreState().isStale(cacheKey)) {
void fetchPage(cached.page, { silent: true });
commitDisplayed({
posts: cached.posts,
postTotal: cached.postTotal,
page: cached.page,
scrollTop: cached.scrollTop,
loading: false,
}, { cacheKey, sort, boardId, keyword, tag, author, titleOnly });
} else {
setPosts(cached.posts);
setPostTotal(cached.postTotal);
setPage(cached.page);
pageRef.current = cached.page;
setLoading(false);
setListPending(false);
setView({ cacheKey, sort, boardId, keyword, tag, author, titleOnly });
}
return;
}
hydratedKeyRef.current = cacheKey;
setRestoreScrollTop(null);
scrollTopRef.current = 0;
if (postsRef.current.length === 0) {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
}
loadFirst();
}, [
limitsLoading,
@@ -333,36 +423,68 @@ export default function HomePage() {
navType,
nav,
loadFirst,
fetchPage,
beginFeedRefresh,
commitDisplayed,
resetFeedView,
isInvalidBoardRoute,
isMissingBoard,
sort,
boardId,
keyword,
tag,
author,
titleOnly,
]);
useEffect(() => {
const onFeedReset = () => beginFeedRefresh();
// Logo 等同 URL 强制刷新:仅复位滚动,不卸列表(数据由 transitionTo 预热)
const onFeedReset = () => {
fetchSeqRef.current += 1;
resetFeedView();
};
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
}, [beginFeedRefresh]);
}, [resetFeedView]);
useEffect(() => {
// Logo / 下拉刷新 / 后台改帖:清空本地列表以露出骨架,再强制拉第 1 页
const fn = () => {
beginFeedRefresh();
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
// 下拉 / posts-refresh / 刷新 commit有预热则一次覆盖禁止先卸列表
const applyWarmOrReload = () => {
fetchSeqRef.current += 1;
const key = cacheKeyRef.current;
const warm = getHomeStoreState().getFeed(key);
if (warm && warm.posts.length > 0) {
// 先写入新数据,再复位滚动(避免先 reset 造成空白闪一下)
commitDisplayed({
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);
return;
}
// 未命中:保留旧 posts静默重拉
if (postsRef.current.length > 0) {
setListPending(true);
setLoading(false);
void loadFirst();
return;
}
setLoading(true);
loadFirst();
void loadFirst();
};
window.addEventListener('posts-refresh', fn);
window.addEventListener(FEED_PULL_REFRESH_EVENT, fn);
window.addEventListener('posts-refresh', applyWarmOrReload);
window.addEventListener(FEED_PULL_REFRESH_EVENT, applyWarmOrReload);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, applyWarmOrReload);
return () => {
window.removeEventListener('posts-refresh', fn);
window.removeEventListener(FEED_PULL_REFRESH_EVENT, fn);
window.removeEventListener('posts-refresh', applyWarmOrReload);
window.removeEventListener(FEED_PULL_REFRESH_EVENT, applyWarmOrReload);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, applyWarmOrReload);
};
}, [beginFeedRefresh, loadFirst]);
}, [
commitDisplayed, loadFirst,
sort, boardId, keyword, tag, author, titleOnly,
]);
// 卸载时取消未执行的 scroll rAF
useEffect(() => () => {
@@ -375,22 +497,23 @@ export default function HomePage() {
if (scrollRafRef.current) return;
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = 0;
getHomeStoreState().patchScroll(cacheKeyRef.current, scrollTopRef.current);
getHomeStoreState().patchScroll(viewKeyRef.current, scrollTopRef.current);
});
}, []);
const handleSortChange = (next: FeedSort) => {
if (next === sort) {
const tid = startTransition();
beginFeedRefresh();
loadFirst();
void Promise.resolve(loadFirst()).finally(() => doneTransition(tid));
return;
}
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits }));
};
const showSortBar = !keyword && !tag && !author;
const showSortBar = !view.keyword && !view.tag && !view.author;
const searchFilters = parseSearchFromUrl(location.pathname, params);
const isSearchActive = !!(keyword || author);
const isSearchActive = !!(view.keyword || view.author);
if (isInvalidBoardRoute || isMissingBoard) {
return (
@@ -401,9 +524,9 @@ export default function HomePage() {
);
}
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
// 冷启动由门闩 / ensureColdBootReady 挡住;有旧列表时软刷新绝不卸空
if ((loading || limitsLoading || (isBoardRoute && boardsLoading)) && posts.length === 0) {
return <FeedPageSkeleton />;
return null;
}
return (
@@ -412,14 +535,19 @@ export default function HomePage() {
<div className="feed-top">
<div className="feed-top__bar">
<FeedHeader
keyword={keyword}
tag={tag}
author={author}
keyword={view.keyword}
tag={view.tag}
author={view.author}
postTotal={postTotal}
titleAs={isSiteHome ? 'h2' : 'h1'}
/>
{showSortBar && (
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
<FeedSortBar
value={view.sort}
pendingValue={listPending ? sort : null}
onChange={handleSortChange}
postTotal={postTotal}
/>
)}
</div>
{isSearchActive && (
@@ -433,8 +561,8 @@ export default function HomePage() {
</div>
<VirtualPostList
posts={posts}
sort={sort}
loading={loading || limitsLoading}
sort={view.sort}
loading={listPending ? false : (loading || limitsLoading)}
hasMore={hasMore}
showPagination={showPagination}
page={page}
@@ -446,15 +574,15 @@ export default function HomePage() {
resetScrollKey={listResetKey}
onScrollTopChange={handleScrollTopChange}
onScrollRestored={() => setRestoreScrollTop(null)}
keyword={keyword || tag || author}
isSearchMode={!!(keyword || author)}
searchKeyword={keyword}
searchAuthor={author}
searchTitleOnly={titleOnly}
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={boardId}
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
boardId={view.boardId}
boardName={ctx?.boards?.find(b => b.id === view.boardId)?.name || ''}
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}
/>
</div>