mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
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:
@@ -1,25 +1,55 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { Turnstile } from '@marsidev/react-turnstile';
|
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;
|
const SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY;
|
||||||
|
|
||||||
export function TurnstileWidget({ onVerify }: { onVerify: (token: string) => void }) {
|
export function TurnstileWidget({ onVerify }: { onVerify: (token: string) => void }) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [error, setError] = 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(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
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) {
|
if (!SITE_KEY) {
|
||||||
console.warn('NEXT_PUBLIC_TURNSTILE_SITE_KEY is not set');
|
console.warn('NEXT_PUBLIC_TURNSTILE_SITE_KEY is not set');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only render on client after mount to avoid hydration issues
|
// Only render on client after mount (hydration) AND once the form is near
|
||||||
if (!mounted) {
|
// the viewport (see above).
|
||||||
return <div className="h-[65px] w-[300px]" />;
|
if (!mounted || !near) {
|
||||||
|
return <div ref={placeholderRef} className="h-[65px] w-[300px]" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user