diff --git a/components/security/Turnstile.tsx b/components/security/Turnstile.tsx index ee405190..9396ca39 100644 --- a/components/security/Turnstile.tsx +++ b/components/security/Turnstile.tsx @@ -63,16 +63,27 @@ export function Turnstile({ // Cloudflare throws error 110200 and retries forever. Skip the real challenge // there — hand the form a sentinel, and the server bypasses Turnstile for // localhost too (lib/turnstile/verify.ts). Production is unaffected. - const isLocalhost = typeof window !== 'undefined' - && /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])$|\.local$/i.test(window.location.hostname); + // + // MUST default false and only flip after mount: the server can't read + // window.location, so deriving this synchronously from `window` made the SSR + // HTML (widget div) diverge from the client's first paint (null) on localhost + // — a hydration mismatch. Starting false keeps SSR and first client paint + // identical; the effect flips it (and fires the bypass token) post-hydration. + const isLocal = () => /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])$|\.local$/i.test(window.location.hostname); + const [isLocalhost, setIsLocalhost] = useState(false); useEffect(() => { - if (isLocalhost) onTokenRef.current('localhost-dev-bypass'); - }, [isLocalhost]); + if (isLocal()) { + setIsLocalhost(true); + onTokenRef.current('localhost-dev-bypass'); + } + }, []); - // Mount: render exactly one widget; clean it up on unmount. + // Mount: render exactly one widget; clean it up on unmount. Read localhost + // straight from window here (not the state, which lands a tick later) so the + // doomed real challenge is never kicked off on a dev host. useEffect(() => { - if (!SITE_KEY || isLocalhost) return; + if (!SITE_KEY || isLocal()) return; let cancelled = false; let poll: ReturnType | undefined; let giveUp: ReturnType | undefined; diff --git a/package.json b/package.json index 8c0a7c91..b70a6533 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:pill-tap": "node scripts/eval-pill-tap.mjs", "test:stale-state": "node scripts/eval-stale-state.mjs", "test:intent-live": "node scripts/eval-intent-matrix.mjs", - "clean": "next clean" + "clean": "rm -rf .next" }, "dependencies": { "@google-analytics/data": "^5.2.1",