diff --git a/src/components/catalog-row/catalog-row.tsx b/src/components/catalog-row/catalog-row.tsx index f71b567c..515cb857 100644 --- a/src/components/catalog-row/catalog-row.tsx +++ b/src/components/catalog-row/catalog-row.tsx @@ -357,14 +357,36 @@ interface CatalogRowProps { row: Comment[]; } -const CatalogRow = memo(({ estimatedHeight, matchedFilterColors, row }: CatalogRowProps) => { - return ( -
- {row.map((post, index) => ( - - ))} -
- ); -}); +const CatalogRow = memo( + ({ estimatedHeight, matchedFilterColors, row }: CatalogRowProps) => { + return ( +
+ {row.map((post, index) => ( + + ))} +
+ ); + }, + (prevProps, nextProps) => { + if (prevProps.estimatedHeight !== nextProps.estimatedHeight || prevProps.row.length !== nextProps.row.length) { + return false; + } + + for (let index = 0; index < prevProps.row.length; index += 1) { + const prevPost = prevProps.row[index]; + const nextPost = nextProps.row[index]; + if (prevPost !== nextPost) { + return false; + } + + const cid = prevPost?.cid || ''; + if (prevProps.matchedFilterColors?.get(cid) !== nextProps.matchedFilterColors?.get(cid)) { + return false; + } + } + + return true; + }, +); export default CatalogRow; diff --git a/src/lib/utils/pretext-height-estimates.ts b/src/lib/utils/pretext-height-estimates.ts index b0f39982..0b4aa0d7 100644 --- a/src/lib/utils/pretext-height-estimates.ts +++ b/src/lib/utils/pretext-height-estimates.ts @@ -137,6 +137,8 @@ const preparedSegmentCache = new Map(); const paragraphHeightCache = new WeakMap>(); const paragraphFloatHeightCache = new WeakMap>(); const nestedPretextElementCache = new WeakMap(); +const catalogPostHeightEstimateCache = new Map(); +const catalogRowHeightEstimateCache = new Map(); let pretextSupport: boolean | undefined; @@ -242,6 +244,9 @@ const getCatalogCardWidth = (imageSize: CatalogImageSize): number => (imageSize const getCatalogMediaMaxSize = (imageSize: CatalogImageSize): number => (imageSize === 'Large' ? LARGE_CATALOG_MEDIA_SIZE : SMALL_CATALOG_MEDIA_SIZE); +const getCatalogEstimateCachePrefix = (imageSize: CatalogImageSize, metrics: ReplyTypographyMetrics, showOPComment: boolean): string => + [imageSize, showOPComment ? '1' : '0', metrics.bodyFontFamily, metrics.bodyFontSizePx].join('\u0000'); + const clampEstimateHeight = (value: number): number => Math.max(MIN_ESTIMATE_HEIGHT, Math.ceil(value)); const getMedianEstimate = (estimates: number[], fallback: number): number => { @@ -701,6 +706,12 @@ export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPC return DEFAULT_CATALOG_ROW_HEIGHT - CATALOG_ROW_PADDING_TOP; } + const cacheKey = post.cid ? `${getCatalogEstimateCachePrefix(imageSize, metrics, showOPComment)}\u0000post\u0000${post.cid}` : undefined; + const cachedHeight = cacheKey ? catalogPostHeightEstimateCache.get(cacheKey) : undefined; + if (cachedHeight !== undefined) { + return cachedHeight; + } + const cardWidth = getCatalogCardWidth(imageSize); const fontSizePx = metrics.bodyFontSizePx; const font = `${fontSizePx}px ${metrics.bodyFontFamily}`; @@ -717,16 +728,30 @@ export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPC CATALOG_CARD_MAX_HEIGHT, CATALOG_CARD_MARGIN_Y + CATALOG_CARD_PADDING_TOP + CATALOG_CARD_PADDING_BOTTOM + mediaHeight + CATALOG_CARD_META_HEIGHT + textHeight, ); - - return Math.max(MIN_ESTIMATE_HEIGHT, Math.ceil(rawHeight)); + const estimatedHeight = Math.max(MIN_ESTIMATE_HEIGHT, Math.ceil(rawHeight)); + if (cacheKey) { + catalogPostHeightEstimateCache.set(cacheKey, estimatedHeight); + } + return estimatedHeight; }; export const getCatalogRowHeightEstimate = ({ imageSize, metrics, row, showOPComment }: CatalogSingleRowHeightEstimateOptions): number => { + const cacheKey = + row.length > 0 ? `${getCatalogEstimateCachePrefix(imageSize, metrics, showOPComment)}\u0000row\u0000${row.map((post) => post?.cid || '').join(',')}` : undefined; + const cachedHeight = cacheKey ? catalogRowHeightEstimateCache.get(cacheKey) : undefined; + if (cachedHeight !== undefined) { + return cachedHeight; + } + const tallestCardHeight = row.reduce((maxHeight, post) => { return Math.max(maxHeight, getCatalogPostHeightEstimate({ imageSize, metrics, post, showOPComment })); }, DEFAULT_CATALOG_ROW_HEIGHT - CATALOG_ROW_PADDING_TOP); - return clampEstimateHeight(CATALOG_ROW_PADDING_TOP + tallestCardHeight); + const estimatedHeight = clampEstimateHeight(CATALOG_ROW_PADDING_TOP + tallestCardHeight); + if (cacheKey) { + catalogRowHeightEstimateCache.set(cacheKey, estimatedHeight); + } + return estimatedHeight; }; export const getCatalogRowHeightEstimates = ({ imageSize, metrics, rows, showOPComment }: CatalogRowHeightEstimateOptions): number[] => { diff --git a/src/views/catalog/__tests__/catalog.test.tsx b/src/views/catalog/__tests__/catalog.test.tsx index ee53ff5f..32ab1308 100644 --- a/src/views/catalog/__tests__/catalog.test.tsx +++ b/src/views/catalog/__tests__/catalog.test.tsx @@ -43,6 +43,7 @@ const testState = vi.hoisted(() => ({ } as Record }>, directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, feed: [] as TestComment[], + feedOptionsCalls: [] as Array<{ postsPerPage?: number }>, filterItems: [] as FilterItem[], filteredDirectoryAddresses: ['music-posting.eth'] as string[], hasMore: false, @@ -122,12 +123,15 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ testState.accountCommentsCalls.push(options); return { accountComments: getScopedAccountComments(options) }; }, - useFeed: (options: { filter?: { filter: (comment: TestComment) => boolean } }) => ({ - feed: options.filter ? testState.feed.filter((comment) => options.filter?.filter(comment)) : testState.feed, - hasMore: testState.hasMore, - loadMore: testState.loadMoreMock, - reset: testState.resetMock, - }), + useFeed: (options: { filter?: { filter: (comment: TestComment) => boolean }; postsPerPage?: number }) => { + testState.feedOptionsCalls.push({ postsPerPage: options.postsPerPage }); + return { + feed: options.filter ? testState.feed.filter((comment) => options.filter?.filter(comment)) : testState.feed, + hasMore: testState.hasMore, + loadMore: testState.loadMoreMock, + reset: testState.resetMock, + }; + }, useCommunity: () => testState.community, })); @@ -305,6 +309,7 @@ describe('Catalog', () => { }, }; testState.feed = []; + testState.feedOptionsCalls = []; testState.filterItems = []; testState.filteredDirectoryAddresses = ['music-posting.eth']; testState.hasMore = false; @@ -368,6 +373,18 @@ describe('Catalog', () => { root = createRoot(container); }); + it('renders single-board catalogs without Virtuoso virtualization', async () => { + testState.feed = [ + { cid: 'board-post-1', title: 'one', communityAddress: 'music-posting.eth' }, + { cid: 'board-post-2', title: 'two', communityAddress: 'music-posting.eth' }, + ]; + + await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' }); + + expect(container.querySelector('[data-testid="virtuoso"]')).toBeNull(); + expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:board-post-1,board-post-2']); + }); + it('canonicalizes multiboard catalog paths and keeps load-more wired for infinite scrolling', async () => { testState.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }]; testState.hasMore = true; @@ -379,6 +396,7 @@ describe('Catalog', () => { }); expect(latestLocation).toBe('/all/catalog'); + expect(testState.feedOptionsCalls.at(-1)?.postsPerPage).toBe(24); await act(async () => { container.querySelector('[data-testid="end-reached"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -387,6 +405,29 @@ describe('Catalog', () => { expect(testState.loadMoreMock).toHaveBeenCalledTimes(1); }); + it('captures Virtuoso state on pagehide instead of wiring a scroll hot-path listener', async () => { + testState.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }]; + const addEventListenerSpy = vi.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener'); + + await renderCatalog({ + catalogProps: { viewType: 'all' }, + initialEntry: '/all/catalog', + routePath: '/all/*', + }); + + expect(addEventListenerSpy.mock.calls.some(([eventName]) => String(eventName) === 'pagehide')).toBe(true); + + act(() => root.unmount()); + + expect(removeEventListenerSpy.mock.calls.some(([eventName]) => String(eventName) === 'pagehide')).toBe(true); + + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + + root = createRoot(container); + }); + it('shows the empty subscriptions state when there are no subscribed boards to browse', async () => { testState.account = { subscriptions: [] }; diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index 105c86d6..0f1041d3 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useCallback } from 'react'; +import { useDeferredValue, useEffect, useMemo, useRef, useCallback } from 'react'; import { useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Comment, useAccount, useCommunity, useFeed, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks'; @@ -236,6 +236,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, const windowWidth = useWindowWidth(); const isMobile = useIsMobile(); const columnCount = Math.floor(windowWidth / columnWidth); + const multiboardCatalogPostsPerPage = Math.max(18, Math.min(24, Math.max(columnCount, 1) * 5)); const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress); const { guiPostsPerPage: boardPostsPerPage, maxGuiPages, paginationFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory); @@ -271,10 +272,20 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, return { communityAddresses, sortType: feedSortType, - postsPerPage: isMultiboard ? 10 : paginationFeedPostsPerPage, + postsPerPage: isMultiboard ? multiboardCatalogPostsPerPage : paginationFeedPostsPerPage, filter: createCombinedFilter(filterItems, searchText, communityAddress || 'all', handleFilterMatch), }; - }, [communityAddresses, feedSortType, isMultiboard, paginationFeedPostsPerPage, filterItems, searchText, communityAddress, handleFilterMatch]); + }, [ + communityAddresses, + feedSortType, + isMultiboard, + paginationFeedPostsPerPage, + multiboardCatalogPostsPerPage, + filterItems, + searchText, + communityAddress, + handleFilterMatch, + ]); const { feed, hasMore, loadMore, reset } = useFeed(feedOptions); const accountCommentLookupOptions = useMemo( @@ -390,6 +401,32 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, }), [communityAddresses, hasMore, cappedFeed.length, communityAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll], ); + const catalogFooter = useMemo( + () => ( + <> + + + } + /> + +
+ + + + +
+
+ + ), + [communityAddresses, hasMore, cappedFeed.length, communityAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll], + ); const isFeedLoaded = feed.length > 0 || state === 'failed'; @@ -426,6 +463,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, return [...topPosts, ...regularPosts]; }, [sortedFeed, filterItems]); + const deferredProcessedFeed = useDeferredValue(processedFeed); + const matchedFilterColors = useMemo(() => { const nextMatchedFilterColors = new Map(); const activeColoredFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '' && item.color); @@ -434,7 +473,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, return nextMatchedFilterColors; } - for (const comment of processedFeed) { + for (const comment of deferredProcessedFeed) { const cid = comment?.cid; if (!cid) { continue; @@ -447,20 +486,29 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, } return nextMatchedFilterColors; - }, [filterItems, processedFeed]); + }, [deferredProcessedFeed, filterItems]); + const rowCacheRef = useRef(new Map()); const rows = useMemo(() => { if (!isFeedLoaded) { + rowCacheRef.current.clear(); return []; } const effectiveColumnCount = Math.max(columnCount, 1); - const nextRows = []; - for (let i = 0; i < processedFeed.length; i += effectiveColumnCount) { - nextRows.push(processedFeed.slice(i, i + effectiveColumnCount)); + const nextRows: Comment[][] = []; + const nextRowCache = new Map(); + for (let i = 0; i < deferredProcessedFeed.length; i += effectiveColumnCount) { + const nextRow = deferredProcessedFeed.slice(i, i + effectiveColumnCount); + const rowKey = nextRow.map((post) => post?.cid || '').join('\u0000'); + const cachedRow = rowCacheRef.current.get(rowKey); + const stableRow = cachedRow && cachedRow.length === nextRow.length && cachedRow.every((post, index) => post === nextRow[index]) ? cachedRow : nextRow; + nextRowCache.set(rowKey, stableRow); + nextRows.push(stableRow); } + rowCacheRef.current = nextRowCache; return nextRows; - }, [columnCount, isFeedLoaded, processedFeed]); + }, [columnCount, deferredProcessedFeed, isFeedLoaded]); const catalogMetrics = useMemo(() => readReplyTypographyMetrics(), [themeKey, windowWidth]); const rowHeightEstimates = useMemo( @@ -480,7 +528,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, // Virtuoso's default DOM measurement path and leaves rows on the fallback height. const catalogSizingProps = useMemo(() => (catalogVirtualizationMode === 'item-size' ? { itemSize: getPretextItemSizeFromElement } : {}), [catalogVirtualizationMode]); const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView; - const catalogViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 2400, top: 1200 } : { bottom: 1200, top: 900 }) : { bottom: 1200, top: 1200 }; + const shouldVirtualizeCatalog = isMultiboardView; + const catalogViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 2400, top: 1200 } : { bottom: 900, top: 600 }) : { bottom: 1200, top: 1200 }; const virtuosoRef = useRef(null); const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}-${sortType}-catalog`; @@ -497,20 +546,29 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, }, [isVisible, navigationType]); useEffect(() => { - if (!isVisible) return; + if (!isVisible || !shouldVirtualizeCatalog) return; const currentKey = virtuosoStateKey; - const setLastVirtuosoState = () => + // Avoid pulling Virtuoso state on every scroll tick in the catalog hot path. + const saveVirtuosoState = () => virtuosoRef.current?.getState((snapshot: StateSnapshot) => { if (snapshot?.ranges?.length) { lastVirtuosoStates[currentKey] = snapshot; } }); - window.addEventListener('scroll', setLastVirtuosoState); - return () => window.removeEventListener('scroll', setLastVirtuosoState); - }, [virtuosoStateKey, isVisible]); + window.addEventListener('pagehide', saveVirtuosoState); + return () => { + saveVirtuosoState(); + window.removeEventListener('pagehide', saveVirtuosoState); + }; + }, [virtuosoStateKey, isVisible, shouldVirtualizeCatalog]); - const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined; + const lastVirtuosoState = shouldVirtualizeCatalog && navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined; + + const renderCatalogRow = useCallback( + (index: number, row: Comment[]) => , + [matchedFilterColors, rowHeightEstimates], + ); useEffect(() => { if (!isVisible) return; @@ -536,21 +594,36 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
{processedFeed?.length !== 0 ? ( <> - } - useWindowScroll={true} - components={footerComponents} - endReached={effectiveInfiniteScroll && hasMore ? loadMore : undefined} - ref={virtuosoRef} - restoreStateFrom={lastVirtuosoState} - initialScrollTop={lastVirtuosoState?.scrollTop} - /> + {shouldVirtualizeCatalog ? ( + + ) : ( + <> + {rows.map((row, index) => ( + post?.cid || '').join('\u0000') || `row-${index}`} + estimatedHeight={rowHeightEstimates[index]} + index={index} + matchedFilterColors={matchedFilterColors} + row={row} + /> + ))} + {catalogFooter} + + )} ) : ( <>