fix(layout): persist resolved viewport tier in cookie to kill SSR flicker
All checks were successful
Build & Push Docker Images / lint (push) Successful in 2m49s
Build & Push Docker Images / build-and-push (push) Successful in 5m33s

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 14:33:36 +02:00
parent ee4d5c8610
commit e4fb425d05
2 changed files with 24 additions and 5 deletions

View File

@@ -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,

View File

@@ -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);