Skip to content

Commit 4fc7649

Browse files
committed
fix: render turnstile client side
1 parent ab70c95 commit 4fc7649

2 files changed

Lines changed: 76 additions & 5 deletions

File tree

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,93 @@
11
import { useRouteLoaderData } from "react-router";
2+
import { useEffect, useRef } from "react";
23

34
type RootData = {
45
publicConfig?: {
56
turnstileSiteKey?: string;
67
};
78
};
89

10+
declare global {
11+
interface Window {
12+
turnstile?: {
13+
render: (element: HTMLElement, options: Record<string, unknown>) => string;
14+
remove: (widgetId: string) => void;
15+
};
16+
}
17+
}
18+
19+
let turnstileScriptPromise: Promise<void> | null = null;
20+
let currentToken = "";
21+
22+
function loadTurnstileScript() {
23+
if (typeof window === "undefined") return Promise.resolve();
24+
if (window.turnstile) return Promise.resolve();
25+
if (turnstileScriptPromise) return turnstileScriptPromise;
26+
27+
turnstileScriptPromise = new Promise((resolve, reject) => {
28+
const existing = document.querySelector<HTMLScriptElement>('script[data-turnstile-api="true"]');
29+
if (existing) {
30+
existing.addEventListener("load", () => resolve(), { once: true });
31+
existing.addEventListener("error", () => reject(new Error("Turnstile script failed to load")), { once: true });
32+
return;
33+
}
34+
35+
const script = document.createElement("script");
36+
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
37+
script.async = true;
38+
script.defer = true;
39+
script.dataset.turnstileApi = "true";
40+
script.addEventListener("load", () => resolve(), { once: true });
41+
script.addEventListener("error", () => reject(new Error("Turnstile script failed to load")), { once: true });
42+
document.head.appendChild(script);
43+
});
44+
return turnstileScriptPromise;
45+
}
46+
947
export function TurnstileWidget() {
1048
const data = useRouteLoaderData("root") as RootData | undefined;
1149
const siteKey = data?.publicConfig?.turnstileSiteKey;
50+
const containerRef = useRef<HTMLDivElement | null>(null);
51+
52+
useEffect(() => {
53+
if (!siteKey || !containerRef.current) return;
54+
let cancelled = false;
55+
let widgetId: string | null = null;
56+
57+
loadTurnstileScript()
58+
.then(() => {
59+
if (cancelled || !window.turnstile || !containerRef.current) return;
60+
currentToken = "";
61+
widgetId = window.turnstile.render(containerRef.current, {
62+
sitekey: siteKey,
63+
action: "turnstile-spin-v2",
64+
callback: (token: string) => {
65+
currentToken = token;
66+
},
67+
"expired-callback": () => {
68+
currentToken = "";
69+
},
70+
"error-callback": () => {
71+
currentToken = "";
72+
},
73+
});
74+
})
75+
.catch(() => {
76+
currentToken = "";
77+
});
78+
79+
return () => {
80+
cancelled = true;
81+
currentToken = "";
82+
if (widgetId && window.turnstile) window.turnstile.remove(widgetId);
83+
};
84+
}, [siteKey]);
85+
1286
if (!siteKey) return null;
13-
return <div className="cf-turnstile" data-sitekey={siteKey} data-action="turnstile-spin-v2" />;
87+
return <div ref={containerRef} />;
1488
}
1589

1690
export function getTurnstileToken() {
1791
if (typeof document === "undefined") return "";
18-
return (document.querySelector('[name="cf-turnstile-response"]') as HTMLInputElement | null)?.value || "";
92+
return currentToken || (document.querySelector('[name="cf-turnstile-response"]') as HTMLInputElement | null)?.value || "";
1993
}

‎apps/web/app/root.tsx‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,6 @@ export function Layout({ children }: { children: React.ReactNode }) {
3232
/>
3333
<Meta />
3434
<Links />
35-
{publicConfig.turnstileSiteKey ? (
36-
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer />
37-
) : null}
3835
</head>
3936
<body>
4037
{children}

0 commit comments

Comments
 (0)