mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
perf(board): reduce mobile reverse-scroll jank
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"task": "mobile-virtuoso-scroll-jank",
|
||||||
|
"last_updated": "2026-03-31",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "F001",
|
||||||
|
"priority": 1,
|
||||||
|
"status": "completed",
|
||||||
|
"description": "Reproduce, profile, and fix mobile-only scroll jank on Virtuoso-backed board feeds, starting with /all using the custom Node RPC.",
|
||||||
|
"verification": [
|
||||||
|
"./scripts/agent-init.sh --smoke",
|
||||||
|
"playwright-cli mobile verification on /all with the custom RPC configured",
|
||||||
|
"yarn build",
|
||||||
|
"yarn lint",
|
||||||
|
"yarn type-check",
|
||||||
|
"yarn doctor"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"src/components/comment-media/comment-media.tsx",
|
||||||
|
"src/views/board/board.tsx",
|
||||||
|
"src/views/board/__tests__/board.test.tsx",
|
||||||
|
"docs/agent-runs/mobile-virtuoso-scroll-jank/feature-list.json",
|
||||||
|
"docs/agent-runs/mobile-virtuoso-scroll-jank/progress.md"
|
||||||
|
],
|
||||||
|
"notes": "Work stayed isolated in a dedicated worktree/branch. Profiling confirmed the mobile multiboard Virtuoso feed was underestimating row height and remounting tall rows too aggressively on reverse scroll. A second pass removed board-state snapshotting from the hot scroll path, added stronger reverse-direction overscan for mobile multiboards, and deduplicated GIF first-frame work in CommentMedia. Desktop comparison remained partially inconclusive because fresh desktop sessions sometimes failed to hydrate feed rows."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Progress Log
|
||||||
|
|
||||||
|
Append one entry per session.
|
||||||
|
|
||||||
|
## 2026-03-31 19:10
|
||||||
|
|
||||||
|
- Item: F001
|
||||||
|
- Summary: Created a dedicated fix worktree, loaded the profiling workflow, and installed dependencies in the fresh checkout after the required smoke bootstrap initially failed to resolve `playwright`.
|
||||||
|
- Files: `docs/agent-runs/mobile-virtuoso-scroll-jank/feature-list.json`, `docs/agent-runs/mobile-virtuoso-scroll-jank/progress.md`
|
||||||
|
- Verification: `./scripts/create-task-worktree.sh fix mobile-virtuoso-scroll-jank`, `corepack yarn install`
|
||||||
|
- Blockers: `./scripts/agent-init.sh --smoke` failed before install because the fresh worktree had no local dependency install yet.
|
||||||
|
- Next: Start a branch-scoped dev server, rerun the smoke flow against that URL, then profile the mobile /all feed with the custom RPC.
|
||||||
|
|
||||||
|
## 2026-03-31 19:24
|
||||||
|
|
||||||
|
- Item: F001
|
||||||
|
- Summary: Profiled mobile `/all` with the custom RPC, confirmed Virtuoso row-height mismatch on mobile multiboards, then raised the mobile board-feed default item height and viewport buffer while making the board scroll-state listener passive.
|
||||||
|
- Files: `src/views/board/board.tsx`, `src/views/board/__tests__/board.test.tsx`, `docs/agent-runs/mobile-virtuoso-scroll-jank/feature-list.json`, `docs/agent-runs/mobile-virtuoso-scroll-jank/progress.md`
|
||||||
|
- Verification: `AGENT_APP_URL=http://127.0.0.1:1356 ./scripts/agent-init.sh --smoke`, `yarn test src/views/board/__tests__/board.test.tsx`, `yarn test`, `yarn build`, `yarn lint`, `yarn type-check`, `yarn doctor`, `playwright-cli` mobile `/all` load-and-scroll check, Playwright mobile `/all` profiling probe
|
||||||
|
- Blockers: Fresh desktop comparator sessions intermittently failed to hydrate `/all` rows, so desktop scroll-jank comparison stayed inconclusive even though mobile evidence was sufficient to isolate the board-feed issue.
|
||||||
|
- Next: Ready for user review on `codex/fix/mobile-virtuoso-scroll-jank`.
|
||||||
|
|
||||||
|
## 2026-03-31 19:44
|
||||||
|
|
||||||
|
- Item: F001
|
||||||
|
- Summary: After the first pass still reproduced reverse-scroll stutter, shifted the board feed off the per-scroll `getState()` path, increased mobile multiboard reverse overscan, and removed duplicate GIF first-frame work inside `CommentMedia`.
|
||||||
|
- Files: `src/views/board/board.tsx`, `src/views/board/__tests__/board.test.tsx`, `src/components/comment-media/comment-media.tsx`, `docs/agent-runs/mobile-virtuoso-scroll-jank/feature-list.json`, `docs/agent-runs/mobile-virtuoso-scroll-jank/progress.md`
|
||||||
|
- Verification: `yarn test src/views/board/__tests__/board.test.tsx`, `playwright-cli` mobile `/all` repro with the custom RPC on `http://127.0.0.1:1356`, `yarn test`, `yarn type-check`, `yarn lint`, `yarn build`, `yarn doctor`
|
||||||
|
- Blockers: Headless scripted scroll traces are noisy for long-task timing, so browser evidence is strongest on reduced remount churn and removal of duplicate GIF thumbnail work rather than a perfectly stable synthetic benchmark.
|
||||||
|
- Next: Have the user retry the exact mobile `/all` reverse-scroll path on this branch and confirm whether the remaining hitch is gone or narrow any residual stutter further.
|
||||||
@@ -27,10 +27,13 @@ interface MediaProps {
|
|||||||
setShowThumbnail: (showThumbnail: boolean) => void;
|
setShowThumbnail: (showThumbnail: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GifFrameState = ReturnType<typeof useFetchGifFirstFrame>;
|
||||||
|
|
||||||
const Thumbnail = ({
|
const Thumbnail = ({
|
||||||
commentMediaInfo,
|
commentMediaInfo,
|
||||||
deleted,
|
deleted,
|
||||||
displayHeight,
|
displayHeight,
|
||||||
|
gifFrameState,
|
||||||
displayWidth,
|
displayWidth,
|
||||||
isFloatingEmbed,
|
isFloatingEmbed,
|
||||||
isOutOfFeed,
|
isOutOfFeed,
|
||||||
@@ -39,13 +42,13 @@ const Thumbnail = ({
|
|||||||
removed,
|
removed,
|
||||||
spoiler,
|
spoiler,
|
||||||
setShowThumbnail,
|
setShowThumbnail,
|
||||||
}: MediaProps) => {
|
}: MediaProps & { gifFrameState: GifFrameState }) => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
|
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
|
||||||
|
|
||||||
let thumbnailComponent: React.ReactNode = null;
|
let thumbnailComponent: React.ReactNode = null;
|
||||||
const iframeThumbnail = patternThumbnailUrl || thumbnail;
|
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 hasThumbnail = getHasThumbnail(commentMediaInfo, url);
|
||||||
const gifThumbnailButtonProps =
|
const gifThumbnailButtonProps =
|
||||||
gifFrameStatus === 'loading'
|
gifFrameStatus === 'loading'
|
||||||
@@ -421,8 +424,9 @@ const CommentMedia = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { thumbnailHeight, thumbnailWidth, url } = commentMediaInfo || {};
|
const { thumbnailHeight, thumbnailWidth, url } = commentMediaInfo || {};
|
||||||
|
const gifFrameState = useFetchGifFirstFrame(commentMediaInfo?.type === 'gif' ? url : undefined);
|
||||||
let type = commentMediaInfo?.type;
|
let type = commentMediaInfo?.type;
|
||||||
const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
|
const { status: gifFrameStatus } = gifFrameState;
|
||||||
|
|
||||||
if (type === 'gif' && gifFrameStatus === 'ready') {
|
if (type === 'gif' && gifFrameStatus === 'ready') {
|
||||||
type = 'animated gif';
|
type = 'animated gif';
|
||||||
@@ -477,6 +481,7 @@ const CommentMedia = ({
|
|||||||
<Thumbnail
|
<Thumbnail
|
||||||
commentMediaInfo={commentMediaInfo}
|
commentMediaInfo={commentMediaInfo}
|
||||||
displayHeight={displayHeight}
|
displayHeight={displayHeight}
|
||||||
|
gifFrameState={gifFrameState}
|
||||||
displayWidth={displayWidth}
|
displayWidth={displayWidth}
|
||||||
isFloatingEmbed={isFloatingEmbed}
|
isFloatingEmbed={isFloatingEmbed}
|
||||||
isOutOfFeed={isOutOfFeed}
|
isOutOfFeed={isOutOfFeed}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ const testState = vi.hoisted(() => ({
|
|||||||
feedStateString: 'syncing',
|
feedStateString: 'syncing',
|
||||||
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
|
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
|
||||||
hasMore: false,
|
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(),
|
loadMoreMock: vi.fn(),
|
||||||
pageSizes: {
|
pageSizes: {
|
||||||
guiPostsPerPage: 2,
|
guiPostsPerPage: 2,
|
||||||
@@ -114,16 +117,25 @@ vi.mock('react-virtuoso', () => ({
|
|||||||
{
|
{
|
||||||
components,
|
components,
|
||||||
data = [],
|
data = [],
|
||||||
|
defaultItemHeight,
|
||||||
|
increaseViewportBy,
|
||||||
|
minOverscanItemCount,
|
||||||
endReached,
|
endReached,
|
||||||
itemContent,
|
itemContent,
|
||||||
}: {
|
}: {
|
||||||
components?: { Footer?: React.ComponentType };
|
components?: { Footer?: React.ComponentType };
|
||||||
data?: TestComment[];
|
data?: TestComment[];
|
||||||
|
defaultItemHeight?: number;
|
||||||
|
increaseViewportBy?: { top: number; bottom: number };
|
||||||
|
minOverscanItemCount?: { top: number; bottom: number };
|
||||||
endReached?: ((index: number) => void) | undefined;
|
endReached?: ((index: number) => void) | undefined;
|
||||||
itemContent: (index: number, item: TestComment) => React.ReactNode;
|
itemContent: (index: number, item: TestComment) => React.ReactNode;
|
||||||
},
|
},
|
||||||
ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>,
|
ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>,
|
||||||
) => {
|
) => {
|
||||||
|
testState.lastVirtuosoDefaultItemHeight = defaultItemHeight;
|
||||||
|
testState.lastVirtuosoIncreaseViewportBy = increaseViewportBy;
|
||||||
|
testState.lastVirtuosoMinOverscanItemCount = minOverscanItemCount;
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
getState: (cb) => cb({ ranges: [0], scrollTop: 42 }),
|
getState: (cb) => cb({ ranges: [0], scrollTop: 42 }),
|
||||||
}));
|
}));
|
||||||
@@ -277,6 +289,9 @@ describe('Board', () => {
|
|||||||
testState.feedStateString = 'syncing';
|
testState.feedStateString = 'syncing';
|
||||||
testState.filteredDirectoryAddresses = ['music-posting.eth'];
|
testState.filteredDirectoryAddresses = ['music-posting.eth'];
|
||||||
testState.hasMore = false;
|
testState.hasMore = false;
|
||||||
|
testState.lastVirtuosoDefaultItemHeight = undefined;
|
||||||
|
testState.lastVirtuosoIncreaseViewportBy = undefined;
|
||||||
|
testState.lastVirtuosoMinOverscanItemCount = undefined;
|
||||||
testState.pageSizes = {
|
testState.pageSizes = {
|
||||||
guiPostsPerPage: 2,
|
guiPostsPerPage: 2,
|
||||||
infiniteFeedPostsPerPage: 2,
|
infiniteFeedPostsPerPage: 2,
|
||||||
@@ -394,6 +409,29 @@ describe('Board', () => {
|
|||||||
expect(testState.registerCommentsMock).toHaveBeenCalledWith(testState.feed);
|
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 () => {
|
it('canonicalizes multiboard paths and shows the subscriptions empty state', async () => {
|
||||||
testState.account = { subscriptions: [] };
|
testState.account = { subscriptions: [] };
|
||||||
testState.filteredDirectoryAddresses = [];
|
testState.filteredDirectoryAddresses = [];
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import LoadingEllipsis from '../../components/loading-ellipsis';
|
|||||||
import BoardPagination from '../../components/board-pagination';
|
import BoardPagination from '../../components/board-pagination';
|
||||||
import { CatalogButton } from '../../components/board-buttons/board-buttons';
|
import { CatalogButton } from '../../components/board-buttons/board-buttons';
|
||||||
import { PageFooterDesktop, PageFooterMobile } from '../../components/footer';
|
import { PageFooterDesktop, PageFooterMobile } from '../../components/footer';
|
||||||
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import { Post } from '../post';
|
import { Post } from '../post';
|
||||||
|
|
||||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||||
@@ -139,6 +140,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
|||||||
|
|
||||||
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
|
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
|
||||||
const setEnableInfiniteScroll = useFeedViewSettingsStore((state) => state.setEnableInfiniteScroll);
|
const setEnableInfiniteScroll = useFeedViewSettingsStore((state) => state.setEnableInfiniteScroll);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const isForcedInfiniteScroll = isInAllView || isInSubscriptionsView || isInModView;
|
const isForcedInfiniteScroll = isInAllView || isInSubscriptionsView || isInModView;
|
||||||
const effectiveInfiniteScroll = enableInfiniteScroll || isForcedInfiniteScroll;
|
const effectiveInfiniteScroll = enableInfiniteScroll || isForcedInfiniteScroll;
|
||||||
const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
|
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 virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||||
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}-${BOARD_SORT_TYPE}`;
|
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}-${BOARD_SORT_TYPE}`;
|
||||||
const navigationType = useNavigationType();
|
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} />, []);
|
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;
|
if (!isVisible) return;
|
||||||
|
|
||||||
const currentKey = virtuosoStateKey;
|
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) => {
|
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 () => {
|
||||||
|
saveVirtuosoState();
|
||||||
|
window.removeEventListener('pagehide', saveVirtuosoState);
|
||||||
|
};
|
||||||
}, [virtuosoStateKey, isVisible]);
|
}, [virtuosoStateKey, isVisible]);
|
||||||
|
|
||||||
const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
|
const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
|
||||||
@@ -442,8 +453,9 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
|||||||
)}
|
)}
|
||||||
{effectiveInfiniteScroll ? (
|
{effectiveInfiniteScroll ? (
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
defaultItemHeight={300}
|
defaultItemHeight={defaultBoardItemHeight}
|
||||||
increaseViewportBy={isInAllView || isInSubscriptionsView || isInModView ? { bottom: 600, top: 600 } : { bottom: 1200, top: 1200 }}
|
increaseViewportBy={boardViewportBuffer}
|
||||||
|
minOverscanItemCount={boardMinOverscanItemCount}
|
||||||
totalCount={displayFeed.length}
|
totalCount={displayFeed.length}
|
||||||
data={displayFeed}
|
data={displayFeed}
|
||||||
computeItemKey={(index, post) => post?.cid || `post-${index}`}
|
computeItemKey={(index, post) => post?.cid || `post-${index}`}
|
||||||
|
|||||||
Reference in New Issue
Block a user