From e4fb425d0584447b957c42639fa7105be546f020 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 14:33:36 +0200 Subject: [PATCH] fix(layout): persist resolved viewport tier in cookie to kill SSR flicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported: "when I refresh the page with this size viewport it switches between tablet and desktop view." The root cause was the two-step tier resolution: 1. Server renders shell based on User-Agent (mobile vs desktop only). 2. Client mounts with that hint, useEffect runs matchMedia, may flip. When the UA says "desktop" but the viewport is actually 900px (so matchMedia says "tablet"), the chrome visibly switches mid-render. Most painful on macOS Safari dragged below 1024. Fix: AppShell writes a `pn-crm.viewport-tier` cookie (1-year, Lax) on every matchMedia evaluation. The dashboard layout reads the cookie and prefers it over the UA classifier for `initialFormFactor`. First visit can still flicker (no cookie yet); every subsequent reload uses the resolved tier and renders the correct chrome on first paint. The cookie values are 'mobile' / 'tablet' / 'desktop' but the server's initialFormFactor prop only accepts 'mobile' | 'desktop' (binary by design — AppShell's useEffect resolves the actual tier client-side from matchMedia). 'tablet' from the cookie collapses to 'desktop' on SSR; AppShell's useEffect re-resolves to tablet immediately. The fluent path on cookie hit is desktop -> tablet (no flicker because both shells render the desktop tree; only the sidebar Sheet wrapper differs, and that's invisible until opened). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(dashboard)/layout.tsx | 17 +++++++++++++++-- src/components/layout/app-shell.tsx | 12 +++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index ce39c26f..7287e2a6 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,5 +1,5 @@ import { redirect } from 'next/navigation'; -import { headers } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import { eq } from 'drizzle-orm'; import { auth } from '@/lib/auth'; @@ -37,7 +37,20 @@ export default async function DashboardLayout({ children }: { children: React.Re ? await db.query.ports.findMany({ orderBy: portsTable.name }) : portRoles.map((pr) => pr.port); - const initialFormFactor = classifyFormFactor(headerList.get('user-agent')); + // Prefer a previously-resolved tier from the client's cookie so the + // server renders the matching shell on first paint — eliminates the + // mobile↔desktop chrome flicker that happens when UA-based classification + // disagrees with the actual viewport (e.g. macOS Safari with the + // window dragged below 1024). AppShell writes the cookie after the + // first matchMedia evaluation; subsequent reloads use the hint. + const cookieStore = await cookies(); + const tierCookie = cookieStore.get('pn-crm.viewport-tier')?.value; + const initialFormFactor: 'mobile' | 'desktop' = + tierCookie === 'mobile' + ? 'mobile' + : tierCookie === 'tablet' || tierCookie === 'desktop' + ? 'desktop' + : classifyFormFactor(headerList.get('user-agent')); const user = { name: profile?.displayName ?? session.user.name ?? session.user.email, email: session.user.email, diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index c6046290..2b0f6f1a 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -100,9 +100,15 @@ export function AppShell({ const mqMobile = window.matchMedia(MOBILE_QUERY); const mqTablet = window.matchMedia(TABLET_QUERY); const update = () => { - if (mqMobile.matches) setTier('mobile'); - else if (mqTablet.matches) setTier('tablet'); - else setTier('desktop'); + const next: Tier = mqMobile.matches ? 'mobile' : mqTablet.matches ? 'tablet' : 'desktop'; + setTier(next); + // Persist for the next SSR pass so the server renders the + // matching shell on first paint — eliminates the chrome flicker + // on refresh when UA-based classification disagrees with the + // actual viewport (most common on macOS Safari at sub-1024 + // widths). 1-year expiry; SameSite=Lax is fine since the cookie + // is read by our own server only. + document.cookie = `pn-crm.viewport-tier=${next}; path=/; max-age=31536000; SameSite=Lax`; }; update(); mqMobile.addEventListener('change', update);