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}
+ >
+ )}
>
) : (
<>