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 useReplyModalStore from './stores/use-reply-modal-store';
|
||||||
import useCreateBoardModalStore from './stores/use-create-board-modal-store';
|
import useCreateBoardModalStore from './stores/use-create-board-modal-store';
|
||||||
import useSpecialThemeStore from './stores/use-special-theme-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 useIsMobile from './hooks/use-is-mobile';
|
||||||
import useTheme from './hooks/use-theme';
|
import useTheme from './hooks/use-theme';
|
||||||
import { useDirectories } from './hooks/use-directories';
|
import { useDirectories } from './hooks/use-directories';
|
||||||
@@ -21,6 +23,8 @@ import NotFound from './views/not-found';
|
|||||||
import NotAllowed from './views/not-allowed';
|
import NotAllowed from './views/not-allowed';
|
||||||
import PendingPost from './views/pending-post';
|
import PendingPost from './views/pending-post';
|
||||||
import Post from './views/post';
|
import Post from './views/post';
|
||||||
|
import Board from './views/board';
|
||||||
|
import Catalog from './views/catalog';
|
||||||
import ModQueueView from './views/mod-queue';
|
import ModQueueView from './views/mod-queue';
|
||||||
import { DesktopBoardButtons, MobileBoardButtons } from './components/board-buttons';
|
import { DesktopBoardButtons, MobileBoardButtons } from './components/board-buttons';
|
||||||
import BoardHeader from './components/board-header';
|
import BoardHeader from './components/board-header';
|
||||||
@@ -53,10 +57,20 @@ const BoardLayout = () => {
|
|||||||
const subplebbitAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, directories) : undefined;
|
const subplebbitAddress = boardIdentifier ? getSubplebbitAddress(boardIdentifier, directories) : undefined;
|
||||||
const pendingPost = useAccountComment({ commentIndex: accountCommentIndex ? parseInt(accountCommentIndex) : undefined });
|
const pendingPost = useAccountComment({ commentIndex: accountCommentIndex ? parseInt(accountCommentIndex) : undefined });
|
||||||
const { closeCreateBoardModal } = useCreateBoardModalStore();
|
const { closeCreateBoardModal } = useCreateBoardModalStore();
|
||||||
|
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
|
||||||
|
const clearFeeds = useFeedCacheStore((state) => state.clearFeeds);
|
||||||
|
|
||||||
const isOnPostRoute = isPostRoute(location.pathname);
|
const isOnPostRoute = isPostRoute(location.pathname);
|
||||||
const isOnPendingPostRoute = isPendingPostRoute(location.pathname);
|
const isOnPendingPostRoute = isPendingPostRoute(location.pathname);
|
||||||
const isOnModQueueRoute = isModQueueRoute(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
|
// Christmas theme
|
||||||
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
|
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
|
||||||
@@ -101,8 +115,8 @@ const BoardLayout = () => {
|
|||||||
<DesktopBoardButtons />
|
<DesktopBoardButtons />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<FeedCacheContainer />
|
{enableInfiniteScroll && <FeedCacheContainer />}
|
||||||
{(isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute) && <Outlet />}
|
{shouldRenderOutlet && <Outlet />}
|
||||||
</div>
|
</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 ModQueueRoute = () => {
|
||||||
const { boardIdentifier } = useParams();
|
const { boardIdentifier } = useParams();
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
@@ -188,56 +230,62 @@ const ModQueueRoute = () => {
|
|||||||
return hasModQueueAccessRole(accountRole) ? <ModQueueView /> : <Navigate to='/not-allowed' replace />;
|
return hasModQueueAccessRole(accountRole) ? <ModQueueView /> : <Navigate to='/not-allowed' replace />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const App = () => (
|
const App = () => {
|
||||||
<div className={styles.app}>
|
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
|
||||||
<Routes>
|
const boardFeedElement = enableInfiniteScroll ? null : <BoardFeedRoute />;
|
||||||
<Route element={<GlobalLayout />}>
|
const catalogFeedElement = enableInfiniteScroll ? null : <CatalogFeedRoute />;
|
||||||
<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} />
|
|
||||||
|
|
||||||
<Route path='/subs/:timeFilterName/:pageNumber' element={null} />
|
return (
|
||||||
<Route path='/subs/:timeFilterName?' element={null} />
|
<div className={styles.app}>
|
||||||
<Route path='/subs/:timeFilterName?/settings' element={null} />
|
<Routes>
|
||||||
<Route path='/subs/catalog/:timeFilterName?' element={null} />
|
<Route element={<GlobalLayout />}>
|
||||||
<Route path='/subs/catalog/:timeFilterName?/settings' element={null} />
|
<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='/subs/:timeFilterName/:pageNumber' element={boardFeedElement} />
|
||||||
<Route path='/mod/:timeFilterName?' element={null} />
|
<Route path='/subs/:timeFilterName?' element={boardFeedElement} />
|
||||||
<Route path='/mod/:timeFilterName?/settings' element={null} />
|
<Route path='/subs/:timeFilterName?/settings' element={boardFeedElement} />
|
||||||
<Route path='/mod/catalog/:timeFilterName?' element={null} />
|
<Route path='/subs/catalog/:timeFilterName?' element={catalogFeedElement} />
|
||||||
<Route path='/mod/catalog/:timeFilterName?/settings' element={null} />
|
<Route path='/subs/catalog/:timeFilterName?/settings' element={catalogFeedElement} />
|
||||||
|
|
||||||
<Route path='/mod/modqueue' element={<ModQueueRoute />} />
|
<Route path='/mod/:timeFilterName/:pageNumber' element={boardFeedElement} />
|
||||||
<Route path='/mod/modqueue/settings' element={<ModQueueRoute />} />
|
<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='/mod/modqueue' element={<ModQueueRoute />} />
|
||||||
<Route path='/:boardIdentifier' element={null} />
|
<Route path='/mod/modqueue/settings' element={<ModQueueRoute />} />
|
||||||
<Route path='/:boardIdentifier/settings' element={null} />
|
|
||||||
<Route path='/:boardIdentifier/catalog' element={null} />
|
|
||||||
<Route path='/:boardIdentifier/catalog/settings' element={null} />
|
|
||||||
|
|
||||||
<Route path='/:boardIdentifier/modqueue' element={<ModQueueRoute />} />
|
<Route path='/:boardIdentifier/:pageNumber' element={boardFeedElement} />
|
||||||
<Route path='/:boardIdentifier/modqueue/settings' element={<ModQueueRoute />} />
|
<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/modqueue' element={<ModQueueRoute />} />
|
||||||
<Route path='/:boardIdentifier/thread/:commentCid/settings' element={<Post />} />
|
<Route path='/:boardIdentifier/modqueue/settings' element={<ModQueueRoute />} />
|
||||||
|
|
||||||
<Route path='/pending/:accountCommentIndex' element={<PendingPost />} />
|
<Route path='/:boardIdentifier/thread/:commentCid' element={<Post />} />
|
||||||
<Route path='/pending/:accountCommentIndex/settings' element={<PendingPost />} />
|
<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>
|
||||||
<Route path='/not-allowed' element={<NotAllowed />} />
|
</Routes>
|
||||||
<Route path='/not-found' element={<NotFound />} />
|
</div>
|
||||||
<Route path='*' element={<NotFound />} />
|
);
|
||||||
</Route>
|
};
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default App;
|
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;
|
maxCacheSize: number;
|
||||||
accessFeed: (key: string, type: 'board' | 'catalog') => void;
|
accessFeed: (key: string, type: 'board' | 'catalog') => void;
|
||||||
removeFeed: (key: string) => void;
|
removeFeed: (key: string) => void;
|
||||||
|
clearFeeds: () => void;
|
||||||
isFeedCached: (key: string) => boolean;
|
isFeedCached: (key: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +46,12 @@ const useFeedCacheStore = create<FeedCacheState>((set, get) => ({
|
|||||||
set({ cachedFeeds: cachedFeeds.filter((feed) => feed.key !== key) });
|
set({ cachedFeeds: cachedFeeds.filter((feed) => feed.key !== key) });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearFeeds: () => {
|
||||||
|
const { cachedFeeds } = get();
|
||||||
|
if (cachedFeeds.length === 0) return;
|
||||||
|
set({ cachedFeeds: [] });
|
||||||
|
},
|
||||||
|
|
||||||
isFeedCached: (key: string) => {
|
isFeedCached: (key: string) => {
|
||||||
const { cachedFeeds } = get();
|
const { cachedFeeds } = get();
|
||||||
return cachedFeeds.some((feed) => feed.key === key);
|
return cachedFeeds.some((feed) => feed.key === key);
|
||||||
|
|||||||
Reference in New Issue
Block a user