From 109866b5d073306902b0d8b92dd7256b665f5c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 4 Jul 2026 16:13:15 +0200 Subject: [PATCH] fix(layout): apply the sidebar viewport default after hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reading matchMedia (or localStorage) inside the initial render branches the hydration render away from the SSR markup — the sidebar is only CSS-hidden below md and still hydrates its DOM there, so the phone viewport tripped React #418 in the dashboard console guard. The collapsed state now gates on the mounted probe like the module filter above it: SSR and hydration paint the expanded shell, the stored pref or tablet default applies right after. useIsMobile learns the lg cut for the tablet probe. --- src/components/layout/sidebar-nav.tsx | 47 ++++++++++++++------------- src/hooks/use-is-mobile.ts | 11 +++++-- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 4abb16fbf..e28186030 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -25,6 +25,7 @@ import { import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; +import { useIsMobile } from "@/hooks/use-is-mobile"; import { useMounted } from "@/hooks/use-mounted"; import { useTheme } from "@/components/providers"; import { useTranslations } from "@/lib/i18n/context"; @@ -252,35 +253,37 @@ export function SidebarNav() { () => visibleNavDestinations(user?.modules, mounted), [user?.modules, mounted], ); - const [collapsed, setCollapsed] = useState(() => { - if (typeof window === "undefined") return false; + // Collapsed = the user's stored choice, or — with no stored choice — a + // viewport default: tablet widths (md–lg, e.g. an iPad held upright) + // fall back to the icon rail so the content column keeps its room; a + // 256 px sidebar squeezes a 768 px viewport down to a 512 px column. + // Everything is gated on `mounted` (same pattern as the module filter + // above): SSR and the hydration render both paint the expanded shell, + // the stored pref / viewport default applies on the first client render + // after hydration settles. Branching earlier — localStorage or + // matchMedia inside the initial render — is a React #418 hydration + // mismatch, because the sidebar is only CSS-hidden below `md` and still + // hydrates its DOM there. + const tabletOrBelow = useIsMobile("lg"); + const [collapsedPref, setCollapsedPref] = useState(() => { + if (typeof window === "undefined") return null; try { const stored = localStorage.getItem(STORAGE_KEY); - if (stored !== null) return stored === "true"; + return stored === null ? null : stored === "true"; } catch { - return false; - } - // No stored preference: tablet widths (md–lg, e.g. an iPad held - // upright) start on the icon rail so the content column keeps its - // room — a 256 px sidebar squeezes a 768 px viewport down to 512 px. - // A manual toggle persists and wins on every later visit. - try { - return window.matchMedia("(max-width: 1023.98px)").matches; - } catch { - return false; + return null; } }); + const collapsed = mounted ? (collapsedPref ?? tabletOrBelow) : false; function toggleCollapsed() { - setCollapsed((prev) => { - const next = !prev; - try { - localStorage.setItem(STORAGE_KEY, String(next)); - } catch { - // Ignore storage errors - } - return next; - }); + const next = !collapsed; + setCollapsedPref(next); + try { + localStorage.setItem(STORAGE_KEY, String(next)); + } catch { + // Ignore storage errors + } } // v1.17.1 (F-1 residue) — the sidebar footer utility links derive from diff --git a/src/hooks/use-is-mobile.ts b/src/hooks/use-is-mobile.ts index ee3445dd3..0142cbb14 100644 --- a/src/hooks/use-is-mobile.ts +++ b/src/hooks/use-is-mobile.ts @@ -36,8 +36,13 @@ import { useSyncExternalStore } from "react"; * need a tighter cut (e.g. the Coach drawer, which flips to a * bottom-sheet only below `sm` / 640 px) pass an explicit breakpoint. */ -function getMediaQuery(breakpoint: "sm" | "md"): string { - const maxWidth = breakpoint === "sm" ? "639.98px" : "767.98px"; +function getMediaQuery(breakpoint: "sm" | "md" | "lg"): string { + const maxWidth = + breakpoint === "sm" + ? "639.98px" + : breakpoint === "md" + ? "767.98px" + : "1023.98px"; return `(max-width: ${maxWidth})`; } @@ -59,7 +64,7 @@ function getServerSnapshot(): boolean { return false; } -export function useIsMobile(breakpoint: "sm" | "md" = "md"): boolean { +export function useIsMobile(breakpoint: "sm" | "md" | "lg" = "md"): boolean { const query = getMediaQuery(breakpoint); return useSyncExternalStore( subscribe(query),