🐛 LCP - Fade-in wrapper hides server-painted text until hydration
@joshbermanssw is already working on this.
Since Jul 24, 2026.
Assessment
This issue has not been assessed yet.
Description
Sub-issue of #4894. Top of wave 1 — this is the measured cause of LCP 9.4 s, and it is independent of bundle size.
Supersedes the deferred half of #4898. That issue framed the useInView work as forced-reflow cleanup and required "the fade-in-on-scroll behaviour is visually unchanged"; both are wrong given the evidence below. #4898 auto-closed when #4914 merged, which only shipped its url(null) half.
The problem
components/layout/v2ComponentWrapper.tsx hides text that the server already painted, and reveals it only after hydration. LCP cannot fire until that reveal, so it is gated on downloading and executing the page's entire JS payload.
Measured on the deployed page (Lighthouse 13.4, mobile emulation):
| PSI (Moto G, Slow 4G) | Local run | |
|---|---|---|
| FCP | 2.8 s | 1,430 ms |
| LCP | 9.4 s | 6,145 ms |
| Gap | 6.5 s | 4,715 ms |
The LCP element is not an image — it is block 2's description paragraph:
selector: div.px-4 > div.flex > section.text-left > p.py-2
tina-field: eventsv2.blocks.2.description
That text is fully present in the server HTML (offset 244,344 of the deployed document) and its wrapper renders without opacity-0, so it paints at FCP. Lighthouse's LCP breakdown accounts for only 308 ms of the 6,145 ms (TTFB 149 + element render delay 159) — the remaining 5,836 ms is unattributed, because it is not load time at all.
The gap scaling with device speed (4.7 s locally, 6.5 s on a throttled Moto G) is what a hydration-gated reveal predicts.
Why it happens
The server cannot know which blocks are in the viewport — that depends on the device — so a JS fade-in-on-scroll has no correct starting state. The component tries to thread this with a three-state flag:
const [isInInitialViewport, setIsInInitialViewport] = useState(null);
const isInView = useInView(ref, { once: true, margin: "-100px" });
useEffect(() => { setIsInInitialViewport(isInView); }, [isInView]);
className={classNames("transition-opacity duration-300",
isInInitialViewport === false && "opacity-0",
!isInInitialViewport && isInView && "opacity-100")}
The intent is right: null = not checked (render visible), false = started off-screen (hide, then fade in), true = already on screen (never hide).
It fails on one detail. useInView returns false before the IntersectionObserver has reported, so "not yet measured" and "genuinely off-screen" are indistinguishable. On hydration the effect fires with isInView === false and writes false for every block — including on-screen ones — applying opacity-0 to already-painted text. A frame or two later the observer fires, state flips, and the text fades back over 300 ms.
Chrome discards opacity: 0 elements as LCP candidates, so LCP is recorded only on that fade-back-in.
This is not a sloppy implementation of a simple idea — it is a correct-looking implementation of something JavaScript cannot do at hydration time.
Proposed fix
Make the fade CSS-only, so it never depends on hydration and content is visible by default:
<section ref={ref} className="relative z-30 fade-in-on-scroll">
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: view()) {
.fade-in-on-scroll {
animation: fade-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
}
}
This also removes framer-motion from the critical path and drops the per-block getBoundingClientRect(), closing the rest of #4898.
Design decision required: above-the-fold blocks will no longer fade. That is deliberate — it is exactly the case that cannot be done correctly from the server — and nobody sees a fade on content already on screen. Browsers without animation-timeline (currently Firefox) show content immediately, which is also what they would get today if JS were slow.
Acceptance criteria
- No element receives
opacity-0after hydration if it was rendered visible by the server - Above-the-fold content is never hidden at any point in the page lifecycle
- LCP on
/events/ai-for-business-leadersfalls toward FCP rather than incrementally — if it does not, the diagnosis is wrong and should be re-traced before further work - Below-the-fold blocks still fade in on scroll where the browser supports it, and appear immediately where it does not
prefers-reduced-motionis respected- framer-motion's
useInViewand the per-blockgetBoundingClientRect()are gone fromV2ComponentWrapper - Bleed backgrounds,
redGlowandgridOverlaystill render correctly
Notes
- Affects every page built from v2/v3 blocks, not only this one — 39 wrapper instances on this page alone.
- #4912 (−1,141,595 B entry JS) and #4914 (−631 KiB per load) both help by shortening what hydration waits on, but neither removes this mechanism.
- Dominant language
- HTML
- Stars
- 14
- Forks
- 10
- Avg merge
- 13h 51m
- Merged PRs (30d)
- 38
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from SSWConsulting/SSW.Website
-
Needs Refinement YakShaver
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
SSWConsulting/SSW.Website#4883 ·
-
YakShaver YakShaver360
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
SSWConsulting/SSW.Website#4875 · 5 comments · 2 reactions ·
-
Needs Refinement YakShaver
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
SSWConsulting/SSW.Website#4829 · 1 comment · 1 reaction ·
-
Needs Refinement YakShaver
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
SSWConsulting/SSW.Website#4651 · 1 reaction ·
-
Needs Refinement YakShaver
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
SSWConsulting/SSW.Website#4417 ·