From d2851c019e72791e9e1fc35284082b8e5ad5aa30 Mon Sep 17 00:00:00 2001 From: oabrivard Date: Thu, 2 Apr 2026 21:45:24 +0200 Subject: [PATCH] fix: add timeout to Turnstile polling loop to prevent infinite retries When the Cloudflare Turnstile script fails to load (e.g., 503 from CDN), the polling interval ran forever, causing the page to appear stuck in a refresh loop. Now stops after 100 attempts (10s) and calls onError. Also adds dedicated unit tests for the Turnstile component covering immediate render, delayed load, timeout, and cleanup-during-polling. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/__tests__/turnstile.test.tsx | 99 +++++++++++++++++++++++ frontend/src/components/Turnstile.tsx | 10 ++- 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 frontend/src/__tests__/turnstile.test.tsx diff --git a/frontend/src/__tests__/turnstile.test.tsx b/frontend/src/__tests__/turnstile.test.tsx new file mode 100644 index 0000000..a9cc75d --- /dev/null +++ b/frontend/src/__tests__/turnstile.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, cleanup } from '@solidjs/testing-library'; +import Turnstile from '~/components/Turnstile'; + +beforeEach(() => { + vi.useFakeTimers(); + delete (window as any).turnstile; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + delete (window as any).turnstile; + cleanup(); +}); + +describe('Turnstile component', () => { + it('should render widget immediately when window.turnstile is available', () => { + const onToken = vi.fn(); + const renderMock = vi.fn((_el: HTMLElement, opts: any) => { + opts.callback('test-token'); + return 'widget-1'; + }); + + (window as any).turnstile = { + render: renderMock, + reset: vi.fn(), + remove: vi.fn(), + }; + + render(() => ); + + expect(renderMock).toHaveBeenCalledTimes(1); + expect(onToken).toHaveBeenCalledWith('test-token'); + }); + + it('should poll and render when window.turnstile appears after delay', () => { + const onToken = vi.fn(); + const renderMock = vi.fn((_el: HTMLElement, opts: any) => { + opts.callback('delayed-token'); + return 'widget-2'; + }); + + render(() => ); + + // Turnstile not yet available — advance a few ticks + vi.advanceTimersByTime(300); + expect(onToken).not.toHaveBeenCalled(); + + // Simulate script loading + (window as any).turnstile = { + render: renderMock, + reset: vi.fn(), + remove: vi.fn(), + }; + + vi.advanceTimersByTime(100); + + expect(renderMock).toHaveBeenCalledTimes(1); + expect(onToken).toHaveBeenCalledWith('delayed-token'); + }); + + it('should call onError after 10 seconds if window.turnstile never loads', () => { + const onToken = vi.fn(); + const onError = vi.fn(); + + render(() => ); + + // Advance to just before timeout (99 ticks = 9.9s) + vi.advanceTimersByTime(9900); + expect(onError).not.toHaveBeenCalled(); + + // Advance past timeout (100th tick = 10s) + vi.advanceTimersByTime(100); + expect(onError).toHaveBeenCalledTimes(1); + expect(onToken).not.toHaveBeenCalled(); + + // Verify interval is cleared — no further calls after timeout + vi.advanceTimersByTime(1000); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('should clear interval on unmount during polling', () => { + const onToken = vi.fn(); + const onError = vi.fn(); + + const { unmount } = render(() => ( + + )); + + vi.advanceTimersByTime(500); + unmount(); + + // Advance past what would be the timeout — nothing should fire + vi.advanceTimersByTime(15000); + expect(onError).not.toHaveBeenCalled(); + expect(onToken).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/Turnstile.tsx b/frontend/src/components/Turnstile.tsx index 6083967..ca72810 100644 --- a/frontend/src/components/Turnstile.tsx +++ b/frontend/src/components/Turnstile.tsx @@ -35,8 +35,9 @@ const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? '1x00000000000000000 * **Widget lifecycle**: * 1. On mount, if `window.turnstile` is available (script already loaded), * the widget is rendered immediately. - * 2. Otherwise, a 100ms polling interval waits for the external Turnstile - * script to load and expose the global API, then renders. + * 2. Otherwise, a 100 ms polling interval waits for the external Turnstile + * script to load (up to 10 seconds / 100 attempts). If the script does + * not load in time, polling stops and `onError` is called. * 3. On cleanup (component unmount), the widget is removed via * `turnstile.remove()` and any polling interval is cleared to prevent * memory leaks. @@ -67,10 +68,15 @@ const Turnstile: Component = (props) => { if (window.turnstile) { renderWidget(); } else { + let attempts = 0; const checkInterval = setInterval(() => { + attempts++; if (window.turnstile) { clearInterval(checkInterval); renderWidget(); + } else if (attempts >= 100) { + clearInterval(checkInterval); + props.onError?.(); } }, 100);