perf(board): reduce desktop reverse-scroll jank on /all

Apply the asymmetric viewport buffer that already fixed mobile to desktop
multiboard feeds, and trim per-post mount cost so remounts during scroll-up
are cheaper:

- Desktop multiboard buffer goes from {600,600} to {1200,2400} (top-heavy),
  matching the mobile pattern from 99c0bbfac so items stay mounted longer
  when scrolling back up
- Cache feed post height estimate by CID in pretext-height-estimates so
  remounts skip the Pretext text-measurement work
- Memoize CommentMedia and switch its expanded-media-store reads to atomic
  selectors so it stops rerendering on unrelated store changes
- Memoize useCommentMediaInfo return so its reference is stable for memo
  comparators downstream
- Stop useFetchGifFirstFrame from forcing an extra render on every post
  mount by bailing out of equivalent setState calls
- Extract PendingModerationActions out of PostInfo so the two
  usePublishCommentModeration calls only run on the post page when
  mod-approval is actually pending, not on every feed item
This commit is contained in:
Tommaso Casaburi
2026-04-18 13:31:02 +07:00
parent d07f5b3962
commit e161606147
8 changed files with 228 additions and 142 deletions
@@ -34,12 +34,15 @@ vi.mock('../../../lib/utils/url-utils', () => ({
getHostname: () => testState.hostname,
}));
vi.mock('../../../stores/use-expanded-media-store', () => ({
default: () => ({
vi.mock('../../../stores/use-expanded-media-store', () => {
const getState = () => ({
fitExpandedImagesToScreen: testState.fitExpandedImagesToScreen,
unmuteExpandedVideoSound: testState.unmuteExpandedVideoSound,
}),
}));
});
return {
default: (selector?: (s: ReturnType<typeof getState>) => unknown) => (selector ? selector(getState()) : getState()),
};
});
vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({
default: () => ({
+22 -4
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDimensions } from '../../lib/utils/media-utils';
import { getHostname } from '../../lib/utils/url-utils';
@@ -202,7 +202,8 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
const { t } = useTranslation();
const { thumbnail, type, url } = commentMediaInfo || {};
const isMobile = useIsMobile();
const { fitExpandedImagesToScreen, unmuteExpandedVideoSound } = useExpandedMediaStore();
const fitExpandedImagesToScreen = useExpandedMediaStore((s) => s.fitExpandedImagesToScreen);
const unmuteExpandedVideoSound = useExpandedMediaStore((s) => s.unmuteExpandedVideoSound);
const mediaClass = `${isMobile ? styles.mediaMobile : isReply ? styles.mediaDesktopReply : styles.mediaDesktopOp} ${
fitExpandedImagesToScreen ? styles.fitToScreen : ''
}`;
@@ -299,7 +300,7 @@ const Image = ({ commentMediaInfo, disableToggle = false, displayHeight, display
const isReply = parentCid;
const isMobile = useIsMobile();
const [isImageExpanded, setIsImageExpanded] = useState(initialExpanded);
const { fitExpandedImagesToScreen } = useExpandedMediaStore();
const fitExpandedImagesToScreen = useExpandedMediaStore((s) => s.fitExpandedImagesToScreen);
const mediaDimensions = getMediaDimensions(commentMediaInfo);
const mediaClass = `${isMobile ? styles.mediaMobile : isReply ? styles.mediaDesktopReply : styles.mediaDesktopOp} ${
fitExpandedImagesToScreen ? styles.fitToScreen : ''
@@ -501,4 +502,21 @@ const CommentMedia = ({
);
};
export default CommentMedia;
export default memo(CommentMedia, (prev, next) => {
return (
prev.commentMediaInfo === next.commentMediaInfo &&
prev.deleted === next.deleted &&
prev.disableToggle === next.disableToggle &&
prev.isFloatingEmbed === next.isFloatingEmbed &&
prev.isOutOfFeed === next.isOutOfFeed &&
prev.isReply === next.isReply &&
prev.linkHeight === next.linkHeight &&
prev.linkWidth === next.linkWidth &&
prev.parentCid === next.parentCid &&
prev.purged === next.purged &&
prev.removed === next.removed &&
prev.showThumbnail === next.showThumbnail &&
prev.setShowThumbnail === next.setShowThumbnail &&
prev.spoiler === next.spoiler
);
});
+112 -113
View File
@@ -94,6 +94,117 @@ const useShowOmittedReplies = create<ShowOmittedRepliesState>((set) => ({
})),
}));
const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string; communityAddress: string; post: Comment | undefined }) => {
const { t } = useTranslation();
const {
publishCommentModeration: approvePending,
state: approvePendingState,
error: approvePendingError,
} = usePublishCommentModeration({
commentCid: cid,
communityAddress,
commentModeration: approvePendingCommentModeration,
onChallenge: async (...args: any) => {
addChallenge([...args, post]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error) => {
console.error('Approve failed:', error);
},
});
const {
publishCommentModeration: rejectPending,
state: rejectPendingState,
error: rejectPendingError,
} = usePublishCommentModeration({
commentCid: cid,
communityAddress,
commentModeration: rejectPendingCommentModeration,
onChallenge: async (...args: any) => {
addChallenge([...args, post]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error) => {
console.error('Reject failed:', error);
},
});
const [initiatedPendingAction, setInitiatedPendingAction] = useState<'approve' | 'reject' | null>(null);
const handlePendingApprove = useCallback(async () => {
if (!window.confirm(t('double_confirm'))) return;
setInitiatedPendingAction('approve');
try {
await approvePending();
} catch (e) {
console.error(e);
}
}, [approvePending, t]);
const handlePendingReject = useCallback(async () => {
if (!window.confirm(t('double_confirm'))) return;
setInitiatedPendingAction('reject');
try {
await rejectPending();
} catch (e) {
console.error(e);
}
}, [rejectPending, t]);
const isApprovingPending =
initiatedPendingAction === 'approve' && approvePendingState !== 'initializing' && approvePendingState !== 'succeeded' && approvePendingState !== 'failed';
const isRejectingPending =
initiatedPendingAction === 'reject' && rejectPendingState !== 'initializing' && rejectPendingState !== 'succeeded' && rejectPendingState !== 'failed';
const isPublishingPending = isApprovingPending || isRejectingPending;
const approvePendingSucceeded = initiatedPendingAction === 'approve' && approvePendingState === 'succeeded';
const rejectPendingSucceeded = initiatedPendingAction === 'reject' && rejectPendingState === 'succeeded';
const approvePendingFailed = initiatedPendingAction === 'approve' && approvePendingState === 'failed';
const rejectPendingFailed = initiatedPendingAction === 'reject' && rejectPendingState === 'failed';
const pendingStatus = approvePendingSucceeded ? 'approved' : rejectPendingSucceeded ? 'rejected' : approvePendingFailed || rejectPendingFailed ? 'failed' : null;
const pendingErrorMessage = approvePendingFailed ? approvePendingError?.message : rejectPendingFailed ? rejectPendingError?.message : undefined;
return (
<span className={styles.modQueueActions}>
{pendingStatus === 'approved' ? (
<span className={styles.modQueueStatusApproved}>{t('approved')}</span>
) : pendingStatus === 'rejected' ? (
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
) : pendingStatus === 'failed' ? (
<span className={styles.modQueueStatusRejected}>
{t('failed')}
{pendingErrorMessage ? `: ${pendingErrorMessage}` : ''}
</span>
) : isPublishingPending ? (
<LoadingEllipsis string={t('publishing')} />
) : (
<>
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={handlePendingApprove} disabled={isPublishingPending}>
{t('approve')}
</button>
]
</span>
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={handlePendingReject} disabled={isPublishingPending}>
{t('reject')}
</button>
]
</span>
</>
)}
</span>
);
};
const PostInfo = ({
post,
postReplyCount = 0,
@@ -142,86 +253,6 @@ const PostInfo = ({
const pendingApproval = post?.pendingApproval;
const shouldShowPendingApprovalButtons = isInPostPageView && !isInModQueueView && pendingApproval && isAccountMod && communityAddress;
// Moderation actions for pending approval posts
const {
publishCommentModeration: approvePending,
state: approvePendingState,
error: approvePendingError,
} = usePublishCommentModeration({
commentCid: cid,
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
commentModeration: approvePendingCommentModeration,
onChallenge: async (...args: any) => {
addChallenge([...args, post]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error) => {
console.error('Approve failed:', error);
},
});
const {
publishCommentModeration: rejectPending,
state: rejectPendingState,
error: rejectPendingError,
} = usePublishCommentModeration({
commentCid: cid,
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
commentModeration: rejectPendingCommentModeration,
onChallenge: async (...args: any) => {
addChallenge([...args, post]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error) => {
console.error('Reject failed:', error);
},
});
const [initiatedPendingAction, setInitiatedPendingAction] = useState<'approve' | 'reject' | null>(null);
const handlePendingApprove = useCallback(async () => {
const confirm = window.confirm(t('double_confirm'));
if (!confirm) {
return;
}
setInitiatedPendingAction('approve');
try {
await approvePending();
} catch (e) {
console.error(e);
}
}, [approvePending, t]);
const handlePendingReject = useCallback(async () => {
const confirm = window.confirm(t('double_confirm'));
if (!confirm) {
return;
}
setInitiatedPendingAction('reject');
try {
await rejectPending();
} catch (e) {
console.error(e);
}
}, [rejectPending, t]);
const isApprovingPending =
initiatedPendingAction === 'approve' && approvePendingState !== 'initializing' && approvePendingState !== 'succeeded' && approvePendingState !== 'failed';
const isRejectingPending =
initiatedPendingAction === 'reject' && rejectPendingState !== 'initializing' && rejectPendingState !== 'succeeded' && rejectPendingState !== 'failed';
const isPublishingPending = isApprovingPending || isRejectingPending;
const approvePendingSucceeded = initiatedPendingAction === 'approve' && approvePendingState === 'succeeded';
const rejectPendingSucceeded = initiatedPendingAction === 'reject' && rejectPendingState === 'succeeded';
const approvePendingFailed = initiatedPendingAction === 'approve' && approvePendingState === 'failed';
const rejectPendingFailed = initiatedPendingAction === 'reject' && rejectPendingState === 'failed';
const pendingStatus = approvePendingSucceeded ? 'approved' : rejectPendingSucceeded ? 'rejected' : approvePendingFailed || rejectPendingFailed ? 'failed' : null;
const pendingErrorMessage = approvePendingFailed ? approvePendingError?.message : rejectPendingFailed ? rejectPendingError?.message : undefined;
// Check if post is awaiting approval and over threshold (for mod queue view)
const approved = post?.approved;
const alreadyApproved = approved === true;
@@ -473,39 +504,7 @@ const PostInfo = ({
)}
</span>
)}
{shouldShowPendingApprovalButtons && (
<span className={styles.modQueueActions}>
{pendingStatus === 'approved' ? (
<span className={styles.modQueueStatusApproved}>{t('approved')}</span>
) : pendingStatus === 'rejected' ? (
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
) : pendingStatus === 'failed' ? (
<span className={styles.modQueueStatusRejected}>
{t('failed')}
{pendingErrorMessage ? `: ${pendingErrorMessage}` : ''}
</span>
) : isPublishingPending ? (
<LoadingEllipsis string={t('publishing')} />
) : (
<>
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={handlePendingApprove} disabled={isPublishingPending}>
{t('approve')}
</button>
]
</span>
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={handlePendingReject} disabled={isPublishingPending}>
{t('reject')}
</button>
]
</span>
</>
)}
</span>
)}
{shouldShowPendingApprovalButtons && communityAddress && cid && <PendingModerationActions cid={cid} communityAddress={communityAddress} post={post} />}
</span>
{!(removed || deleted || purged) && !isModQueue && <PostMenuDesktop postMenu={postMenuProps} />}
{cid && parentCid && <ReplyBacklinks post={post} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
+12 -13
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useMemo, useState, useEffect } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { getCommentMediaInfo, fetchWebpageThumbnailIfNeeded, CommentMediaInfo } from '../lib/utils/media-utils';
import { isPendingPostView, isPostPageView } from '../lib/utils/view-utils';
@@ -65,16 +65,15 @@ export const useCommentMediaInfo = (link: string, thumbnailUrl: string, linkWidt
};
}, [link, thumbnailUrl, linkWidth, linkHeight, isInPostPageView, isInPendingPostView]);
const mediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
// Return media info with cached thumbnail dimensions if available
if (thumbnailDimensions && mediaInfo) {
return {
...mediaInfo,
thumbnailWidth: thumbnailDimensions.width,
thumbnailHeight: thumbnailDimensions.height,
};
}
return mediaInfo;
return useMemo(() => {
const mediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
if (thumbnailDimensions && mediaInfo) {
return {
...mediaInfo,
thumbnailWidth: thumbnailDimensions.width,
thumbnailHeight: thumbnailDimensions.height,
};
}
return mediaInfo;
}, [link, thumbnailUrl, linkWidth, linkHeight, thumbnailDimensions]);
};
+9 -5
View File
@@ -11,6 +11,10 @@ interface GifFirstFrameState {
status: GifFirstFrameStatus;
}
const IDLE_STATE: GifFirstFrameState = { frameUrl: null, status: 'idle' };
const LOADING_STATE: GifFirstFrameState = { frameUrl: null, status: 'loading' };
const FAILED_STATE: GifFirstFrameState = { frameUrl: null, status: 'failed' };
const getCachedGifFrame = async (url: string): Promise<string | null> => {
return await gifFrameDb.getItem(url);
};
@@ -79,20 +83,20 @@ const parseGif = async (buf: ArrayBuffer): Promise<Blob> => {
};
const useFetchGifFirstFrame = (url: string | undefined) => {
const [gifFirstFrame, setGifFirstFrame] = useState<GifFirstFrameState>({ frameUrl: null, status: 'idle' });
const [gifFirstFrame, setGifFirstFrame] = useState<GifFirstFrameState>(IDLE_STATE);
useEffect(() => {
if (!url) {
setGifFirstFrame({ frameUrl: null, status: 'idle' });
setGifFirstFrame((prev) => (prev.status === 'idle' ? prev : IDLE_STATE));
return;
}
let isActive = true;
setGifFirstFrame({ frameUrl: null, status: 'loading' });
setGifFirstFrame((prev) => (prev.status === 'loading' ? prev : LOADING_STATE));
const fetchFrame = async () => {
if (failedUrls.has(url)) {
if (isActive) setGifFirstFrame({ frameUrl: null, status: 'failed' });
if (isActive) setGifFirstFrame((prev) => (prev.status === 'failed' ? prev : FAILED_STATE));
return;
}
@@ -119,7 +123,7 @@ const useFetchGifFirstFrame = (url: string | undefined) => {
} catch (error) {
failedUrls.add(url);
console.error('Failed to load GIF frame:', error);
if (isActive) setGifFirstFrame({ frameUrl: null, status: 'failed' });
if (isActive) setGifFirstFrame((prev) => (prev.status === 'failed' ? prev : FAILED_STATE));
}
};
+44 -2
View File
@@ -139,6 +139,8 @@ const paragraphFloatHeightCache = new WeakMap<PreparedTextWithSegments, Map<stri
const nestedPretextElementCache = new WeakMap<HTMLElement, HTMLElement | null>();
const catalogPostHeightEstimateCache = new Map<string, number>();
const catalogRowHeightEstimateCache = new Map<string, number>();
const feedPostHeightEstimateCache = new Map<string, number>();
const FEED_POST_HEIGHT_ESTIMATE_CACHE_LIMIT = 2000;
let pretextSupport: boolean | undefined;
@@ -646,6 +648,30 @@ export const getFeedPostHeightEstimate = ({
const previewRepliesHeight = previewHeights.reduce((sum, value) => sum + value, 0);
const previewReplyCount = previewReplies.length;
const cacheKey = post.cid
? [
isMobile ? '1' : '0',
windowWidth,
showBoardLabel ? '1' : '0',
showSummary ? '1' : '0',
metrics.bodyFontFamily,
metrics.bodyFontSizePx,
metrics.mobileContentFontSizePx,
metrics.abbrFontSizePx,
post.cid,
post.updatedAt || 0,
previewReplyCount,
previewRepliesHeight,
].join('\u0000')
: undefined;
if (cacheKey) {
const cached = feedPostHeightEstimateCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
}
if (isMobile) {
const fontSizePx = metrics.mobileContentFontSizePx;
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
@@ -666,7 +692,15 @@ export const getFeedPostHeightEstimate = ({
// Mobile board cards render preview replies more compactly than the thread-reply estimator assumes.
const mobileCalibration = getMobileFeedCardCalibration(previewReplyCount);
return clampEstimateHeight(rawEstimate - mobileCalibration);
const mobileResult = clampEstimateHeight(rawEstimate - mobileCalibration);
if (cacheKey) {
if (feedPostHeightEstimateCache.size >= FEED_POST_HEIGHT_ESTIMATE_CACHE_LIMIT) {
const firstKey = feedPostHeightEstimateCache.keys().next().value;
if (firstKey !== undefined) feedPostHeightEstimateCache.delete(firstKey);
}
feedPostHeightEstimateCache.set(cacheKey, mobileResult);
}
return mobileResult;
}
const fontSizePx = metrics.bodyFontSizePx;
@@ -698,7 +732,15 @@ export const getFeedPostHeightEstimate = ({
(showSummary ? DESKTOP_FEED_CARD_SUMMARY_HEIGHT : 0) +
previewRepliesHeight;
return clampEstimateHeight(rawEstimate + DESKTOP_FEED_CARD_BASE_CALIBRATION);
const desktopResult = clampEstimateHeight(rawEstimate + DESKTOP_FEED_CARD_BASE_CALIBRATION);
if (cacheKey) {
if (feedPostHeightEstimateCache.size >= FEED_POST_HEIGHT_ESTIMATE_CACHE_LIMIT) {
const firstKey = feedPostHeightEstimateCache.keys().next().value;
if (firstKey !== undefined) feedPostHeightEstimateCache.delete(firstKey);
}
feedPostHeightEstimateCache.set(cacheKey, desktopResult);
}
return desktopResult;
};
export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPComment }: CatalogPostHeightEstimateOptions): number => {
+21
View File
@@ -432,6 +432,27 @@ describe('Board', () => {
expect(testState.lastVirtuosoMinOverscanItemCount).toEqual({ top: 8, bottom: 4 });
});
it('uses an asymmetric reverse-scroll buffer on desktop multiboard feeds', async () => {
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: 1280,
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.lastVirtuosoIncreaseViewportBy).toEqual({ top: 2400, bottom: 1200 });
});
it('canonicalizes multiboard paths and shows the subscriptions empty state', async () => {
testState.account = { subscriptions: [] };
testState.filteredDirectoryAddresses = [];
+1 -1
View File
@@ -402,7 +402,7 @@ 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 boardViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 1400, top: 2400 } : { bottom: 600, top: 600 }) : { bottom: 1200, top: 1200 };
const boardViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 1400, top: 2400 } : { bottom: 1200, top: 2400 }) : { bottom: 1200, top: 1200 };
const boardMinOverscanItemCount = isMultiboardView && isMobile ? { bottom: 4, top: 8 } : undefined;
const boardItemContent = useCallback(