From a9b8d30a805546b4e7743b2b1aa91f09ad0d9d59 Mon Sep 17 00:00:00 2001 From: mehmetali <36207866+realmehmetali@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:57:12 -0700 Subject: [PATCH 1/2] Retry transient IDKit polling errors --- .../react/src/__tests__/hooks.test.tsx | 147 ++++++++++++++++++ js/packages/react/src/hooks/common.ts | 75 +++++++++ js/packages/react/src/hooks/useIDKitFlow.ts | 8 +- .../react/src/hooks/useIDKitInviteCodeFlow.ts | 14 +- 4 files changed, 241 insertions(+), 3 deletions(-) diff --git a/js/packages/react/src/__tests__/hooks.test.tsx b/js/packages/react/src/__tests__/hooks.test.tsx index 77dd941e..ac7fd616 100644 --- a/js/packages/react/src/__tests__/hooks.test.tsx +++ b/js/packages/react/src/__tests__/hooks.test.tsx @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { IDKitErrorCodes } from "@worldcoin/idkit-core"; import packageJson from "../../package.json"; import { toErrorCode } from "../hooks/common"; +import { useIDKitInviteCodeRequest } from "../hooks/useIDKitInviteCodeRequest"; import { useIDKitRequest } from "../hooks/useIDKitRequest"; import { useIDKitSession } from "../hooks/useIDKitSession"; @@ -498,6 +499,152 @@ describe("request/session hooks", () => { expect(result.current.errorCode).toBe(IDKitErrorCodes.ConnectionFailed); }); + it("request hook retries transient poll failures without recreating the request", async () => { + const pollOnce = vi + .fn() + .mockRejectedValueOnce(IDKitErrorCodes.ConnectionFailed) + .mockResolvedValueOnce({ + type: "confirmed", + result: { proof: "ok" }, + }); + requestMock.mockReturnValue({ + preset: vi.fn(async () => makeRequest(pollOnce)), + }); + + const { result } = renderHook(() => + useIDKitRequest({ + app_id: "app_test", + action: "test-action", + rp_context: baseRpContext, + allow_legacy_proofs: false, + preset: { type: "OrbLegacy" }, + polling: { interval: 0 }, + }), + ); + + act(() => { + result.current.open(); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(pollOnce).toHaveBeenCalledTimes(2); + expect(requestMock).toHaveBeenCalledTimes(1); + }); + + it("request hook bounds transient poll retries while visible", async () => { + const pollOnce = vi + .fn() + .mockRejectedValue(IDKitErrorCodes.ConnectionFailed); + requestMock.mockReturnValue({ + preset: vi.fn(async () => makeRequest(pollOnce)), + }); + + const { result } = renderHook(() => + useIDKitRequest({ + app_id: "app_test", + action: "test-action", + rp_context: baseRpContext, + allow_legacy_proofs: false, + preset: { type: "OrbLegacy" }, + polling: { interval: 0 }, + }), + ); + + act(() => { + result.current.open(); + }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.errorCode).toBe(IDKitErrorCodes.ConnectionFailed); + expect(pollOnce).toHaveBeenCalledTimes(6); + }); + + it("request hook preserves the retry budget while the page is hidden", async () => { + const visibilityState = vi + .spyOn(document, "visibilityState", "get") + .mockReturnValue("hidden"); + const pollOnce = vi.fn(); + for (let attempt = 0; attempt < 7; attempt += 1) { + pollOnce.mockRejectedValueOnce(IDKitErrorCodes.ConnectionFailed); + } + pollOnce.mockResolvedValueOnce({ + type: "confirmed", + result: { proof: "ok" }, + }); + requestMock.mockReturnValue({ + preset: vi.fn(async () => makeRequest(pollOnce)), + }); + + try { + const { result } = renderHook(() => + useIDKitRequest({ + app_id: "app_test", + action: "test-action", + rp_context: baseRpContext, + allow_legacy_proofs: false, + preset: { type: "OrbLegacy" }, + polling: { interval: 0 }, + }), + ); + + act(() => { + result.current.open(); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(pollOnce).toHaveBeenCalledTimes(8); + } finally { + visibilityState.mockRestore(); + } + }); + + it("invite-code hook retries transient poll failures", async () => { + const pollOnce = vi + .fn() + .mockRejectedValueOnce(IDKitErrorCodes.ConnectionFailed) + .mockResolvedValueOnce({ + type: "confirmed", + result: { proof: "ok" }, + }); + requestWithInviteCodeMock.mockReturnValue({ + preset: vi.fn(async () => ({ + ...makeRequest(pollOnce), + expiresAt: 1_800_000_000, + })), + }); + + const { result } = renderHook(() => + useIDKitInviteCodeRequest({ + app_id: "app_test", + action: "test-action", + rp_context: baseRpContext, + allow_legacy_proofs: false, + preset: { type: "OrbLegacy" }, + polling: { interval: 0 }, + }), + ); + + act(() => { + result.current.open(); + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(pollOnce).toHaveBeenCalledTimes(2); + expect(requestWithInviteCodeMock).toHaveBeenCalledTimes(1); + }); + it("request hook maps confirmed status without payload to unexpected_response", async () => { requestMock.mockReturnValue({ preset: vi.fn(async () => diff --git a/js/packages/react/src/hooks/common.ts b/js/packages/react/src/hooks/common.ts index 09af6c11..dfaed8ae 100644 --- a/js/packages/react/src/hooks/common.ts +++ b/js/packages/react/src/hooks/common.ts @@ -1,5 +1,13 @@ import { IDKitErrorCodes } from "@worldcoin/idkit-core"; +const retryablePollErrors = new Set([ + IDKitErrorCodes.ConnectionFailed, + IDKitErrorCodes.GenericError, + IDKitErrorCodes.UnexpectedResponse, +]); +const maxVisiblePollRetries = 5; +const maxPollRetryDelay = 5_000; + type IDKitHookStatus = | "idle" | "waiting_for_connection" @@ -53,6 +61,73 @@ export async function delay(ms: number, signal?: AbortSignal): Promise { }); } +type PollOnceWithRetryOptions = { + interval: number; + signal: AbortSignal; + startedAt: number; + timeout: number; +}; + +function isPageHidden(): boolean { + return ( + typeof document !== "undefined" && document.visibilityState === "hidden" + ); +} + +/** + * Retries transport-level polling failures without recreating the active + * verification request. Retries are bounded while the page is visible, but a + * backgrounded page keeps its retry budget for when the user returns from the + * World App handoff. The flow's overall timeout remains authoritative. + */ +export async function pollOnceWithRetry( + pollOnce: () => Promise, + options: PollOnceWithRetryOptions, +): Promise { + let consecutiveFailures = 0; + let visibleFailures = 0; + + while (true) { + ensureNotAborted(options.signal); + + if (Date.now() - options.startedAt > options.timeout) { + throw IDKitErrorCodes.Timeout; + } + + try { + return await pollOnce(); + } catch (error) { + ensureNotAborted(options.signal); + + const errorCode = toErrorCode(error); + if (!retryablePollErrors.has(errorCode)) { + throw error; + } + + if (!isPageHidden()) { + if (visibleFailures >= maxVisiblePollRetries) { + throw error; + } + visibleFailures += 1; + } + + consecutiveFailures += 1; + const elapsed = Date.now() - options.startedAt; + const remaining = options.timeout - elapsed; + if (remaining <= 0) { + throw IDKitErrorCodes.Timeout; + } + + const retryDelay = Math.min( + options.interval * 2 ** Math.min(consecutiveFailures - 1, 3), + maxPollRetryDelay, + remaining, + ); + await delay(retryDelay, options.signal); + } + } +} + const knownErrorCodes = new Set(Object.values(IDKitErrorCodes)); function asKnownErrorCode(value: unknown): IDKitErrorCodes | null { diff --git a/js/packages/react/src/hooks/useIDKitFlow.ts b/js/packages/react/src/hooks/useIDKitFlow.ts index 4a7ff97b..d5fb1860 100644 --- a/js/packages/react/src/hooks/useIDKitFlow.ts +++ b/js/packages/react/src/hooks/useIDKitFlow.ts @@ -11,6 +11,7 @@ import { createInitialHookState, delay, ensureNotAborted, + pollOnceWithRetry, toErrorCode, type HookState, } from "./common"; @@ -141,7 +142,12 @@ export function useIDKitFlow( return; } - const nextStatus = await request.pollOnce(); + const nextStatus = await pollOnceWithRetry(() => request.pollOnce(), { + interval: pollInterval, + signal: controller.signal, + startedAt, + timeout, + }); ensureNotAborted(controller.signal); if (nextStatus.type === "confirmed") { diff --git a/js/packages/react/src/hooks/useIDKitInviteCodeFlow.ts b/js/packages/react/src/hooks/useIDKitInviteCodeFlow.ts index 19b0fa4d..e3a6a6bc 100644 --- a/js/packages/react/src/hooks/useIDKitInviteCodeFlow.ts +++ b/js/packages/react/src/hooks/useIDKitInviteCodeFlow.ts @@ -7,7 +7,12 @@ import { type IDKitInviteCodeRequest, } from "@worldcoin/idkit-core"; import type { FlowConfig, IDKitInviteCodeHookResult } from "../types"; -import { delay, ensureNotAborted, toErrorCode } from "./common"; +import { + delay, + ensureNotAborted, + pollOnceWithRetry, + toErrorCode, +} from "./common"; import { createInitialInviteCodeHookState, type InviteCodeHookState, @@ -146,7 +151,12 @@ export function useIDKitInviteCodeFlow( return; } - const nextStatus = await request.pollOnce(); + const nextStatus = await pollOnceWithRetry(() => request.pollOnce(), { + interval: pollInterval, + signal: controller.signal, + startedAt, + timeout, + }); ensureNotAborted(controller.signal); if (nextStatus.type === "confirmed") { From 36d26df62253cf5bdb58094c134edfcaeca0b0b3 Mon Sep 17 00:00:00 2001 From: mehmetali <36207866+realmehmetali@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:40:19 -0700 Subject: [PATCH 2/2] fix(react): enforce timeout after retry polls --- .../react/src/__tests__/hooks.test.tsx | 29 ++++++++++++++++++- js/packages/react/src/hooks/common.ts | 6 +++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/js/packages/react/src/__tests__/hooks.test.tsx b/js/packages/react/src/__tests__/hooks.test.tsx index ac7fd616..cf5078b7 100644 --- a/js/packages/react/src/__tests__/hooks.test.tsx +++ b/js/packages/react/src/__tests__/hooks.test.tsx @@ -2,7 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { IDKitErrorCodes } from "@worldcoin/idkit-core"; import packageJson from "../../package.json"; -import { toErrorCode } from "../hooks/common"; +import { pollOnceWithRetry, toErrorCode } from "../hooks/common"; import { useIDKitInviteCodeRequest } from "../hooks/useIDKitInviteCodeRequest"; import { useIDKitRequest } from "../hooks/useIDKitRequest"; import { useIDKitSession } from "../hooks/useIDKitSession"; @@ -534,6 +534,33 @@ describe("request/session hooks", () => { expect(requestMock).toHaveBeenCalledTimes(1); }); + it("retry polling rejects a result that resolves after the overall timeout", async () => { + const startedAt = 1_000; + let now = startedAt; + const dateNow = vi.spyOn(Date, "now").mockImplementation(() => now); + const pollOnce = vi + .fn() + .mockRejectedValueOnce(IDKitErrorCodes.ConnectionFailed) + .mockImplementationOnce(async () => { + now = startedAt + 11; + return { type: "confirmed", result: { proof: "late" } }; + }); + + try { + await expect( + pollOnceWithRetry(pollOnce, { + interval: 0, + signal: new AbortController().signal, + startedAt, + timeout: 10, + }), + ).rejects.toBe(IDKitErrorCodes.Timeout); + expect(pollOnce).toHaveBeenCalledTimes(2); + } finally { + dateNow.mockRestore(); + } + }); + it("request hook bounds transient poll retries while visible", async () => { const pollOnce = vi .fn() diff --git a/js/packages/react/src/hooks/common.ts b/js/packages/react/src/hooks/common.ts index dfaed8ae..8c8a55bf 100644 --- a/js/packages/react/src/hooks/common.ts +++ b/js/packages/react/src/hooks/common.ts @@ -95,7 +95,11 @@ export async function pollOnceWithRetry( } try { - return await pollOnce(); + const result = await pollOnce(); + if (Date.now() - options.startedAt > options.timeout) { + throw IDKitErrorCodes.Timeout; + } + return result; } catch (error) { ensureNotAborted(options.signal);