diff --git a/src/components/settings/__tests__/notification-status-card.test.tsx b/src/components/settings/__tests__/notification-status-card.test.tsx index bd46f631d..a472b5593 100644 --- a/src/components/settings/__tests__/notification-status-card.test.tsx +++ b/src/components/settings/__tests__/notification-status-card.test.tsx @@ -35,6 +35,14 @@ vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ isAuthenticated: true }), })); +// The card gates its auth/loading branches behind `useMounted()` so SSR and +// the first client paint render identically (`null`) and avoid a React #418 +// hydration mismatch. These tests assert the POST-hydration content tree, so +// pin the probe to `true` — the state the card reaches once hydrated. +vi.mock("@/hooks/use-mounted", () => ({ + useMounted: () => true, +})); + import { I18nProvider } from "@/lib/i18n/context"; import { NotificationStatusCard } from "../notification-status-card"; diff --git a/src/components/settings/notification-status-card.tsx b/src/components/settings/notification-status-card.tsx index bd3ebd9d2..f2b18631a 100644 --- a/src/components/settings/notification-status-card.tsx +++ b/src/components/settings/notification-status-card.tsx @@ -32,6 +32,7 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; @@ -65,6 +66,7 @@ const TEST_ENDPOINTS: Record = { }; export function NotificationStatusCard() { + const hydrated = useMounted(); const { isAuthenticated } = useAuth(); const { t } = useTranslations(); const fmt = useFormatters(); @@ -108,7 +110,14 @@ export function NotificationStatusCard() { }, }); - if (!isAuthenticated) return null; + // SSR and the first client paint must render the SAME tree shape. `useAuth` + // reads a client TanStack query, so `isAuthenticated` is `false` on the + // server (renders `null`) but `true` on the hydrating client (would render + // the loading card) — different shapes → React #418 hydration mismatch. + // Gate every auth/loading-dependent branch behind the post-mount probe so + // both passes render `null`; the real content lands as an ordinary + // post-hydration client update, not a hydration-time branch. + if (!hydrated || !isAuthenticated) return null; if (isLoading) { return ( diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 2318cad96..317f50a26 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -42,6 +42,7 @@ import { import { cn } from "@/lib/utils"; import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import type { ModuleKey } from "@/lib/modules/registry"; import { @@ -294,10 +295,6 @@ function navHighlightSlug(slug: SettingsSectionSlug): SettingsSectionSlug { return LAYOUT_CHILD_SLUGS.has(slug) ? "layout" : slug; } -// Stable no-op subscribe for the hydration probe below — the value never -// changes after the first commit, so there is nothing to subscribe to. -const subscribeNoop = () => () => {}; - function deriveActiveSlug( pathname: string | null, override?: SettingsSectionSlug, @@ -340,16 +337,12 @@ export function SettingsShell({ // download buttons). Gate the filter on a post-mount flag so SSR and the // first client render ALWAYS emit the same fail-open list; the real filter // applies once, after hydration, as an ordinary client update. - // `useSyncExternalStore` is the SSR-safe hydration probe: it returns the - // server snapshot (`false`) on the server render AND the first client paint, - // then the client snapshot (`true`) after hydration commits. No setState in - // an effect (which the lint rule flags as a cascading render), and no risk - // of the two passes disagreeing. - const hydrated = React.useSyncExternalStore( - subscribeNoop, - () => true, - () => false, - ); + // `useMounted()` is the SSR-safe hydration probe: it returns the server + // snapshot (`false`) on the server render AND the first client paint, then + // the client snapshot (`true`) after hydration commits. No setState in an + // effect (which the lint rule flags as a cascading render), and no risk of + // the two passes disagreeing. + const hydrated = useMounted(); const modules = user?.modules; const visibleSections = SETTINGS_SECTIONS.filter( (section) =>