fix(catalog): stop virtualizing single-board catalogs (#1121)

* fix(catalog): defer pretext row work and trim multiboard append batches

* fix(catalog): stop virtualizing single-board catalogs
This commit is contained in:
Tommaso Casaburi
2026-04-03 15:08:22 +07:00
committed by GitHub
parent 251e103db3
commit 7ba28becc5
4 changed files with 210 additions and 49 deletions
+47 -6
View File
@@ -43,6 +43,7 @@ const testState = vi.hoisted(() => ({
} as Record<string, { address: string; features?: Record<string, unknown> }>,
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<HTMLButtonElement>('[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: [] };
+104 -31
View File
@@ -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(
() => (
<>
<CatalogFooter communityAddresses={communityAddresses} hasMore={hasMore} combinedFeedLength={cappedFeed.length} showLoadingEllipsis={effectiveInfiniteScroll} />
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
communityAddress={communityAddress}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
/>
}
/>
<PageFooterMobile>
<div className={mobileFooterStyles.mobileFooterButtons}>
<ReturnButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<ArchiveButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<TopButton />
<RefreshButton />
</div>
</PageFooterMobile>
</>
),
[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<string, string>();
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<string, Comment[]>());
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<string, Comment[]>();
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<VirtuosoHandle | null>(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[]) => <CatalogRow estimatedHeight={rowHeightEstimates[index]} index={index} matchedFilterColors={matchedFilterColors} row={row} />,
[matchedFilterColors, rowHeightEstimates],
);
useEffect(() => {
if (!isVisible) return;
@@ -536,21 +594,36 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
<div className={styles.catalog}>
{processedFeed?.length !== 0 ? (
<>
<Virtuoso
defaultItemHeight={defaultCatalogRowHeight}
heightEstimates={catalogVirtualizationMode === 'off' ? undefined : rowHeightEstimates}
increaseViewportBy={catalogViewportBuffer}
{...catalogSizingProps}
totalCount={rows?.length || 0}
data={rows}
itemContent={(index, row) => <CatalogRow estimatedHeight={rowHeightEstimates[index]} index={index} matchedFilterColors={matchedFilterColors} row={row} />}
useWindowScroll={true}
components={footerComponents}
endReached={effectiveInfiniteScroll && hasMore ? loadMore : undefined}
ref={virtuosoRef}
restoreStateFrom={lastVirtuosoState}
initialScrollTop={lastVirtuosoState?.scrollTop}
/>
{shouldVirtualizeCatalog ? (
<Virtuoso
defaultItemHeight={defaultCatalogRowHeight}
heightEstimates={catalogVirtualizationMode === 'off' ? undefined : rowHeightEstimates}
increaseViewportBy={catalogViewportBuffer}
{...catalogSizingProps}
totalCount={rows?.length || 0}
data={rows}
itemContent={renderCatalogRow}
useWindowScroll={true}
components={footerComponents}
endReached={effectiveInfiniteScroll && hasMore ? loadMore : undefined}
ref={virtuosoRef}
restoreStateFrom={lastVirtuosoState}
initialScrollTop={lastVirtuosoState?.scrollTop}
/>
) : (
<>
{rows.map((row, index) => (
<CatalogRow
key={row.map((post) => post?.cid || '').join('\u0000') || `row-${index}`}
estimatedHeight={rowHeightEstimates[index]}
index={index}
matchedFilterColors={matchedFilterColors}
row={row}
/>
))}
{catalogFooter}
</>
)}
</>
) : (
<>