From 202e0b1bc5714c3aa0405271bad6295603025c7c Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 23:59:30 +0200 Subject: [PATCH] refactor(layout): single-tree responsive shell (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fix the dashboard layout mounted BOTH the desktop and mobile shells to the DOM on every page, hidden via CSS data-shell rules. Two Tabs providers had data-state="active" concurrently, every fetch fired twice, every component piece of state lived in two trees, a11y landmarks duplicated, and half the click attempts hit the wrong layer. New client wrapper mounts exactly ONE tree based on the server-classified User-Agent (no hydration mismatch, no first-paint flash on real mobile devices) plus a runtime matchMedia subscription that swaps shells when the viewport crosses 1024px (e.g. desktop browser resized). Knock-on changes: - Dashboard layout fetches once and hands the data to AppShell; AppShell picks Desktop (Sidebar + Topbar + main) or MobileLayout - Stripped the now-orphan data-shell CSS rules from globals.css — nothing emits the attribute any more - MobileLayout drops its data-shell="mobile" attribute (was the lever the dead CSS rules pulled) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/(dashboard)/layout.tsx | 53 ++++++-------- src/app/globals.css | 33 ++------- src/components/layout/app-shell.tsx | 71 +++++++++++++++++++ .../layout/mobile/mobile-layout.tsx | 10 +-- 4 files changed, 103 insertions(+), 64 deletions(-) create mode 100644 src/components/layout/app-shell.tsx diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 22261b37..b0eb9992 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -10,14 +10,14 @@ import { QueryProvider } from '@/providers/query-provider'; import { SocketProvider } from '@/providers/socket-provider'; import { PortProvider } from '@/providers/port-provider'; import { PermissionsProvider } from '@/providers/permissions-provider'; -import { Sidebar } from '@/components/layout/sidebar'; -import { Topbar } from '@/components/layout/topbar'; -import { MobileLayout } from '@/components/layout/mobile/mobile-layout'; +import { AppShell } from '@/components/layout/app-shell'; import { RealtimeToasts } from '@/components/shared/realtime-toasts'; import { WebVitalsReporter } from '@/components/shared/web-vitals-reporter'; +import { classifyFormFactor } from '@/lib/form-factor'; export default async function DashboardLayout({ children }: { children: React.ReactNode }) { - const session = await auth.api.getSession({ headers: await headers() }); + const headerList = await headers(); + const session = await auth.api.getSession({ headers: headerList }); if (!session?.user) redirect('/login'); // Super admins have implicit access to every port; everyone else only sees @@ -35,6 +35,12 @@ 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')); + const user = { + name: profile?.displayName ?? session.user.name ?? session.user.email, + email: session.user.email, + }; + return ( @@ -42,33 +48,18 @@ export default async function DashboardLayout({ children }: { children: React.Re - {/* Desktop shell - hidden by CSS on mobile */} -
- -
- -
- {children} -
-
-
- - {/* Mobile shell - hidden by CSS on desktop */} - {children} + {/* #26: AppShell mounts ONE responsive tree (desktop OR + * mobile) per render — never both — so pages don't pay the + * double-state, double-fetch, double-Tabs-provider tax. */} + + {children} +
diff --git a/src/app/globals.css b/src/app/globals.css index 217ca564..aea66b90 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -319,35 +319,12 @@ } } -/* ─── Form-factor shell visibility ────────────────────────────────────────── - * Two shells (desktop + mobile) render to the DOM on every page; CSS hides - * the inactive one. The data-form-factor body attribute is set server-side - * from User-Agent (see src/lib/form-factor.ts). The media-query fallback - * handles desktop browsers resized below lg (1024px), or stripped UAs. - * - * IMPORTANT: only `display: none` rules are emitted - we never set a positive - * display, because the desktop shell uses Tailwind's `flex` class which would - * be overridden by `display: block` (same specificity, later cascade). +/* #26: dual-shell CSS visibility rules removed. AppShell now mounts a + * single responsive tree based on the server-classified form-factor + + * runtime matchMedia, so there is no inactive shell in the DOM to hide. + * The data-form-factor body attribute is still set on the root layout + * (downstream styles + analytics use it). */ -[data-shell='mobile'] { - display: none; -} - -@media (max-width: 1023.98px) { - [data-shell='desktop'] { - display: none; - } - [data-shell='mobile'] { - display: block; - } -} - -body[data-form-factor='mobile'] [data-shell='desktop'] { - display: none; -} -body[data-form-factor='mobile'] [data-shell='mobile'] { - display: block; -} /* * React Query Devtools floating button collides with the bottom tab bar's diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx new file mode 100644 index 00000000..fb7fe8dc --- /dev/null +++ b/src/components/layout/app-shell.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useEffect, useState, type ComponentProps, type ReactNode } from 'react'; + +import { Sidebar } from '@/components/layout/sidebar'; +import { Topbar } from '@/components/layout/topbar'; +import { MobileLayout } from '@/components/layout/mobile/mobile-layout'; + +type SidebarProps = ComponentProps; +type TopbarProps = ComponentProps; + +interface AppShellProps { + portRoles: SidebarProps['portRoles']; + isSuperAdmin: boolean; + user: NonNullable; + ports: TopbarProps['ports']; + /** + * Server-rendered form-factor hint (from the request User-Agent). The + * shell mounts the matching tree on first render so we never paint the + * wrong shell, and only switches if the runtime viewport matchMedia + * disagrees (e.g. desktop browser resized below lg). + */ + initialFormFactor: 'mobile' | 'desktop'; + children: ReactNode; +} + +const MOBILE_QUERY = '(max-width: 1023.98px)'; + +/** + * #26: single-tree responsive shell. Pre-fix the layout mounted BOTH + * desktop and mobile shells in the DOM and CSS-hid one — doubling React + * state, fetches, Tabs providers, and a11y landmarks. AppShell decides + * once per render which tree to mount, so a page only ever runs the + * effects + queries it actually displays. + * + * SSR safety: the server passes its UA-classified hint via `initialFormFactor`; + * the first client render uses the same value so hydration matches. After + * mount, a matchMedia subscription overrides if the viewport disagrees. + */ +export function AppShell({ + portRoles, + isSuperAdmin, + user, + ports, + initialFormFactor, + children, +}: AppShellProps) { + const [isMobile, setIsMobile] = useState(initialFormFactor === 'mobile'); + + useEffect(() => { + const mq = window.matchMedia(MOBILE_QUERY); + const update = () => setIsMobile(mq.matches); + update(); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, []); + + if (isMobile) { + return {children}; + } + + return ( +
+ +
+ +
{children}
+
+
+ ); +} diff --git a/src/components/layout/mobile/mobile-layout.tsx b/src/components/layout/mobile/mobile-layout.tsx index ee0c4dfc..a73452ad 100644 --- a/src/components/layout/mobile/mobile-layout.tsx +++ b/src/components/layout/mobile/mobile-layout.tsx @@ -11,17 +11,17 @@ import { MobileSearchOverlay } from '@/components/search/mobile-search-overlay'; /** * Mobile shell: fixed compact topbar + scrollable content + fixed bottom tab - * bar. Renders only when CSS reveals it (data-shell="mobile") - both shells - * are in the DOM, see src/app/globals.css. The bottom tabs and More sheet - * derive the active port slug from the URL themselves, so this layout takes - * no portSlug prop. + * bar. Mounted by AppShell when the viewport classifies as mobile — never + * concurrent with the desktop tree. The bottom tabs and More sheet derive + * the active port slug from the URL themselves, so this layout takes no + * portSlug prop. */ export function MobileLayout({ children }: { children: ReactNode }) { const [moreOpen, setMoreOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); return ( -
+