🐛 LCP - Fade-in wrapper hides server-painted text until hydration

Open
#4,924 0 comments 0 reactions 1 assignee View on GitHub

@joshbermanssw is already working on this.

Since Jul 24, 2026.

Assessment

This issue has not been assessed yet.

Description

Type: Bug

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

  1. No element receives opacity-0 after hydration if it was rendered visible by the server
  2. Above-the-fold content is never hidden at any point in the page lifecycle
  3. LCP on /events/ai-for-business-leaders falls toward FCP rather than incrementally — if it does not, the diagnosis is wrong and should be re-traced before further work
  4. Below-the-fold blocks still fade in on scroll where the browser supports it, and appear immediately where it does not
  5. prefers-reduced-motion is respected
  6. framer-motion's useInView and the per-block getBoundingClientRect() are gone from V2ComponentWrapper
  7. Bleed backgrounds, redGlow and gridOverlay still 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from SSWConsulting/SSW.Website

All issues in SSWConsulting/SSW.Website

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.