mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
refactor(feed): gate feed cache to infinite scroll mode
This commit is contained in:
+93
-45
@@ -8,6 +8,8 @@ import { preloadThemeAssets } from './lib/utils/preload-utils';
|
||||
import useReplyModalStore from './stores/use-reply-modal-store';
|
||||
import useCreateBoardModalStore from './stores/use-create-board-modal-store';
|
||||
import useSpecialThemeStore from './stores/use-special-theme-store';
|
||||
import useFeedViewSettingsStore from './stores/use-feed-view-settings-store';
|
||||
import useFeedCacheStore from './stores/use-feed-cache-store';
|
||||
import useIsMobile from './hooks/use-is-mobile';
|
||||
import useTheme from './hooks/use-theme';
|
||||
import { useDirectories } from './hooks/use-directories';
|
||||
@@ -21,6 +23,8 @@ import NotFound from './views/not-found';
|
||||
import NotAllowed from './views/not-allowed';
|
||||
import PendingPost from './views/pending-post';
|
||||
import Post from './views/post';
|
||||
import Board from './views/board';
|
||||
import Catalog from './views/catalog';
|
||||
import ModQueueView from './views/mod-queue';
|
||||
import { DesktopBoardButtons, MobileBoardButtons } from './components/board-buttons';
|
||||
import BoardHeader from './components/board-header';
|
||||
@@ -53,10 +57,20 @@ const BoardLayout = () => {
|
||||
const subplebbitAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, directories) : undefined;
|
||||
const pendingPost = useAccountComment({ commentIndex: accountCommentIndex ? parseInt(accountCommentIndex) : undefined });
|
||||
const { closeCreateBoardModal } = useCreateBoardModalStore();
|
||||
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
|
||||
const clearFeeds = useFeedCacheStore((state) => state.clearFeeds);
|
||||
|
||||
const isOnPostRoute = isPostRoute(location.pathname);
|
||||
const isOnPendingPostRoute = isPendingPostRoute(location.pathname);
|
||||
const isOnModQueueRoute = isModQueueRoute(location.pathname);
|
||||
const shouldRenderOutlet = !enableInfiniteScroll || isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute;
|
||||
|
||||
// Clear feed cache when switching from infinite scroll to pagination
|
||||
useEffect(() => {
|
||||
if (!enableInfiniteScroll) {
|
||||
clearFeeds();
|
||||
}
|
||||
}, [enableInfiniteScroll, clearFeeds]);
|
||||
|
||||
// Christmas theme
|
||||
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
|
||||
@@ -101,8 +115,8 @@ const BoardLayout = () => {
|
||||
<DesktopBoardButtons />
|
||||
</>
|
||||
)}
|
||||
<FeedCacheContainer />
|
||||
{(isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute) && <Outlet />}
|
||||
{enableInfiniteScroll && <FeedCacheContainer />}
|
||||
{shouldRenderOutlet && <Outlet />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -145,6 +159,34 @@ const GlobalLayout = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Wraps Board with viewType/boardIdentifier derived from current route. Used when infinite scroll is OFF. */
|
||||
const BoardFeedRoute = () => {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const viewType: 'all' | 'subs' | 'mod' | 'board' = isAllView(location.pathname)
|
||||
? 'all'
|
||||
: isSubscriptionsView(location.pathname, params)
|
||||
? 'subs'
|
||||
: isModView(location.pathname)
|
||||
? 'mod'
|
||||
: 'board';
|
||||
return <Board viewType={viewType} boardIdentifier={params.boardIdentifier} />;
|
||||
};
|
||||
|
||||
/** Wraps Catalog with viewType/boardIdentifier derived from current route. Used when infinite scroll is OFF. */
|
||||
const CatalogFeedRoute = () => {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const viewType: 'all' | 'subs' | 'mod' | 'board' = isAllView(location.pathname)
|
||||
? 'all'
|
||||
: isSubscriptionsView(location.pathname, params)
|
||||
? 'subs'
|
||||
: isModView(location.pathname)
|
||||
? 'mod'
|
||||
: 'board';
|
||||
return <Catalog viewType={viewType} boardIdentifier={params.boardIdentifier} />;
|
||||
};
|
||||
|
||||
const ModQueueRoute = () => {
|
||||
const { boardIdentifier } = useParams();
|
||||
const account = useAccount();
|
||||
@@ -188,56 +230,62 @@ const ModQueueRoute = () => {
|
||||
return hasModQueueAccessRole(accountRole) ? <ModQueueView /> : <Navigate to='/not-allowed' replace />;
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<div className={styles.app}>
|
||||
<Routes>
|
||||
<Route element={<GlobalLayout />}>
|
||||
<Route path='/' element={<Home />} />
|
||||
<Route path='/faq' element={<FAQ />} />
|
||||
<Route path='/rules/:boardIdentifier?' element={<Rules />} />
|
||||
<Route element={<BoardLayout />}>
|
||||
<Route path='/all/:timeFilterName/:pageNumber' element={null} />
|
||||
<Route path='/all/:timeFilterName?' element={null} />
|
||||
<Route path='/all/:timeFilterName?/settings' element={null} />
|
||||
<Route path='/all/catalog/:timeFilterName?' element={null} />
|
||||
<Route path='/all/catalog/:timeFilterName?/settings' element={null} />
|
||||
const App = () => {
|
||||
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
|
||||
const boardFeedElement = enableInfiniteScroll ? null : <BoardFeedRoute />;
|
||||
const catalogFeedElement = enableInfiniteScroll ? null : <CatalogFeedRoute />;
|
||||
|
||||
<Route path='/subs/:timeFilterName/:pageNumber' element={null} />
|
||||
<Route path='/subs/:timeFilterName?' element={null} />
|
||||
<Route path='/subs/:timeFilterName?/settings' element={null} />
|
||||
<Route path='/subs/catalog/:timeFilterName?' element={null} />
|
||||
<Route path='/subs/catalog/:timeFilterName?/settings' element={null} />
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
<Routes>
|
||||
<Route element={<GlobalLayout />}>
|
||||
<Route path='/' element={<Home />} />
|
||||
<Route path='/faq' element={<FAQ />} />
|
||||
<Route path='/rules/:boardIdentifier?' element={<Rules />} />
|
||||
<Route element={<BoardLayout />}>
|
||||
<Route path='/all/:timeFilterName/:pageNumber' element={boardFeedElement} />
|
||||
<Route path='/all/:timeFilterName?' element={boardFeedElement} />
|
||||
<Route path='/all/:timeFilterName?/settings' element={boardFeedElement} />
|
||||
<Route path='/all/catalog/:timeFilterName?' element={catalogFeedElement} />
|
||||
<Route path='/all/catalog/:timeFilterName?/settings' element={catalogFeedElement} />
|
||||
|
||||
<Route path='/mod/:timeFilterName/:pageNumber' element={null} />
|
||||
<Route path='/mod/:timeFilterName?' element={null} />
|
||||
<Route path='/mod/:timeFilterName?/settings' element={null} />
|
||||
<Route path='/mod/catalog/:timeFilterName?' element={null} />
|
||||
<Route path='/mod/catalog/:timeFilterName?/settings' element={null} />
|
||||
<Route path='/subs/:timeFilterName/:pageNumber' element={boardFeedElement} />
|
||||
<Route path='/subs/:timeFilterName?' element={boardFeedElement} />
|
||||
<Route path='/subs/:timeFilterName?/settings' element={boardFeedElement} />
|
||||
<Route path='/subs/catalog/:timeFilterName?' element={catalogFeedElement} />
|
||||
<Route path='/subs/catalog/:timeFilterName?/settings' element={catalogFeedElement} />
|
||||
|
||||
<Route path='/mod/modqueue' element={<ModQueueRoute />} />
|
||||
<Route path='/mod/modqueue/settings' element={<ModQueueRoute />} />
|
||||
<Route path='/mod/:timeFilterName/:pageNumber' element={boardFeedElement} />
|
||||
<Route path='/mod/:timeFilterName?' element={boardFeedElement} />
|
||||
<Route path='/mod/:timeFilterName?/settings' element={boardFeedElement} />
|
||||
<Route path='/mod/catalog/:timeFilterName?' element={catalogFeedElement} />
|
||||
<Route path='/mod/catalog/:timeFilterName?/settings' element={catalogFeedElement} />
|
||||
|
||||
<Route path='/:boardIdentifier/:pageNumber' element={null} />
|
||||
<Route path='/:boardIdentifier' element={null} />
|
||||
<Route path='/:boardIdentifier/settings' element={null} />
|
||||
<Route path='/:boardIdentifier/catalog' element={null} />
|
||||
<Route path='/:boardIdentifier/catalog/settings' element={null} />
|
||||
<Route path='/mod/modqueue' element={<ModQueueRoute />} />
|
||||
<Route path='/mod/modqueue/settings' element={<ModQueueRoute />} />
|
||||
|
||||
<Route path='/:boardIdentifier/modqueue' element={<ModQueueRoute />} />
|
||||
<Route path='/:boardIdentifier/modqueue/settings' element={<ModQueueRoute />} />
|
||||
<Route path='/:boardIdentifier/:pageNumber' element={boardFeedElement} />
|
||||
<Route path='/:boardIdentifier' element={boardFeedElement} />
|
||||
<Route path='/:boardIdentifier/settings' element={boardFeedElement} />
|
||||
<Route path='/:boardIdentifier/catalog' element={catalogFeedElement} />
|
||||
<Route path='/:boardIdentifier/catalog/settings' element={catalogFeedElement} />
|
||||
|
||||
<Route path='/:boardIdentifier/thread/:commentCid' element={<Post />} />
|
||||
<Route path='/:boardIdentifier/thread/:commentCid/settings' element={<Post />} />
|
||||
<Route path='/:boardIdentifier/modqueue' element={<ModQueueRoute />} />
|
||||
<Route path='/:boardIdentifier/modqueue/settings' element={<ModQueueRoute />} />
|
||||
|
||||
<Route path='/pending/:accountCommentIndex' element={<PendingPost />} />
|
||||
<Route path='/pending/:accountCommentIndex/settings' element={<PendingPost />} />
|
||||
<Route path='/:boardIdentifier/thread/:commentCid' element={<Post />} />
|
||||
<Route path='/:boardIdentifier/thread/:commentCid/settings' element={<Post />} />
|
||||
|
||||
<Route path='/pending/:accountCommentIndex' element={<PendingPost />} />
|
||||
<Route path='/pending/:accountCommentIndex/settings' element={<PendingPost />} />
|
||||
</Route>
|
||||
<Route path='/not-allowed' element={<NotAllowed />} />
|
||||
<Route path='/not-found' element={<NotFound />} />
|
||||
<Route path='*' element={<NotFound />} />
|
||||
</Route>
|
||||
<Route path='/not-allowed' element={<NotAllowed />} />
|
||||
<Route path='/not-found' element={<NotFound />} />
|
||||
<Route path='*' element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import useFeedCacheStore from '../use-feed-cache-store';
|
||||
|
||||
describe('useFeedCacheStore', () => {
|
||||
beforeEach(() => {
|
||||
useFeedCacheStore.getState().clearFeeds();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useFeedCacheStore.getState().clearFeeds();
|
||||
});
|
||||
|
||||
it('accessFeed adds entries', () => {
|
||||
const { accessFeed } = useFeedCacheStore.getState();
|
||||
|
||||
accessFeed('plebbit/board', 'board');
|
||||
const afterFirst = useFeedCacheStore.getState().cachedFeeds;
|
||||
expect(afterFirst.length).toBe(1);
|
||||
expect(afterFirst[0].key).toBe('plebbit/board');
|
||||
expect(afterFirst[0].type).toBe('board');
|
||||
|
||||
accessFeed('plebbit/catalog', 'catalog');
|
||||
const afterSecond = useFeedCacheStore.getState().cachedFeeds;
|
||||
expect(afterSecond.length).toBe(2);
|
||||
expect(afterSecond.some((f) => f.key === 'plebbit/catalog' && f.type === 'catalog')).toBe(true);
|
||||
});
|
||||
|
||||
it('evicts least recently accessed when cache exceeds maxCacheSize (2)', () => {
|
||||
const { accessFeed, isFeedCached } = useFeedCacheStore.getState();
|
||||
vi.useFakeTimers();
|
||||
|
||||
accessFeed('a', 'board');
|
||||
vi.advanceTimersByTime(1);
|
||||
accessFeed('b', 'board');
|
||||
vi.advanceTimersByTime(1);
|
||||
accessFeed('c', 'board');
|
||||
|
||||
expect(isFeedCached('a')).toBe(false);
|
||||
expect(isFeedCached('b')).toBe(true);
|
||||
expect(isFeedCached('c')).toBe(true);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('clearFeeds empties cache', () => {
|
||||
const { accessFeed, clearFeeds } = useFeedCacheStore.getState();
|
||||
|
||||
accessFeed('plebbit/board', 'board');
|
||||
accessFeed('other/board', 'catalog');
|
||||
expect(useFeedCacheStore.getState().cachedFeeds.length).toBe(2);
|
||||
|
||||
clearFeeds();
|
||||
const after = useFeedCacheStore.getState().cachedFeeds;
|
||||
expect(after.length).toBe(0);
|
||||
expect(after).toEqual([]);
|
||||
});
|
||||
|
||||
it('clearFeeds is idempotent when already empty', () => {
|
||||
const { clearFeeds } = useFeedCacheStore.getState();
|
||||
|
||||
expect(useFeedCacheStore.getState().cachedFeeds.length).toBe(0);
|
||||
|
||||
clearFeeds();
|
||||
clearFeeds();
|
||||
clearFeeds();
|
||||
|
||||
const after = useFeedCacheStore.getState().cachedFeeds;
|
||||
expect(after.length).toBe(0);
|
||||
expect(after).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ interface FeedCacheState {
|
||||
maxCacheSize: number;
|
||||
accessFeed: (key: string, type: 'board' | 'catalog') => void;
|
||||
removeFeed: (key: string) => void;
|
||||
clearFeeds: () => void;
|
||||
isFeedCached: (key: string) => boolean;
|
||||
}
|
||||
|
||||
@@ -45,6 +46,12 @@ const useFeedCacheStore = create<FeedCacheState>((set, get) => ({
|
||||
set({ cachedFeeds: cachedFeeds.filter((feed) => feed.key !== key) });
|
||||
},
|
||||
|
||||
clearFeeds: () => {
|
||||
const { cachedFeeds } = get();
|
||||
if (cachedFeeds.length === 0) return;
|
||||
set({ cachedFeeds: [] });
|
||||
},
|
||||
|
||||
isFeedCached: (key: string) => {
|
||||
const { cachedFeeds } = get();
|
||||
return cachedFeeds.some((feed) => feed.key === key);
|
||||
|
||||
Reference in New Issue
Block a user