Skip to content

useBountyStatus: a transient fetch failure mid-session silently swaps the live bounty for static fallbackBounty and fires a false onStatusChange #571

Description

@chonilius

Problem

useBountyStatus polls with fetchBounty(bountyId, fallbackBounty) from src/lib/api.ts:

export async function fetchBounty(id, fallback) {
  try {
    const raw = await request<RawBounty>(`/bounties/${id}`);
    return { data: adaptBounty(raw), source: "live" };
  } catch {
    return { data: fallback, source: "mock" };
  }
}

That's right for the first server-side render (#1). Inside a polling loop, though, it means any single failed poll, such as a timeout, a 5xx or a brief network drop, resolves successfully with the static fallbackBounty snapshot the page was rendered with.

useSmartPolling then compares it with the previous live data using useBountyStatus's compareFn (status plus claimedBy):

  1. The data regresses. If the bounty moved on, for example open → claimed live, the fallback still says open. The compare sees a change, so data is replaced by the stale snapshot: the badge jumps back to "Open" and source flips to mock.
  2. onStatusChange fires with the stale status. Parents act on a transition that never happened. When the next poll succeeds, it fires again with the real status.
  3. The error UI is unreachable. fetchBounty never throws, so useSmartPolling's error is never set. BountyStatus's "Failed to load status / Retry" branch can never render, and ClaimButton's 2s claim-race polling can briefly show a claimed bounty as claimable again.

Suggested fix

  • Once live data has been received, a failed poll should keep the last live data and surface an error or stale flag, instead of replacing it with fallbackBounty.
  • For example: let the polling fetchFn use a throwing variant, such as the underlying request()/adapter, and apply the fallback only when there's no previous live result.
  • Add a test covering: live data (claimed), then a failed poll, and assert the status is still claimed, onStatusChange isn't called, and error is set.

Related: #1 (live vs mock distinction), #46.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghelp wantedExtra attention is neededvery 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