From dca984f30cec80d9d3b47f3856cc797a70dbfa7a Mon Sep 17 00:00:00 2001 From: tasklyappai Date: Wed, 22 Jul 2026 23:24:00 -0400 Subject: [PATCH 1/2] fix(auth): stop Turnstile SSR/client hydration mismatch on /login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isLocalhost was derived synchronously from window.location.hostname, so the server (no window) computed false and rendered the widget container while the client on localhost computed true and returned null — a hydration mismatch that regenerated the login form tree. Surfaced once a NEXT_PUBLIC_TURNSTILE_SITE_KEY was present in the dev env. Make it SSR-safe: isLocalhost is now useState(false) so SSR and the first client paint render the identical container; a post-mount effect flips it (and fires the localhost-dev-bypass token). The real-challenge skip inside the effects reads window directly via isLocal(), so localhost dev still never kicks off the doomed 110200 Cloudflare challenge. Verified: typecheck, 62 Turnstile tests, and a real-browser /login load with no hydration error in the dev log. Co-Authored-By: Claude Opus 4.8 (1M context) --- components/security/Turnstile.tsx | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) 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; From 1ee7dbac65fe82a39f8f49f759dcf556e4d004da Mon Sep 17 00:00:00 2001 From: tasklyappai Date: Thu, 23 Jul 2026 17:52:09 -0400 Subject: [PATCH 2/2] fix(scripts): make npm run clean actually clear .next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next 16 has no `clean` subcommand, so `next clean` read "clean" as a project directory and errored ("no such directory: .../clean") — the documented cache-clear command never worked. Point it at `rm -rf .next`, which is what CLAUDE.md says it does. Handy right here: the app/on → app/[province] route rename leaves a stale .next/types until cleared. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",