From 3d75db917e3c18d6a023a1dfb9ed1ac050e4ba1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 30 Jun 2026 09:49:09 +0200 Subject: [PATCH] fix(settings): keep the section nav hydration-stable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings section navigation filtered its entries by the resolved `user.modules` map directly in render. That map resolves at different points on the server-rendered pass and the hydrating client pass, so the two renders emitted a different number of nav entries — a hydration mismatch that made React discard and regenerate the tree, leaving every control on the page non-interactive (a disclosure that wouldn't open, an export button that did nothing). Emit the full fail-open list on both the server pass and the first client paint (gated behind a `useSyncExternalStore` hydration probe), then apply the module filter once, after hydration. Server and first client render now always match, so the nav — and the rest of the settings page — stays interactive. --- CHANGELOG.md | 4 +++ .../__tests__/settings-shell.test.tsx | 26 +++++++++++----- src/components/settings/settings-shell.tsx | 31 ++++++++++++++++++- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f0a0734a..4f5e445c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - **Custom metrics.** Define your own measurement — a name, a unit, optional target range, decimal places, and a short description — then log values against it and chart the trend over time, right alongside the built-in measurements. Useful for anything the standard catalog doesn't cover (a symptom score you track, a device reading, a habit count). Each definition and its values are private to your account; they are a deliberately separate, log-and-chart store, so they are not synced to Withings or Apple Health, not exported to FHIR, and not used by AI Insights. +### Fixed + +- The settings pages could intermittently render with non-interactive controls (a disclosure that wouldn't open, an export button that did nothing) when the section navigation resolved a different set of entries on the server than on the client. The navigation now renders identically on both passes and applies its module-based filtering once after load, so every settings control stays responsive. + ## [1.25.8] — 2026-06-29 — Carrier in the sign-in overview A focused configuration release. No schema change. diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index a65e9a258..a4bb432a7 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -248,17 +248,27 @@ describe("", () => { ); }); - it("module-gates the Coach entry off `user.modules.coach` (v1.18.0 S5)", () => { - // Fail-open default: coach undefined → entry shown. + it("surfaces the Coach entry on the fail-open default (v1.18.0 S5)", () => { + // coach undefined → entry shown. expect(renderShell({ active: "account" })).toContain( 'href="/settings/coach"', ); - // Explicitly disabled → entry hidden. - const disabled = renderShell({ - active: "account", - modules: { coach: false }, - }); - expect(disabled).not.toContain('href="/settings/coach"'); + }); + + it("renders the full fail-open nav on the server pass, even for a disabled module (v1.25.9)", () => { + // v1.25.9 — the module filter must NOT run during SSR / the first client + // paint. `useAuth().user.modules` resolves at different times on the + // server-rendered pass and the hydrating client pass, so filtering in the + // SSR markup produced a different nav `
  • ` count than the client's first + // render — a React #418 hydration mismatch that regenerated the whole tree + // and left every settings control non-interactive (dead disclosure / + // download buttons). The shell now emits the full fail-open list on both + // the server pass and the first client paint, and applies the module + // filter once, after mount. So a disabled module's entry is STILL present + // in the SSR markup here; its client-side removal after hydration is + // covered by the e2e suite (settings pages must stay interactive). + const ssr = renderShell({ active: "account", modules: { coach: false } }); + expect(ssr).toContain('href="/settings/coach"'); }); it("always surfaces the Health-record entry, regardless of `doctorReport` (v1.18.6.1)", () => { diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index b4a73e6f8..2318cad96 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -294,6 +294,10 @@ 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, @@ -324,9 +328,34 @@ export function SettingsShell({ // (`!== false`): a missing key, or a not-yet-resolved `/me` payload, // reads as enabled so an entry never silently disappears. Entries with // no `moduleGate` (global / CORE) are always shown. + // + // v1.25.9 — the module filter must NOT run during SSR / the first client + // paint. `useAuth().user` resolves from the `/api/auth/me` query, which is + // populated on the server-rendered pass and the hydrating client pass at + // DIFFERENT times (a cache race): the server may render the full list while + // the client hydrates with `modules` already resolved (or vice versa), + // producing a different number of nav `
  • `s and a React #418 hydration + // mismatch. On a mismatch React discards and regenerates the whole tree, + // which dropped the event handlers on every settings page (dead toggles / + // 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, + ); const modules = user?.modules; const visibleSections = SETTINGS_SECTIONS.filter( - (section) => !section.moduleGate || modules?.[section.moduleGate] !== false, + (section) => + !hydrated || + !section.moduleGate || + modules?.[section.moduleGate] !== false, ); const activeSection = visibleSections.find( (section) => section.slug === activeSlug,