diff --git a/.changeset/strict-worms-allow.md b/.changeset/strict-worms-allow.md new file mode 100644 index 00000000000..b63127fc0fa --- /dev/null +++ b/.changeset/strict-worms-allow.md @@ -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 diff --git a/packages/clerk-js/src/utils/__tests__/captcha.test.ts b/packages/clerk-js/src/utils/__tests__/captcha.test.ts index f2e65933bd9..b18b0ee6dbf 100644 --- a/packages/clerk-js/src/utils/__tests__/captcha.test.ts +++ b/packages/clerk-js/src/utils/__tests__/captcha.test.ts @@ -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'; @@ -250,3 +250,146 @@ describe('Nonce support', () => { }); }); }); + +describe('getTurnstileToken container guard', () => { + let mockRender: ReturnType; + let mockReset: ReturnType; + let mockRemove: ReturnType; + + 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); + }); +}); diff --git a/packages/clerk-js/src/utils/captcha/turnstile.ts b/packages/clerk-js/src/utils/captcha/turnstile.ts index 40cce095d46..728f6b30094 100644 --- a/packages/clerk-js/src/utils/captcha/turnstile.ts +++ b/packages/clerk-js/src/utils/captcha/turnstile.ts @@ -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);