fix: 用 Zustand 缓存 Feed 列表,返回时恢复滚动且不重复请求

点击 Logo 仍强制刷新;后退忽略 history 上的 refreshFeed,并支持过期静默刷新与下拉刷新。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 01:50:33 +08:00
parent 185649db7c
commit 132ca5c82c
8 changed files with 384 additions and 92 deletions

View File

@@ -51,7 +51,8 @@
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.4",
"zod": "^4.4.3"
"zod": "^4.4.3",
"zustand": "^4.5.7"
},
"devDependencies": {
"@types/node": "^25.9.3",
@@ -4480,6 +4481,34 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

@@ -52,7 +52,8 @@
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.4",
"zod": "^4.4.3"
"zod": "^4.4.3",
"zustand": "^4.5.7"
},
"devDependencies": {
"@types/node": "^25.9.3",

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { Loader2, ArrowDown } from 'lucide-react';
import { FEED_PULL_REFRESH_EVENT } from '../utils/feedCache';
/** 触发刷新的下拉距离px */
const REFRESH_THRESHOLD = 68;
@@ -167,6 +168,18 @@ export default function PullToRefresh() {
setSettling(true);
setPull(REFRESH_THRESHOLD * 0.7);
window.setTimeout(() => {
// Feed 页:应用内强制重拉(保留 SPA 其它状态);其它页仍整页 reload
const isFeed = !!(
document.querySelector('.main-content--feed-mobile-scroll')
|| document.querySelector('.page-wrap--feed .post-list-scroll')
);
if (isFeed) {
window.dispatchEvent(new Event(FEED_PULL_REFRESH_EVENT));
setRefreshing(false);
setSettling(true);
setPull(0);
return;
}
window.location.reload();
}, 180);
return;

View File

@@ -161,6 +161,12 @@ export default function VirtualPostList({
onScrollTopChangeRef.current?.(0);
}, [resetScrollKey, virtualizer, getScrollElement]);
useLayoutEffect(() => {
// 收到新的恢复目标时允许再次 restorenull 表示已消费,勿动标记
if (restoreScrollTop == null) return;
restoredRef.current = false;
}, [restoreScrollTop]);
useLayoutEffect(() => {
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
const el = getScrollElement();
@@ -173,10 +179,6 @@ export default function VirtualPostList({
onScrollRestoredRef.current?.();
}, [restoreScrollTop, posts.length, virtualizer, getScrollElement]);
useEffect(() => {
restoredRef.current = false;
}, [restoreScrollTop]);
useEffect(() => {
const el = getScrollElement();
if (!el) return;

View File

@@ -386,6 +386,7 @@ export default function MainLayout() {
<Menu size={18} aria-hidden />
</button>
)}
{/* 点 Logo回首页并强制刷新列表已在首页时也会重拉 */}
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
<SiteBrandMark branding={branding} className="header-logo-mark" />
{!isMobile && <span className="header-logo-text">{branding.name}</span>}

View File

@@ -1,5 +1,12 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useOutletContext, useSearchParams, useLocation, useParams } from 'react-router-dom';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
useNavigate,
useOutletContext,
useSearchParams,
useLocation,
useParams,
useNavigationType,
} from 'react-router-dom';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
@@ -12,13 +19,13 @@ import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../comp
import { useForumLimits } from '../hooks/useForumLimits';
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
import {
getFeedCache,
setFeedCache,
clearAllFeedCache,
navigateFeed,
FEED_RESET_EVENT,
FEED_PULL_REFRESH_EVENT,
type FeedNavState,
} from '../utils/feedCache';
import { feedCacheKey, getHomeStoreState } from '../store/homeStore';
import { openForumPost } from '../utils/openPost';
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
@@ -35,9 +42,41 @@ function boardIdFromLocation(routeId: string | undefined, searchParams: URLSearc
return q > 0 ? q : 0;
}
type FeedHydrate = {
posts: PostItem[];
postTotal: number;
page: number;
scrollTop: number;
loading: boolean;
};
/** 首屏同步读取 Zustand避免先骨架屏再恢复 */
function readHydrate(
boardId: number,
keyword: string,
sort: FeedSort,
tag: string,
author: string,
titleOnly: boolean,
): FeedHydrate {
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
const cached = getHomeStoreState().getFeed(key);
if (cached && cached.posts.length > 0) {
return {
posts: cached.posts,
postTotal: cached.postTotal,
page: cached.page,
scrollTop: cached.scrollTop,
loading: false,
};
}
return { posts: [], postTotal: 0, page: 1, scrollTop: 0, loading: true };
}
export default function HomePage() {
const nav = useNavigate();
const location = useLocation();
const navType = useNavigationType();
const { id: boardRouteId } = useParams();
const [params] = useSearchParams();
const ctx = useOutletContext<LayoutCtx>();
@@ -96,20 +135,49 @@ export default function HomePage() {
}
}, [queryBoardId, boardId, boardRouteId, params, nav, limits, location.pathname, location.search]);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postTotal, setPostTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(null);
const cacheKey = useMemo(
() => feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly }),
[boardId, keyword, sort, tag, author, titleOnly],
);
// 筛选键变化时同步水合(含首次挂载),保证第一帧就有列表数据
const initial = useMemo(
() => readHydrate(boardId, keyword, sort, tag, author, titleOnly),
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅随筛选键变化重置
[cacheKey],
);
const [posts, setPosts] = useState<PostItem[]>(initial.posts);
const [postTotal, setPostTotal] = useState(initial.postTotal);
const [page, setPage] = useState(initial.page);
const [loading, setLoading] = useState(initial.loading);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(
initial.posts.length > 0 ? initial.scrollTop : null,
);
const [listResetKey, setListResetKey] = useState(0);
const scrollTopRef = useRef(0);
const skipCacheSaveRef = useRef(false);
const scrollTopRef = useRef(initial.scrollTop);
const loadingRef = useRef(false);
const pageRef = useRef(1);
const pageRef = useRef(initial.page);
const cacheKeyRef = useRef(cacheKey);
const scrollRafRef = useRef(0);
/** 当前筛选键是否已完成「进入页」水合(避免 effect 重跑时反复 setRestoreScrollTop */
const hydratedKeyRef = useRef<string | null>(null);
pageRef.current = page;
// 与当前筛选一致的列表快照(供卸载/切换筛选时写入缓存)
const feedSnapRef = useRef({ boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page });
cacheKeyRef.current = cacheKey;
// 筛选键切换:用新键的缓存重置本地 stateuseMemo initial 不会自动 setState
useEffect(() => {
const next = readHydrate(boardId, keyword, sort, tag, author, titleOnly);
hydratedKeyRef.current = null;
setPosts(next.posts);
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);
}, [cacheKey, boardId, keyword, sort, tag, author, titleOnly]);
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
const showPagination = totalPages > 1 && posts.length > 0;
@@ -121,16 +189,38 @@ export default function HomePage() {
setListResetKey(k => k + 1);
}, []);
/** 强制刷新:清空全部 Feed 缓存并滚回顶部 */
const beginFeedRefresh = useCallback(() => {
skipCacheSaveRef.current = true;
clearAllFeedCache();
resetFeedView();
}, [resetFeedView]);
const fetchPage = useCallback(async (p: number) => {
/** 把当前列表写入 Zustand滚动位置用 ref避免闭包过期 */
const persistFeed = useCallback((
nextPosts: PostItem[],
nextTotal: number,
nextPage: number,
opts?: { scrollTop?: number; touchFetchTime?: boolean },
) => {
const key = cacheKeyRef.current;
const prev = getHomeStoreState().getFeed(key);
getHomeStoreState().setFeed(key, {
posts: nextPosts,
postTotal: nextTotal,
page: nextPage,
scrollTop: opts?.scrollTop ?? scrollTopRef.current,
lastFetchTime: opts?.touchFetchTime === false
? (prev?.lastFetchTime ?? Date.now())
: Date.now(),
});
}, []);
const fetchPage = useCallback(async (p: number, opts?: { silent?: boolean }) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
const silent = !!opts?.silent;
// 静默刷新:保留现有列表,不展示加载骨架
if (!silent) setLoading(true);
try {
const data = await api.posts({
page: p,
@@ -148,17 +238,20 @@ export default function HomePage() {
setPostTotal(total);
setPage(p);
pageRef.current = p;
persistFeed(batch, total, p, { touchFetchTime: true });
} catch (e: unknown) {
if (!silent) {
notify.error(e instanceof Error ? e.message : '加载失败');
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
}
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize]);
}, [boardId, keyword, tag, author, titleOnly, sort, pageSize, persistFeed]);
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
@@ -168,89 +261,84 @@ export default function HomePage() {
if (p < 1 || p > maxPage) return;
if (p === pageRef.current) return;
resetFeedView();
getHomeStoreState().patchScroll(cacheKeyRef.current, 0);
fetchPage(p);
}, [fetchPage, postTotal, pageSize, resetFeedView]);
const handleSelectPost = useCallback((id: number) => {
// 离开前再写一次滚动,确保详情页返回可还原
getHomeStoreState().patchScroll(cacheKeyRef.current, scrollTopRef.current);
openForumPost(nav, id, limits.open_posts_in_new_tab);
}, [nav, limits.open_posts_in_new_tab]);
// 等限制就绪后再拉列表;筛选变化时重载
// 等限制就绪后再决定:强制刷新 / 用缓存 / 静默刷新 / 首拉
useEffect(() => {
if (limitsLoading || isInvalidBoardRoute || isMissingBoard) return;
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
if (forceRefresh) {
// 浏览器后退/前进POP忽略 history 上残留的 refreshFeed避免误清空缓存
if (forceRefresh && navType !== 'POP') {
hydratedKeyRef.current = cacheKey;
beginFeedRefresh();
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
setLoading(true);
loadFirst();
// 消费后清掉 state防止该 history 条目永远带着刷新标记
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
return;
}
const cached = getFeedCache(boardId, keyword, sort, tag, author, titleOnly);
// POP 带回 refreshFeed 时也清掉,避免下次同条目再误触发
if (forceRefresh && navType === 'POP') {
nav(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
}
const cached = getHomeStoreState().getFeed(cacheKey);
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;
setLoading(false);
}
// 超过 TTL后台静默刷新不重置滚动
if (getHomeStoreState().isStale(cacheKey)) {
void fetchPage(cached.page, { silent: true });
}
return;
}
hydratedKeyRef.current = cacheKey;
setRestoreScrollTop(null);
scrollTopRef.current = 0;
loadFirst();
}, [
limitsLoading,
pageSize,
boardId,
keyword,
tag,
author,
titleOnly,
sort,
cacheKey,
location.key,
location.state,
location.pathname,
location.search,
location.hash,
navType,
nav,
loadFirst,
fetchPage,
beginFeedRefresh,
isInvalidBoardRoute,
isMissingBoard,
]);
// 筛选未变时同步列表快照;变筛选的那一帧先保留旧快照供 cleanup 写入
if (
feedSnapRef.current.boardId === boardId
&& feedSnapRef.current.keyword === keyword
&& feedSnapRef.current.tag === tag
&& feedSnapRef.current.author === author
&& feedSnapRef.current.titleOnly === titleOnly
&& feedSnapRef.current.sort === sort
) {
feedSnapRef.current = { boardId, keyword, tag, author, titleOnly, sort, posts, postTotal, page };
}
// 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps否则会用旧列表污染新 keyword
useEffect(() => {
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位
feedSnapRef.current = { boardId, keyword, tag, author, titleOnly, sort, posts: [], postTotal: 0, page: 1 };
return () => {
if (skipCacheSaveRef.current) return;
const snap = feedSnapRef.current;
if (snap.posts.length === 0) return;
setFeedCache(snap.boardId, snap.keyword, snap.sort, {
posts: snap.posts,
postTotal: snap.postTotal,
page: snap.page,
scrollTop: scrollTopRef.current,
}, snap.tag, snap.author, snap.titleOnly);
};
}, [boardId, keyword, tag, author, titleOnly, sort]);
useEffect(() => {
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
}, [loading, posts.length]);
useEffect(() => {
const onFeedReset = () => beginFeedRefresh();
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
@@ -258,14 +346,39 @@ export default function HomePage() {
}, [beginFeedRefresh]);
useEffect(() => {
// Logo / 下拉刷新 / 后台改帖:清空本地列表以露出骨架,再强制拉第 1 页
const fn = () => {
beginFeedRefresh();
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
setLoading(true);
loadFirst();
};
window.addEventListener('posts-refresh', fn);
return () => window.removeEventListener('posts-refresh', fn);
window.addEventListener(FEED_PULL_REFRESH_EVENT, fn);
return () => {
window.removeEventListener('posts-refresh', fn);
window.removeEventListener(FEED_PULL_REFRESH_EVENT, fn);
};
}, [beginFeedRefresh, loadFirst]);
// 卸载时取消未执行的 scroll rAF
useEffect(() => () => {
if (scrollRafRef.current) cancelAnimationFrame(scrollRafRef.current);
}, []);
const handleScrollTopChange = useCallback((top: number) => {
scrollTopRef.current = top;
// rAF 节流写入 store避免每个 scroll 事件都触发订阅者
if (scrollRafRef.current) return;
scrollRafRef.current = requestAnimationFrame(() => {
scrollRafRef.current = 0;
getHomeStoreState().patchScroll(cacheKeyRef.current, scrollTopRef.current);
});
}, []);
const handleSortChange = (next: FeedSort) => {
if (next === sort) {
beginFeedRefresh();
@@ -331,7 +444,7 @@ export default function HomePage() {
onSelect={handleSelectPost}
restoreScrollTop={restoreScrollTop}
resetScrollKey={listResetKey}
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollTopChange={handleScrollTopChange}
onScrollRestored={() => setRestoreScrollTop(null)}
keyword={keyword || tag || author}
isSearchMode={!!(keyword || author)}

View File

@@ -0,0 +1,106 @@
import { create } from 'zustand';
import type { PostItem } from '../api/types';
import type { FeedSort } from '../components/FeedSortBar';
/** 列表缓存过期时间:超过后返回首页时静默刷新,仍先展示旧数据 */
export const FEED_STALE_MS = 5 * 60 * 1000;
/** 单条 Feed 筛选对应的缓存快照 */
export type FeedCacheEntry = {
posts: PostItem[];
postTotal: number;
page: number;
scrollTop: number;
/** 上次成功拉取 API 的时间戳ms */
lastFetchTime: number;
};
type FeedKeyParts = {
boardId: number;
keyword: string;
sort: FeedSort;
tag?: string;
author?: string;
titleOnly?: boolean;
};
/** 与筛选条件一一对应的缓存键 */
export function feedCacheKey(parts: FeedKeyParts): string {
const {
boardId,
keyword,
sort,
tag = '',
author = '',
titleOnly = false,
} = parts;
return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`;
}
type HomeStoreState = {
/** 按筛选键存放多份列表,首页 / 板块 / 搜索互不覆盖 */
feeds: Record<string, FeedCacheEntry>;
getFeed: (key: string) => FeedCacheEntry | null;
setFeed: (key: string, data: Omit<FeedCacheEntry, 'lastFetchTime'> & { lastFetchTime?: number }) => void;
/** 滚动时只更新 scrollTop不改动列表数据 */
patchScroll: (key: string, scrollTop: number) => void;
isStale: (key: string, now?: number) => boolean;
clearFeed: (key: string) => void;
clearAll: () => void;
};
export const useHomeStore = create<HomeStoreState>((set, get) => ({
feeds: {},
getFeed: (key) => get().feeds[key] ?? null,
setFeed: (key, data) => {
set((state) => ({
feeds: {
...state.feeds,
[key]: {
posts: data.posts,
postTotal: data.postTotal,
page: data.page,
scrollTop: data.scrollTop,
lastFetchTime: data.lastFetchTime ?? Date.now(),
},
},
}));
},
patchScroll: (key, scrollTop) => {
const prev = get().feeds[key];
if (!prev) return;
// 数值未变则跳过,减少无意义的 store 更新
if (prev.scrollTop === scrollTop) return;
set((state) => ({
feeds: {
...state.feeds,
[key]: { ...prev, scrollTop },
},
}));
},
isStale: (key, now = Date.now()) => {
const entry = get().feeds[key];
if (!entry) return true;
return now - entry.lastFetchTime > FEED_STALE_MS;
},
clearFeed: (key) => {
set((state) => {
if (!(key in state.feeds)) return state;
const next = { ...state.feeds };
delete next[key];
return { feeds: next };
});
},
clearAll: () => set({ feeds: {} }),
}));
/** 非 React 路径(如 feedCache 封装)直接读写 */
export function getHomeStoreState() {
return useHomeStore.getState();
}

View File

@@ -1,10 +1,16 @@
import type { NavigateFunction } from 'react-router-dom';
import type { PostItem } from '../api/types';
import type { FeedSort } from '../components/FeedSortBar';
import {
feedCacheKey,
getHomeStoreState,
type FeedCacheEntry,
} from '../store/homeStore';
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
export type FeedNavState = { refreshFeed?: boolean };
/** 对外暴露的缓存形状(兼容旧调用方;不含 lastFetchTime */
export type FeedCache = {
posts: PostItem[];
postTotal: number;
@@ -12,18 +18,13 @@ export type FeedCache = {
scrollTop: number;
};
/** 仅存内存SPA 内返回可恢复,浏览器刷新自动清空 */
const store = new Map<string, FeedCache>();
function cacheKey(
boardId: number,
keyword: string,
sort: FeedSort,
tag = '',
author = '',
titleOnly = false,
) {
return `${boardId}:${keyword}:${tag}:${author}:${titleOnly ? 1 : 0}:${sort}`;
function toPublic(entry: FeedCacheEntry): FeedCache {
return {
posts: entry.posts,
postTotal: entry.postTotal,
page: entry.page,
scrollTop: entry.scrollTop,
};
}
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
@@ -35,10 +36,12 @@ export function getFeedCache(
author = '',
titleOnly = false,
): FeedCache | null {
return store.get(cacheKey(boardId, keyword, sort, tag, author, titleOnly)) ?? null;
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
const entry = getHomeStoreState().getFeed(key);
return entry ? toPublic(entry) : null;
}
/** 保存帖子列表缓存 */
/** 保存帖子列表缓存(写入 Zustand并记录拉取时间 */
export function setFeedCache(
boardId: number,
keyword: string,
@@ -48,20 +51,44 @@ export function setFeedCache(
author = '',
titleOnly = false,
) {
store.set(cacheKey(boardId, keyword, sort, tag, author, titleOnly), data);
const key = feedCacheKey({ boardId, keyword, sort, tag, author, titleOnly });
getHomeStoreState().setFeed(key, data);
}
/** 清除所有帖子列表缓存 */
export function clearAllFeedCache() {
store.clear();
getHomeStoreState().clearAll();
}
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
export const FEED_RESET_EVENT = 'feed-reset';
/** 清除缓存并导航到帖子列表(重复点击同一入口时也会刷新) */
/** 手机下拉刷新Feed 页):强制重拉列表并重置滚动,不整页 reload */
export const FEED_PULL_REFRESH_EVENT = 'feed-pull-refresh';
/** 当前浏览器地址是否已是目标 Feed URL忽略 hash */
function isSameFeedUrl(url: string): boolean {
try {
const target = new URL(url, window.location.origin);
return (
window.location.pathname === target.pathname
&& window.location.search === target.search
);
} catch {
return false;
}
}
/**
* 清除缓存并导航到帖子列表。
* 已在目标 URL如首页再点 Logo时额外派发强制重拉不依赖 RR 是否换 key。
*/
export function navigateFeed(nav: NavigateFunction, url: string) {
clearAllFeedCache();
window.dispatchEvent(new Event(FEED_RESET_EVENT));
// 同 URL 再点(典型:左上角 Logo必须立刻重拉否则可能只清缓存、界面仍显示旧列表
if (isSameFeedUrl(url)) {
window.dispatchEvent(new Event(FEED_PULL_REFRESH_EVENT));
}
nav(url, { state: { refreshFeed: true } satisfies FeedNavState });
}