Skip to content

Contributor/Sponsor dashboard fetch effects have no cancellation and no cross-fetch correlation — stale-response races and a Live data badge that can lie #74

Description

@chonilius

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.idAuthContext.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 (b1b5, 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

  • Rapidly triggering multiple re-runs of the contributor/sponsor dashboard's stats-fetching effect (e.g. via a test that resolves an earlier mocked request after a later one) results in the last-started request's data winning, not the last-to-resolve.
  • A test demonstrates that when the bounty-list fetch fails (falls back to mock) while the stats fetch succeeds, the page does not display "Live data" as an unqualified, page-wide claim — either the badge reflects the partial failure, or per-section indicators make the mock fallback visible where it's actually happening.
  • No regression to the common case: a single successful load, single component lifetime, produces the same visible result as before.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignarchitectureArchitecture/design issuebugSomething isn't workingvery hardVery difficult task, expert-level effort required

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions