From 319d88eec9c8298706552ff61c3019ca7e045f0f Mon Sep 17 00:00:00 2001 From: Josh Berman Date: Mon, 27 Jul 2026 10:27:09 +1000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20Stop=20the=20fade-in=20wrapper=20hi?= =?UTF-8?q?ding=20content=20that=20has=20already=20painted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useInView returns false before its IntersectionObserver first reports, and the effect wrote that false into state — so on hydration every block on the page flipped to opacity-0 and faded back over 300ms. Chrome will not accept an opacity-0 element as an LCP candidate, so the LCP element was disqualified until the fade finished. Replace it with a useFadeIn hook that stays "pending" until the observer actually reports, so a block can only ever go hidden -> visible. Co-Authored-By: Claude Opus 5 (1M context) --- components/layout/v2ComponentWrapper.tsx | 50 ++++++++++++++++++------ 1 file changed, 37 insertions(+), 13 deletions(-) diff --git a/components/layout/v2ComponentWrapper.tsx b/components/layout/v2ComponentWrapper.tsx index e7e6280e48..eb983d2121 100644 --- a/components/layout/v2ComponentWrapper.tsx +++ b/components/layout/v2ComponentWrapper.tsx @@ -1,10 +1,8 @@ "use client"; import classNames from "classnames"; -import { useInView } from "framer-motion"; import Image from "next/image"; -import { UseInViewOptions } from "framer-motion"; -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { backgroundOptions } from "../blocksSubtemplates/tinaFormElements/colourOptions/blockBackgroundOptions"; type BackgroundData = { @@ -18,6 +16,38 @@ type BackgroundData = { }; }; +// "pending" until the observer's first report, so a block never goes +// visible→hidden after paint — hiding painted content disqualifies it as an +// LCP candidate. +const useFadeIn = (rootMargin: string) => { + const ref = useRef(null); + const [state, setState] = useState<"pending" | "hidden" | "visible">( + "pending" + ); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setState("visible"); + observer.disconnect(); + } else { + setState((current) => (current === "pending" ? "hidden" : current)); + } + }, + { rootMargin } + ); + + observer.observe(element); + return () => observer.disconnect(); + }, [rootMargin]); + + return [ref, state] as const; +}; + const V2ComponentWrapper = ({ data, children, @@ -26,7 +56,7 @@ const V2ComponentWrapper = ({ }: { data: BackgroundData; children: React.ReactNode; - fadeInMargin?: UseInViewOptions["margin"]; + fadeInMargin?: string; className?: string; }) => { //Bleed effect setup @@ -47,13 +77,7 @@ const V2ComponentWrapper = ({ }; }, []); - //Fade-in effect setup - const ref = useRef(null); - const isInView = useInView(ref, { once: true, margin: fadeInMargin }); - useEffect(() => { - setIsInInitialViewport(isInView); - }, [isInView]); - const [isInInitialViewport, setIsInInitialViewport] = React.useState(null); + const [ref, fadeState] = useFadeIn(fadeInMargin); return (