mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
perf: implement LRU-cached persistent feed mounting to eliminate Virtuoso flash
Keep Board/Catalog feeds mounted outside React Router's control to prevent Virtuoso from remounting when navigating to/from post pages. The feed is hidden via CSS when viewing posts and shown again when returning, eliminating the visible flash/displacement that occurred during scroll restoration. - Add FeedCacheContainer to manage LRU cache of 2 feeds - Modify Board/Catalog to accept cache props and visibility state - Add route utilities for feed/post route detection
This commit is contained in:
+50
-23
@@ -6,14 +6,14 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
import styles from './board.module.css';
|
||||
import { shouldShowSnow } from '../../lib/snow';
|
||||
import { getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
|
||||
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
|
||||
import { useDefaultSubplebbitAddresses } from '../../hooks/use-default-subplebbits';
|
||||
import { useDefaultSubplebbitAddresses, useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
|
||||
import { useResolvedSubplebbitAddress, useBoardPath } from '../../hooks/use-resolved-subplebbit-address';
|
||||
import { useFeedStateString } from '../../hooks/use-state-string';
|
||||
import useTimeFilter from '../../hooks/use-time-filter';
|
||||
import useInterfaceSettingsStore from '../../stores/use-interface-settings-store';
|
||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
import useSortingStore from '../../stores/use-sorting-store';
|
||||
import { getSubplebbitAddress } from '../../lib/utils/route-utils';
|
||||
import ErrorDisplay from '../../components/error-display/error-display';
|
||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
||||
import SubplebbitDescription from '../../components/subplebbit-description';
|
||||
@@ -33,21 +33,42 @@ const createThreadsWithoutImagesFilter = () => ({
|
||||
key: 'threads-with-images-only',
|
||||
});
|
||||
|
||||
const Board = () => {
|
||||
export interface BoardProps {
|
||||
// Props from FeedCacheContainer for cached feeds
|
||||
feedCacheKey?: string;
|
||||
viewType?: 'all' | 'subs' | 'mod' | 'board';
|
||||
boardIdentifier?: string;
|
||||
timeFilterNameFromCache?: string;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, timeFilterNameFromCache, isVisible = true }: BoardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const subplebbitAddress = useResolvedSubplebbitAddress();
|
||||
const boardPath = useBoardPath(subplebbitAddress);
|
||||
const params = useParams();
|
||||
const { hideThreadsWithoutImages } = useInterfaceSettingsStore();
|
||||
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
// Use props from cache if provided, otherwise fall back to URL-derived values
|
||||
const isInAllView = viewType ? viewType === 'all' : false;
|
||||
const isInSubscriptionsView = viewType ? viewType === 'subs' : false;
|
||||
const isInModView = viewType ? viewType === 'mod' : false;
|
||||
|
||||
// Resolve subplebbit address from cache props or URL
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
const resolvedAddressFromUrl = useResolvedSubplebbitAddress();
|
||||
const subplebbitAddress = useMemo(() => {
|
||||
if (boardIdentifierProp) {
|
||||
return getSubplebbitAddress(boardIdentifierProp, defaultSubplebbits);
|
||||
}
|
||||
return resolvedAddressFromUrl;
|
||||
}, [boardIdentifierProp, defaultSubplebbits, resolvedAddressFromUrl]);
|
||||
|
||||
const boardPath = useBoardPath(subplebbitAddress);
|
||||
const defaultSubplebbitAddresses = useDefaultSubplebbitAddresses();
|
||||
|
||||
const account = useAccount();
|
||||
const subscriptions = account?.subscriptions;
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
|
||||
const isInModView = isModView(location.pathname);
|
||||
const { accountSubplebbits } = useAccountSubplebbits();
|
||||
const accountSubplebbitAddresses = Object.keys(accountSubplebbits);
|
||||
|
||||
@@ -65,7 +86,9 @@ const Board = () => {
|
||||
}, [isInAllView, isInSubscriptionsView, isInModView, subplebbitAddress, defaultSubplebbitAddresses, subscriptions, accountSubplebbitAddresses]);
|
||||
|
||||
const { sortType } = useSortingStore();
|
||||
const { timeFilterSeconds, timeFilterName } = useTimeFilter();
|
||||
const { timeFilterSeconds, timeFilterName: timeFilterNameFromHook } = useTimeFilter();
|
||||
// Use time filter from cache if provided
|
||||
const timeFilterName = timeFilterNameFromCache || timeFilterNameFromHook;
|
||||
|
||||
const feedOptions = {
|
||||
subplebbitAddresses,
|
||||
@@ -82,8 +105,11 @@ const Board = () => {
|
||||
|
||||
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
||||
useEffect(() => {
|
||||
setResetFunction(reset);
|
||||
}, [reset, setResetFunction, feed]);
|
||||
// Only set reset function when this feed is visible
|
||||
if (isVisible) {
|
||||
setResetFunction(reset);
|
||||
}
|
||||
}, [reset, setResetFunction, feed, isVisible]);
|
||||
|
||||
// show account comments instantly in the feed once published (cid defined), instead of waiting for the feed to update
|
||||
const filteredComments = useMemo(
|
||||
@@ -175,7 +201,6 @@ const Board = () => {
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const params = useParams();
|
||||
const currentTimeFilterName = params?.timeFilterName || timeFilterName;
|
||||
|
||||
const Footer = () => {
|
||||
@@ -272,21 +297,23 @@ const Board = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// save the last Virtuoso state to restore it when navigating back
|
||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||
// include pathname in key so each board has its own scroll state
|
||||
const virtuosoStateKey = `${location.pathname}-${sortType}-${timeFilterSeconds}`;
|
||||
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}-${timeFilterSeconds}` : `${location.pathname}-${sortType}-${timeFilterSeconds}`;
|
||||
const navigationType = useNavigationType();
|
||||
|
||||
// When entering a board via link (PUSH/REPLACE), force scroll to top to avoid inheriting prior view scroll.
|
||||
const hasBeenVisibleRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (navigationType !== 'POP') {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
if (isVisible && !hasBeenVisibleRef.current) {
|
||||
hasBeenVisibleRef.current = true;
|
||||
if (navigationType !== 'POP') {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
}
|
||||
}
|
||||
}, [navigationType, location.pathname]);
|
||||
}, [isVisible, navigationType]);
|
||||
|
||||
useEffect(() => {
|
||||
// capture the key at effect creation time to prevent race conditions during navigation
|
||||
if (!isVisible) return;
|
||||
|
||||
const currentKey = virtuosoStateKey;
|
||||
const setLastVirtuosoState = () => {
|
||||
virtuosoRef.current?.getState((snapshot: StateSnapshot) => {
|
||||
@@ -297,15 +324,15 @@ const Board = () => {
|
||||
};
|
||||
window.addEventListener('scroll', setLastVirtuosoState);
|
||||
return () => window.removeEventListener('scroll', setLastVirtuosoState);
|
||||
}, [virtuosoStateKey]);
|
||||
}, [virtuosoStateKey, isVisible]);
|
||||
|
||||
// only restore scroll state on back/forward navigation (POP), not when clicking links (PUSH)
|
||||
const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
const boardTitle = title ? title : shortAddress || subplebbitAddress;
|
||||
document.title = boardTitle + ' - 5chan';
|
||||
}, [title, shortAddress, subplebbitAddress]);
|
||||
}, [title, shortAddress, subplebbitAddress, isVisible]);
|
||||
|
||||
const shouldShowErrorToUser = error?.message && feed.length === 0;
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Comment, useAccount, useFeed, useSubplebbit, useBlock, useAccountComments } from '@plebbit/plebbit-react-hooks';
|
||||
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
||||
import { getCommentMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils';
|
||||
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import useCatalogFeedRows from '../../hooks/use-catalog-feed-rows';
|
||||
import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits';
|
||||
import { useResolvedSubplebbitAddress, useBoardPath } from '../../hooks/use-resolved-subplebbit-address';
|
||||
@@ -16,6 +15,7 @@ import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
import useInterfaceSettingsStore from '../../stores/use-interface-settings-store';
|
||||
import useSortingStore from '../../stores/use-sorting-store';
|
||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||
import { getSubplebbitAddress } from '../../lib/utils/route-utils';
|
||||
import CatalogRow from '../../components/catalog-row';
|
||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
||||
import styles from './catalog.module.css';
|
||||
@@ -123,20 +123,40 @@ const createCombinedFilter = (
|
||||
};
|
||||
};
|
||||
|
||||
const Catalog = () => {
|
||||
export interface CatalogProps {
|
||||
// Props from FeedCacheContainer for cached feeds
|
||||
feedCacheKey?: string;
|
||||
viewType?: 'all' | 'subs' | 'mod' | 'board';
|
||||
boardIdentifier?: string;
|
||||
timeFilterNameFromCache?: string;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, timeFilterNameFromCache, isVisible = true }: CatalogProps) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const subplebbitAddress = useResolvedSubplebbitAddress();
|
||||
const boardPath = useBoardPath(subplebbitAddress);
|
||||
const params = useParams();
|
||||
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
// Use props from cache if provided, otherwise fall back to URL-derived values
|
||||
const isInAllView = viewType ? viewType === 'all' : false;
|
||||
const isInSubscriptionsView = viewType ? viewType === 'subs' : false;
|
||||
|
||||
// Resolve subplebbit address from cache props or URL
|
||||
const defaultSubplebbits = useDefaultSubplebbits();
|
||||
const resolvedAddressFromUrl = useResolvedSubplebbitAddress();
|
||||
const subplebbitAddress = useMemo(() => {
|
||||
if (boardIdentifierProp) {
|
||||
return getSubplebbitAddress(boardIdentifierProp, defaultSubplebbits);
|
||||
}
|
||||
return resolvedAddressFromUrl;
|
||||
}, [boardIdentifierProp, defaultSubplebbits, resolvedAddressFromUrl]);
|
||||
|
||||
const boardPath = useBoardPath(subplebbitAddress);
|
||||
const { hideAdultBoards } = useInterfaceSettingsStore();
|
||||
const { showTextOnlyThreads, filterItems, searchText, clearMatchedFilters } = useCatalogFiltersStore();
|
||||
|
||||
const account = useAccount();
|
||||
const subscriptions = account?.subscriptions;
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
|
||||
const subplebbitAddresses = useMemo(() => {
|
||||
const filteredDefaultSubplebbits = defaultSubplebbits
|
||||
@@ -166,8 +186,10 @@ const Catalog = () => {
|
||||
const columnCount = Math.floor(useWindowWidth() / columnWidth);
|
||||
const postsPerPage = columnCount <= 2 ? 10 : columnCount === 3 ? 15 : columnCount === 4 ? 20 : 25;
|
||||
|
||||
const { timeFilterSeconds, timeFilterName } = useTimeFilter();
|
||||
const { timeFilterSeconds, timeFilterName: timeFilterNameFromHook } = useTimeFilter();
|
||||
const { sortType } = useSortingStore();
|
||||
// Use time filter from cache if provided
|
||||
const timeFilterName = timeFilterNameFromCache || timeFilterNameFromHook;
|
||||
|
||||
// Create a stable callback for filter matching
|
||||
const handleFilterMatch = useCallback((filterIndex: number, cid: string, subplebbitAddress: string) => {
|
||||
@@ -303,8 +325,11 @@ const Catalog = () => {
|
||||
|
||||
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
||||
useEffect(() => {
|
||||
setResetFunction(reset);
|
||||
}, [reset, setResetFunction]);
|
||||
// Only set reset function when this feed is visible
|
||||
if (isVisible) {
|
||||
setResetFunction(reset);
|
||||
}
|
||||
}, [reset, setResetFunction, isVisible]);
|
||||
|
||||
const subplebbit = useSubplebbit({ subplebbitAddress });
|
||||
const { error, shortAddress, state, title } = subplebbit || {};
|
||||
@@ -344,7 +369,6 @@ const Catalog = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const params = useParams<{ sortType?: string; timeFilterName?: string }>();
|
||||
const currentTimeFilterName = params?.timeFilterName || timeFilterName;
|
||||
|
||||
const Footer = () => {
|
||||
@@ -450,21 +474,23 @@ const Catalog = () => {
|
||||
|
||||
const rows = useCatalogFeedRows(columnCount, processedFeed, isFeedLoaded, subplebbit);
|
||||
|
||||
// save the last Virtuoso state to restore it when navigating back
|
||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||
// include pathname in key so each board has its own scroll state
|
||||
const virtuosoStateKey = `${location.pathname}-${sortType}-${timeFilterSeconds}-catalog`;
|
||||
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}-${timeFilterSeconds}` : `${location.pathname}-${sortType}-${timeFilterSeconds}-catalog`;
|
||||
const navigationType = useNavigationType();
|
||||
|
||||
// When entering a board via link (PUSH/REPLACE), force scroll to top to avoid inheriting prior view scroll.
|
||||
const hasBeenVisibleRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (navigationType !== 'POP') {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
if (isVisible && !hasBeenVisibleRef.current) {
|
||||
hasBeenVisibleRef.current = true;
|
||||
if (navigationType !== 'POP') {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
|
||||
}
|
||||
}
|
||||
}, [navigationType, location.pathname]);
|
||||
}, [isVisible, navigationType]);
|
||||
|
||||
useEffect(() => {
|
||||
// capture the key at effect creation time to prevent race conditions during navigation
|
||||
if (!isVisible) return;
|
||||
|
||||
const currentKey = virtuosoStateKey;
|
||||
const setLastVirtuosoState = () =>
|
||||
virtuosoRef.current?.getState((snapshot: StateSnapshot) => {
|
||||
@@ -474,17 +500,17 @@ const Catalog = () => {
|
||||
});
|
||||
window.addEventListener('scroll', setLastVirtuosoState);
|
||||
return () => window.removeEventListener('scroll', setLastVirtuosoState);
|
||||
}, [virtuosoStateKey]);
|
||||
}, [virtuosoStateKey, isVisible]);
|
||||
|
||||
// only restore scroll state on back/forward navigation (POP), not when clicking links (PUSH)
|
||||
const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
let documentTitle = title ? title : shortAddress;
|
||||
if (isInAllView) documentTitle = t('all');
|
||||
else if (isInSubscriptionsView) documentTitle = t('subscriptions');
|
||||
document.title = documentTitle + ` - ${t('catalog')} - 5chan`;
|
||||
}, [title, shortAddress, isInAllView, isInSubscriptionsView, t]);
|
||||
}, [title, shortAddress, isInAllView, isInSubscriptionsView, t, isVisible]);
|
||||
|
||||
// Clear matched filters when component mounts or when subplebbit changes
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user