perf(board): reduce mobile reverse-scroll jank

This commit is contained in:
Tommaso Casaburi
2026-03-31 19:47:36 +07:00
parent eeb288e2dc
commit 99c0bbfac5
5 changed files with 121 additions and 8 deletions
@@ -27,10 +27,13 @@ interface MediaProps {
setShowThumbnail: (showThumbnail: boolean) => void;
}
type GifFrameState = ReturnType<typeof useFetchGifFirstFrame>;
const Thumbnail = ({
commentMediaInfo,
deleted,
displayHeight,
gifFrameState,
displayWidth,
isFloatingEmbed,
isOutOfFeed,
@@ -39,13 +42,13 @@ const Thumbnail = ({
removed,
spoiler,
setShowThumbnail,
}: MediaProps) => {
}: MediaProps & { gifFrameState: GifFrameState }) => {
const isMobile = useIsMobile();
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
let thumbnailComponent: React.ReactNode = null;
const iframeThumbnail = patternThumbnailUrl || thumbnail;
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = gifFrameState;
const hasThumbnail = getHasThumbnail(commentMediaInfo, url);
const gifThumbnailButtonProps =
gifFrameStatus === 'loading'
@@ -421,8 +424,9 @@ const CommentMedia = ({
const { t } = useTranslation();
const isMobile = useIsMobile();
const { thumbnailHeight, thumbnailWidth, url } = commentMediaInfo || {};
const gifFrameState = useFetchGifFirstFrame(commentMediaInfo?.type === 'gif' ? url : undefined);
let type = commentMediaInfo?.type;
const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
const { status: gifFrameStatus } = gifFrameState;
if (type === 'gif' && gifFrameStatus === 'ready') {
type = 'animated gif';
@@ -477,6 +481,7 @@ const CommentMedia = ({
<Thumbnail
commentMediaInfo={commentMediaInfo}
displayHeight={displayHeight}
gifFrameState={gifFrameState}
displayWidth={displayWidth}
isFloatingEmbed={isFloatingEmbed}
isOutOfFeed={isOutOfFeed}
+38
View File
@@ -35,6 +35,9 @@ const testState = vi.hoisted(() => ({
feedStateString: 'syncing',
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
hasMore: false,
lastVirtuosoDefaultItemHeight: undefined as number | undefined,
lastVirtuosoIncreaseViewportBy: undefined as { top: number; bottom: number } | undefined,
lastVirtuosoMinOverscanItemCount: undefined as { top: number; bottom: number } | undefined,
loadMoreMock: vi.fn(),
pageSizes: {
guiPostsPerPage: 2,
@@ -114,16 +117,25 @@ vi.mock('react-virtuoso', () => ({
{
components,
data = [],
defaultItemHeight,
increaseViewportBy,
minOverscanItemCount,
endReached,
itemContent,
}: {
components?: { Footer?: React.ComponentType };
data?: TestComment[];
defaultItemHeight?: number;
increaseViewportBy?: { top: number; bottom: number };
minOverscanItemCount?: { top: number; bottom: number };
endReached?: ((index: number) => void) | undefined;
itemContent: (index: number, item: TestComment) => React.ReactNode;
},
ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>,
) => {
testState.lastVirtuosoDefaultItemHeight = defaultItemHeight;
testState.lastVirtuosoIncreaseViewportBy = increaseViewportBy;
testState.lastVirtuosoMinOverscanItemCount = minOverscanItemCount;
React.useImperativeHandle(ref, () => ({
getState: (cb) => cb({ ranges: [0], scrollTop: 42 }),
}));
@@ -277,6 +289,9 @@ describe('Board', () => {
testState.feedStateString = 'syncing';
testState.filteredDirectoryAddresses = ['music-posting.eth'];
testState.hasMore = false;
testState.lastVirtuosoDefaultItemHeight = undefined;
testState.lastVirtuosoIncreaseViewportBy = undefined;
testState.lastVirtuosoMinOverscanItemCount = undefined;
testState.pageSizes = {
guiPostsPerPage: 2,
infiniteFeedPostsPerPage: 2,
@@ -394,6 +409,29 @@ describe('Board', () => {
expect(testState.registerCommentsMock).toHaveBeenCalledWith(testState.feed);
});
it('uses a taller Virtuoso default item height on mobile feeds', async () => {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 480,
writable: true,
});
testState.feed = [
{ cid: 'first-post', communityAddress: 'music-posting.eth' },
{ cid: 'second-post', communityAddress: 'music-posting.eth' },
];
await renderBoard({
boardProps: { viewType: 'all' },
initialEntry: '/all',
routePath: '/all/*',
});
expect(testState.lastVirtuosoDefaultItemHeight).toBe(420);
expect(testState.lastVirtuosoIncreaseViewportBy).toEqual({ top: 2400, bottom: 1400 });
expect(testState.lastVirtuosoMinOverscanItemCount).toEqual({ top: 8, bottom: 4 });
});
it('canonicalizes multiboard paths and shows the subscriptions empty state', async () => {
testState.account = { subscriptions: [] };
testState.filteredDirectoryAddresses = [];
+17 -5
View File
@@ -24,6 +24,7 @@ import LoadingEllipsis from '../../components/loading-ellipsis';
import BoardPagination from '../../components/board-pagination';
import { CatalogButton } from '../../components/board-buttons/board-buttons';
import { PageFooterDesktop, PageFooterMobile } from '../../components/footer';
import useIsMobile from '../../hooks/use-is-mobile';
import { Post } from '../post';
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
@@ -139,6 +140,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const setEnableInfiniteScroll = useFeedViewSettingsStore((state) => state.setEnableInfiniteScroll);
const isMobile = useIsMobile();
const isForcedInfiniteScroll = isInAllView || isInSubscriptionsView || isInModView;
const effectiveInfiniteScroll = enableInfiniteScroll || isForcedInfiniteScroll;
const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
@@ -378,6 +380,10 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}-${BOARD_SORT_TYPE}`;
const navigationType = useNavigationType();
const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView;
const defaultBoardItemHeight = isMobile ? 420 : 300;
const boardViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 1400, top: 2400 } : { bottom: 600, top: 600 }) : { bottom: 1200, top: 1200 };
const boardMinOverscanItemCount = isMultiboardView && isMobile ? { bottom: 4, top: 8 } : undefined;
const boardItemContent = useCallback((index: number, post: Comment | undefined) => <Post index={index} post={post} />, []);
@@ -395,15 +401,20 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
if (!isVisible) return;
const currentKey = virtuosoStateKey;
const setLastVirtuosoState = () => {
// Saving the snapshot on every scroll tick makes board scrolling do extra work
// in the hottest path. Capturing on teardown still preserves POP restores.
const saveVirtuosoState = () => {
virtuosoRef.current?.getState((snapshot: StateSnapshot) => {
if (snapshot?.ranges?.length) {
lastVirtuosoStates[currentKey] = snapshot;
}
});
};
window.addEventListener('scroll', setLastVirtuosoState);
return () => window.removeEventListener('scroll', setLastVirtuosoState);
window.addEventListener('pagehide', saveVirtuosoState);
return () => {
saveVirtuosoState();
window.removeEventListener('pagehide', saveVirtuosoState);
};
}, [virtuosoStateKey, isVisible]);
const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
@@ -442,8 +453,9 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
)}
{effectiveInfiniteScroll ? (
<Virtuoso
defaultItemHeight={300}
increaseViewportBy={isInAllView || isInSubscriptionsView || isInModView ? { bottom: 600, top: 600 } : { bottom: 1200, top: 1200 }}
defaultItemHeight={defaultBoardItemHeight}
increaseViewportBy={boardViewportBuffer}
minOverscanItemCount={boardMinOverscanItemCount}
totalCount={displayFeed.length}
data={displayFeed}
computeItemKey={(index, post) => post?.cid || `post-${index}`}