mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
1 parent
c306d42347
commit
3d75db917e
3 files changed
+52
-9
No files matched your search
@@ -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)", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user