🐛 Resolve mobile header appearance server-side to fix CLS - #4911
🐛 Resolve mobile header appearance server-side to fix CLS#4911isaaclombardssw wants to merge 3 commits into
Conversation
The header renders in the root layout, which has no access to the current page's data, so hiding the mobile country flag / Contact button ran in a client effect after hydration — reflowing the header and everything below it (CLS 0.133 on /events/ai-for-business-leaders, which hides both). Replace the post-hydration client context with a server-rendered marker: an opting-out page now emits a hidden [data-mm-hide-flag]/[data-mm-hide-contact] element, and unlayered CSS in styles.css hides the matching ssw.megamenu elements via :has() on first paint. SSR and hydration now match, and client-side navigation swaps the marker with the page so the header stays correct with no header-side JS. Chose CSS over the middleware x-pathname path the code comment documented: reading headers() in the root layout would opt every route out of static generation and conflicts with `dynamic = "force-static"` on the events route. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8genJyZboXaa4vgYhJF4P
Coverage report
Test suite run success13 tests passing in 1 suite. Report generated by 🧪jest coverage report action from ae6c251 |
The mobile-header CLS fix relies on body:has(...) selectors. browserslist lists firefox >= 111 but :has() only shipped in Firefox 121; on 111-120 the opt-out simply no-ops (flag/Contact stay visible, no layout shift). Note the graceful degradation in the CSS comment so the trade-off is explicit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q8genJyZboXaa4vgYhJF4P
|
/deploy |
|
Deployed changes to https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net ℹ️ Staging slots are no longer created automatically - comment |
Measured: CLS on mobileStaging slot (this branch) vs prod (main),
CLS is the clean, attributable win: the header no longer strips the flag/Contact button after hydration, so the content doesn't shift. 0.008 is comfortably under the 0.1 "good" threshold (AC #3). The perf-score bump mostly follows the CLS/LCP change; TBT/LCP swings are partly run-to-run noise and slot-vs-prod variance, so I'm anchoring on CLS. Repro: npx lighthouse@12 https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/events/ai-for-business-leaders \
--form-factor=mobile --screenEmulation.mobile --only-categories=performance \
--quiet --chrome-flags="--headless=new" --output=json --output-path=after.json
# baseline: same command against https://www.ssw.com.au/events/ai-for-business-leadersBaseline = prod (main). This branch is a few commits off main, so it isn't a fully isolated single-variable build, but CLS on this page is driven by the header change. |
There was a problem hiding this comment.
Pull request overview
This PR fixes mobile header-related CLS by removing the post-hydration React-context approach for hiding the flag/Contact button and replacing it with a server-rendered marker plus CSS :has() selectors, so the initial paint matches the final header layout.
Changes:
- Replace the client
HeaderAppearanceProvider/hooks with aHeaderAppearanceMarkerelement rendered by pages that opt out. - Add global CSS rules that detect the marker and hide specific
ssw.megamenuelements on first paint. - Simplify
MegaMenuWrapperto always render the flag/Contact button (leaving only/company/contact-usphone hiding handled viahidePhone).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| styles.css | Adds unlayered :has()-based rules to hide megamenu internals when a marker is present. |
| components/server/MegaMenuWrapper.tsx | Removes header-appearance context usage and always renders flag/Contact; keeps contact-us phone hiding. |
| app/components/page-layout.tsx | Removes the HeaderAppearanceProvider wrapper. |
| app/components/header-appearance.tsx | Replaces context/hooks with a marker component and documents the SSR/CSS approach. |
| app/(events)/events/[...filename]/eventsv2.tsx | Renders the marker based on per-page appearance data instead of running a hydration-time hook. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| export type MobileHeaderAppearance = { | ||
| hideFlag?: boolean | null; | ||
| hideContactButton?: boolean | null; | ||
| }; |
Closes #4906 (parent PBI #4894, item 12).
Root cause
The header (
MegaMenuWrapper) renders in the root layout, which has no access to the current page's data. So the per-page decision to hide the mobile country flag / Contact button ran in a clientuseEffect(useMobileHeaderAppearance) that pushed the page'sappearanceinto React context after hydration. The header then removed those elements, reflowing itself and everything below it — CLS 0.133 on/events/ai-for-business-leaders, which sets bothhideFlagandhideContactButton.Fix
Render the final state server-side and let CSS do the hiding, so the first paint is already correct:
HeaderAppearanceMarker) carryingdata-mm-hide-flag/data-mm-hide-contact.styles.cssreads it withbody:has([data-mm-hide-flag]) …and hides the matchingssw.megamenuelements. No header-side JS.MegaMenuWrappernow always renders the flag/Contact button (it only still hides the phone on/company/contact-us, which it already knows at SSR from the pathname).Why CSS, not the middleware path the comment documented
header-appearance.tsxpreviously suggested anx-pathnamemiddleware header + server-side resolution in the layout. I did not take that route: readingheaders()in the root layout opts every route out of static generation, and directly conflicts withexport const dynamic = "force-static"on the events route — it would break the static build for a heavily-cached marketing site. The CSS approach keeps every route static and adds zero runtime cost.Trade-off to be aware of: the CSS selectors target
ssw.megamenu@4.13.8internals (the flagimg[alt$="flag"], the mobile divider, the.gap-2.sm:flex-grow-0phone-button wrapper). The version is pinned; there's a comment to revisit on upgrade. Cleaner long-term would be a stabledata-*hook or className prop upstreamed intossw.megamenu.Expected impact
CLS on
/events/ai-for-business-leadersshould drop from 0.133 toward ~0 for the header contribution, since the flag/Contact button no longer appear-then-disappear.Verified
pnpm lintpasses (only the pre-existing, unrelatedz-[2]tailwind warning).Still needs manual verification
pnpm build/ typecheck could not be run here — Tina codegen requires TinaCloud creds (clientId/token/branch), so the generated client isn't present. Please run a full build in CI/locally with creds./events/ai-for-business-leadersto confirm CLS < 0.1./events/ai-for-business-leaders(both hidden, no post-hydration shift). Confirm client-side nav between a hiding page and a non-hiding page updates the header correctly, and no hydration-mismatch warnings.🤖 Generated with Claude Code
https://claude.ai/code/session_01Q8genJyZboXaa4vgYhJF4P