diff --git a/js/packages/core/src/transports/native.test.ts b/js/packages/core/src/transports/native.test.ts new file mode 100644 index 0000000..a2c9b13 --- /dev/null +++ b/js/packages/core/src/transports/native.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { IDKitErrorCodes } from "../types/result"; +import type { BuilderConfig } from "./native"; +import { createNativeRequest } from "./native"; + +const baseConfig: BuilderConfig = { + type: "request", + app_id: "app_staging_test", + action: "test-action", +}; + +describe("native transport request lifecycle", () => { + let listeners: Array<(event: MessageEvent) => void> = []; + let activeRequest: any; + + beforeEach(() => { + listeners = []; + activeRequest = null; + + (globalThis as any).window = { + addEventListener: vi.fn( + (type: string, handler: (e: MessageEvent) => void) => { + if (type === "message") listeners.push(handler); + }, + ), + removeEventListener: vi.fn( + (type: string, handler: (e: MessageEvent) => void) => { + if (type !== "message") return; + listeners = listeners.filter((h) => h !== handler); + }, + ), + Android: { + postMessage: vi.fn(), + }, + }; + }); + + afterEach(() => { + activeRequest?.cancel?.(); + vi.useRealTimers(); + vi.restoreAllMocks(); + delete (globalThis as any).window; + }); + + it("reuses the in-flight native request instead of cancelling it", async () => { + const req1 = createNativeRequest({ payload: 1 }, baseConfig); + activeRequest = req1; + const req2 = createNativeRequest({ payload: 2 }, baseConfig); + + expect(req2).toBe(req1); + + const completionPromise = req2.pollUntilCompletion({ timeout: 1000 }); + + // Simulate World App native success message. + listeners.forEach((handler) => + handler({ + data: { + type: "miniapp-verify-action", + payload: { + status: "success", + protocol_version: "3.0", + verification_level: "orb", + proof: "0x01", + merkle_root: "0x02", + nullifier_hash: "0x03", + }, + }, + } as MessageEvent), + ); + + const completion = await completionPromise; + expect(completion.success).toBe(true); + }); + + it("allows creating a fresh request after timeout", async () => { + vi.useFakeTimers(); + + const req1 = createNativeRequest({ payload: 1 }, baseConfig); + activeRequest = req1; + + const completionPromise = req1.pollUntilCompletion({ timeout: 1000 }); + await vi.advanceTimersByTimeAsync(1000); + + await expect(completionPromise).resolves.toEqual({ + success: false, + error: IDKitErrorCodes.Timeout, + }); + + const req2 = createNativeRequest({ payload: 2 }, baseConfig); + expect(req2).not.toBe(req1); + activeRequest = req2; + }); + + it("allows creating a fresh request after abort", async () => { + const req1 = createNativeRequest({ payload: 1 }, baseConfig); + activeRequest = req1; + + const controller = new AbortController(); + const completionPromise = req1.pollUntilCompletion({ + timeout: 1000, + signal: controller.signal, + }); + + controller.abort(); + + await expect(completionPromise).resolves.toEqual({ + success: false, + error: IDKitErrorCodes.Cancelled, + }); + + const req2 = createNativeRequest({ payload: 2 }, baseConfig); + expect(req2).not.toBe(req1); + activeRequest = req2; + }); +}); diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts index 4bbd27f..c293322 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -62,11 +62,12 @@ let _activeNativeRequest: NativeIDKitRequest | null = null; /** * Create an IDKitRequest that communicates via World App postMessage. * - * Only one native request can be in flight at a time. If a previous request - * is still pending, it is cancelled before the new one starts. This prevents - * two listeners from consuming the same World App response (World App does - * not echo a correlation ID, so there is no way to match responses to - * requests). + * Only one native request should be in flight at a time. + * + * If another request is created while one is still pending, we reuse the + * active request instead of cancelling it. This avoids a race where the first + * request removes its listener and resolves as `cancelled` even though World + * App later posts a successful response. * * @param wasmPayload - Pre-built payload from the WASM module (same format as bridge) * @param config - Builder config (used for response normalization) @@ -75,8 +76,11 @@ export function createNativeRequest( wasmPayload: unknown, config: BuilderConfig, ): IDKitRequest { - if (_activeNativeRequest) { - _activeNativeRequest.cancel(); + if (_activeNativeRequest?.isPending()) { + console.warn( + "IDKit native request already in flight. Reusing active request.", + ); + return _activeNativeRequest; } const request = new NativeIDKitRequest(wasmPayload, config); _activeNativeRequest = request; @@ -89,6 +93,7 @@ class NativeIDKitRequest implements IDKitRequest { private resultPromise: Promise; private resolved = false; private cancelled = false; + private settled = false; private resolvedResult: IDKitResult | null = null; private messageHandler: ((event: MessageEvent) => void) | null = null; private rejectFn: ((reason: Error) => void) | null = null; @@ -148,6 +153,7 @@ class NativeIDKitRequest implements IDKitRequest { this.resultPromise .catch(() => {}) .finally(() => { + this.settled = true; this.cleanup(); if (_activeNativeRequest === this) { _activeNativeRequest = null; @@ -176,6 +182,10 @@ class NativeIDKitRequest implements IDKitRequest { } } + isPending(): boolean { + return !this.settled && !this.cancelled; + } + async pollOnce(): Promise { if (this.resolved && this.resolvedResult) { return { type: "confirmed", result: this.resolvedResult }; @@ -187,28 +197,53 @@ class NativeIDKitRequest implements IDKitRequest { options?: WaitOptions, ): Promise { const timeout = options?.timeout ?? 300000; + let timeoutId: ReturnType | undefined; + let abortHandler: (() => void) | null = null; + let waiterTerminationCode: + | IDKitErrorCodes.Timeout + | IDKitErrorCodes.Cancelled + | null = null; try { const result = await Promise.race([ this.resultPromise, new Promise((_, reject) => { if (options?.signal) { - options.signal.addEventListener("abort", () => - reject(new NativeVerifyError(IDKitErrorCodes.Cancelled)), - ); + abortHandler = () => { + waiterTerminationCode = IDKitErrorCodes.Cancelled; + reject(new NativeVerifyError(IDKitErrorCodes.Cancelled)); + }; + if (options.signal.aborted) { + abortHandler(); + return; + } + options.signal.addEventListener("abort", abortHandler, { + once: true, + }); } - setTimeout( - () => reject(new NativeVerifyError(IDKitErrorCodes.Timeout)), - timeout, - ); + timeoutId = setTimeout(() => { + waiterTerminationCode = IDKitErrorCodes.Timeout; + reject(new NativeVerifyError(IDKitErrorCodes.Timeout)); + }, timeout); }), ]); return { success: true, result }; } catch (error) { if (error instanceof NativeVerifyError) { + if (waiterTerminationCode === error.code && this.isPending()) { + // Ensure timeout/abort does not leave a stale active request. + this.cancel(); + } return { success: false, error: error.code }; } return { success: false, error: IDKitErrorCodes.GenericError }; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (options?.signal && abortHandler) { + options.signal.removeEventListener("abort", abortHandler); + } } } } diff --git a/js/packages/react/src/__tests__/hooks.test.tsx b/js/packages/react/src/__tests__/hooks.test.tsx index 49d48de..322e462 100644 --- a/js/packages/react/src/__tests__/hooks.test.tsx +++ b/js/packages/react/src/__tests__/hooks.test.tsx @@ -4,12 +4,11 @@ import { IDKitErrorCodes } from "@worldcoin/idkit-core"; import { useIDKitRequest } from "../hooks/useIDKitRequest"; import { useIDKitSession } from "../hooks/useIDKitSession"; -const { requestMock, createSessionMock, proveSessionMock } = - vi.hoisted(() => ({ - requestMock: vi.fn(), - createSessionMock: vi.fn(), - proveSessionMock: vi.fn(), - })); +const { requestMock, createSessionMock, proveSessionMock } = vi.hoisted(() => ({ + requestMock: vi.fn(), + createSessionMock: vi.fn(), + proveSessionMock: vi.fn(), +})); vi.mock("@worldcoin/idkit-core", () => ({ IDKit: {