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) <noreply@anthropic.com>
master
oabrivard 2 months ago
parent bc68434ed8
commit d2851c019e

@ -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(() => <Turnstile onToken={onToken} />);
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 onToken={onToken} />);
// 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(() => <Turnstile onToken={onToken} onError={onError} />);
// 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(() => (
<Turnstile onToken={onToken} onError={onError} />
));
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();
});
});

@ -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<TurnstileProps> = (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);

Loading…
Cancel
Save