Problem
useLeaderboard (src/hooks/useLeaderboard.ts) triggers refresh() via a useCallback keyed on [offset, sortKey] (line 44-57), invoked from a useEffect on [refresh] (line 59-63) and again every REFRESH_MS (30s) on an interval. Each call to refresh() does:
fetchLeaderboard(offset, PAGE_SIZE, sortKey)
.then(({ entries, total }) => { setEntries(entries); setTotal(total); ... })
.catch(...)
.finally(() => setIsLoading(false));
There is no AbortController, no request-id/sequence check, and no cancellation on unmount or on subsequent calls. If a user changes sortKey (via setSortKey, which also resets page to 1) and then quickly clicks to another page, or if the 30-second auto-refresh interval fires while a user-triggered refresh() from a sort/page change is still in flight, two overlapping fetchLeaderboard calls race. Whichever network response resolves last wins and overwrites entries/total/lastRefreshed, regardless of which request was actually issued more recently — a classic out-of-order-response bug. Because fetchLeaderboardFromEvents in src/lib/soroban.ts involves a Soroban RPC getEvents scan (not a fast, uniform-latency call), response times for different sort/offset combinations can vary significantly, making this race easy to hit in practice (e.g. switching sort key twice quickly, or paging while the background 30s refresh is mid-flight).
Acceptance Criteria
Relevant Files
src/hooks/useLeaderboard.ts — refresh() (~L44-57) has no cancellation/sequencing guard against overlapping requests
src/lib/soroban.ts — getLeaderboard/fetchLeaderboardFromEvents has highly variable latency depending on whether the API or the RPC event-scan fallback is used, making the race easy to trigger
Additional Notes
Confirmed against current source (src/hooks/useLeaderboard.ts)
Re-reading the hook top-to-bottom confirms the bug is exactly as described and finds one more contributing factor. The full sequence:
const refresh = useCallback(() => {
setIsLoading(true);
fetchLeaderboard(offset, PAGE_SIZE, sortKey)
.then(({ entries, total }) => {
setEntries(entries);
setTotal(total);
setLastRefreshed(new Date());
})
.catch(() => { setEntries([]); setTotal(0); })
.finally(() => setIsLoading(false));
}, [offset, sortKey]);
useEffect(() => {
refresh();
const id = setInterval(refresh, REFRESH_MS);
return () => clearInterval(id);
}, [refresh]);
Because refresh is recreated whenever offset or sortKey changes, the useEffect above tears down and re-registers the 30s setInterval on every page/sort change — but it does not cancel any fetchLeaderboard promise that's already in flight from the previous refresh invocation. That in-flight promise keeps its .then/.catch/.finally handlers alive and will still call setEntries/setTotal/setIsLoading on the (still-mounted, but now representing a different offset/sortKey) component when it eventually resolves.
setSortKey (defined just below the snippet in the Relevant Files range) also resets page to 1 synchronously in the same tick as changing sortKey, so a user toggling sort twice in quick succession produces two overlapping requests whose offsets are both 0 but whose sortKey differs — the response body shape is identical ({entries, total}) so nothing structurally prevents the stale one from being applied silently.
Edge cases not yet covered by the acceptance criteria
- Unmount during in-flight fetch. There is no cleanup flag checked before the
.then callbacks run. If useLeaderboard unmounts (e.g. user navigates away from the leaderboard route) while a request is in flight, React will emit "Can't perform a React state update on an unmounted component" in dev, and in a suspense/concurrent context this can mask other bugs. Any AbortController-based fix should also gate the .then callbacks on a mountedRef/cleanup check, not just cancel the network request (fetch abort rejects the promise, but sorobanService.getLeaderboard's internal fallback path in fetchLeaderboardFromEvents swallows errors internally in some branches — see below — so an aborted request may not even reject cleanly).
- The API-vs-event-scan fallback changes latency profile mid-session.
SorobanService.getLeaderboard (src/lib/soroban.ts ~L1426-1439) tries fetchLeaderboardFromApi first and only falls back to fetchLeaderboardFromEvents (~L1467-1541) on a thrown error, logging console.warn('[SmartDrop] leaderboard API failed, falling back to event scan:', err). If the indexer API is flaky (intermittent 500s or timeouts), successive refresh() calls can alternate between the fast API path and the slow on-chain getEvents scan path (which itself calls getLatestLedger() then a getEvents RPC scan over LEADERBOARD_LOOKBACK_LEDGERS and aggregates in-memory), making the race trivially easy to reproduce in production without any user needing to double-click anything — a plain page load followed by an auto-refresh 30s later can race against a slow late-arriving retry.
searchQuery filtering happens client-side over entries (the paged derivation right after refresh), so a race that overwrites entries with a stale, different-sortKey page will also silently corrupt the search results shown to the user, not just the raw table — worth covering in the regression test.
Implementation sketch
const requestIdRef = useRef(0);
const refresh = useCallback(() => {
const id = ++requestIdRef.current;
setIsLoading(true);
fetchLeaderboard(offset, PAGE_SIZE, sortKey)
.then(({ entries, total }) => {
if (id !== requestIdRef.current) return; // stale response, drop it
setEntries(entries);
setTotal(total);
setLastRefreshed(new Date());
})
.catch(() => {
if (id !== requestIdRef.current) return;
setEntries([]);
setTotal(0);
})
.finally(() => {
if (id === requestIdRef.current) setIsLoading(false);
});
}, [offset, sortKey]);
An incrementing ref is simpler to reason about here than threading an AbortController through sorobanService.getLeaderboard → fetchLeaderboardFromApi/fetchLeaderboardFromEvents, since the event-scan path makes multiple sequential RPC calls (getLatestLedger then getEvents) that would each need signal support and abort-aware error handling. The sequence-number approach gets correctness without touching soroban.ts at all, at the cost of not actually cancelling the underlying network request (acceptable for a read-only leaderboard scan, but worth calling out explicitly in the PR description since it means abandoned event-scans still consume RPC quota).
Testing strategy for reviewers
Problem
useLeaderboard(src/hooks/useLeaderboard.ts) triggersrefresh()via auseCallbackkeyed on[offset, sortKey](line 44-57), invoked from auseEffecton[refresh](line 59-63) and again everyREFRESH_MS(30s) on an interval. Each call torefresh()does:There is no
AbortController, no request-id/sequence check, and no cancellation on unmount or on subsequent calls. If a user changessortKey(viasetSortKey, which also resetspageto 1) and then quickly clicks to another page, or if the 30-second auto-refresh interval fires while a user-triggeredrefresh()from a sort/page change is still in flight, two overlappingfetchLeaderboardcalls race. Whichever network response resolves last wins and overwritesentries/total/lastRefreshed, regardless of which request was actually issued more recently — a classic out-of-order-response bug. BecausefetchLeaderboardFromEventsinsrc/lib/soroban.tsinvolves a Soroban RPCgetEventsscan (not a fast, uniform-latency call), response times for different sort/offset combinations can vary significantly, making this race easy to hit in practice (e.g. switching sort key twice quickly, or paging while the background 30s refresh is mid-flight).Acceptance Criteria
AbortControllerwired throughfetchLeaderboard) so only the response matching the latest request is applied to state.fetchLeaderboardpromises out of order and asserts the hook's final state reflects the most recently issued request, not the most recently resolved one.Relevant Files
src/hooks/useLeaderboard.ts— refresh() (~L44-57) has no cancellation/sequencing guard against overlapping requestssrc/lib/soroban.ts— getLeaderboard/fetchLeaderboardFromEvents has highly variable latency depending on whether the API or the RPC event-scan fallback is used, making the race easy to triggerAdditional Notes
Confirmed against current source (
src/hooks/useLeaderboard.ts)Re-reading the hook top-to-bottom confirms the bug is exactly as described and finds one more contributing factor. The full sequence:
Because
refreshis recreated wheneveroffsetorsortKeychanges, theuseEffectabove tears down and re-registers the 30ssetIntervalon every page/sort change — but it does not cancel anyfetchLeaderboardpromise that's already in flight from the previousrefreshinvocation. That in-flight promise keeps its.then/.catch/.finallyhandlers alive and will still callsetEntries/setTotal/setIsLoadingon the (still-mounted, but now representing a different offset/sortKey) component when it eventually resolves.setSortKey(defined just below the snippet in the Relevant Files range) also resetspageto 1 synchronously in the same tick as changingsortKey, so a user toggling sort twice in quick succession produces two overlapping requests whose offsets are both0but whosesortKeydiffers — the response body shape is identical ({entries, total}) so nothing structurally prevents the stale one from being applied silently.Edge cases not yet covered by the acceptance criteria
.thencallbacks run. IfuseLeaderboardunmounts (e.g. user navigates away from the leaderboard route) while a request is in flight, React will emit "Can't perform a React state update on an unmounted component" in dev, and in a suspense/concurrent context this can mask other bugs. Any AbortController-based fix should also gate the.thencallbacks on amountedRef/cleanup check, not just cancel the network request (fetch abort rejects the promise, butsorobanService.getLeaderboard's internal fallback path infetchLeaderboardFromEventsswallows errors internally in some branches — see below — so an aborted request may not even reject cleanly).SorobanService.getLeaderboard(src/lib/soroban.ts~L1426-1439) triesfetchLeaderboardFromApifirst and only falls back tofetchLeaderboardFromEvents(~L1467-1541) on a thrown error, loggingconsole.warn('[SmartDrop] leaderboard API failed, falling back to event scan:', err). If the indexer API is flaky (intermittent 500s or timeouts), successiverefresh()calls can alternate between the fast API path and the slow on-chaingetEventsscan path (which itself callsgetLatestLedger()then agetEventsRPC scan overLEADERBOARD_LOOKBACK_LEDGERSand aggregates in-memory), making the race trivially easy to reproduce in production without any user needing to double-click anything — a plain page load followed by an auto-refresh 30s later can race against a slow late-arriving retry.searchQueryfiltering happens client-side overentries(thepagedderivation right afterrefresh), so a race that overwritesentrieswith a stale, different-sortKeypage will also silently corrupt the search results shown to the user, not just the raw table — worth covering in the regression test.Implementation sketch
An incrementing ref is simpler to reason about here than threading an
AbortControllerthroughsorobanService.getLeaderboard→fetchLeaderboardFromApi/fetchLeaderboardFromEvents, since the event-scan path makes multiple sequential RPC calls (getLatestLedgerthengetEvents) that would each needsignalsupport and abort-aware error handling. The sequence-number approach gets correctness without touchingsoroban.tsat all, at the cost of not actually cancelling the underlying network request (acceptable for a read-only leaderboard scan, but worth calling out explicitly in the PR description since it means abandoned event-scans still consume RPC quota).Testing strategy for reviewers
fetchLeaderboarddirectly (it's already exported for this reason) with two manually-controlled promises (e.g. viadeferred()helpers) — resolve the second-issued one first and assert final state matches it, then resolve the first-issued one and assert state does not revert.useEffectstill fires everyREFRESH_MSafter asortKey/offsetchange (i.e., the fix doesn't accidentally cancel/duplicate thesetIntervalteardown, sincerefresh's identity changing is what currently re-triggers that effect).SEARCH_DEBOUNCE_MS) still narrowspagedcorrectly against whatever the latestentriesare, per edge case 3 above.