fix(layout): apply the sidebar viewport default after hydration

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-04 16:13:15 +02:00
1 parent fe61f34fca
commit 109866b5d0
2 files changed
+33 -25

No files matched your search

+25 -22
View File
@@ -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<boolean | null>(() => {
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
+8 -3
View File
@@ -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),