From a31711699ce378e4062a2c8b2e29e8a5265ce6dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 30 Jun 2026 13:02:07 +0200 Subject: [PATCH] fix(settings): stop the integrations notification card hydrating from null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NotificationStatusCard read useAuth().isAuthenticated, which is false during SSR and true on the client, so the card rendered null on the server and a loading card on first client paint — a tree-shape change React reported as a hydration mismatch on every authenticated load of the integrations page, regenerating the subtree and dropping the channel controls' handlers. Gate the card behind the shared useMounted() hydration probe so the server and the first client paint agree (both render null); the real status loads as an ordinary post-hydration update. Fold the settings shell's own inline copy of the probe onto the same hook. --- .../notification-status-card.test.tsx | 8 +++++++ .../settings/notification-status-card.tsx | 11 +++++++++- src/components/settings/settings-shell.tsx | 21 +++++++------------ 3 files changed, 25 insertions(+), 15 deletions(-) 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) =>