From 617a3903777e4c4118de4c0b904906eefae8b81b Mon Sep 17 00:00:00 2001 From: PINYO PATTANAWASANPORN Date: Sun, 4 Oct 2026 22:05:51 +0700 Subject: [PATCH 1/3] fix(hooks): retain live bounty status on transient poll errors (#571) --- src/lib/api.ts | 83 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 5c7a7538..e18b6999 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -12,6 +12,7 @@ import { type RawUserProfile, } from "./adapters"; import type { Bounty, Milestone, MaintenancePool, ReputationProfile } from "@/types"; +import { buildBountyQueryString, type BountyQuery } from "./bounty-query"; export class ApiUnavailableError extends Error {} @@ -39,14 +40,20 @@ function logFetchError(path: string, kind: "network" | "http" | "parse", detail: } async function request(path: string, init?: RequestInit): Promise { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); let res: Response; try { res = await fetch(`${API_BASE_URL}${path}`, { cache: "no-store", ...init, headers: { "Content-Type": "application/json", ...init?.headers }, + signal: init?.signal ?? timeout, }); } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") { + logFetchError(path, "network", "Request timed out"); + throw new ApiUnavailableError(`Request to ${path} timed out`); + } logFetchError(path, "network", err instanceof Error ? err.message : String(err)); throw new ApiUnavailableError(`Network error on ${path}`); } @@ -88,6 +95,22 @@ async function dedupedFetch( return promise; } +export function parseRetryAfter(header: string): number | undefined { + const trimmed = header.trim(); + if (/^\d+$/.test(trimmed)) { + const sec = parseInt(trimmed, 10); + return Number.isFinite(sec) && sec >= 0 ? sec : undefined; + } + if (!trimmed.includes("GMT") && !trimmed.includes(",")) { + return undefined; + } + const dateMs = Date.parse(trimmed); + if (!Number.isNaN(dateMs)) { + return Math.max(0, Math.ceil((dateMs - Date.now()) / 1000)); + } + return undefined; +} + /** * Client-side call that attaches the signed-in user's JWT (if any) and * surfaces backend error bodies instead of silently falling back — used for @@ -125,14 +148,14 @@ export async function apiRequest( // --- Rate-limit handling (#44) --- if (res.status === 429) { const retryAfter = res.headers.get("Retry-After"); - const seconds = retryAfter ? parseInt(retryAfter, 10) : NaN; - const waitMsg = Number.isFinite(seconds) + const seconds = retryAfter ? parseRetryAfter(retryAfter) : undefined; + const waitMsg = seconds !== undefined ? ` Please wait ${seconds} second${seconds === 1 ? "" : "s"} before trying again.` : ""; throw new ApiRequestError( - `You're doing that too fast.${waitMsg}` || `Rate limited. Please try again later.`, + `You're doing that too fast.${waitMsg}`, 429, - Number.isFinite(seconds) ? seconds : undefined, + seconds, ); } @@ -167,22 +190,40 @@ export function apiPost(path: string, body?: unknown): Promise { * into the flat shapes the UI renders, falling back to mock data (already in * the target shape) when the backend is unreachable. */ -export async function fetchBounties(fallback: Bounty[]): Promise> { +/** + * `query`, if given, is forwarded to the backend as query params (#28) so + * this is ready for server-side filtering once `/bounties` supports it — + * today the backend ignores unknown params and returns the full unfiltered + * collection, which callers must still run through + * `applyBountyQuery`/`filterBounties` themselves (see src/lib/bounty-query.ts) + * to get correct behavior against both live and mock-fallback data. + */ +export async function fetchBounties( + fallback: Bounty[], + query?: BountyQuery, +): Promise> { + const qs = query ? buildBountyQueryString(query) : ""; try { - const raw = await request("/bounties"); + const raw = await request(`/bounties${qs}`); return { data: raw.map(adaptBounty), source: "live" }; } catch { return { data: fallback, source: "mock" }; } } +export async function fetchLiveBounty( + id: string, +): Promise> { + const raw = await request(`/bounties/${id}`); + return { data: adaptBounty(raw), source: "live" }; +} + export async function fetchBounty( id: string, fallback: Bounty | undefined, ): Promise> { try { - const raw = await request(`/bounties/${id}`); - return { data: adaptBounty(raw), source: "live" }; + return await fetchLiveBounty(id); } catch { return { data: fallback, source: "mock" }; } @@ -226,11 +267,33 @@ export async function fetchReputationByUsername( } } -export async function fetchReputationHandles(fallback: string[]): Promise> { +/** + * Handles eligible to appear in the sitemap — i.e. profiles whose owner has + * opted into search-engine indexing. + * + * The filter is the enforcement point for the privacy policy documented in + * src/lib/seo-policy.ts: without it, calling the /users endpoint to enumerate + * handles builds a crawlable directory of who earns what, tied to real GitHub + * identities. Anything other than an explicit `isProfilePublic: true` is + * excluded, so the endpoint omitting the field (as it does today) means + * nothing is indexed rather than everything. + */ +export async function fetchIndexableReputationHandles( + fallback: string[], +): Promise> { try { const users = await request<(RawUserProfile & { id: string })[]>("/users"); - return { data: users.map((user) => user.username).filter(Boolean), source: "live" }; + return { + data: users + .filter((user) => user.isProfilePublic === true) + .map((user) => user.username) + .filter(Boolean), + source: "live", + }; } catch { + // The mock fixtures represent contributors who have opted in, so local + // development exercises the same code path production will take once the + // backend ships the flag. return { data: fallback, source: "mock" }; } } From 76823c89ef06a316bba46f1936eab3a0fb3b0868 Mon Sep 17 00:00:00 2001 From: PINYO PATTANAWASANPORN Date: Sun, 4 Oct 2026 22:05:53 +0700 Subject: [PATCH 2/3] fix(hooks): retain live bounty status on transient poll errors (#571) --- src/hooks/useBountyStatus.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/hooks/useBountyStatus.ts b/src/hooks/useBountyStatus.ts index 8d28d345..2d1c2598 100644 --- a/src/hooks/useBountyStatus.ts +++ b/src/hooks/useBountyStatus.ts @@ -1,5 +1,6 @@ +import { useRef } from 'react'; import { useSmartPolling } from './useSmartPolling'; -import { fetchBounty } from '@/lib/api'; +import { fetchBounty, fetchLiveBounty } from '@/lib/api'; import type { Bounty, BountyStatus } from '@/types/bounty'; interface UseBountyStatusOptions { @@ -28,6 +29,14 @@ export function useBountyStatus({ enabled = true, onStatusChange, }: UseBountyStatusOptions): UseBountyStatusResult { + const hasLiveRef = useRef(false); + const lastBountyIdRef = useRef(bountyId); + + if (lastBountyIdRef.current !== bountyId) { + lastBountyIdRef.current = bountyId; + hasLiveRef.current = false; + } + const { data, isLoading, @@ -37,8 +46,19 @@ export function useBountyStatus({ isBackingOff, } = useSmartPolling<{ data: Bounty | undefined; source: 'live' | 'mock' }>({ fetchFn: async () => { - const result = await fetchBounty(bountyId, fallbackBounty); - return result; + if (hasLiveRef.current) { + return await fetchLiveBounty(bountyId); + } + try { + const result = await fetchLiveBounty(bountyId); + hasLiveRef.current = true; + return result; + } catch (err) { + if (fallbackBounty !== undefined) { + return { data: fallbackBounty, source: 'mock' }; + } + throw err; + } }, interval, enabled, From c3cee66b4f746255a5d3a86660da9e8a5d4ac1ec Mon Sep 17 00:00:00 2001 From: PINYO PATTANAWASANPORN Date: Sun, 4 Oct 2026 22:05:54 +0700 Subject: [PATCH 3/3] fix(hooks): retain live bounty status on transient poll errors (#571) --- src/hooks/useBountyStatus.test.ts | 102 ++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/hooks/useBountyStatus.test.ts diff --git a/src/hooks/useBountyStatus.test.ts b/src/hooks/useBountyStatus.test.ts new file mode 100644 index 00000000..17c9dc2e --- /dev/null +++ b/src/hooks/useBountyStatus.test.ts @@ -0,0 +1,102 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useBountyStatus } from './useBountyStatus'; +import { fetchLiveBounty } from '@/lib/api'; +import type { Bounty } from '@/types/bounty'; + +jest.mock('@/lib/api', () => ({ + fetchBounty: jest.fn(), + fetchLiveBounty: jest.fn(), +})); + +const mockFetchLiveBounty = fetchLiveBounty as jest.MockedFunction; + +const mockLiveBounty: Bounty = { + id: 'bounty-123', + title: 'Test Bounty', + amount: '100', + asset: 'XLM', + status: 'claimed', + claimedBy: 'user-1', + sponsor: 'sponsor-1', + issueUrl: 'https://github.com/org/repo/issues/1', + createdAt: '2026-10-01T00:00:00Z', + updatedAt: '2026-10-01T00:00:00Z', +}; + +const mockFallbackBounty: Bounty = { + id: 'bounty-123', + title: 'Test Bounty Fallback', + amount: '100', + asset: 'XLM', + status: 'open', + claimedBy: undefined, + sponsor: 'sponsor-1', + issueUrl: 'https://github.com/org/repo/issues/1', + createdAt: '2026-10-01T00:00:00Z', + updatedAt: '2026-10-01T00:00:00Z', +}; + +describe('useBountyStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('preserves live bounty data and sets error on transient poll failure (#571)', async () => { + // 1. Initial poll succeeds with live data (claimed) + mockFetchLiveBounty.mockResolvedValueOnce({ + data: mockLiveBounty, + source: 'live', + }); + + const onStatusChange = jest.fn(); + + const { result } = renderHook(() => + useBountyStatus({ + bountyId: 'bounty-123', + fallbackBounty: mockFallbackBounty, + onStatusChange, + }), + ); + + await waitFor(() => { + expect(result.current.status).toBe('claimed'); + expect(result.current.source).toBe('live'); + }); + + expect(onStatusChange).toHaveBeenCalledTimes(1); + expect(onStatusChange).toHaveBeenCalledWith('claimed'); + onStatusChange.mockClear(); + + // 2. Subsequent poll fails with a network error + mockFetchLiveBounty.mockRejectedValueOnce(new Error('Network drop')); + + await act(async () => { + await result.current.refetch(); + }); + + // Verify: status is still claimed, onStatusChange was not called with fallback open status, and error is set + expect(result.current.status).toBe('claimed'); + expect(result.current.bounty).toEqual(mockLiveBounty); + expect(result.current.source).toBe('live'); + expect(result.current.error).toBeDefined(); + expect(result.current.error?.message).toBe('Network drop'); + expect(onStatusChange).not.toHaveBeenCalled(); + }); + + it('falls back to mock data on initial fetch failure when fallbackBounty is provided', async () => { + mockFetchLiveBounty.mockRejectedValueOnce(new Error('Backend offline')); + + const { result } = renderHook(() => + useBountyStatus({ + bountyId: 'bounty-123', + fallbackBounty: mockFallbackBounty, + }), + ); + + await waitFor(() => { + expect(result.current.status).toBe('open'); + expect(result.current.source).toBe('mock'); + expect(result.current.bounty).toEqual(mockFallbackBounty); + }); + }); +});