Skip to content

Commit 86d42b9

Browse files
authored
fix(clerk-js): Turnstile retry logic (#7957)
1 parent 168bc15 commit 86d42b9

4 files changed

Lines changed: 154 additions & 2 deletions

File tree

.changeset/strict-worms-allow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
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

packages/clerk-js/src/ui/components/OrganizationProfile/__tests__/InviteMembersPage.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ describe('InviteMembersPage', () => {
155155
{ wrapper },
156156
);
157157
await userEvent.type(getByTestId('tag-input'), 'test+1@clerk.com,');
158-
await userEvent.click(getByRole('button', { name: /mydefaultrole/i }));
158+
await waitFor(() => expect(getByRole('button', { name: /mydefaultrole/i })).toBeInTheDocument());
159159
});
160160

161161
it("initializes if there's only one role available", async () => {

packages/clerk-js/src/utils/__tests__/captcha.test.ts

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeEach, describe, expect, it, vi } from 'vitest';
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22

33
import { shouldRetryTurnstileErrorCode } from '../captcha/turnstile';
44
import type { CaptchaOptions } from '../captcha/types';
@@ -250,3 +250,146 @@ describe('Nonce support', () => {
250250
});
251251
});
252252
});
253+
254+
describe('getTurnstileToken container guard', () => {
255+
let mockRender: ReturnType<typeof vi.fn>;
256+
let mockReset: ReturnType<typeof vi.fn>;
257+
let mockRemove: ReturnType<typeof vi.fn>;
258+
259+
beforeEach(() => {
260+
vi.useFakeTimers();
261+
vi.resetModules();
262+
263+
mockRender = vi.fn();
264+
mockReset = vi.fn();
265+
mockRemove = vi.fn();
266+
267+
(window as any).turnstile = {
268+
render: mockRender,
269+
reset: mockReset,
270+
remove: mockRemove,
271+
};
272+
});
273+
274+
afterEach(() => {
275+
vi.useRealTimers();
276+
delete (window as any).turnstile;
277+
document.body.innerHTML = '';
278+
});
279+
280+
const baseOpts: CaptchaOptions = {
281+
siteKey: 'test-site-key',
282+
widgetType: 'invisible',
283+
invisibleSiteKey: 'test-invisible-key',
284+
captchaProvider: 'turnstile',
285+
};
286+
287+
it('should reject immediately when container is removed before retry fires', async () => {
288+
const { getTurnstileToken } = await import('../captcha/turnstile');
289+
290+
let errorCallback: (code: string) => void;
291+
292+
mockRender.mockImplementation((_selector: string, opts: any) => {
293+
errorCallback = opts['error-callback'];
294+
return 'widget-1';
295+
});
296+
297+
const tokenPromise = getTurnstileToken(baseOpts);
298+
// Attach handler early to prevent PromiseRejectionHandledWarning
299+
const rejection = tokenPromise.catch(e => e);
300+
// Flush microtask queue so async setup (loadCaptcha, container creation) completes
301+
await vi.advanceTimersByTimeAsync(0);
302+
303+
// Trigger a retriable error
304+
errorCallback!('300010');
305+
306+
// Remove the invisible container before the retry setTimeout fires
307+
const invisibleWidget = document.querySelector('.clerk-invisible-captcha');
308+
if (invisibleWidget) {
309+
document.body.removeChild(invisibleWidget);
310+
}
311+
312+
// Advance past the 250ms retry delay
313+
await vi.advanceTimersByTimeAsync(300);
314+
315+
const error = await rejection;
316+
expect(error).toMatchObject({
317+
captchaError: expect.stringContaining('300010'),
318+
});
319+
320+
expect(mockReset).not.toHaveBeenCalled();
321+
});
322+
323+
it('should proceed with captcha.reset when container still exists', async () => {
324+
const { getTurnstileToken } = await import('../captcha/turnstile');
325+
326+
let errorCallback: (code: string) => void;
327+
328+
mockRender.mockImplementation((_selector: string, opts: any) => {
329+
errorCallback = opts['error-callback'];
330+
return 'widget-1';
331+
});
332+
333+
const tokenPromise = getTurnstileToken(baseOpts);
334+
await vi.advanceTimersByTimeAsync(0);
335+
336+
// Trigger a retriable error - container still exists
337+
errorCallback!('300010');
338+
339+
// Advance past the 250ms retry delay (container is still in the DOM)
340+
await vi.advanceTimersByTimeAsync(300);
341+
342+
expect(mockReset).toHaveBeenCalledWith('widget-1');
343+
344+
// Trigger a non-retriable error to end the test (retries exhausted after 2 more)
345+
errorCallback!('300010');
346+
await vi.advanceTimersByTimeAsync(300);
347+
errorCallback!('300010');
348+
349+
await expect(tokenPromise).rejects.toMatchObject({
350+
captchaError: expect.stringContaining('300010'),
351+
});
352+
});
353+
354+
it('should include all accumulated error codes when rejecting due to missing container', async () => {
355+
const { getTurnstileToken } = await import('../captcha/turnstile');
356+
357+
let errorCallback: (code: string) => void;
358+
359+
mockRender.mockImplementation((_selector: string, opts: any) => {
360+
errorCallback = opts['error-callback'];
361+
return 'widget-1';
362+
});
363+
364+
const tokenPromise = getTurnstileToken(baseOpts);
365+
const rejection = tokenPromise.catch(e => e);
366+
await vi.advanceTimersByTimeAsync(0);
367+
368+
// First error triggers retry
369+
errorCallback!('600010');
370+
371+
// Let the first retry fire (container still exists)
372+
await vi.advanceTimersByTimeAsync(300);
373+
expect(mockReset).toHaveBeenCalledTimes(1);
374+
375+
// Second error triggers another retry
376+
errorCallback!('600010');
377+
378+
// Remove container before second retry fires
379+
const invisibleWidget = document.querySelector('.clerk-invisible-captcha');
380+
if (invisibleWidget) {
381+
document.body.removeChild(invisibleWidget);
382+
}
383+
384+
await vi.advanceTimersByTimeAsync(300);
385+
386+
// Should reject with both error codes
387+
const error = await rejection;
388+
expect(error).toMatchObject({
389+
captchaError: expect.stringContaining('600010,600010'),
390+
});
391+
392+
// captcha.reset should only have been called once (the first retry)
393+
expect(mockReset).toHaveBeenCalledTimes(1);
394+
});
395+
});

packages/clerk-js/src/utils/captcha/turnstile.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ export const getTurnstileToken = async (opts: CaptchaOptions) => {
212212
*/
213213
if (retries < 2 && shouldRetryTurnstileErrorCode(errorCode.toString())) {
214214
setTimeout(() => {
215+
if (widgetContainerQuerySelector && !document.querySelector(widgetContainerQuerySelector)) {
216+
reject([errorCodes.join(','), id]);
217+
return;
218+
}
215219
captcha.reset(id as string);
216220
retries++;
217221
}, 250);

0 commit comments

Comments
 (0)