Skip to content

⚡ Stop the fade-in wrapper hiding content that has already painted - #4934

Draft
joshbermanssw wants to merge 1 commit into
mainfrom
perf/fade-in-lcp-flash
Draft

⚡ Stop the fade-in wrapper hiding content that has already painted#4934
joshbermanssw wants to merge 1 commit into
mainfrom
perf/fade-in-lcp-flash

Conversation

@joshbermanssw

Copy link
Copy Markdown
Member

TL;DR

On hydration every V2ComponentWrapper flipped to opacity-0 and faded back over 300ms — 39 of them on /events/ai-for-business-leaders, including the one wrapping the LCP element. Chrome will not accept an opacity: 0 element as an LCP candidate, so the LCP element stayed disqualified until the fade finished.

Why

useInView returns false on the first client render, before its IntersectionObserver has reported. The old effect wrote that false straight into state, which retroactively hid content that had already painted from SSR.

PageSpeed on that page shows FCP 1.3s against LCP 2.9s, with Speed Index 1.7s and CLS 0 — the viewport is visually settled more than a second before Chrome records the LCP, which is the signature of an element that is on screen but ineligible.

How

useFadeIn replaces the useInView + effect + useState trio with a three-state machine. A block stays "pending" (no opacity class) until the observer's first report, so it can only ever go hidden → visible, never visible → hidden.

Reviewer notes

  • Above-the-fold blocks never hide. They stay "pending" and get no opacity class at all, which is the fix.
  • Below-the-fold blocks still fade. The observer reports them off-screen, they go "hidden", and fade in on scroll. The user cannot see that transition and it cannot affect LCP.
  • Opacity, not display. Deliberately kept — display: none would disqualify the LCP element just the same, and would drop blocks out of layout and break the current CLS of 0.
  • framer-motion dropped from this component. Negative rootMargin on a raw IntersectionObserver matches what its margin option did. fadeInMargin is retyped string; no caller passes it, so every block keeps the -100px default.

Verify

LCP is the metric to watch, not the overall score. Single PageSpeed runs on this page fluctuate heavily — .lighthouserc.json is still "numberOfRuns": 1 on the desktop preset, so compare medians over ~5 mobile runs before and after.

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) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 27, 2026 00:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

No linked issues found. Please add the corresponding issues in the pull request description.
Use GitHub automation to close the issue when a PR is merged

@joshbermanssw joshbermanssw added the no-issue This label is used to bypass the PR-Lint check. Should be used for non-coding changes label Jul 27, 2026
@joshbermanssw
joshbermanssw marked this pull request as draft July 27, 2026 00:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-issue This label is used to bypass the PR-Lint check. Should be used for non-coding changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants