diff --git a/.changeset/protect-check-lifecycle-extraction.md b/.changeset/protect-check-lifecycle-extraction.md new file mode 100644 index 00000000000..0c105cdd496 --- /dev/null +++ b/.changeset/protect-check-lifecycle-extraction.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Extract the Protect check lifecycle helpers (`executeProtectCheckWithTimeout`, `submitProtectCheckProof`) into the internal `@clerk/shared/internal/clerk-js/protectCheckLifecycle` module. Internal refactor; no public API changes. diff --git a/.changeset/protect-check-ui-refactor.md b/.changeset/protect-check-ui-refactor.md new file mode 100644 index 00000000000..7dd6434842f --- /dev/null +++ b/.changeset/protect-check-ui-refactor.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +The Protect check cards now drive their challenge lifecycle through shared internal helpers. No behavioral changes. diff --git a/packages/shared/src/internal/clerk-js/__tests__/protectCheckLifecycle.test.ts b/packages/shared/src/internal/clerk-js/__tests__/protectCheckLifecycle.test.ts new file mode 100644 index 00000000000..dce1e950d1d --- /dev/null +++ b/packages/shared/src/internal/clerk-js/__tests__/protectCheckLifecycle.test.ts @@ -0,0 +1,250 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ClerkAPIResponseError } from '@/error'; +import type { ProtectCheckResource } from '@/types'; + +import { + executeProtectCheckWithTimeout, + isProtectCheckExpired, + submitProtectCheckProof, +} from '../protectCheckLifecycle'; + +vi.mock('../protectCheck', () => ({ + executeProtectCheck: vi.fn(), +})); + +import { executeProtectCheck } from '../protectCheck'; + +const mockExecute = vi.mocked(executeProtectCheck); + +const protectCheck = (overrides: Partial = {}): ProtectCheckResource => ({ + status: 'pending', + token: 'challenge-token', + sdkUrl: 'https://protect.example.com/sdk.js', + ...overrides, +}); + +const alreadyResolvedError = () => + new ClerkAPIResponseError('Already resolved', { + data: [{ code: 'protect_check_already_resolved', message: 'Already resolved', long_message: '' }], + status: 400, + clerkTraceId: 'trace_123', + }); + +beforeEach(() => { + mockExecute.mockReset(); +}); + +describe('isProtectCheckExpired', () => { + it('is false when expiresAt is absent', () => { + expect(isProtectCheckExpired(protectCheck())).toBe(false); + }); + + it('compares expiresAt (unix milliseconds) against now', () => { + expect(isProtectCheckExpired(protectCheck({ expiresAt: Date.now() - 1_000 }))).toBe(true); + expect(isProtectCheckExpired(protectCheck({ expiresAt: Date.now() + 60_000 }))).toBe(false); + }); +}); + +describe('executeProtectCheckWithTimeout', () => { + it('clears the container before running so a previous run cannot leave a stale widget', async () => { + const container = document.createElement('div'); + container.appendChild(document.createElement('span')); + mockExecute.mockResolvedValue('proof-token'); + + await executeProtectCheckWithTimeout(protectCheck(), container); + + expect(container.childNodes.length).toBe(0); + }); + + it('resolves with the proof token and forwards the challenge to executeProtectCheck', async () => { + const container = document.createElement('div'); + mockExecute.mockResolvedValue('proof-token'); + + const check = protectCheck({ token: 'opaque', uiHints: { reason: 'device_new' } }); + await expect(executeProtectCheckWithTimeout(check, container)).resolves.toBe('proof-token'); + + expect(mockExecute).toHaveBeenCalledWith( + check, + container, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + describe('timeout', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('aborts the SDK and rejects with protect_check_timed_out when the script never settles', async () => { + const container = document.createElement('div'); + let sdkSignal: AbortSignal | undefined; + mockExecute.mockImplementation((_check, _container, opts) => { + sdkSignal = opts?.signal; + return new Promise(() => {}); // hung SDK + }); + + const promise = executeProtectCheckWithTimeout(protectCheck(), container, { timeoutMs: 1_000 }); + const assertion = expect(promise).rejects.toMatchObject({ code: 'protect_check_timed_out' }); + await vi.advanceTimersByTimeAsync(1_000); + await assertion; + expect(sdkSignal?.aborted).toBe(true); + }); + + it('does not abort the caller controller on timeout', async () => { + const container = document.createElement('div'); + const caller = new AbortController(); + mockExecute.mockImplementation(() => new Promise(() => {})); + + const promise = executeProtectCheckWithTimeout(protectCheck(), container, { + signal: caller.signal, + timeoutMs: 1_000, + }); + const assertion = expect(promise).rejects.toMatchObject({ code: 'protect_check_timed_out' }); + await vi.advanceTimersByTimeAsync(1_000); + await assertion; + expect(caller.signal.aborted).toBe(false); + }); + + it('swallows setWidgetVisible signals from a zombie script after timeout', async () => { + const container = document.createElement('div'); + const setWidgetVisible = vi.fn().mockResolvedValue(undefined); + let scriptSetWidgetVisible: ((visible: boolean) => Promise) | undefined; + mockExecute.mockImplementation((_check, _container, opts) => { + scriptSetWidgetVisible = opts?.setWidgetVisible; + return new Promise(() => {}); + }); + + const promise = executeProtectCheckWithTimeout(protectCheck(), container, { setWidgetVisible, timeoutMs: 1_000 }); + const assertion = expect(promise).rejects.toMatchObject({ code: 'protect_check_timed_out' }); + await vi.advanceTimersByTimeAsync(1_000); + await assertion; + + await scriptSetWidgetVisible!(true); + expect(setWidgetVisible).not.toHaveBeenCalled(); + }); + + it('clears the timeout once the script settles', async () => { + const container = document.createElement('div'); + mockExecute.mockResolvedValue('proof-token'); + + await expect(executeProtectCheckWithTimeout(protectCheck(), container, { timeoutMs: 1_000 })).resolves.toBe( + 'proof-token', + ); + + expect(vi.getTimerCount()).toBe(0); + }); + }); + + it('links the caller signal into the SDK signal (one-way)', async () => { + const container = document.createElement('div'); + const caller = new AbortController(); + let sdkSignal: AbortSignal | undefined; + mockExecute.mockImplementation((_check, _container, opts) => { + sdkSignal = opts?.signal; + return new Promise(() => {}); + }); + + void executeProtectCheckWithTimeout(protectCheck(), container, { signal: caller.signal, timeoutMs: 50 }).catch( + () => {}, + ); + await vi.waitFor(() => expect(mockExecute).toHaveBeenCalled()); + expect(sdkSignal?.aborted).toBe(false); + + caller.abort(); + expect(sdkSignal?.aborted).toBe(true); + }); + + it('passes an already-aborted signal through to the SDK', async () => { + const container = document.createElement('div'); + const caller = new AbortController(); + caller.abort(); + let sdkSignal: AbortSignal | undefined; + mockExecute.mockImplementation((_check, _container, opts) => { + sdkSignal = opts?.signal; + return Promise.resolve('unused'); + }); + + await executeProtectCheckWithTimeout(protectCheck(), container, { signal: caller.signal }); + expect(sdkSignal?.aborted).toBe(true); + }); + + it('forwards visibility signals from a live run', async () => { + const container = document.createElement('div'); + const setWidgetVisible = vi.fn().mockResolvedValue(undefined); + mockExecute.mockImplementation(async (_check, _container, opts) => { + await opts?.setWidgetVisible?.(true); + return 'proof-token'; + }); + + await executeProtectCheckWithTimeout(protectCheck(), container, { setWidgetVisible }); + expect(setWidgetVisible).toHaveBeenCalledWith(true); + }); +}); + +describe('submitProtectCheckProof', () => { + it('returns the submitted resource on success', async () => { + const updated = { id: 'si_updated' }; + const submit = vi.fn().mockResolvedValue(updated); + + const result = await submitProtectCheckProof({ + proofToken: 'proof-abc', + submitProtectCheck: submit, + reload: vi.fn(), + getResource: () => ({ id: 'si_live' }), + }); + + expect(submit).toHaveBeenCalledWith({ proofToken: 'proof-abc' }); + expect(result).toEqual({ status: 'submitted', resource: updated }); + }); + + it('treats protect_check_already_resolved as soft success: reloads and returns the live resource', async () => { + const live = { id: 'si_live' }; + const reload = vi.fn().mockResolvedValue(undefined); + + const result = await submitProtectCheckProof({ + proofToken: 'proof-abc', + submitProtectCheck: vi.fn().mockRejectedValue(alreadyResolvedError()), + reload, + getResource: () => live, + }); + + expect(reload).toHaveBeenCalled(); + expect(result).toEqual({ status: 'already_resolved', resource: live }); + }); + + it('returns cancelled (and does not reload) when the caller cancelled during a failing submit', async () => { + const reload = vi.fn(); + + const result = await submitProtectCheckProof({ + proofToken: 'proof-abc', + submitProtectCheck: vi.fn().mockRejectedValue(alreadyResolvedError()), + reload, + getResource: () => ({}), + isCancelled: () => true, + }); + + expect(result).toEqual({ status: 'cancelled' }); + expect(reload).not.toHaveBeenCalled(); + }); + + it('rethrows any other submit failure untouched', async () => { + const failure = new ClerkAPIResponseError('Blocked', { + data: [{ code: 'action_blocked', message: 'Blocked', long_message: '' }], + status: 403, + clerkTraceId: 'trace_456', + }); + + await expect( + submitProtectCheckProof({ + proofToken: 'proof-abc', + submitProtectCheck: vi.fn().mockRejectedValue(failure), + reload: vi.fn(), + getResource: () => ({}), + }), + ).rejects.toBe(failure); + }); +}); diff --git a/packages/shared/src/internal/clerk-js/protectCheckLifecycle.ts b/packages/shared/src/internal/clerk-js/protectCheckLifecycle.ts new file mode 100644 index 00000000000..7e01948e7e1 --- /dev/null +++ b/packages/shared/src/internal/clerk-js/protectCheckLifecycle.ts @@ -0,0 +1,141 @@ +import { ClerkRuntimeError, isClerkAPIResponseError } from '../../error'; +import type { ProtectCheckResource } from '../../types'; +import { ERROR_CODES } from './constants'; +import type { ExecuteProtectCheckOptions } from './protectCheck'; +import { executeProtectCheck } from './protectCheck'; + +/** Default upper bound on how long we wait for the challenge SDK to settle before failing loud. */ +export const PROTECT_CHECK_SCRIPT_TIMEOUT_MS = 60_000; + +/** + * A plain GET reload does not re-mint a protect_check challenge server-side, so an expired + * challenge would otherwise reload → still expired → reload again, forever. Callers that + * reload on expiry must cap their attempts at this and surface an error instead of spinning + * silently. + * + * NOTE: who re-mints an expired challenge on read (FAPI vs. re-running the gated step) is still + * being decided with the clerk_go team; this cap is the defensive floor until that lands. + */ +export const MAX_EXPIRED_RELOADS = 2; + +/** Whether the challenge expired client-side. `expiresAt` is unix milliseconds. */ +export function isProtectCheckExpired(protectCheck: Pick): boolean { + return protectCheck.expiresAt !== undefined && protectCheck.expiresAt < Date.now(); +} + +export interface ExecuteProtectCheckWithTimeoutOptions extends ExecuteProtectCheckOptions { + /** Overrides the `PROTECT_CHECK_SCRIPT_TIMEOUT_MS` default. */ + timeoutMs?: number; +} + +/** + * `executeProtectCheck` wrapped with the lifecycle guarantees a host needs to run a challenge + * safely: + * + * - The container is cleared first: this run owns it outright, so a solved or errored widget + * from a previous run can't sit under (or stack with) the new one. + * - The whole run races a timeout (default {@link PROTECT_CHECK_SCRIPT_TIMEOUT_MS}); on + * timeout the (possibly hung) SDK is aborted and a retryable `protect_check_timed_out` + * `ClerkRuntimeError` is thrown. + * - The abort contract is best-effort, so a zombie script from a timed-out run can still call + * `setWidgetVisible` late — those signals are swallowed here and never reach the caller. + * + * The caller's `signal` is linked one-way into the run: aborting it aborts the SDK, but a + * timeout does not abort the caller's controller. + */ +export async function executeProtectCheckWithTimeout( + protectCheck: Pick, + container: HTMLDivElement, + options: ExecuteProtectCheckWithTimeoutOptions = {}, +): Promise { + const { signal, setWidgetVisible, timeoutMs = PROTECT_CHECK_SCRIPT_TIMEOUT_MS } = options; + + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + const controller = new AbortController(); + const onCallerAbort = () => controller.abort(); + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener('abort', onCallerAbort, { once: true }); + } + } + + const guardedSetWidgetVisible = setWidgetVisible + ? (visible: boolean): Promise => { + if (controller.signal.aborted) { + return Promise.resolve(); + } + return setWidgetVisible(visible); + } + : undefined; + + let timeoutId: ReturnType | undefined; + try { + return await Promise.race([ + executeProtectCheck(protectCheck, container, { + signal: controller.signal, + setWidgetVisible: guardedSetWidgetVisible, + }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + controller.abort(); + reject( + new ClerkRuntimeError('Protect verification timed out', { + code: ERROR_CODES.PROTECT_CHECK_TIMED_OUT, + }), + ); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + signal?.removeEventListener('abort', onCallerAbort); + } +} + +export type SubmitProtectCheckProofResult = + | { status: 'submitted'; resource: TResource } + /** The server had already moved past this gate; `resource` is the live resource after a reload. */ + | { status: 'already_resolved'; resource: TResource } + /** `isCancelled` reported true while recovering from a submit failure; nothing further ran. */ + | { status: 'cancelled' }; + +/** + * Submits a proof token and absorbs the one submit failure that is actually a success: + * `protect_check_already_resolved` means the server's state has already moved past this gate, + * so the resource is reloaded to clear the stale local `protectCheck` and returned for the + * caller to route on. Every other failure is rethrown untouched. + */ +export async function submitProtectCheckProof(params: { + proofToken: string; + submitProtectCheck: (params: { proofToken: string }) => Promise; + /** Reloads the underlying resource (GET) to pick up fresh server state. */ + reload: () => Promise; + /** Returns the live resource, used to route after a reload (which mutates it in place). */ + getResource: () => TResource; + /** Lets the caller bail out of the recovery path when its context has gone away. */ + isCancelled?: () => boolean; +}): Promise> { + const { proofToken, submitProtectCheck, reload, getResource, isCancelled = () => false } = params; + + let resource: TResource; + try { + resource = await submitProtectCheck({ proofToken }); + } catch (err) { + if (isCancelled()) { + return { status: 'cancelled' }; + } + if (isClerkAPIResponseError(err) && err.errors?.[0]?.code === ERROR_CODES.PROTECT_CHECK_ALREADY_RESOLVED) { + await reload(); + return { status: 'already_resolved', resource: getResource() }; + } + throw err; + } + return { status: 'submitted', resource }; +} diff --git a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx index 1c60b1187d4..11ab96d6488 100644 --- a/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx +++ b/packages/ui/src/components/SignIn/__tests__/SignInProtectCheck.test.tsx @@ -8,15 +8,18 @@ import { fireEvent, render } from '@/test/utils'; import { SignInProtectCheck } from '../SignInProtectCheck'; -vi.mock('@clerk/shared/internal/clerk-js/protectCheck', () => ({ - executeProtectCheck: vi.fn(), +// Only the script execution is mocked; `submitProtectCheckProof` stays real so the +// already-resolved recovery path is exercised end-to-end. +vi.mock('@clerk/shared/internal/clerk-js/protectCheckLifecycle', async importOriginal => ({ + ...(await importOriginal()), + executeProtectCheckWithTimeout: vi.fn(), })); -import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck'; +import { executeProtectCheckWithTimeout } from '@clerk/shared/internal/clerk-js/protectCheckLifecycle'; const { createFixtures } = bindCreateFixtures('SignIn'); -const mockExecute = executeProtectCheck as unknown as ReturnType; +const mockExecute = executeProtectCheckWithTimeout as unknown as ReturnType; beforeEach(() => { mockExecute.mockReset(); diff --git a/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx b/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx index 00e891c7853..87869d3b1cf 100644 --- a/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx +++ b/packages/ui/src/components/SignUp/__tests__/SignUpProtectCheck.test.tsx @@ -9,15 +9,18 @@ import { fireEvent, render } from '@/test/utils'; import { SignUp } from '../index'; import { SignUpProtectCheck } from '../SignUpProtectCheck'; -vi.mock('@clerk/shared/internal/clerk-js/protectCheck', () => ({ - executeProtectCheck: vi.fn(), +// Only the script execution is mocked; `submitProtectCheckProof` stays real so the +// already-resolved recovery path is exercised end-to-end. +vi.mock('@clerk/shared/internal/clerk-js/protectCheckLifecycle', async importOriginal => ({ + ...(await importOriginal()), + executeProtectCheckWithTimeout: vi.fn(), })); -import { executeProtectCheck } from '@clerk/shared/internal/clerk-js/protectCheck'; +import { executeProtectCheckWithTimeout } from '@clerk/shared/internal/clerk-js/protectCheckLifecycle'; const { createFixtures } = bindCreateFixtures('SignUp'); -const mockExecute = executeProtectCheck as unknown as ReturnType; +const mockExecute = executeProtectCheckWithTimeout as unknown as ReturnType; beforeEach(() => { mockExecute.mockReset(); diff --git a/packages/ui/src/hooks/useProtectCheckRunner.ts b/packages/ui/src/hooks/useProtectCheckRunner.ts index 0dce8df171a..76e5db0a157 100644 --- a/packages/ui/src/hooks/useProtectCheckRunner.ts +++ b/packages/ui/src/hooks/useProtectCheckRunner.ts @@ -1,4 +1,4 @@ -import { ClerkRuntimeError, isClerkAPIResponseError } from '@clerk/shared/error'; +import { ClerkRuntimeError } from '@clerk/shared/error'; import { ERROR_CODES } from '@clerk/shared/internal/clerk-js/constants'; import type { ProtectCheckResource } from '@clerk/shared/types'; import React from 'react'; @@ -8,18 +8,13 @@ import { useCardState } from '@/ui/elements/contexts'; import { handleError } from '@/ui/utils/errorHandler'; /** - * A plain GET reload does not re-mint a protect_check challenge server-side, so an expired - * challenge would otherwise reload → still expired → reload again, forever. Cap the attempts - * and surface an error instead of spinning silently. - * - * NOTE: who re-mints an expired challenge on read (FAPI vs. re-running the gated step) is still - * being decided with the clerk_go team; this cap is the defensive floor until that lands. + * Mirrors `MAX_EXPIRED_RELOADS` from `@clerk/shared/internal/clerk-js/protectCheckLifecycle`. + * Kept as a local literal because it is needed before (and outside) the RHC-gated dynamic import + * below — a static value import of that module would drag the challenge loader back into no-RHC + * bundles. */ const MAX_EXPIRED_RELOADS = 2; -/** Upper bound on how long we wait for the challenge SDK to settle before failing loud. */ -const PROTECT_CHECK_SCRIPT_TIMEOUT_MS = 60_000; - export interface ProtectCheckRunnerParams { /** * Reads the current protect_check off the resource. Called fresh on each effect run because @@ -61,7 +56,10 @@ export interface ProtectCheckRunner { * Shared driver for the `` and `` cards. Both run the * exact same lifecycle — load + execute the Protect SDK, submit the proof token, continue the flow * — so the abort/cancel/expiry/timeout/no-RHC handling lives here once instead of being duplicated - * (and drifting) across the two components. + * (and drifting) across the two components. The framework-free parts of that lifecycle (timeout + * race, container ownership, `already_resolved` recovery) live in + * `@clerk/shared/internal/clerk-js/protectCheckLifecycle`; this hook owns the React orchestration + * around them. * * Must be called from within a `CardStateProvider`. */ @@ -141,8 +139,8 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam // Fail closed in no-RHC builds (chrome extension / clerk.no-rhc.js): the gate requires a // remote `import(sdk_url)` we must not perform there. This guard MUST live in the component - // layer — `executeProtectCheck` is in `@clerk/shared`, compiled once with the flag hard-coded - // `false`, so a guard there would never trip. + // layer — the shared lifecycle module is compiled once with the flag hard-coded `false`, so + // a guard there would never trip. if (__BUILD_DISABLE_RHC__) { failWith( ERROR_CODES.PROTECT_CHECK_UNSUPPORTED_ENVIRONMENT, @@ -205,9 +203,11 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam // This run owns the container outright: drop anything a previous run left behind (a solved or // errored widget) so the spinner covers the load phase and a re-rendering SDK can't stack a - // second widget under a stale one. Reset visibility in the same breath — the container is - // empty by construction here, and waiting on the observer callback would leave the state - // stale for a scheduling-dependent window (especially on the MutationObserver fallback). + // second widget under a stale one — synchronously, before the chunk import below can add a + // frame of stale widget. (`executeProtectCheckWithTimeout` clears again; that's idempotent.) + // Reset visibility in the same breath — the container is empty by construction here, and + // waiting on the observer callback would leave the state stale for a scheduling-dependent + // window (especially on the MutationObserver fallback). while (container.firstChild) { container.removeChild(container.firstChild); } @@ -217,9 +217,8 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam setIsRunning(true); const runChallenge = async () => { - let timeoutId: ReturnType | undefined; try { - // Load the Protect SDK loader lazily, gated on the same compile-time flag as the + // Load the Protect check module lazily, gated on the same compile-time flag as the // fail-closed guard above. In no-RHC builds `__BUILD_DISABLE_RHC__` is `true`, so this // branch (and the dynamic `import()` below it) is dead-code-eliminated — the loader and // its remote `import(sdk_url)` are tree-shaken out of those bundles entirely rather than @@ -227,58 +226,33 @@ export function useProtectCheckRunner(params: ProtectCheckRunnerParam if (__BUILD_DISABLE_RHC__) { return; } - const { executeProtectCheck } = await import('@clerk/shared/internal/clerk-js/protectCheck'); - const proofToken = await Promise.race([ - executeProtectCheck(protectCheck, container, { signal: abortController.signal, setWidgetVisible }), - new Promise((_, reject) => { - timeoutId = setTimeout(() => { - // Stop the (possibly hung) SDK and surface a retryable timeout error. - abortController.abort(); - reject( - new ClerkRuntimeError('Protect verification timed out', { - code: ERROR_CODES.PROTECT_CHECK_TIMED_OUT, - }), - ); - }, PROTECT_CHECK_SCRIPT_TIMEOUT_MS); - }), - ]); + const { executeProtectCheckWithTimeout, submitProtectCheckProof } = + await import('@clerk/shared/internal/clerk-js/protectCheckLifecycle'); + const proofToken = await executeProtectCheckWithTimeout(protectCheck, container, { + signal: abortController.signal, + setWidgetVisible, + }); if (cancelled) { return; } - let updatedResource: TResource; - try { - updatedResource = await submitProtectCheck({ proofToken }); - } catch (err) { - if (cancelled) { - return; - } - // `protect_check_already_resolved` is retry-safe: the server's state has already moved - // past this gate. Reload to clear the stale local protectCheck, then continue routing on - // the refreshed live resource. - if (isClerkAPIResponseError(err) && err.errors?.[0]?.code === ERROR_CODES.PROTECT_CHECK_ALREADY_RESOLVED) { - await reload(); - if (isUnmounted()) { - return; - } - await onResolved(getResource(), isUnmounted); - return; - } - throw err; - } - if (isUnmounted()) { + const result = await submitProtectCheckProof({ + proofToken, + submitProtectCheck, + reload, + getResource, + isCancelled: () => cancelled, + }); + if (result.status === 'cancelled' || isUnmounted()) { return; } - await onResolved(updatedResource, isUnmounted); + await onResolved(result.resource, isUnmounted); } catch (err: any) { if (cancelled) { return; } handleError(err, [], card.setError); } finally { - if (timeoutId) { - clearTimeout(timeoutId); - } if (!cancelled) { isRunningRef.current = false; setIsRunning(false);