Overview
ContributorDashboardPage and SponsorDashboardPage are both Client Components that run their data fetching as plain useEffects with no AbortController, no cleanup function, and — in the contributor page's case — two entirely separate effects whose success/failure are never correlated with each other, even though they feed into one shared "Live data"/"Demo data" badge.
No cancellation (src/app/dashboard/contributor/page.tsx:57-97, and the equivalent in sponsor/page.tsx:48-95):
useEffect(() => {
if (loading) return;
if (!user) { setStats({...}); setFetchStatus("loaded"); setIsLive(false); return; }
setFetchStatus("loading");
apiPost<ReputationSnapshot>(`/reputation/${user.id}/recompute`)
.then((snapshot) => { setStats({...}); setFetchStatus("loaded"); setIsLive(true); })
.catch(() => { setStats(null); setFetchStatus("error"); setIsLive(true); });
}, [user, loading]);
This effect re-runs on every change to the user object reference (not just user.id — AuthContext.refresh() calls setUser(profile) with a brand-new object on every successful re-auth, so any unrelated refresh() call anywhere in the tree while this page is mounted — e.g. from WalletContext.connect()'s await refresh() after linking a wallet — re-triggers this effect). There's no generation counter, no AbortController, and no "ignore this response if a newer request has since started" guard. If effect run #1 (slow) and effect run #2 (fast, from a subsequent user object change) are both in flight, whichever .then()/.catch() resolves last wins, regardless of which was started last — a classic out-of-order-resolution race. Given apiPost here hits a /recompute endpoint (an expensive, presumably non-idempotent-in-timing operation, not a cheap cached read), this isn't a hypothetical: it's plausible for an earlier, slower recompute to resolve after a newer one and silently overwrite fresher stats with stale ones.
Uncorrelated fetches driving one badge (contributor/page.tsx, both effects): the "Live data"/"Demo data" badge (isLive) is derived only from the reputation-stats effect above (lines 57-97). The bounty list — bounties, used to compute mine, activeClaims, completedClaims, available, and rendered directly via BountyCard in "Your claims" and "Recommended for you" — comes from a completely separate effect:
useEffect(() => {
fetchBounties(mockBounties).then(setBounties);
}, []);
fetchBounties (src/lib/api.ts:99-106) silently falls back to the mockBounties argument on any failure, with no error surfaced to this component at all — there's no .catch() here because fetchBounties already swallows it internally. This means: the "Live data" badge can be showing (because the stats fetch succeeded) while the bounty listings below it are silently the bundled demo bounties (b1–b5, with claimedBy values like "0xkoda"/"priyaeth") because the separate bounty fetch failed — with no correlation between the two, and no indication to the user that only part of the page is real. Concretely: "Recommended for you" (available = bounties.filter(b => b.status === "open"), not handle-dependent) would show the mock bounties b1 ("Fix wallet reconnect race condition...") and b3 ("Document Soroban escrow contract API reference...") as if they were real, live, clickable recommendations — under a green "Live data" badge — and a user could click through to /issues/b1, which independently mock-falls-back the same way via fetchBounty, reinforcing the illusion.
Requirements
- Add proper effect-race protection to both dashboards' data-fetching effects: either an
AbortController passed into the fetch calls (requires apiPost/fetchBounties/apiRequest in src/lib/api.ts to accept and forward a signal, which they don't today), or a generation-counter/"is this still the latest effect run" ref-based guard that skips setState calls from a superseded run.
- Correlate the "Live data"/"Demo data" badge with every data source rendered on the page, not just the stats fetch. At minimum,
ContributorDashboardPage's badge must reflect whether the bounty-list fetch also succeeded, not just the reputation-stats fetch — a page can't honestly claim to be "Live data" while silently rendering fallback bounty data underneath.
- Where a page has genuinely independent data sources with independent success/failure (which may be unavoidable depending on backend endpoint design), consider per-section status indicators instead of one page-level badge that can't represent partial success — this is a real design decision, not a one-line fix, and should be explicitly discussed in the PR.
Acceptance Criteria
Additional Notes
Precise references:
src/app/dashboard/contributor/page.tsx:53-55 (bounty-fetch effect, no error handling, no correlation with isLive), :57-97 (stats effect, no cancellation, [user, loading] deps where user is a non-stable object reference).
src/app/dashboard/sponsor/page.tsx:48-95 — the equivalent single combined effect for sponsor data; while sponsor doesn't have the two-uncorrelated-fetches half of this bug (its stats and its activeBounties come from one combined response), it has the identical no-cancellation/no-generation-guard hazard.
src/context/AuthContext.tsx:28-47 (refresh) — confirms setUser(profile) constructs a new object on every call, which is what makes the contributor dashboard's [user, loading] effect dependency unstable across unrelated refresh() calls elsewhere in the tree (e.g. WalletContext.tsx:56-57's await refresh() inside connect()).
src/lib/api.ts:27-52 (fetchWithFallback/request) and :99-106 (fetchBounties) — confirms no AbortSignal support anywhere in the fetch layer today; adding real cancellation requires threading a signal through these first.
Relationship to other issues: distinct from #1 (Server Component live/mock distinction — this is Client Component effect-level races) and distinct from the separate "dashboard widgets permanently hardcoded to mock data" issue in this batch (that one is about widgets with no fetch attempt at all; this one is about two real, independent fetches whose outcomes aren't correlated). Also distinct from #44 ("Deduplicate in-flight mutating requests and add rate-limit-aware error handling") — #44 is about deduplicating identical, concurrent mutating requests (e.g. double-clicking Fund); this issue is about out-of-order resolution of a changing sequence of read/recompute requests over a component's lifetime, a different failure mode requiring a different fix (cancellation/generation-guarding vs. request deduplication).
Test/reproduction plan: mock apiPost to return a controllable, delayed promise; trigger the effect twice in sequence (simulating a user object identity change) with the first call's promise resolving after the second's; assert the component's final rendered stats match the second (later-started) call's data, not the first. For the badge-correlation case, mock fetchBounties to reject/fall back while the stats apiPost mock succeeds, and assert the page does not present an unqualified "Live data" state.
Overview
ContributorDashboardPageandSponsorDashboardPageare both Client Components that run their data fetching as plainuseEffects with noAbortController, no cleanup function, and — in the contributor page's case — two entirely separate effects whose success/failure are never correlated with each other, even though they feed into one shared "Live data"/"Demo data" badge.No cancellation (
src/app/dashboard/contributor/page.tsx:57-97, and the equivalent insponsor/page.tsx:48-95):This effect re-runs on every change to the
userobject reference (not justuser.id—AuthContext.refresh()callssetUser(profile)with a brand-new object on every successful re-auth, so any unrelatedrefresh()call anywhere in the tree while this page is mounted — e.g. fromWalletContext.connect()'sawait refresh()after linking a wallet — re-triggers this effect). There's no generation counter, noAbortController, and no "ignore this response if a newer request has since started" guard. If effect run #1 (slow) and effect run #2 (fast, from a subsequentuserobject change) are both in flight, whichever.then()/.catch()resolves last wins, regardless of which was started last — a classic out-of-order-resolution race. GivenapiPosthere hits a/recomputeendpoint (an expensive, presumably non-idempotent-in-timing operation, not a cheap cached read), this isn't a hypothetical: it's plausible for an earlier, slower recompute to resolve after a newer one and silently overwrite fresher stats with stale ones.Uncorrelated fetches driving one badge (
contributor/page.tsx, both effects): the "Live data"/"Demo data" badge (isLive) is derived only from the reputation-stats effect above (lines 57-97). The bounty list —bounties, used to computemine,activeClaims,completedClaims,available, and rendered directly viaBountyCardin "Your claims" and "Recommended for you" — comes from a completely separate effect:fetchBounties(src/lib/api.ts:99-106) silently falls back to themockBountiesargument on any failure, with no error surfaced to this component at all — there's no.catch()here becausefetchBountiesalready swallows it internally. This means: the "Live data" badge can be showing (because the stats fetch succeeded) while the bounty listings below it are silently the bundled demo bounties (b1–b5, withclaimedByvalues like"0xkoda"/"priyaeth") because the separate bounty fetch failed — with no correlation between the two, and no indication to the user that only part of the page is real. Concretely: "Recommended for you" (available = bounties.filter(b => b.status === "open"), not handle-dependent) would show the mock bountiesb1("Fix wallet reconnect race condition...") andb3("Document Soroban escrow contract API reference...") as if they were real, live, clickable recommendations — under a green "Live data" badge — and a user could click through to/issues/b1, which independently mock-falls-back the same way viafetchBounty, reinforcing the illusion.Requirements
AbortControllerpassed into the fetch calls (requiresapiPost/fetchBounties/apiRequestinsrc/lib/api.tsto accept and forward asignal, which they don't today), or a generation-counter/"is this still the latest effect run" ref-based guard that skipssetStatecalls from a superseded run.ContributorDashboardPage's badge must reflect whether the bounty-list fetch also succeeded, not just the reputation-stats fetch — a page can't honestly claim to be "Live data" while silently rendering fallback bounty data underneath.Acceptance Criteria
Additional Notes
Precise references:
src/app/dashboard/contributor/page.tsx:53-55(bounty-fetch effect, no error handling, no correlation withisLive),:57-97(stats effect, no cancellation,[user, loading]deps whereuseris a non-stable object reference).src/app/dashboard/sponsor/page.tsx:48-95— the equivalent single combined effect for sponsor data; while sponsor doesn't have the two-uncorrelated-fetches half of this bug (its stats and itsactiveBountiescome from one combined response), it has the identical no-cancellation/no-generation-guard hazard.src/context/AuthContext.tsx:28-47(refresh) — confirmssetUser(profile)constructs a new object on every call, which is what makes the contributor dashboard's[user, loading]effect dependency unstable across unrelatedrefresh()calls elsewhere in the tree (e.g.WalletContext.tsx:56-57'sawait refresh()insideconnect()).src/lib/api.ts:27-52(fetchWithFallback/request) and:99-106(fetchBounties) — confirms noAbortSignalsupport anywhere in the fetch layer today; adding real cancellation requires threading asignalthrough these first.Relationship to other issues: distinct from #1 (Server Component live/mock distinction — this is Client Component effect-level races) and distinct from the separate "dashboard widgets permanently hardcoded to mock data" issue in this batch (that one is about widgets with no fetch attempt at all; this one is about two real, independent fetches whose outcomes aren't correlated). Also distinct from #44 ("Deduplicate in-flight mutating requests and add rate-limit-aware error handling") — #44 is about deduplicating identical, concurrent mutating requests (e.g. double-clicking Fund); this issue is about out-of-order resolution of a changing sequence of read/recompute requests over a component's lifetime, a different failure mode requiring a different fix (cancellation/generation-guarding vs. request deduplication).
Test/reproduction plan: mock
apiPostto return a controllable, delayed promise; trigger the effect twice in sequence (simulating auserobject identity change) with the first call's promise resolving after the second's; assert the component's final rendered stats match the second (later-started) call's data, not the first. For the badge-correlation case, mockfetchBountiesto reject/fall back while the statsapiPostmock succeeds, and assert the page does not present an unqualified "Live data" state.