Fix Turnstile API initialization race

This commit is contained in:
2026-09-18 01:12:39 +02:00
parent caa24905ec
commit b29862b4b7
2 changed files with 155 additions and 54 deletions
+38 -54
View File
@@ -1,28 +1,7 @@
"use client"; "use client";
import Script from "next/script"; import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react"; import { loadTurnstile, type TurnstileWidgetId } from "@/lib/turnstile/client";
type TurnstileWidgetId = string;
type TurnstileApi = {
render: (
container: HTMLElement,
options: {
sitekey: string;
action: string;
callback: (token: string) => void;
"expired-callback": () => void;
"error-callback": () => void;
},
) => TurnstileWidgetId;
reset: (widgetId: TurnstileWidgetId) => void;
};
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
type TurnstileWidgetProps = { type TurnstileWidgetProps = {
siteKey: string; siteKey: string;
@@ -50,45 +29,50 @@ export function TurnstileWidget({ siteKey, action, resetKey, onTokenChange }: Tu
onTokenChangeRef.current(null); onTokenChangeRef.current(null);
}, [resetKey]); }, [resetKey]);
const render = useCallback(() => { useEffect(() => {
if (!siteKey || !containerRef.current || widgetIdRef.current !== null) return; if (!siteKey || !containerRef.current || widgetIdRef.current !== null) return;
if (!window.turnstile) {
setLoadError(true);
return;
}
try { let cancelled = false;
widgetIdRef.current = window.turnstile.render(containerRef.current, { loadTurnstile()
sitekey: siteKey, .then((turnstile) => {
action, if (cancelled || !containerRef.current || widgetIdRef.current !== null) return;
callback: (token) => {
setVerificationStatus("ready"); try {
onTokenChangeRef.current(token); widgetIdRef.current = turnstile.render(containerRef.current, {
}, sitekey: siteKey,
"expired-callback": () => { action,
setVerificationStatus("expired"); callback: (token) => {
onTokenChangeRef.current(null); setVerificationStatus("ready");
}, onTokenChangeRef.current(token);
"error-callback": () => { },
onTokenChangeRef.current(null); "expired-callback": () => {
setVerificationStatus("expired");
onTokenChangeRef.current(null);
},
"error-callback": () => {
onTokenChangeRef.current(null);
setLoadError(true);
},
});
} catch {
setLoadError(true); setLoadError(true);
}, onTokenChangeRef.current(null);
}
})
.catch(() => {
if (cancelled) return;
setLoadError(true);
onTokenChangeRef.current(null);
}); });
} catch {
setLoadError(true); return () => {
onTokenChangeRef.current(null); cancelled = true;
} };
}, [action, siteKey]); }, [action, siteKey]);
return ( return (
<div className="space-y-2" aria-live="polite"> <div className="space-y-2" aria-live="polite">
<Script <div ref={containerRef} data-turnstile-container="true" />
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
strategy="afterInteractive"
onReady={render}
onError={() => setLoadError(true)}
/>
<div ref={containerRef} />
{!siteKey || loadError ? ( {!siteKey || loadError ? (
<p className="text-sm text-red-700" role="alert"> <p className="text-sm text-red-700" role="alert">
Die Sicherheitsprüfung ist momentan nicht verfügbar. Bitte lade die Seite neu und versuche es erneut. Die Sicherheitsprüfung ist momentan nicht verfügbar. Bitte lade die Seite neu und versuche es erneut.
+117
View File
@@ -0,0 +1,117 @@
"use client";
const TURNSTILE_SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
const API_READY_TIMEOUT_MS = 15_000;
const API_POLL_INTERVAL_MS = 50;
export type TurnstileWidgetId = string;
export type TurnstileApi = {
render: (
container: HTMLElement,
options: {
sitekey: string;
action: string;
callback: (token: string) => void;
"expired-callback": () => void;
"error-callback": () => void;
},
) => TurnstileWidgetId;
reset: (widgetId: TurnstileWidgetId) => void;
};
declare global {
interface Window {
turnstile?: TurnstileApi;
}
}
let turnstilePromise: Promise<TurnstileApi> | null = null;
function getReadyApi(): TurnstileApi | null {
if (typeof window === "undefined") return null;
const api = window.turnstile;
if (!api || typeof api.render !== "function" || typeof api.reset !== "function") return null;
return api;
}
function findTurnstileScript(): HTMLScriptElement | null {
return (
Array.from(document.scripts).find((script) => {
try {
return new URL(script.src, document.baseURI).href === TURNSTILE_SCRIPT_SRC;
} catch {
return false;
}
}) ?? null
);
}
function waitForReadyApi(script: HTMLScriptElement): Promise<TurnstileApi> {
return new Promise((resolve, reject) => {
let settled = false;
const cleanup = () => {
if (intervalId !== undefined) window.clearInterval(intervalId);
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
script.removeEventListener("error", onError);
};
const finish = (error?: Error) => {
if (settled) return;
settled = true;
cleanup();
if (error) reject(error);
else resolve(getReadyApi() as TurnstileApi);
};
const check = () => {
const api = getReadyApi();
if (api) finish();
};
const onError = () => {
finish(new Error("Cloudflare Turnstile script failed to load"));
};
const intervalId = window.setInterval(check, API_POLL_INTERVAL_MS);
const timeoutId = window.setTimeout(() => {
finish(new Error("Cloudflare Turnstile API did not become ready in time"));
}, API_READY_TIMEOUT_MS);
script.addEventListener("error", onError, { once: true });
check();
});
}
/**
* Loads Turnstile once and resolves only after window.turnstile.render/reset exist.
* This also adopts a script already inserted by Next.js or another component.
*/
export function loadTurnstile(): Promise<TurnstileApi> {
if (typeof window === "undefined" || typeof document === "undefined") {
return Promise.reject(new Error("Cloudflare Turnstile requires a browser"));
}
const readyApi = getReadyApi();
if (readyApi) return Promise.resolve(readyApi);
if (turnstilePromise) return turnstilePromise;
const script = findTurnstileScript() ?? document.createElement("script");
if (!script.src) {
script.src = TURNSTILE_SCRIPT_SRC;
script.async = true;
script.defer = true;
script.dataset.turnstileLoader = "true";
document.head.appendChild(script);
}
turnstilePromise = waitForReadyApi(script).catch((error: unknown) => {
turnstilePromise = null;
throw error;
});
return turnstilePromise;
}