From fe7b07c55103cb20c3b470ac8fd35a4ea718a636 Mon Sep 17 00:00:00 2001 From: George Oastler Date: Fri, 25 Sep 2026 00:36:33 +0100 Subject: [PATCH 1/2] Stop a destroyed frictionless widget from acting on a late detection destroy() only guarded the solver mounts. A /frictionless response that landed after destroy could still mount the authenticated badge (firing onHuman), report onError, keep retrying through providerRetry, or add a restart timer to an already-run Teardown so it could never be cleared and later fired onReset and restart. start(), the post-detection path, renderForCaptchaType, the retry finaliser and restartComponentTimeout now all bail once destroyed. --- .changeset/frictionless-stop-after-destroy.md | 5 ++ .../src/procaptchaFrictionless.ts | 5 ++ .../src/tests/procaptchaFrictionless.test.ts | 74 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 .changeset/frictionless-stop-after-destroy.md diff --git a/.changeset/frictionless-stop-after-destroy.md b/.changeset/frictionless-stop-after-destroy.md new file mode 100644 index 0000000000..683122a005 --- /dev/null +++ b/.changeset/frictionless-stop-after-destroy.md @@ -0,0 +1,5 @@ +--- +"@prosopo/procaptcha-frictionless": patch +--- + +Removing or resetting a frictionless widget while its bot-detection request is still running no longer lets that request finish the job. Before, a detection that came back after the widget was gone could still mount the Web Bot Auth badge and call `onHuman` with a token, call `onError`, keep retrying against other providers, or schedule a restart timer that nothing could cancel and that later called `onReset`. The widget now stops as soon as it is destroyed. diff --git a/packages/procaptcha-frictionless/src/procaptchaFrictionless.ts b/packages/procaptcha-frictionless/src/procaptchaFrictionless.ts index 017f891440..6f3d434efc 100644 --- a/packages/procaptcha-frictionless/src/procaptchaFrictionless.ts +++ b/packages/procaptcha-frictionless/src/procaptchaFrictionless.ts @@ -263,6 +263,7 @@ export const mountProcaptchaFrictionless = ( let restartCount = 0; const restartComponentTimeout = () => { + if (destroyed) return; const delay = getRestartDelayMs(restartCount); restartCount += 1; const timer = setTimeout(() => { @@ -322,6 +323,7 @@ export const mountProcaptchaFrictionless = ( autoStart = false, escalationCoords?: RetryCoords, ): Promise => { + if (destroyed) return; // Audio is mounted against the visual session /frictionless just minted. audioChosen = audioChosen && offersAudioAlternative(captchaType); @@ -518,6 +520,7 @@ export const mountProcaptchaFrictionless = ( }; const start = async (): Promise => { + if (destroyed) return; // Procaptcha cannot run over plain HTTP (no SubtleCrypto etc.), which // would otherwise fail later with a cryptic provider-selection error. // Surface a clear, non-retrying message instead. @@ -546,6 +549,7 @@ export const mountProcaptchaFrictionless = ( nextRefreshOf, nextSwitchToImage, ); + if (destroyed) return; const guard = evaluateFrictionlessResult(result); if ("error" === guard.kind) { @@ -600,6 +604,7 @@ export const mountProcaptchaFrictionless = ( state.attemptCount, 5, ).finally(() => { + if (destroyed) return; if (state.attemptCount >= 5) { // Retries swallow the underlying error, so without this a site's // error callback never fires for a failure that retried — it would diff --git a/packages/procaptcha-frictionless/src/tests/procaptchaFrictionless.test.ts b/packages/procaptcha-frictionless/src/tests/procaptchaFrictionless.test.ts index 48201189f6..d52e47161a 100644 --- a/packages/procaptcha-frictionless/src/tests/procaptchaFrictionless.test.ts +++ b/packages/procaptcha-frictionless/src/tests/procaptchaFrictionless.test.ts @@ -620,3 +620,77 @@ describe("tearing down", () => { expect(mocks.mounted).toHaveLength(0); }); }); + +describe("a detection that lands after destroy", () => { + const deferred = (): { + detectBot: BotDetectionFunction; + release: (result: BotDetectionFunctionResult) => void; + fail: (error: Error) => void; + calls: () => number; + } => { + let release: (result: BotDetectionFunctionResult) => void = () => undefined; + let fail: (error: Error) => void = () => undefined; + const detectBot = vi.fn( + () => + new Promise( + ( + resolve: (result: BotDetectionFunctionResult) => void, + reject: (error: Error) => void, + ) => { + release = resolve; + fail = reject; + }, + ), + ); + return { + detectBot, + release: (result: BotDetectionFunctionResult) => release(result), + fail: (error: Error) => fail(error), + calls: () => detectBot.mock.calls.length, + }; + }; + + test("does not pass a dead widget as human", async () => { + const onHuman = vi.fn<(token: string) => void>(); + const detection$ = deferred(); + widget = mountProcaptchaFrictionless( + container, + props(detection$.detectBot, { callbacks: { onHuman } }), + ); + widget.destroy(); + widget = undefined; + detection$.release(detection(CaptchaType.authenticated)); + await settle(); + expect(onHuman).not.toHaveBeenCalled(); + expect(container.textContent).toBe(""); + }); + + test("does not report an error for a dead widget", async () => { + const onError = vi.fn<(error: Error) => void>(); + const detection$ = deferred(); + widget = mountProcaptchaFrictionless( + container, + props(detection$.detectBot, { callbacks: { onError } }), + ); + widget.destroy(); + widget = undefined; + detection$.release({ + sessionId: "session-1", + } as unknown as BotDetectionFunctionResult); + await settle(); + expect(onError).not.toHaveBeenCalled(); + }); + + test("does not retry detection for a dead widget", async () => { + const detection$ = deferred(); + widget = mountProcaptchaFrictionless( + container, + props(detection$.detectBot), + ); + widget.destroy(); + widget = undefined; + detection$.fail(new Error("provider down")); + await waitFor(() => detection$.calls() > 1); + expect(detection$.calls()).toBe(1); + }); +}); From fde2fdcba9ff3ae4a32f49b192d325fcb78c9130 Mon Sep 17 00:00:00 2001 From: Hugh Parry Date: Mon, 26 Oct 2026 13:37:09 +0000 Subject: [PATCH 2/2] Wait out the retry backoff on fake timers instead of polling for 2s --- .../src/tests/procaptchaFrictionless.test.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/procaptcha-frictionless/src/tests/procaptchaFrictionless.test.ts b/packages/procaptcha-frictionless/src/tests/procaptchaFrictionless.test.ts index d52e47161a..27c5e116ed 100644 --- a/packages/procaptcha-frictionless/src/tests/procaptchaFrictionless.test.ts +++ b/packages/procaptcha-frictionless/src/tests/procaptchaFrictionless.test.ts @@ -682,15 +682,21 @@ describe("a detection that lands after destroy", () => { }); test("does not retry detection for a dead widget", async () => { - const detection$ = deferred(); - widget = mountProcaptchaFrictionless( - container, - props(detection$.detectBot), - ); - widget.destroy(); - widget = undefined; - detection$.fail(new Error("provider down")); - await waitFor(() => detection$.calls() > 1); - expect(detection$.calls()).toBe(1); + const longestRetryBackoffMs = 10_000; + vi.useFakeTimers(); + try { + const detection$ = deferred(); + widget = mountProcaptchaFrictionless( + container, + props(detection$.detectBot), + ); + widget.destroy(); + widget = undefined; + detection$.fail(new Error("provider down")); + await vi.advanceTimersByTimeAsync(longestRetryBackoffMs); + expect(detection$.calls()).toBe(1); + } finally { + vi.useRealTimers(); + } }); });