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 ( -
+