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
+20 -36
View File
@@ -1,28 +1,7 @@
"use client";
import Script from "next/script";
import { useCallback, useEffect, useRef, useState } from "react";
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;
}
}
import { useEffect, useRef, useState } from "react";
import { loadTurnstile, type TurnstileWidgetId } from "@/lib/turnstile/client";
type TurnstileWidgetProps = {
siteKey: string;
@@ -50,15 +29,16 @@ export function TurnstileWidget({ siteKey, action, resetKey, onTokenChange }: Tu
onTokenChangeRef.current(null);
}, [resetKey]);
const render = useCallback(() => {
useEffect(() => {
if (!siteKey || !containerRef.current || widgetIdRef.current !== null) return;
if (!window.turnstile) {
setLoadError(true);
return;
}
let cancelled = false;
loadTurnstile()
.then((turnstile) => {
if (cancelled || !containerRef.current || widgetIdRef.current !== null) return;
try {
widgetIdRef.current = window.turnstile.render(containerRef.current, {
widgetIdRef.current = turnstile.render(containerRef.current, {
sitekey: siteKey,
action,
callback: (token) => {
@@ -78,17 +58,21 @@ export function TurnstileWidget({ siteKey, action, resetKey, onTokenChange }: Tu
setLoadError(true);
onTokenChangeRef.current(null);
}
})
.catch(() => {
if (cancelled) return;
setLoadError(true);
onTokenChangeRef.current(null);
});
return () => {
cancelled = true;
};
}, [action, siteKey]);
return (
<div className="space-y-2" aria-live="polite">
<Script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
strategy="afterInteractive"
onReady={render}
onError={() => setLoadError(true)}
/>
<div ref={containerRef} />
<div ref={containerRef} data-turnstile-container="true" />
{!siteKey || loadError ? (
<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.
+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;
}