mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(settings): stop the integrations notification card hydrating from null
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.
This commit is contained in:
1 parent
d0b4b1e4cd
commit
a31711699c
3 files changed
+25
-15
No files matched your search
@@ -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";
|
||||
|
||||
|
||||
@@ -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<ChannelType, string> = {
|
||||
};
|
||||
|
||||
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 (
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in new issue
Block a user