Skip to content

🐛 Resolve mobile header appearance server-side to fix CLS - #4911

Open
isaaclombardssw wants to merge 3 commits into
mainfrom
perf/4906-header-appearance-ssr
Open

🐛 Resolve mobile header appearance server-side to fix CLS#4911
isaaclombardssw wants to merge 3 commits into
mainfrom
perf/4906-header-appearance-ssr

Conversation

@isaaclombardssw

@isaaclombardssw isaaclombardssw commented Jul 23, 2026

Copy link
Copy Markdown
Member

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 client useEffect (useMobileHeaderAppearance) that pushed the page's appearance into 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 both hideFlag and hideContactButton.

Fix

Render the final state server-side and let CSS do the hiding, so the first paint is already correct:

  • An opting-out page server-renders a hidden marker (HeaderAppearanceMarker) carrying data-mm-hide-flag / data-mm-hide-contact.
  • Unlayered CSS in styles.css reads it with body:has([data-mm-hide-flag]) … and hides the matching ssw.megamenu elements. No header-side JS.
  • MegaMenuWrapper now 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).
  • The client context/provider/hook are removed — CSS covers both SSR and client-side navigation (React swaps the marker in/out with the page).

Why CSS, not the middleware path the comment documented

header-appearance.tsx previously suggested an x-pathname middleware header + server-side resolution in the layout. I did not take that route: reading headers() in the root layout opts every route out of static generation, and directly conflicts with export 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.8 internals (the flag img[alt$="flag"], the mobile divider, the .gap-2.sm:flex-grow-0 phone-button wrapper). The version is pinned; there's a comment to revisit on upgrade. Cleaner long-term would be a stable data-* hook or className prop upstreamed into ssw.megamenu.

Expected impact

CLS on /events/ai-for-business-leaders should drop from 0.133 toward ~0 for the header contribution, since the flag/Contact button no longer appear-then-disappear.

Verified

  • pnpm lint passes (only the pre-existing, unrelated z-[2] tailwind warning).
  • Prettier clean on all changed files.
  • CSS diff kept minimal (existing lines left untouched).

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.
  • Re-run mobile Lighthouse on /events/ai-for-business-leaders to confirm CLS < 0.1.
  • Visually check mobile + desktop headers on: homepage, a consulting page, a normal event page (flag + Contact present), and /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

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
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Coverage report

St.
Category Percentage Covered / Total
🔴 Statements 0.18% 84/46744
🔴 Branches 5.05% 25/495
🔴 Functions 0.85% 4/473
🔴 Lines 0.18% 84/46744

Test suite run success

13 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
@isaaclombardssw

Copy link
Copy Markdown
Member Author

/deploy

@github-actions

Copy link
Copy Markdown
Contributor

Deployed changes to https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net

ℹ️ Staging slots are no longer created automatically - comment /deploy to deploy new commits.

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Lighthouse Report

🌐 URL ⚡ Performance ♿ Accessibility ✅ Best Practices 🔍 SEO 📦 Bundle Size 🗑️ Unused Bundle
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/ 41 100 59 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/articles 88 97 59 57 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/company/about-us 61 100 59 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/company/clients 88 95 59 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/company/contact-us 66 92 59 61 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/consulting 43 92 56 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/consulting/net-upgrade 67 99 56 61 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/consulting/web-applications 61 99 59 61 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/employment 63 95 59 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/events/angular-superpowers-tour 71 97 56 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/events/ai-workshop 95 91 56 61 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/events/ai-workshop-essentials 53 98 59 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/netug/sydney 72 94 56 61 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4911.azurewebsites.net/training/internship-fullstack 80 91 59 61 0.00 MB 0.00 MB

@isaaclombardssw

Copy link
Copy Markdown
Member Author

Measured: CLS on mobile

Staging slot (this branch) vs prod (main), /events/ai-for-business-leaders, mobile Lighthouse, median of 3 runs:

Metric Before (prod) After (this PR) Δ
CLS 0.133 0.008 -94%
Performance score 69 81 +12
LCP 3.7 s 2.2 s (see note)
TBT 618 ms 672 ms ~noise

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-leaders

Baseline = 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.

@isaaclombardssw
isaaclombardssw marked this pull request as ready for review July 23, 2026 05:50
Copilot AI review requested due to automatic review settings July 23, 2026 05:50

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.

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 a HeaderAppearanceMarker element rendered by pages that opt out.
  • Add global CSS rules that detect the marker and hide specific ssw.megamenu elements on first paint.
  • Simplify MegaMenuWrapper to always render the flag/Contact button (leaving only /company/contact-us phone hiding handled via hidePhone).

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.

Comment on lines 1 to 4
export type MobileHeaderAppearance = {
hideFlag?: boolean | null;
hideContactButton?: boolean | null;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

💄 Header - Mobile header flashes flag/Contact for one frame before hiding

2 participants