From a300977ed4e3b5b4846b153a0e284e80763d8b17 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 19 Aug 2026 01:13:57 +0200 Subject: [PATCH] fix(feed): read further ahead so fast scrolling doesn't stall at "Load more" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keyset pagination is strictly serial (a page needs the previous page's cursor) and the read-ahead only fired within a fixed 4 rows of the loaded end, pulling one 60-item page per cycle. A fast free-spin flick (MX Master & co.) out-scrolls that tiny lookahead, lands on an empty bottom, and waits out a chain of serial round-trips — the multi-second "Load more" hang. Scale the lookahead to the rendered row count (PREFETCH_VIEWPORTS screenfuls); since the effect re-runs when a page settles it chains, keeping a couple of screens buffered ahead of the fold so the content is usually already there. Bounded, and a no-op for finite lists. --- frontend/src/components/VirtualGrid.tsx | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/VirtualGrid.tsx b/frontend/src/components/VirtualGrid.tsx index b81afad4..c5e0a7ed 100644 --- a/frontend/src/components/VirtualGrid.tsx +++ b/frontend/src/components/VirtualGrid.tsx @@ -23,9 +23,17 @@ import { useVirtualizer } from "@tanstack/react-virtual"; // (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter). const GRID_GAP = 16; const LIST_GAP = 4; -// Start fetching the next page this many rows before the end scrolls into view -// (keeps the old IntersectionObserver's generous prefetch feel). +// Infinite-scroll read-ahead. The next page is pulled once the last rendered row comes within +// `lookahead` rows of the loaded end, where lookahead = max(PREFETCH_ROWS, rendered-rows × +// PREFETCH_VIEWPORTS). Sizing it to the number of rows the viewport currently renders makes it adapt +// to window height / column count / row height, and keeps ~PREFETCH_VIEWPORTS screenfuls buffered +// below the fold. The fixed floor is the finite-list / first-paint case. const PREFETCH_ROWS = 4; +// Keep this many screenfuls of rows loaded ahead of the fold. A fast free-spin flick (MX Master & +// co.) out-scrolls a mere 4-row lookahead and lands on an empty bottom, then waits out serial keyset +// round-trips — the "Load more" hangs for seconds. Buffering a couple of screens ahead means the +// content is usually already there when the user gets to it. +const PREFETCH_VIEWPORTS = 2; /** Columns that fit `width`, or 1 for the single-column layout (`minCol == null`). */ function columnsFor(minCol: number | null, width: number): number { @@ -140,13 +148,17 @@ export default function VirtualGrid({ const virtualRows = virtualizer.getVirtualItems(); - // Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of the end, pull - // the next page. A no-op for finite lists (no fetchNextPage / hasNextPage false). + // Infinite-scroll read-ahead: once the last rendered row is within `lookahead` rows of the end, + // pull the next page. A no-op for finite lists (no fetchNextPage / hasNextPage false). Because this + // effect re-runs when a page settles (isFetchingNextPage flips false) it CHAINS — refilling the + // buffer page by page as the user consumes it, so a fast scroll reaches the loaded end far less + // often. See PREFETCH_VIEWPORTS above for why the lookahead scales with the rendered row count. useEffect(() => { - if (!fetchNextPage) return; + if (!fetchNextPage || !hasNextPage || isFetchingNextPage) return; const last = virtualRows[virtualRows.length - 1]; if (!last) return; - if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) { + const lookahead = Math.max(PREFETCH_ROWS, virtualRows.length * PREFETCH_VIEWPORTS); + if (last.index >= rows.length - 1 - lookahead) { fetchNextPage(); } }, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]);