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);