mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
232 lines
11 KiB
Markdown
232 lines
11 KiB
Markdown
# 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`](apps/client/src/features/base/components/grid/grid-container.tsx)) 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:
|
||
|
||
1. User scrolls rapidly. Effect runs. Condition holds. Ref updates to N. `onFetchNextPage()` dispatched.
|
||
2. react-query enqueues a fetch. But the internal state object mutations reaching our `isFetchingNextPage` snapshot happen across a microtask boundary; within the SAME React render commit there's a window where `isFetchingNextPage` is still `false` from React's perspective.
|
||
3. The effect dep list includes `virtualItems`, which `virtualizer.getVirtualItems()` mints fresh every render. Any other state change (scroll position, virtual measurements, React 18 batching flushes) re-fires the effect.
|
||
4. Our `rows.length <= ref` gate blocks further fires AT THE SAME LENGTH. But because the user is SCROLLING, as soon as a page DOES commit, `rows.length` jumps, and on that same commit render the effect can fire repeatedly (e.g., due to scroll-driven re-measures) because the `isFetchingNextPage` false window can overlap with the new render.
|
||
5. 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 `isFetchingNextPage` flipped 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:
|
||
|
||
```ts
|
||
// 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:
|
||
|
||
```ts
|
||
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):
|
||
|
||
```ts
|
||
// 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 * 2` keeps the trigger threshold equivalent to the old `lastItem.index >= rows.length - OVERSCAN * 2` rule (20 rows * 36 px = 720 px).
|
||
- `pendingFetchRef.current = true` is set BEFORE `onFetchNextPage()` so a synchronous re-entry can't slip through.
|
||
- `passive: true` on 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:
|
||
|
||
```ts
|
||
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:
|
||
- `false` initially
|
||
- set `true` synchronously just before `onFetchNextPage()`
|
||
- set back to `false` as soon as `isFetchingNextPage` observed goes back to `false`
|
||
|
||
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
|
||
|
||
```bash
|
||
pnpm nx run client:build
|
||
```
|
||
|
||
Expected: success.
|
||
|
||
### Step 7: Commit
|
||
|
||
```bash
|
||
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 dev` as an agent.** Hand off.
|
||
|
||
On the 10K base (should also work on 100K):
|
||
|
||
- [ ] **Rapid continuous scroll to bottom.**
|
||
1. DevTools → Network → filter to `Fetch/XHR` → clear.
|
||
2. Scroll the scrollbar smoothly from top to bottom of the 10K base, no pauses.
|
||
3. Expected: roughly one `POST /bases/rows` per 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.
|
||
|
||
- [ ] **Idle at bottom for 30 s.**
|
||
1. After reaching the bottom, wait.
|
||
2. Zero additional requests fire.
|
||
|
||
- [ ] **Scroll up and back down.**
|
||
1. Scroll up to row 5000, then back to bottom.
|
||
2. No refetch of already-cached pages; only new pages (if any remain) fire.
|
||
|
||
- [ ] **Sort change mid-scroll.**
|
||
1. While at row 5000, change the sort from Title-asc to Title-desc.
|
||
2. Pagination resets cleanly. Fetches the first page of the new sort order; scrolling continues to fetch normally.
|
||
|
||
- [ ] **Filter that narrows to few hundred rows.**
|
||
1. Apply a filter producing ~200 matches.
|
||
2. Scroll to bottom. Exactly ~2 fetches (first page was already loaded + next one). Stops cleanly.
|
||
|
||
- [ ] **Regression: unsorted base.**
|
||
1. Remove sort/filter. Scroll through. Fetches still fire correctly.
|
||
|
||
- [ ] **Regression: create row at top of scroll.**
|
||
1. 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 `IntersectionObserver` sentinel. 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.
|