mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
fe61f34fca
commit
109866b5d0
2 files changed
+33
-25
No files matched your search
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user