fix(feed): read further ahead so fast scrolling doesn't stall at "Load more"

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.
This commit is contained in:
2026-08-19 01:13:57 +02:00
parent ddb7818d38
commit a300977ed4
+18 -6
View File
@@ -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<T>({
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]);