From 896ca4ad13072f38fbb1e81e9f2147ed99cbe009 Mon Sep 17 00:00:00 2001 From: plebeius Date: Mon, 8 Dec 2025 22:40:16 +0100 Subject: [PATCH] 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 --- src/app.tsx | 57 ++++++----- .../feed-cache-container.module.css | 13 +++ .../feed-cache-container.tsx | 99 +++++++++++++++++++ src/components/feed-cache-container/index.ts | 1 + src/lib/utils/route-utils.ts | 82 +++++++++++++++ src/stores/use-feed-cache-store.ts | 54 ++++++++++ src/views/board/board.tsx | 73 +++++++++----- src/views/catalog/catalog.tsx | 68 +++++++++---- 8 files changed, 376 insertions(+), 71 deletions(-) create mode 100644 src/components/feed-cache-container/feed-cache-container.module.css create mode 100644 src/components/feed-cache-container/feed-cache-container.tsx create mode 100644 src/components/feed-cache-container/index.ts create mode 100644 src/stores/use-feed-cache-store.ts diff --git a/src/app.tsx b/src/app.tsx index b1c1aee1..cadb588f 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -9,10 +9,8 @@ import useSpecialThemeStore from './stores/use-special-theme-store'; import useIsMobile from './hooks/use-is-mobile'; import useTheme from './hooks/use-theme'; import { useDefaultSubplebbits } from './hooks/use-default-subplebbits'; -import { getSubplebbitAddress } from './lib/utils/route-utils'; +import { getSubplebbitAddress, isPostRoute, isPendingPostRoute } from './lib/utils/route-utils'; import styles from './app.module.css'; -import Board from './views/board'; -import Catalog from './views/catalog'; import FAQ from './views/faq'; import Home from './views/home'; import NotFound from './views/not-found'; @@ -22,6 +20,7 @@ import { DesktopBoardButtons, MobileBoardButtons } from './components/board-butt import BoardHeader from './components/board-header'; import ChallengeModal from './components/challenge-modal'; import CreateBoardModal from './components/create-board-modal'; +import FeedCacheContainer from './components/feed-cache-container'; import ReplyModal from './components/reply-modal'; import PostForm from './components/post-form'; import SubplebbitStats from './components/subplebbit-stats'; @@ -43,6 +42,9 @@ const BoardLayout = () => { const pendingPost = useAccountComment({ commentIndex: accountCommentIndex ? parseInt(accountCommentIndex) : undefined }); const { closeCreateBoardModal } = useCreateBoardModalStore(); + const isOnPostRoute = isPostRoute(location.pathname); + const isOnPendingPostRoute = isPendingPostRoute(location.pathname); + // Christmas theme const { isEnabled: isSpecialEnabled } = useSpecialThemeStore(); useEffect(() => { @@ -86,7 +88,8 @@ const BoardLayout = () => { )} - + + {(isOnPostRoute || isOnPendingPostRoute) && } ); }; @@ -141,36 +144,36 @@ const App = () => ( } /> } /> }> - } /> - } /> + + + + + + + + + + + + + + + + + + + + } /> - } /> - } /> - - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - - } /> - } /> - - } /> - } /> - } /> - } /> - } /> } /> } /> } /> } /> } /> + + } /> + } /> } /> } /> diff --git a/src/components/feed-cache-container/feed-cache-container.module.css b/src/components/feed-cache-container/feed-cache-container.module.css new file mode 100644 index 00000000..79003f5c --- /dev/null +++ b/src/components/feed-cache-container/feed-cache-container.module.css @@ -0,0 +1,13 @@ +.visible { +} + +.hidden { + visibility: hidden; + position: absolute; + top: 0; + left: 0; + width: 100%; + pointer-events: none; + overflow: hidden; + height: 0; +} diff --git a/src/components/feed-cache-container/feed-cache-container.tsx b/src/components/feed-cache-container/feed-cache-container.tsx new file mode 100644 index 00000000..3547e1ec --- /dev/null +++ b/src/components/feed-cache-container/feed-cache-container.tsx @@ -0,0 +1,99 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import useFeedCacheStore, { CachedFeed } from '../../stores/use-feed-cache-store'; +import { getFeedCacheKey, getFeedType, isFeedRoute } from '../../lib/utils/route-utils'; +import Board from '../../views/board'; +import Catalog from '../../views/catalog'; +import styles from './feed-cache-container.module.css'; + +interface FeedContextFromKey { + viewType: 'all' | 'subs' | 'mod' | 'board'; + boardIdentifier?: string; + timeFilterName?: string; +} + +const parseFeedKey = (key: string): FeedContextFromKey => { + const segments = key.split('/').filter(Boolean); + + const filteredSegments = segments.filter((s) => s !== 'catalog'); + if (filteredSegments[0] === 'all') { + return { + viewType: 'all', + timeFilterName: filteredSegments[1], + }; + } + if (filteredSegments[0] === 'subs') { + return { + viewType: 'subs', + timeFilterName: filteredSegments[1], + }; + } + if (filteredSegments[0] === 'mod') { + return { + viewType: 'mod', + timeFilterName: filteredSegments[1], + }; + } + + return { + viewType: 'board', + boardIdentifier: filteredSegments[0], + timeFilterName: filteredSegments[1], + }; +}; + +interface CachedFeedWrapperProps { + feed: CachedFeed; + isVisible: boolean; +} + +const CachedFeedWrapper = ({ feed, isVisible }: CachedFeedWrapperProps) => { + const context = parseFeedKey(feed.key); + + return ( +
+ {feed.type === 'catalog' ? ( + + ) : ( + + )} +
+ ); +}; + +const FeedCacheContainer = () => { + const location = useLocation(); + const { cachedFeeds, accessFeed } = useFeedCacheStore(); + + const currentFeedKey = getFeedCacheKey(location.pathname); + const isOnFeedRoute = isFeedRoute(location.pathname); + const feedType = getFeedType(location.pathname); + + useEffect(() => { + if (isOnFeedRoute && currentFeedKey && feedType) { + accessFeed(currentFeedKey, feedType); + } + }, [currentFeedKey, isOnFeedRoute, feedType, accessFeed]); + + return ( + <> + {cachedFeeds.map((feed) => ( + + ))} + + ); +}; + +export default FeedCacheContainer; diff --git a/src/components/feed-cache-container/index.ts b/src/components/feed-cache-container/index.ts new file mode 100644 index 00000000..74e8a166 --- /dev/null +++ b/src/components/feed-cache-container/index.ts @@ -0,0 +1 @@ +export { default } from './feed-cache-container'; diff --git a/src/lib/utils/route-utils.ts b/src/lib/utils/route-utils.ts index 265154fa..693dc990 100644 --- a/src/lib/utils/route-utils.ts +++ b/src/lib/utils/route-utils.ts @@ -100,3 +100,85 @@ export const isDirectoryBoard = (identifier: string, subplebbits: MultisubSubple const directoryToAddress = getDirectoryToAddressMap(subplebbits); return directoryToAddress.has(identifier); }; + +export const isFeedRoute = (pathname: string): boolean => { + const normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; + + if (normalizedPath.includes('/thread/')) return false; + if (normalizedPath.endsWith('/description')) return false; + if (normalizedPath.endsWith('/rules')) return false; + if (normalizedPath.startsWith('/pending/')) return false; + + const pathWithoutSettings = normalizedPath.replace(/\/settings$/, ''); + + if (pathWithoutSettings.startsWith('/all')) return true; + if (pathWithoutSettings.startsWith('/subs')) return true; + if (pathWithoutSettings.startsWith('/mod')) return true; + + const segments = pathWithoutSettings.split('/').filter(Boolean); + if (segments.length >= 1) { + if (segments.length === 1) return true; + if (segments.length === 2 && segments[1] === 'catalog') return true; + if (segments.length === 2 && /^(1h|24h|1w|1m|1y|all)$/.test(segments[1])) return true; + if (segments.length === 3 && segments[1] === 'catalog' && /^(1h|24h|1w|1m|1y|all)$/.test(segments[2])) return true; + } + + return false; +}; + +export const isPostRoute = (pathname: string): boolean => { + const normalizedPath = pathname.replace(/\/settings$/, ''); + + if (normalizedPath.includes('/thread/')) return true; + if (normalizedPath.endsWith('/description')) return true; + if (normalizedPath.endsWith('/rules')) return true; + + return false; +}; + +export const isPendingPostRoute = (pathname: string): boolean => { + const normalizedPath = pathname.replace(/\/settings$/, ''); + return normalizedPath.startsWith('/pending/'); +}; + +export const getFeedCacheKey = (pathname: string): string | null => { + let normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; + normalizedPath = normalizedPath.replace(/\/settings$/, ''); + + if (normalizedPath.includes('/thread/')) { + const parts = normalizedPath.split('/thread/'); + return parts[0] || null; + } + + if (normalizedPath.endsWith('/description') || normalizedPath.endsWith('/rules')) { + return normalizedPath.replace(/\/(description|rules)$/, ''); + } + + if (normalizedPath.startsWith('/pending/')) { + return null; + } + + if (isFeedRoute(pathname)) { + return normalizedPath; + } + + return null; +}; + +export const getFeedType = (pathname: string): 'board' | 'catalog' | null => { + const normalizedPath = pathname.replace(/\/settings$/, ''); + + if (normalizedPath.includes('/catalog')) { + return 'catalog'; + } + + if (isFeedRoute(pathname)) { + return 'board'; + } + + if (isPostRoute(pathname)) { + return 'board'; + } + + return null; +}; diff --git a/src/stores/use-feed-cache-store.ts b/src/stores/use-feed-cache-store.ts new file mode 100644 index 00000000..5fbbafc5 --- /dev/null +++ b/src/stores/use-feed-cache-store.ts @@ -0,0 +1,54 @@ +import { create } from 'zustand'; + +export interface CachedFeed { + key: string; + type: 'board' | 'catalog'; + lastAccessed: number; +} + +interface FeedCacheState { + cachedFeeds: CachedFeed[]; + maxCacheSize: number; + accessFeed: (key: string, type: 'board' | 'catalog') => void; + removeFeed: (key: string) => void; + isFeedCached: (key: string) => boolean; +} + +const useFeedCacheStore = create((set, get) => ({ + cachedFeeds: [], + maxCacheSize: 2, + + accessFeed: (key: string, type: 'board' | 'catalog') => { + const { cachedFeeds, maxCacheSize } = get(); + const now = Date.now(); + const existingIndex = cachedFeeds.findIndex((feed) => feed.key === key); + + if (existingIndex !== -1) { + const updatedFeeds = [...cachedFeeds]; + updatedFeeds[existingIndex] = { ...updatedFeeds[existingIndex], lastAccessed: now }; + set({ cachedFeeds: updatedFeeds }); + } else { + const newFeed: CachedFeed = { key, type, lastAccessed: now }; + let updatedFeeds = [...cachedFeeds, newFeed]; + + if (updatedFeeds.length > maxCacheSize) { + updatedFeeds.sort((a, b) => a.lastAccessed - b.lastAccessed); + updatedFeeds = updatedFeeds.slice(1); + } + + set({ cachedFeeds: updatedFeeds }); + } + }, + + removeFeed: (key: string) => { + const { cachedFeeds } = get(); + set({ cachedFeeds: cachedFeeds.filter((feed) => feed.key !== key) }); + }, + + isFeedCached: (key: string) => { + const { cachedFeeds } = get(); + return cachedFeeds.some((feed) => feed.key === key); + }, +})); + +export default useFeedCacheStore; diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx index 19164450..198b1ad6 100644 --- a/src/views/board/board.tsx +++ b/src/views/board/board.tsx @@ -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(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; diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index 7594b42e..eb0693d0 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -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 = () => { ); - 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(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(() => {