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
- Mount
useInfiniteScroll with resetKey: 'k1'. Page 1 resolves successfully (hasMore: true).
- Call
loadMore(). This starts a page-2 fetch and sets inFlightRef.current = true. Do not let this fetch resolve yet.
- While that fetch is still pending, change
resetKey to 'k2' (e.g., the user types into the diary search box or changes a filter).
- 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.)
- Eventually resolve the stale page-2 promise from step 2.
- 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.
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, testit.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
useInfiniteScrollwithresetKey: 'k1'. Page 1 resolves successfully (hasMore: true).loadMore(). This starts a page-2 fetch and setsinFlightRef.current = true. Do not let this fetch resolve yet.resetKeyto'k2'(e.g., the user types into the diary search box or changes a filter).fetchPageis never called for page 1 under the new key. (In the reproduction test,fetchPageis expected to be called a 3rd time — it never is; it stays at 2 calls.).thenhandler still closes over the OLD page number (2) and unconditionally appends its items onto whateveritemscurrently is — which the reset effect just cleared to[]. The stale batch ends up as the hook's entire result set,hasMore/statusare derived from the STALE response, andpageRef.currentis bumped to a corrupted value (stale page + 1) for the NEW resetKey's session.Expected Behavior
A
resetKeychange must always win: it should immediately clear items, reset to page 1, and issue a freshfetchPage(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 theinFlightRefguard (if (inFlightRef.current) return;inuseInfiniteScroll.ts), becauseinFlightRef.currentis stilltruefrom the stale in-flight fetch. No fresh page-1 request is ever issued. When the stale promise resolves, its closure still haspage === 2(not 1), sosetItemstakes the[...prev, ...result.items]branch instead of thepage === 1replace branch, appending the stale batch onto the now-empty array.pageRef.currentandhasMore/statusare 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 asit.failingso CI stays green and flags this as a live tripwire — it will start failing loudly, in the "unexpectedly passing" sense, once fixed):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."void runFetch(1)unconditionally, which is silently dropped by the guard above when a stale fetch is in flight..thenclosure (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
resetKeyitself) in a ref, bump it on reset, and have each in-flight fetch's.then/.catchcheck that the epoch hasn't changed before applyingsetItems/setStatus/etc. The reset effect should also bypass or resetinFlightRefso its ownrunFetch(1)is not swallowed by a stale in-flight guard.