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]);