Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 175 additions & 1 deletion js/packages/react/src/__tests__/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ 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";

Expand Down Expand Up @@ -498,6 +499,179 @@ 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("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()
.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 () =>
Expand Down
79 changes: 79 additions & 0 deletions js/packages/react/src/hooks/common.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { IDKitErrorCodes } from "@worldcoin/idkit-core";

const retryablePollErrors = new Set<IDKitErrorCodes>([
IDKitErrorCodes.ConnectionFailed,
IDKitErrorCodes.GenericError,
IDKitErrorCodes.UnexpectedResponse,
]);
const maxVisiblePollRetries = 5;
const maxPollRetryDelay = 5_000;

type IDKitHookStatus =
| "idle"
| "waiting_for_connection"
Expand Down Expand Up @@ -53,6 +61,77 @@ export async function delay(ms: number, signal?: AbortSignal): Promise<void> {
});
}

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<TResult>(
pollOnce: () => Promise<TResult>,
options: PollOnceWithRetryOptions,
): Promise<TResult> {
let consecutiveFailures = 0;
let visibleFailures = 0;

while (true) {
ensureNotAborted(options.signal);

if (Date.now() - options.startedAt > options.timeout) {
throw IDKitErrorCodes.Timeout;
}

try {
const result = await pollOnce();
if (Date.now() - options.startedAt > options.timeout) {
throw IDKitErrorCodes.Timeout;
}
return result;
} 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<string>(Object.values(IDKitErrorCodes));

function asKnownErrorCode(value: unknown): IDKitErrorCodes | null {
Expand Down
8 changes: 7 additions & 1 deletion js/packages/react/src/hooks/useIDKitFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createInitialHookState,
delay,
ensureNotAborted,
pollOnceWithRetry,
toErrorCode,
type HookState,
} from "./common";
Expand Down Expand Up @@ -141,7 +142,12 @@ export function useIDKitFlow<TResult>(
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") {
Expand Down
14 changes: 12 additions & 2 deletions js/packages/react/src/hooks/useIDKitInviteCodeFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -146,7 +151,12 @@ export function useIDKitInviteCodeFlow<TResult>(
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") {
Expand Down