From 2e44b70e0be4b6c530710a43915412e1014cad2f Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 6 Jul 2026 09:53:56 -0400 Subject: [PATCH] =?UTF-8?q?perf(site):=20lazy-load=20Turnstile=20=E2=80=94?= =?UTF-8?q?=20450KB=20challenge=20leaves=20the=20mobile=20critical=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contact form's Turnstile widget pulled ~450KB of Cloudflare challenge scripts eagerly on every page load, including the homepage where the form sits far below the fold. On slow-4G mobile that download competes with the critical path during load (the biggest single drag on the mobile PageSpeed score; desktop was unaffected, hence the 97-vs-75 split). The widget now mounts only once its container is within 800px of the viewport; the existing placeholder reserves layout, and on /contact it still loads immediately. --- .../components/contact/turnstile-widget.tsx | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/contact/turnstile-widget.tsx b/apps/web/src/components/contact/turnstile-widget.tsx index 12f89b4..4be34c5 100644 --- a/apps/web/src/components/contact/turnstile-widget.tsx +++ b/apps/web/src/components/contact/turnstile-widget.tsx @@ -1,25 +1,55 @@ 'use client'; import { Turnstile } from '@marsidev/react-turnstile'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; export function TurnstileWidget({ onVerify }: { onVerify: (token: string) => void }) { const [mounted, setMounted] = useState(false); const [error, setError] = useState(false); + // Lazy-load gate: Turnstile pulls ~450KB of challenge scripts the moment it + // mounts. On the homepage the form sits far below the fold, and on a slow + // mobile connection that download competes with the critical path during + // load (it was the biggest single drag on the mobile PageSpeed score). So + // the widget only mounts once its container is within ~800px of the + // viewport; the placeholder below reserves the layout. On /contact, where + // the form is at the top, the observer fires immediately. + const placeholderRef = useRef(null); + const [near, setNear] = useState(false); useEffect(() => { setMounted(true); }, []); + useEffect(() => { + if (near) return; + const el = placeholderRef.current; + if (!el || typeof IntersectionObserver === 'undefined') { + setNear(true); + return; + } + const io = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setNear(true); + io.disconnect(); + } + }, + { rootMargin: '800px' }, + ); + io.observe(el); + return () => io.disconnect(); + }, [near]); + if (!SITE_KEY) { console.warn('NEXT_PUBLIC_TURNSTILE_SITE_KEY is not set'); return null; } - // Only render on client after mount to avoid hydration issues - if (!mounted) { - return
; + // Only render on client after mount (hydration) AND once the form is near + // the viewport (see above). + if (!mounted || !near) { + return
; } if (error) {