mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -357,14 +357,36 @@ interface CatalogRowProps {
|
|||||||
row: Comment[];
|
row: Comment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const CatalogRow = memo(({ estimatedHeight, matchedFilterColors, row }: CatalogRowProps) => {
|
const CatalogRow = memo(
|
||||||
return (
|
({ estimatedHeight, matchedFilterColors, row }: CatalogRowProps) => {
|
||||||
<div className={styles.row} data-pretext-height={estimatedHeight}>
|
return (
|
||||||
{row.map((post, index) => (
|
<div className={styles.row} data-pretext-height={estimatedHeight}>
|
||||||
<CatalogPost key={post?.cid || index} matchedFilterColor={matchedFilterColors?.get(post?.cid || '')} post={post} />
|
{row.map((post, index) => (
|
||||||
))}
|
<CatalogPost key={post?.cid || index} matchedFilterColor={matchedFilterColors?.get(post?.cid || '')} post={post} />
|
||||||
</div>
|
))}
|
||||||
);
|
</div>
|
||||||
});
|
);
|
||||||
|
},
|
||||||
|
(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;
|
export default CatalogRow;
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ const preparedSegmentCache = new Map<string, PreparedTextWithSegments>();
|
|||||||
const paragraphHeightCache = new WeakMap<PreparedText, Map<string, number>>();
|
const paragraphHeightCache = new WeakMap<PreparedText, Map<string, number>>();
|
||||||
const paragraphFloatHeightCache = new WeakMap<PreparedTextWithSegments, Map<string, number>>();
|
const paragraphFloatHeightCache = new WeakMap<PreparedTextWithSegments, Map<string, number>>();
|
||||||
const nestedPretextElementCache = new WeakMap<HTMLElement, HTMLElement | null>();
|
const nestedPretextElementCache = new WeakMap<HTMLElement, HTMLElement | null>();
|
||||||
|
const catalogPostHeightEstimateCache = new Map<string, number>();
|
||||||
|
const catalogRowHeightEstimateCache = new Map<string, number>();
|
||||||
|
|
||||||
let pretextSupport: boolean | undefined;
|
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 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 clampEstimateHeight = (value: number): number => Math.max(MIN_ESTIMATE_HEIGHT, Math.ceil(value));
|
||||||
|
|
||||||
const getMedianEstimate = (estimates: number[], fallback: number): number => {
|
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;
|
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 cardWidth = getCatalogCardWidth(imageSize);
|
||||||
const fontSizePx = metrics.bodyFontSizePx;
|
const fontSizePx = metrics.bodyFontSizePx;
|
||||||
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
|
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
|
||||||
@@ -717,16 +728,30 @@ export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPC
|
|||||||
CATALOG_CARD_MAX_HEIGHT,
|
CATALOG_CARD_MAX_HEIGHT,
|
||||||
CATALOG_CARD_MARGIN_Y + CATALOG_CARD_PADDING_TOP + CATALOG_CARD_PADDING_BOTTOM + mediaHeight + CATALOG_CARD_META_HEIGHT + textHeight,
|
CATALOG_CARD_MARGIN_Y + CATALOG_CARD_PADDING_TOP + CATALOG_CARD_PADDING_BOTTOM + mediaHeight + CATALOG_CARD_META_HEIGHT + textHeight,
|
||||||
);
|
);
|
||||||
|
const estimatedHeight = Math.max(MIN_ESTIMATE_HEIGHT, Math.ceil(rawHeight));
|
||||||
return 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 => {
|
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) => {
|
const tallestCardHeight = row.reduce((maxHeight, post) => {
|
||||||
return Math.max(maxHeight, getCatalogPostHeightEstimate({ imageSize, metrics, post, showOPComment }));
|
return Math.max(maxHeight, getCatalogPostHeightEstimate({ imageSize, metrics, post, showOPComment }));
|
||||||
}, DEFAULT_CATALOG_ROW_HEIGHT - CATALOG_ROW_PADDING_TOP);
|
}, 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[] => {
|
export const getCatalogRowHeightEstimates = ({ imageSize, metrics, rows, showOPComment }: CatalogRowHeightEstimateOptions): number[] => {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
} as Record<string, { address: string; features?: Record<string, unknown> }>,
|
} as Record<string, { address: string; features?: Record<string, unknown> }>,
|
||||||
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
|
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
|
||||||
feed: [] as TestComment[],
|
feed: [] as TestComment[],
|
||||||
|
feedOptionsCalls: [] as Array<{ postsPerPage?: number }>,
|
||||||
filterItems: [] as FilterItem[],
|
filterItems: [] as FilterItem[],
|
||||||
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
|
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
@@ -122,12 +123,15 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
|||||||
testState.accountCommentsCalls.push(options);
|
testState.accountCommentsCalls.push(options);
|
||||||
return { accountComments: getScopedAccountComments(options) };
|
return { accountComments: getScopedAccountComments(options) };
|
||||||
},
|
},
|
||||||
useFeed: (options: { filter?: { filter: (comment: TestComment) => boolean } }) => ({
|
useFeed: (options: { filter?: { filter: (comment: TestComment) => boolean }; postsPerPage?: number }) => {
|
||||||
feed: options.filter ? testState.feed.filter((comment) => options.filter?.filter(comment)) : testState.feed,
|
testState.feedOptionsCalls.push({ postsPerPage: options.postsPerPage });
|
||||||
hasMore: testState.hasMore,
|
return {
|
||||||
loadMore: testState.loadMoreMock,
|
feed: options.filter ? testState.feed.filter((comment) => options.filter?.filter(comment)) : testState.feed,
|
||||||
reset: testState.resetMock,
|
hasMore: testState.hasMore,
|
||||||
}),
|
loadMore: testState.loadMoreMock,
|
||||||
|
reset: testState.resetMock,
|
||||||
|
};
|
||||||
|
},
|
||||||
useCommunity: () => testState.community,
|
useCommunity: () => testState.community,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -305,6 +309,7 @@ describe('Catalog', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
testState.feed = [];
|
testState.feed = [];
|
||||||
|
testState.feedOptionsCalls = [];
|
||||||
testState.filterItems = [];
|
testState.filterItems = [];
|
||||||
testState.filteredDirectoryAddresses = ['music-posting.eth'];
|
testState.filteredDirectoryAddresses = ['music-posting.eth'];
|
||||||
testState.hasMore = false;
|
testState.hasMore = false;
|
||||||
@@ -368,6 +373,18 @@ describe('Catalog', () => {
|
|||||||
root = createRoot(container);
|
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 () => {
|
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.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }];
|
||||||
testState.hasMore = true;
|
testState.hasMore = true;
|
||||||
@@ -379,6 +396,7 @@ describe('Catalog', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(latestLocation).toBe('/all/catalog');
|
expect(latestLocation).toBe('/all/catalog');
|
||||||
|
expect(testState.feedOptionsCalls.at(-1)?.postsPerPage).toBe(24);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
container.querySelector<HTMLButtonElement>('[data-testid="end-reached"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
container.querySelector<HTMLButtonElement>('[data-testid="end-reached"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||||
@@ -387,6 +405,29 @@ describe('Catalog', () => {
|
|||||||
expect(testState.loadMoreMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('shows the empty subscriptions state when there are no subscribed boards to browse', async () => {
|
||||||
testState.account = { subscriptions: [] };
|
testState.account = { subscriptions: [] };
|
||||||
|
|
||||||
|
|||||||
+104
-31
@@ -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 { useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Comment, useAccount, useCommunity, useFeed, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks';
|
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 windowWidth = useWindowWidth();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const columnCount = Math.floor(windowWidth / columnWidth);
|
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 communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
|
||||||
const { guiPostsPerPage: boardPostsPerPage, maxGuiPages, paginationFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory);
|
const { guiPostsPerPage: boardPostsPerPage, maxGuiPages, paginationFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory);
|
||||||
@@ -271,10 +272,20 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
return {
|
return {
|
||||||
communityAddresses,
|
communityAddresses,
|
||||||
sortType: feedSortType,
|
sortType: feedSortType,
|
||||||
postsPerPage: isMultiboard ? 10 : paginationFeedPostsPerPage,
|
postsPerPage: isMultiboard ? multiboardCatalogPostsPerPage : paginationFeedPostsPerPage,
|
||||||
filter: createCombinedFilter(filterItems, searchText, communityAddress || 'all', handleFilterMatch),
|
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 { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
|
||||||
const accountCommentLookupOptions = useMemo(
|
const accountCommentLookupOptions = useMemo(
|
||||||
@@ -390,6 +401,32 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
}),
|
}),
|
||||||
[communityAddresses, hasMore, cappedFeed.length, communityAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll],
|
[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';
|
const isFeedLoaded = feed.length > 0 || state === 'failed';
|
||||||
|
|
||||||
@@ -426,6 +463,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
return [...topPosts, ...regularPosts];
|
return [...topPosts, ...regularPosts];
|
||||||
}, [sortedFeed, filterItems]);
|
}, [sortedFeed, filterItems]);
|
||||||
|
|
||||||
|
const deferredProcessedFeed = useDeferredValue(processedFeed);
|
||||||
|
|
||||||
const matchedFilterColors = useMemo(() => {
|
const matchedFilterColors = useMemo(() => {
|
||||||
const nextMatchedFilterColors = new Map<string, string>();
|
const nextMatchedFilterColors = new Map<string, string>();
|
||||||
const activeColoredFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '' && item.color);
|
const activeColoredFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '' && item.color);
|
||||||
@@ -434,7 +473,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
return nextMatchedFilterColors;
|
return nextMatchedFilterColors;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const comment of processedFeed) {
|
for (const comment of deferredProcessedFeed) {
|
||||||
const cid = comment?.cid;
|
const cid = comment?.cid;
|
||||||
if (!cid) {
|
if (!cid) {
|
||||||
continue;
|
continue;
|
||||||
@@ -447,20 +486,29 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return nextMatchedFilterColors;
|
return nextMatchedFilterColors;
|
||||||
}, [filterItems, processedFeed]);
|
}, [deferredProcessedFeed, filterItems]);
|
||||||
|
|
||||||
|
const rowCacheRef = useRef(new Map<string, Comment[]>());
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (!isFeedLoaded) {
|
if (!isFeedLoaded) {
|
||||||
|
rowCacheRef.current.clear();
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveColumnCount = Math.max(columnCount, 1);
|
const effectiveColumnCount = Math.max(columnCount, 1);
|
||||||
const nextRows = [];
|
const nextRows: Comment[][] = [];
|
||||||
for (let i = 0; i < processedFeed.length; i += effectiveColumnCount) {
|
const nextRowCache = new Map<string, Comment[]>();
|
||||||
nextRows.push(processedFeed.slice(i, i + effectiveColumnCount));
|
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;
|
return nextRows;
|
||||||
}, [columnCount, isFeedLoaded, processedFeed]);
|
}, [columnCount, deferredProcessedFeed, isFeedLoaded]);
|
||||||
|
|
||||||
const catalogMetrics = useMemo(() => readReplyTypographyMetrics(), [themeKey, windowWidth]);
|
const catalogMetrics = useMemo(() => readReplyTypographyMetrics(), [themeKey, windowWidth]);
|
||||||
const rowHeightEstimates = useMemo(
|
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.
|
// Virtuoso's default DOM measurement path and leaves rows on the fallback height.
|
||||||
const catalogSizingProps = useMemo(() => (catalogVirtualizationMode === 'item-size' ? { itemSize: getPretextItemSizeFromElement } : {}), [catalogVirtualizationMode]);
|
const catalogSizingProps = useMemo(() => (catalogVirtualizationMode === 'item-size' ? { itemSize: getPretextItemSizeFromElement } : {}), [catalogVirtualizationMode]);
|
||||||
const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView;
|
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 virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||||
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}-${sortType}-catalog`;
|
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}-${sortType}-catalog`;
|
||||||
@@ -497,20 +546,29 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
}, [isVisible, navigationType]);
|
}, [isVisible, navigationType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) return;
|
if (!isVisible || !shouldVirtualizeCatalog) return;
|
||||||
|
|
||||||
const currentKey = virtuosoStateKey;
|
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) => {
|
virtuosoRef.current?.getState((snapshot: StateSnapshot) => {
|
||||||
if (snapshot?.ranges?.length) {
|
if (snapshot?.ranges?.length) {
|
||||||
lastVirtuosoStates[currentKey] = snapshot;
|
lastVirtuosoStates[currentKey] = snapshot;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.addEventListener('scroll', setLastVirtuosoState);
|
window.addEventListener('pagehide', saveVirtuosoState);
|
||||||
return () => window.removeEventListener('scroll', setLastVirtuosoState);
|
return () => {
|
||||||
}, [virtuosoStateKey, isVisible]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) return;
|
if (!isVisible) return;
|
||||||
@@ -536,21 +594,36 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
<div className={styles.catalog}>
|
<div className={styles.catalog}>
|
||||||
{processedFeed?.length !== 0 ? (
|
{processedFeed?.length !== 0 ? (
|
||||||
<>
|
<>
|
||||||
<Virtuoso
|
{shouldVirtualizeCatalog ? (
|
||||||
defaultItemHeight={defaultCatalogRowHeight}
|
<Virtuoso
|
||||||
heightEstimates={catalogVirtualizationMode === 'off' ? undefined : rowHeightEstimates}
|
defaultItemHeight={defaultCatalogRowHeight}
|
||||||
increaseViewportBy={catalogViewportBuffer}
|
heightEstimates={catalogVirtualizationMode === 'off' ? undefined : rowHeightEstimates}
|
||||||
{...catalogSizingProps}
|
increaseViewportBy={catalogViewportBuffer}
|
||||||
totalCount={rows?.length || 0}
|
{...catalogSizingProps}
|
||||||
data={rows}
|
totalCount={rows?.length || 0}
|
||||||
itemContent={(index, row) => <CatalogRow estimatedHeight={rowHeightEstimates[index]} index={index} matchedFilterColors={matchedFilterColors} row={row} />}
|
data={rows}
|
||||||
useWindowScroll={true}
|
itemContent={renderCatalogRow}
|
||||||
components={footerComponents}
|
useWindowScroll={true}
|
||||||
endReached={effectiveInfiniteScroll && hasMore ? loadMore : undefined}
|
components={footerComponents}
|
||||||
ref={virtuosoRef}
|
endReached={effectiveInfiniteScroll && hasMore ? loadMore : undefined}
|
||||||
restoreStateFrom={lastVirtuosoState}
|
ref={virtuosoRef}
|
||||||
initialScrollTop={lastVirtuosoState?.scrollTop}
|
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}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user