11 KiB
Infinite Scroll Fetch Loop Fix v2 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Kill the pagination loop that still fires extra POST /bases/rows requests as the user scrolls continuously through a 10K+ base, even after the v1 rows-length guard.
Architecture: Replace the virtualItems-polling effect with a scroll-event-driven trigger plus a synchronous pending-fetch ref. Two independent dedup mechanisms: (a) only consider firing after a real scroll event (debounced), (b) never dispatch a second fetchNextPage until the previous one's resulting page has been committed to the cache. The previous v1 fix relied on rows.length growing after each commit, which under sustained scrolling lets react-query schedule multiple fetches before its own isFetchingNextPage observed-true state propagates to our render.
Tech Stack: React 18, @tanstack/react-virtual, @tanstack/react-query v5.
Background
What v1 fixed and what it missed
v1 (grid-container.tsx, commit c4d8b6c3) added lastTriggeredRowsLenRef — "don't fire again until rows.length grows past the last value we triggered on." That breaks the idle-at-bottom loop (where the effect was firing every render on the same rows.length).
It still leaks under sustained scrolling:
- User scrolls rapidly. Effect runs. Condition holds. Ref updates to N.
onFetchNextPage()dispatched. - react-query enqueues a fetch. But the internal state object mutations reaching our
isFetchingNextPagesnapshot happen across a microtask boundary; within the SAME React render commit there's a window whereisFetchingNextPageis stillfalsefrom React's perspective. - The effect dep list includes
virtualItems, whichvirtualizer.getVirtualItems()mints fresh every render. Any other state change (scroll position, virtual measurements, React 18 batching flushes) re-fires the effect. - Our
rows.length <= refgate blocks further fires AT THE SAME LENGTH. But because the user is SCROLLING, as soon as a page DOES commit,rows.lengthjumps, and on that same commit render the effect can fire repeatedly (e.g., due to scroll-driven re-measures) because theisFetchingNextPagefalse window can overlap with the new render. - If enough fetches pile up, react-query dispatches each in sequence. Requests are only partly deduped — each call while a fetch is in flight returns the same promise, BUT if the commit has already landed and
isFetchingNextPageflipped briefly to false before our render observed it, that call dispatches a NEW fetch. Net: 10× more dispatches than pages committed.
Reported symptom: on the 10K base the loop kicks in after a while of scrolling. Network panel shows 1176 requests initiated, only 124 loaded — ~10× over-dispatch.
The fix shape
Two orthogonal locks that together make over-dispatch impossible:
Lock A — synchronous pending flag. A pendingFetchRef set to true SYNCHRONOUSLY right before onFetchNextPage() is called. Cleared in a separate effect that watches isFetchingNextPage transitioning back to false (i.e., the fetch that we started has resolved). While the ref is set, the trigger never re-fires. This eliminates the same-render-double-fire window that the v1 length guard couldn't cover.
Lock B — real scroll events, not render-driven polling. Attach a listener to scrollRef's scroll event, debounced ~50 ms. On each debounced scroll, compute "am I near the bottom" from raw scrollTop + clientHeight >= scrollHeight - threshold. This removes virtualItems from the trigger path entirely — no effect is running on every render.
With both locks, the worst-case dispatch cadence is one per debounced scroll tick where you're near the bottom. Combined with Lock A's pending gate, you get at most one request in flight at a time, committing sequentially as the user scrolls.
Keep the v1 lastTriggeredRowsLenRef guard as a safety net (it's cheap and prevents re-trigger against stale row-set data).
File Structure
Modified:
apps/client/src/features/base/components/grid/grid-container.tsx— replace the current trigger effect; add a pending ref + a reset effect.
No new files, no new deps, nothing server-side.
Task 1: Replace the trigger with a scroll-driven + pending-guarded version
File: apps/client/src/features/base/components/grid/grid-container.tsx
Step 1: Add a pending ref next to the existing lastTriggeredRowsLenRef
Inside GridContainer, right after const lastTriggeredRowsLenRef = useRef(0); (around line 70), add:
// Synchronous guard: set to true the moment we dispatch `onFetchNextPage`,
// cleared only after `isFetchingNextPage` has transitioned back to false.
// This closes the "React 18 batching / snapshot staleness" window where
// `isFetchingNextPage` from the hook is still observed false even though
// a dispatch is already in flight — which is how the v1 length-only
// guard still permits over-dispatch during sustained scrolling.
const pendingFetchRef = useRef(false);
Step 2: DELETE the existing trigger effect
Locate the effect at roughly lines 122-130:
useEffect(() => {
if (!hasNextPage || isFetchingNextPage || !onFetchNextPage) return;
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (lastItem.index < rows.length - OVERSCAN * 2) return;
if (rows.length <= lastTriggeredRowsLenRef.current) return;
lastTriggeredRowsLenRef.current = rows.length;
onFetchNextPage();
}, [virtualItems, rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
Delete it entirely. We're replacing render-polling with scroll-event-driven.
Step 3: Add a new scroll-event-driven effect in its place
Insert this (same location):
// Scroll-event-driven pagination trigger. Previously this was a
// render-effect that polled `virtualItems` — which runs on every render
// (virtualItems has fresh identity each call) and over-dispatches when
// React's `isFetchingNextPage` snapshot is stale relative to react-query's
// in-flight state. A plain scroll event with a small debounce and a
// synchronous pending ref fires at most once per scroll pulse AND
// at most one in-flight request at a time.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const NEAR_BOTTOM_PX = ROW_HEIGHT * OVERSCAN * 2;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const maybeFetch = () => {
if (!onFetchNextPage) return;
if (!hasNextPage) return;
if (isFetchingNextPage) return;
if (pendingFetchRef.current) return;
const node = scrollRef.current;
if (!node) return;
const distanceFromBottom =
node.scrollHeight - (node.scrollTop + node.clientHeight);
if (distanceFromBottom > NEAR_BOTTOM_PX) return;
if (rows.length <= lastTriggeredRowsLenRef.current) return;
pendingFetchRef.current = true;
lastTriggeredRowsLenRef.current = rows.length;
onFetchNextPage();
};
const onScroll = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(maybeFetch, 50);
};
// Also evaluate once on mount / when deps change — covers the case
// where the user hasn't scrolled yet but the viewport is already
// past the near-bottom threshold (e.g. first page is short).
maybeFetch();
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
el.removeEventListener("scroll", onScroll);
if (debounceTimer) clearTimeout(debounceTimer);
};
}, [rows.length, hasNextPage, isFetchingNextPage, onFetchNextPage]);
Notes the implementer must not change:
- The
maybeFetch()call BEFORE adding the scroll listener is intentional — it handles "viewport already past threshold on mount/commit" without requiring a scroll. NEAR_BOTTOM_PX = ROW_HEIGHT * OVERSCAN * 2keeps the trigger threshold equivalent to the oldlastItem.index >= rows.length - OVERSCAN * 2rule (20 rows * 36 px = 720 px).pendingFetchRef.current = trueis set BEFOREonFetchNextPage()so a synchronous re-entry can't slip through.passive: trueon the listener is performance-critical on large lists.
Step 4: Add an effect that clears pendingFetchRef when a fetch resolves
Immediately after the effect from Step 3, add:
useEffect(() => {
if (!isFetchingNextPage) {
// react-query's fetch we triggered has resolved (data committed +
// isFetchingNextPage back to false). Release the pending gate.
pendingFetchRef.current = false;
}
}, [isFetchingNextPage]);
This is the counterpart to Step 3's pendingFetchRef.current = true. The flag lifecycle:
falseinitially- set
truesynchronously just beforeonFetchNextPage() - set back to
falseas soon asisFetchingNextPageobserved goes back tofalse
Between those, no new dispatch is allowed.
Step 5: Keep the existing reset effect
The effect at roughly lines 132-140 that resets lastTriggeredRowsLenRef to 0 when rows.length drops (view/filter/sort switch) must stay as-is. Do NOT delete it.
Step 6: Build
pnpm nx run client:build
Expected: success.
Step 7: Commit
git add apps/client/src/features/base/components/grid/grid-container.tsx
git commit -m "fix(base): drive pagination from scroll events with in-flight gate to kill dispatch loop"
Task 2: USER smoke test
⚠️ Do not run
pnpm devas an agent. Hand off.
On the 10K base (should also work on 100K):
-
Rapid continuous scroll to bottom.
- DevTools → Network → filter to
Fetch/XHR→ clear. - Scroll the scrollbar smoothly from top to bottom of the 10K base, no pauses.
- Expected: roughly one
POST /bases/rowsper page (~100 total for 10K rows / 100 per page). NO "n requests in flight with only k loaded" state — completed count should track initiated count closely.
- DevTools → Network → filter to
-
Idle at bottom for 30 s.
- After reaching the bottom, wait.
- Zero additional requests fire.
-
Scroll up and back down.
- Scroll up to row 5000, then back to bottom.
- No refetch of already-cached pages; only new pages (if any remain) fire.
-
Sort change mid-scroll.
- While at row 5000, change the sort from Title-asc to Title-desc.
- Pagination resets cleanly. Fetches the first page of the new sort order; scrolling continues to fetch normally.
-
Filter that narrows to few hundred rows.
- Apply a filter producing ~200 matches.
- Scroll to bottom. Exactly ~2 fetches (first page was already loaded + next one). Stops cleanly.
-
Regression: unsorted base.
- Remove sort/filter. Scroll through. Fetches still fire correctly.
-
Regression: create row at top of scroll.
- Scroll to top, add a new row. It appears. No extra pagination fires.
If any step shows over-dispatch (initiated » loaded) or misses a page, report which with approximate counts.
Out of scope
- Swapping to an
IntersectionObserversentinel. Scroll-event + debounce achieves the same dedup without the complexity of maintaining a sentinel element inside a virtualized grid. - Measuring real row heights via
measureElement. Unrelated to the dispatch loop; useful later for pixel-perfect scrolling. - A visible "loading more…" indicator during fetch. UX only.