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):
- 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.
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.
- 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.
Problem
useBountyStatuspolls withfetchBounty(bountyId, fallbackBounty)fromsrc/lib/api.ts: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
fallbackBountysnapshot the page was rendered with.useSmartPollingthen compares it with the previous live data usinguseBountyStatus'scompareFn(status plusclaimedBy):open → claimedlive, the fallback still saysopen. The compare sees a change, sodatais replaced by the stale snapshot: the badge jumps back to "Open" andsourceflips tomock.onStatusChangefires with the stale status. Parents act on a transition that never happened. When the next poll succeeds, it fires again with the real status.fetchBountynever throws, souseSmartPolling'serroris never set.BountyStatus's "Failed to load status / Retry" branch can never render, andClaimButton's 2s claim-race polling can briefly show a claimed bounty as claimable again.Suggested fix
fallbackBounty.fetchFnuse a throwing variant, such as the underlyingrequest()/adapter, and apply the fallback only when there's no previous live result.onStatusChangeisn't called, anderroris set.Related: #1 (live vs mock distinction), #46.