From 10191d03a02943d4e190ad62bc24eb6a34e9a008 Mon Sep 17 00:00:00 2001 From: Thanan Traiongthawon <95660+nullcoder@users.noreply.github.com> Date: Sun, 8 Jun 2025 02:12:47 -0700 Subject: [PATCH] fix: prevent Turnstile widget from constantly reloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use refs to store callbacks and avoid re-renders - Add isRenderedRef to ensure widget is only rendered once - Keep callbacks up-to-date without triggering re-renders - Update tests to verify callbacks are properly forwarded This approach is more optimal as it: 1. Prevents the widget from reloading when parent re-renders 2. Keeps callbacks fresh without stale closures 3. Only re-renders when sitekey, theme, or size changes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- app/create/page.tsx | 2 -- components/ui/turnstile.test.tsx | 21 +++++++++++++++--- components/ui/turnstile.tsx | 38 +++++++++++++++++--------------- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/app/create/page.tsx b/app/create/page.tsx index ba8cee7..ad83809 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -32,7 +32,6 @@ import { Turnstile } from "@/components/ui/turnstile"; export default function CreateGistPage() { // Get Turnstile site key - safe to use on client as it's a public key const turnstileSiteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; - console.log("Turnstile Site Key:", turnstileSiteKey); const router = useRouter(); const multiFileEditorRef = useRef(null); const [files, setFiles] = useState(() => [ @@ -330,7 +329,6 @@ export default function CreateGistPage() { onVerify={(token) => { setTurnstileToken(token); setIsTurnstileReady(true); - // Don't clear errors here - let them persist }} onError={() => { setError( diff --git a/components/ui/turnstile.test.tsx b/components/ui/turnstile.test.tsx index ee0880e..960ec16 100644 --- a/components/ui/turnstile.test.tsx +++ b/components/ui/turnstile.test.tsx @@ -51,9 +51,9 @@ describe("Turnstile", () => { expect.any(HTMLElement), expect.objectContaining({ sitekey: "test-site-key", - callback: mockOnVerify, - "error-callback": mockOnError, - "expired-callback": mockOnExpire, + callback: expect.any(Function), + "error-callback": expect.any(Function), + "expired-callback": expect.any(Function), theme: "auto", size: "normal", }) @@ -63,6 +63,21 @@ describe("Turnstile", () => { // Check container exists const turnstileContainer = container.querySelector(".cf-turnstile"); expect(turnstileContainer).toBeInTheDocument(); + + // Test that callbacks are properly forwarded + const renderCall = mockRender.mock.calls[0][1]; + + // Test onVerify callback + renderCall.callback("test-token"); + expect(mockOnVerify).toHaveBeenCalledWith("test-token"); + + // Test onError callback + renderCall["error-callback"]("test-error"); + expect(mockOnError).toHaveBeenCalledWith("test-error"); + + // Test onExpire callback + renderCall["expired-callback"](); + expect(mockOnExpire).toHaveBeenCalled(); }); it("applies custom theme and size", async () => { diff --git a/components/ui/turnstile.tsx b/components/ui/turnstile.tsx index 4a767f2..4529656 100644 --- a/components/ui/turnstile.tsx +++ b/components/ui/turnstile.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, memo } from "react"; import Script from "next/script"; import { cn } from "@/lib/utils"; @@ -34,7 +34,7 @@ declare global { } } -export function Turnstile({ +export const Turnstile = memo(function Turnstile({ sitekey, onVerify, onError, @@ -46,33 +46,34 @@ export function Turnstile({ const containerRef = useRef(null); const widgetIdRef = useRef(null); const [isScriptLoaded, setIsScriptLoaded] = useState(false); + const isRenderedRef = useRef(false); + // Store callbacks in refs to avoid re-renders + const callbacksRef = useRef({ onVerify, onError, onExpire }); useEffect(() => { - if (!isScriptLoaded || !containerRef.current) return; + callbacksRef.current = { onVerify, onError, onExpire }; + }); - // Clean up any existing widget - if (widgetIdRef.current && window.turnstile) { - try { - window.turnstile.remove(widgetIdRef.current); - } catch { - // Widget might already be removed - } - } + useEffect(() => { + if (!isScriptLoaded || !containerRef.current || isRenderedRef.current) + return; - // Render new widget + // Render widget only once if (window.turnstile && containerRef.current) { try { widgetIdRef.current = window.turnstile.render(containerRef.current, { sitekey, - callback: onVerify, - "error-callback": onError, - "expired-callback": onExpire, + callback: (token: string) => callbacksRef.current.onVerify(token), + "error-callback": (error: string) => + callbacksRef.current.onError?.(error), + "expired-callback": () => callbacksRef.current.onExpire?.(), theme, size, }); + isRenderedRef.current = true; } catch (error) { console.error("Failed to render Turnstile widget:", error); - onError?.("Failed to load verification widget"); + callbacksRef.current.onError?.("Failed to load verification widget"); } } @@ -84,9 +85,10 @@ export function Turnstile({ } catch { // Widget might already be removed } + isRenderedRef.current = false; } }; - }, [isScriptLoaded, sitekey, onVerify, onError, onExpire, theme, size]); + }, [isScriptLoaded, sitekey, theme, size]); return ( <> @@ -108,4 +110,4 @@ export function Turnstile({ /> ); -} +});