fix(settings): keep the section nav hydration-stable

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-30 09:49:09 +02:00
1 parent c306d42347
commit 3d75db917e
3 files changed
+52 -9

No files matched your search

+4
View File
@@ -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.
@@ -248,17 +248,27 @@ describe("<SettingsShell>", () => {
);
});
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 `<li>` 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)", () => {
+30 -1
View File
@@ -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 `<li>`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,