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
5 changes: 5 additions & 0 deletions .changeset/strict-worms-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix a crash in the Turnstile CAPTCHA retry logic where captcha.reset() was called after the widget's DOM container had already been removed, causing an unhandled error
145 changes: 144 additions & 1 deletion packages/clerk-js/src/utils/__tests__/captcha.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { shouldRetryTurnstileErrorCode } from '../captcha/turnstile';
import type { CaptchaOptions } from '../captcha/types';
Expand Down Expand Up @@ -250,3 +250,146 @@ describe('Nonce support', () => {
});
});
});

describe('getTurnstileToken container guard', () => {
let mockRender: ReturnType<typeof vi.fn>;
let mockReset: ReturnType<typeof vi.fn>;
let mockRemove: ReturnType<typeof vi.fn>;

beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();

mockRender = vi.fn();
mockReset = vi.fn();
mockRemove = vi.fn();

(window as any).turnstile = {
render: mockRender,
reset: mockReset,
remove: mockRemove,
};
});

afterEach(() => {
vi.useRealTimers();
delete (window as any).turnstile;
document.body.innerHTML = '';
});

const baseOpts: CaptchaOptions = {
siteKey: 'test-site-key',
widgetType: 'invisible',
invisibleSiteKey: 'test-invisible-key',
captchaProvider: 'turnstile',
};

it('should reject immediately when container is removed before retry fires', async () => {
const { getTurnstileToken } = await import('../captcha/turnstile');

let errorCallback: (code: string) => void;

mockRender.mockImplementation((_selector: string, opts: any) => {
errorCallback = opts['error-callback'];
return 'widget-1';
});

const tokenPromise = getTurnstileToken(baseOpts);
// Attach handler early to prevent PromiseRejectionHandledWarning
const rejection = tokenPromise.catch(e => e);
// Flush microtask queue so async setup (loadCaptcha, container creation) completes
await vi.advanceTimersByTimeAsync(0);

// Trigger a retriable error
errorCallback!('300010');

// Remove the invisible container before the retry setTimeout fires
const invisibleWidget = document.querySelector('.clerk-invisible-captcha');
if (invisibleWidget) {
document.body.removeChild(invisibleWidget);
}

// Advance past the 250ms retry delay
await vi.advanceTimersByTimeAsync(300);

const error = await rejection;
expect(error).toMatchObject({
captchaError: expect.stringContaining('300010'),
});

expect(mockReset).not.toHaveBeenCalled();
});

it('should proceed with captcha.reset when container still exists', async () => {
const { getTurnstileToken } = await import('../captcha/turnstile');

let errorCallback: (code: string) => void;

mockRender.mockImplementation((_selector: string, opts: any) => {
errorCallback = opts['error-callback'];
return 'widget-1';
});

const tokenPromise = getTurnstileToken(baseOpts);
await vi.advanceTimersByTimeAsync(0);

// Trigger a retriable error - container still exists
errorCallback!('300010');

// Advance past the 250ms retry delay (container is still in the DOM)
await vi.advanceTimersByTimeAsync(300);

expect(mockReset).toHaveBeenCalledWith('widget-1');

// Trigger a non-retriable error to end the test (retries exhausted after 2 more)
errorCallback!('300010');
await vi.advanceTimersByTimeAsync(300);
errorCallback!('300010');

await expect(tokenPromise).rejects.toMatchObject({
captchaError: expect.stringContaining('300010'),
});
});

it('should include all accumulated error codes when rejecting due to missing container', async () => {
const { getTurnstileToken } = await import('../captcha/turnstile');

let errorCallback: (code: string) => void;

mockRender.mockImplementation((_selector: string, opts: any) => {
errorCallback = opts['error-callback'];
return 'widget-1';
});

const tokenPromise = getTurnstileToken(baseOpts);
const rejection = tokenPromise.catch(e => e);
await vi.advanceTimersByTimeAsync(0);

// First error triggers retry
errorCallback!('600010');

// Let the first retry fire (container still exists)
await vi.advanceTimersByTimeAsync(300);
expect(mockReset).toHaveBeenCalledTimes(1);

// Second error triggers another retry
errorCallback!('600010');

// Remove container before second retry fires
const invisibleWidget = document.querySelector('.clerk-invisible-captcha');
if (invisibleWidget) {
document.body.removeChild(invisibleWidget);
}

await vi.advanceTimersByTimeAsync(300);

// Should reject with both error codes
const error = await rejection;
expect(error).toMatchObject({
captchaError: expect.stringContaining('600010,600010'),
});

// captcha.reset should only have been called once (the first retry)
expect(mockReset).toHaveBeenCalledTimes(1);
});
});
4 changes: 4 additions & 0 deletions packages/clerk-js/src/utils/captcha/turnstile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
*/
if (retries < 2 && shouldRetryTurnstileErrorCode(errorCode.toString())) {
setTimeout(() => {
if (widgetContainerQuerySelector && !document.querySelector(widgetContainerQuerySelector)) {
reject([errorCodes.join(','), id]);
return;
}
captcha.reset(id as string);
retries++;
}, 250);
Expand Down
Loading