Skip to content

BUG-2060-1: useInfiniteScroll drops fresh page-1 fetch and leaks stale data on resetKey race #2061

Description

@steilerDev

BUG-2060-1: useInfiniteScroll drops the fresh page-1 fetch and leaks stale data when resetKey changes while a page>=2 fetch is in flight

Severity: Major

Component: client/src/hooks/useInfiniteScroll.ts (Issue #2060 — Diary infinite-scroll rework)

Found in: client/src/hooks/useInfiniteScroll.test.tsx, test it.failing('FINDING (#2060): a resetKey change while a stale page>=2 fetch is in flight must not let the stale response leak into the new result set')

Steps to Reproduce

  1. Mount useInfiniteScroll with resetKey: 'k1'. Page 1 resolves successfully (hasMore: true).
  2. Call loadMore(). This starts a page-2 fetch and sets inFlightRef.current = true. Do not let this fetch resolve yet.
  3. While that fetch is still pending, change resetKey to 'k2' (e.g., the user types into the diary search box or changes a filter).
  4. Observe: fetchPage is never called for page 1 under the new key. (In the reproduction test, fetchPage is expected to be called a 3rd time — it never is; it stays at 2 calls.)
  5. Eventually resolve the stale page-2 promise from step 2.
  6. Observe: its .then handler still closes over the OLD page number (2) and unconditionally appends its items onto whatever items currently is — which the reset effect just cleared to []. The stale batch ends up as the hook's entire result set, hasMore/status are derived from the STALE response, and pageRef.current is bumped to a corrupted value (stale page + 1) for the NEW resetKey's session.

Expected Behavior

A resetKey change must always win: it should immediately clear items, reset to page 1, and issue a fresh fetchPage(1) call for the new key — regardless of whether an older fetch from the previous key is still in flight. When that stale fetch later resolves, its result must be discarded (not appended to the new, unrelated result set).

Actual Behavior

The reset effect's runFetch(1) call is silently swallowed by the inFlightRef guard (if (inFlightRef.current) return; in useInfiniteScroll.ts), because inFlightRef.current is still true from the stale in-flight fetch. No fresh page-1 request is ever issued. When the stale promise resolves, its closure still has page === 2 (not 1), so setItems takes the [...prev, ...result.items] branch instead of the page === 1 replace branch, appending the stale batch onto the now-empty array. pageRef.current and hasMore/status are likewise derived from the stale response, corrupting the hook's state for the new resetKey.

Concretely, for a diary user: type a search query while an in-flight "Load more" request from the previous filter is still pending → the list can end up showing entries from the old (pre-edit) filter's second page instead of the new filter's first page, and further pagination state is corrupted.

Evidence

Reproduced empirically in client/src/hooks/useInfiniteScroll.test.tsx (kept in the suite as it.failing so CI stays green and flags this as a live tripwire — it will start failing loudly, in the "unexpectedly passing" sense, once fixed):

Expected number of calls: 3
Received number of calls: 2

  await waitFor(() => expect(fetchPage).toHaveBeenCalledTimes(3));

Root cause is in client/src/hooks/useInfiniteScroll.ts:

  • runFetch (around line 64): if (inFlightRef.current) return; guards re-entrancy but has no way to distinguish "a dedupe of the same request" from "a stale request that should be abandoned in favor of a reset."
  • The reset effect (around line 98): calls void runFetch(1) unconditionally, which is silently dropped by the guard above when a stale fetch is in flight.
  • The stale fetch's .then closure (around lines 64-79) has no way to know it has been superseded by a resetKey change, so it applies its (now stale) result unconditionally.

Suggested Fix Direction (for backend/frontend-developer, not prescriptive)

Track an epoch/generation counter (or the current resetKey itself) in a ref, bump it on reset, and have each in-flight fetch's .then/.catch check that the epoch hasn't changed before applying setItems/setStatus/etc. The reset effect should also bypass or reset inFlightRef so its own runFetch(1) is not swallowed by a stale in-flight guard.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions