You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Companion to the useLeaderboard race-condition bug described elsewhere in this batch: there is currently no test in the repository that exercises out-of-order response resolution for useLeaderboard. Establishing this test before or alongside the fix ensures the race condition is actually verified to be fixed (rather than just 'probably fixed') and prevents a future refactor from silently reintroducing it. This is called out as its own issue because it requires constructing a deliberately-out-of-order mock (fetchLeaderboard resolving a later-issued request before an earlier one) which is a non-trivial test-authoring task in its own right — properly modeling the sequencing requires either fake timers or manually resolvable promises, not just vi.fn().mockResolvedValue(...).
Acceptance Criteria
Add a test using manually-controlled/deferred promises (not simple mocked resolved values) for fetchLeaderboard that resolves a second, later-issued call before an earlier, first-issued call.
Assert the hook's final entries/total/lastRefreshed state reflects the most recently issued request's data, not whichever resolved last.
Add a companion test for the 30-second auto-refresh interval racing against a manual refresh() triggered by a user action (sort/page change).
Ensure the test suite fails against the current (unfixed) implementation and passes once the race-condition fix from the companion issue lands — i.e. write this as a genuine regression test, not a tautological one.
Relevant Files
src/hooks/useLeaderboard.ts — refresh() (~L44-57) is the function under test; currently has no protection against out-of-order responses
src/app/leaderboard/page.tsx — the consumer whose displayed data would be affected by the race condition
Additional Notes
Confirmed against current source (src/hooks/useLeaderboard.ts, 97 lines)
refresh (L44-57) is a plain useCallback with no request-identity tracking whatsoever:
constrefresh=useCallback(()=>{setIsLoading(true);fetchLeaderboard(offset,PAGE_SIZE,sortKey).then(({ entries, total })=>{setEntries(entries);setTotal(total);setLastRefreshed(newDate());}).catch(()=>{setEntries([]);setTotal(0);}).finally(()=>setIsLoading(false));},[offset,sortKey]);
There is no sequence number, no AbortController, and no comparison of "is this the response for the request I most recently issued." Every .then callback unconditionally calls setEntries/setTotal/setLastRefreshed regardless of whether a newer call to refresh has been issued since. refresh itself is re-created (new closure) whenever offset or sortKey changes (dependency array, L57), and the effect at L59-63 (useEffect(() => { refresh(); const id = setInterval(refresh, REFRESH_MS); ... }, [refresh])) re-fires immediately whenever refresh's identity changes — i.e., every sort/page change causes an immediate extra refresh() call on top of whatever the 30s interval (REFRESH_MS, L14) has already in flight. This is precisely the setup where a slow first request (e.g. triggered by an initial page load or previous sort) can resolve after a fast second request (e.g. triggered by a rapid follow-up sort click), and since neither .then checks staleness, the earlier-issued-but-later-resolving response wins and silently overwrites the newer, correct one.
Concrete repro shape for the test: call setSortKey('stake') then immediately setSortKey('credits') (or setPageState twice in a row) with fetchLeaderboard mocked so the first call's promise resolves after the second call's promise — assert the hook's final entries/total reflect the 'credits' (second, later-issued) result, not 'stake'.
The finally(() => setIsLoading(false)) compounds the bug
Beyond stale data winning, isLoading also has no request-identity guard: if request A (slow) is still in flight when request B (fast) resolves and sets isLoading(false), then when A finally resolves/rejects its own .finally will also call setIsLoading(false) — harmless in this specific case since both set it to the same value, but if a third overlapping request were mid-flight this compounds unpredictably with the entries/total bug: a user could see isLoading: false (looks settled) while stale data from an earlier request is what's actually displayed, i.e., no loading-spinner signal that anything is still wrong.
The REFRESH_MS = 30_000 interval (L14) and the useEffect(() => { refresh(); const id = setInterval(refresh, REFRESH_MS); return () => clearInterval(id); }, [refresh]) (L59-63) mean that every time refresh's identity changes (any sort/page change, since those are in its dependency array), the interval is torn down and recreated with a fresh 30s countdown starting from that moment — so a user who manually triggers setSortKey/setPageState right before the auto-refresh was about to fire effectively delays the next auto-refresh by a full 30s. That's a separate, milder bug from the race condition itself (more a UX/staleness-window issue than a correctness bug), but the two interact: an auto-refresh tick landing in the small window between a user's setSortKey call and its resulting refresh() firing is the exact "manual refresh racing against auto-refresh" scenario the AC asks to be tested, and reproducing it requires careful fake-timer sequencing (advance timers partway, trigger the state change, advance the rest of the way) rather than a single vi.advanceTimersByTimeAsync(30000) call.
Search functionality is unaffected by the race (worth noting to avoid a wasted test)
searchQuery/paged (L65-69) filter over already-fetched entries client-side with no network request involved (debounced via L35-38's separate useEffect, unrelated to refresh), so there's no race to test there — search doesn't need coverage as part of this issue.
Implementation sketch for the companion fix (#79), informing what this test should assert once it lands
Simplest fix: a request-id ref (const requestIdRef = useRef(0)), incremented at the top of refresh before calling fetchLeaderboard, captured in a local variable, and checked inside .then/.catch before committing state:
constrefresh=useCallback(()=>{constrequestId=++requestIdRef.current;setIsLoading(true);fetchLeaderboard(offset,PAGE_SIZE,sortKey).then(({ entries, total })=>{if(requestId!==requestIdRef.current)return;// stale, discardsetEntries(entries);setTotal(total);setLastRefreshed(newDate());}).catch(()=>{if(requestId!==requestIdRef.current)return;setEntries([]);setTotal(0);}).finally(()=>{if(requestId===requestIdRef.current)setIsLoading(false);});},[offset,sortKey]);
This is a minimal, low-risk change that the regression test in this issue should be written to validate directly (assert stale responses are discarded, not merely that final state happens to look right).
Testing strategy
Test file: new src/hooks/useLeaderboard.test.ts (none currently exists per the issue's own framing — confirm via find src/hooks -iname "*leaderboard*") or co-located alongside useSorobanEvents.test.ts's pattern for hook testing (renderHook from @/test/renderHook, vi.useFakeTimers()).
Mock fetchLeaderboard (exported at L17-23, wraps sorobanService.getLeaderboard) directly via vi.mock on the module, or inject a test double for sorobanService.getLeaderboard if fetchLeaderboard isn't independently mockable — check whether the hook imports fetchLeaderboard from the same module (it's defined in the same file, L17, and used at L46) which means mocking requires either vi.spyOn on the module's own export (awkward for same-file functions) or mocking sorobanService.getLeaderboard upstream instead — likely the cleaner seam.
Direct companion to #79 ("useLeaderboard has no race-condition guard — rapid sort/page changes can let a stale response overwrite a newer one"), which is the implementation-side bug for the same refresh function (L44-57) described here. These should land together: #79's fix supplies the request-id (or AbortController) mechanism, and this issue's tests are what prove it actually works and stays working. Also touches the same consumer as #78 ("Leaderboard's 'You are rank X' banner only checks the currently-fetched page, not the user's true global rank") — connectedRank (L71-75) is derived from entries, so a stale-overwrite bug here could also transiently show an incorrect rank banner; not the same root cause as #78 but worth a shared mention in src/app/leaderboard/page.tsx if that page is touched for either fix.
Problem
Companion to the
useLeaderboardrace-condition bug described elsewhere in this batch: there is currently no test in the repository that exercises out-of-order response resolution foruseLeaderboard. Establishing this test before or alongside the fix ensures the race condition is actually verified to be fixed (rather than just 'probably fixed') and prevents a future refactor from silently reintroducing it. This is called out as its own issue because it requires constructing a deliberately-out-of-order mock (fetchLeaderboardresolving a later-issued request before an earlier one) which is a non-trivial test-authoring task in its own right — properly modeling the sequencing requires either fake timers or manually resolvable promises, not justvi.fn().mockResolvedValue(...).Acceptance Criteria
fetchLeaderboardthat resolves a second, later-issued call before an earlier, first-issued call.entries/total/lastRefreshedstate reflects the most recently issued request's data, not whichever resolved last.refresh()triggered by a user action (sort/page change).Relevant Files
src/hooks/useLeaderboard.ts— refresh() (~L44-57) is the function under test; currently has no protection against out-of-order responsessrc/app/leaderboard/page.tsx— the consumer whose displayed data would be affected by the race conditionAdditional Notes
Confirmed against current source (
src/hooks/useLeaderboard.ts, 97 lines)refresh(L44-57) is a plainuseCallbackwith no request-identity tracking whatsoever:There is no sequence number, no
AbortController, and no comparison of "is this the response for the request I most recently issued." Every.thencallback unconditionally callssetEntries/setTotal/setLastRefreshedregardless of whether a newer call torefreshhas been issued since.refreshitself is re-created (new closure) wheneveroffsetorsortKeychanges (dependency array, L57), and the effect at L59-63 (useEffect(() => { refresh(); const id = setInterval(refresh, REFRESH_MS); ... }, [refresh])) re-fires immediately wheneverrefresh's identity changes — i.e., every sort/page change causes an immediate extrarefresh()call on top of whatever the 30s interval (REFRESH_MS, L14) has already in flight. This is precisely the setup where a slow first request (e.g. triggered by an initial page load or previous sort) can resolve after a fast second request (e.g. triggered by a rapid follow-up sort click), and since neither.thenchecks staleness, the earlier-issued-but-later-resolving response wins and silently overwrites the newer, correct one.Concrete repro shape for the test: call
setSortKey('stake')then immediatelysetSortKey('credits')(orsetPageStatetwice in a row) withfetchLeaderboardmocked so the first call's promise resolves after the second call's promise — assert the hook's finalentries/totalreflect the'credits'(second, later-issued) result, not'stake'.The
finally(() => setIsLoading(false))compounds the bugBeyond stale data winning,
isLoadingalso has no request-identity guard: if request A (slow) is still in flight when request B (fast) resolves and setsisLoading(false), then when A finally resolves/rejects its own.finallywill also callsetIsLoading(false)— harmless in this specific case since both set it to the same value, but if a third overlapping request were mid-flight this compounds unpredictably with the entries/total bug: a user could seeisLoading: false(looks settled) while stale data from an earlier request is what's actually displayed, i.e., no loading-spinner signal that anything is still wrong.Auto-refresh vs. manual refresh race (AC #3)
The
REFRESH_MS = 30_000interval (L14) and theuseEffect(() => { refresh(); const id = setInterval(refresh, REFRESH_MS); return () => clearInterval(id); }, [refresh])(L59-63) mean that every timerefresh's identity changes (any sort/page change, since those are in its dependency array), the interval is torn down and recreated with a fresh 30s countdown starting from that moment — so a user who manually triggerssetSortKey/setPageStateright before the auto-refresh was about to fire effectively delays the next auto-refresh by a full 30s. That's a separate, milder bug from the race condition itself (more a UX/staleness-window issue than a correctness bug), but the two interact: an auto-refresh tick landing in the small window between a user'ssetSortKeycall and its resultingrefresh()firing is the exact "manual refresh racing against auto-refresh" scenario the AC asks to be tested, and reproducing it requires careful fake-timer sequencing (advance timers partway, trigger the state change, advance the rest of the way) rather than a singlevi.advanceTimersByTimeAsync(30000)call.Search functionality is unaffected by the race (worth noting to avoid a wasted test)
searchQuery/paged(L65-69) filter over already-fetchedentriesclient-side with no network request involved (debounced via L35-38's separateuseEffect, unrelated torefresh), so there's no race to test there — search doesn't need coverage as part of this issue.Implementation sketch for the companion fix (#79), informing what this test should assert once it lands
Simplest fix: a request-id ref (
const requestIdRef = useRef(0)), incremented at the top ofrefreshbefore callingfetchLeaderboard, captured in a local variable, and checked inside.then/.catchbefore committing state:This is a minimal, low-risk change that the regression test in this issue should be written to validate directly (assert stale responses are discarded, not merely that final state happens to look right).
Testing strategy
src/hooks/useLeaderboard.test.ts(none currently exists per the issue's own framing — confirm viafind src/hooks -iname "*leaderboard*") or co-located alongsideuseSorobanEvents.test.ts's pattern for hook testing (renderHookfrom@/test/renderHook,vi.useFakeTimers()).let resolveFirst: (v: any) => void; const firstPromise = new Promise(r => { resolveFirst = r; }), same forresolveSecond) rather thanmockResolvedValueOncechains, since the whole point is controlling resolution order independent of call order — callrefresh()/trigger a sort change to issue call Integrate Frontend with Deployed Soroban Contracts via RPC #1 (returnsfirstPromise), then trigger a second change issuing call Complete End-to-End Asset Locking Flow with Freighter Wallet #2 (returnssecondPromise), thenresolveSecond(...)beforeresolveFirst(...), and assert final hook state matches call Complete End-to-End Asset Locking Flow with Freighter Wallet #2's data.fetchLeaderboard(exported at L17-23, wrapssorobanService.getLeaderboard) directly viavi.mockon the module, or inject a test double forsorobanService.getLeaderboardiffetchLeaderboardisn't independently mockable — check whether the hook importsfetchLeaderboardfrom the same module (it's defined in the same file, L17, and used at L46) which means mocking requires eithervi.spyOnon the module's own export (awkward for same-file functions) or mockingsorobanService.getLeaderboardupstream instead — likely the cleaner seam.mainfirst (verify it actually goes red without the request-id guard) before/alongside the useLeaderboard has no race-condition guard — rapid sort/page changes can let a stale response overwrite a newer one #79 fix landing, per the issue's own AC Implement Global Error Handling with Toast Notifications #4 — this is worth being explicit about in the PR description so reviewers don't mistake a passing-by-coincidence test for a real regression guard.Cross-reference
Direct companion to #79 ("useLeaderboard has no race-condition guard — rapid sort/page changes can let a stale response overwrite a newer one"), which is the implementation-side bug for the same
refreshfunction (L44-57) described here. These should land together: #79's fix supplies the request-id (orAbortController) mechanism, and this issue's tests are what prove it actually works and stays working. Also touches the same consumer as #78 ("Leaderboard's 'You are rank X' banner only checks the currently-fetched page, not the user's true global rank") —connectedRank(L71-75) is derived fromentries, so a stale-overwrite bug here could also transiently show an incorrect rank banner; not the same root cause as #78 but worth a shared mention insrc/app/leaderboard/page.tsxif that page is touched for either fix.