perf(site): lazy-load Turnstile — 450KB challenge leaves the mobile critical path

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.
This commit is contained in:
jwinterm
2026-07-06 09:53:56 -04:00
parent 3391c7cc6c
commit 2e44b70e0b
@@ -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<HTMLDivElement>(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 <div className="h-[65px] w-[300px]" />;
// Only render on client after mount (hydration) AND once the form is near
// the viewport (see above).
if (!mounted || !near) {
return <div ref={placeholderRef} className="h-[65px] w-[300px]" />;
}
if (error) {