Files
HealthLog/src/components/settings/record-settings-section-gate.tsx
T
Marc-André Bombeck 057fd3b49c perf(bundle): keep Zod off the first load of four eager routes
zod 4.6 grew the shared Zod chunk from 63 to 89 KB gz (the new schema
compiler and its memoizer ride along because every schema goes through
the `z` namespace), and the dashboard, measurements, mood and settings
routes each carried it for values that need no validation.

Move the measurement source list, the notes limit, the screening check
and the Coach source list into value-only modules the schemas are built
from, import the settings classifier and response check from their
leaves, and load the gate's managed settings and anamnesis sections on
demand. Re-base the four route baselines to the measured values and the
aggregate cap to 4975, with the reason in the budget comment.
2026-10-16 06:58:48 +02:00

146 lines
5.7 KiB
TypeScript

"use client";
import type { ComponentType, ReactNode } from "react";
import dynamic from "next/dynamic";
import { CircleSlash, Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { classifySettingsDestination } from "@/lib/record-settings/classification";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ManagedIntegrationStatus } from "@/components/settings/managed-integration-status";
import { SettingsCard } from "@/components/settings/settings-card";
import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
// The two record-scoped surfaces load on demand. Both are forms that validate
// with Zod (the managed settings patch, the allergy and family-history
// entries), and every settings page renders this gate, so importing them
// statically put Zod into the first load of every section for a branch that
// only a shared or managed record ever reaches. The classifier is imported
// from its leaf for the same reason: the `@/lib/record-settings` barrel also
// re-exports the settings-patch schemas.
const ManagedRecordSettingsSection = dynamic(() =>
import("@/components/settings/managed-record-settings-section").then(
(m) => m.ManagedRecordSettingsSection,
),
);
const RecordAnamnesisSection = dynamic(() =>
import("@/components/settings/record-anamnesis-section").then(
(m) => m.RecordAnamnesisSection,
),
);
/**
* The record-CONTENT destinations, and what each one renders inside a record.
*
* Keyed by slug so the branch below asks which page it is on rather than which
* category the page belongs to. Every `manage-writable` slug must have an
* entry and nothing else may: the contract test holds both directions, which
* is what stops a new destination inheriting this one's forms.
*/
export const RECORD_CONTENT_SECTIONS: Partial<
Record<SettingsSectionSlug, ComponentType>
> = {
anamnesis: RecordAnamnesisSection,
};
const MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION = {
account: "profile",
modules: "modules",
notifications: "notifications",
thresholds: "thresholds",
coach: "coach",
} as const;
/**
* Stops actor-scoped Settings components from mounting while the browser is
* switched into another record. The only managed integration surface is its
* status projection; all other direct links resolve to a local refusal state.
*/
export function RecordSettingsSectionGate({
section,
children,
}: {
section: SettingsSectionSlug;
children: ReactNode;
}) {
const { isLoading } = useAuth();
const { inSharedRecord, recordKind, level } = useRecordCapabilities();
const { t } = useTranslations();
// A shared session must never begin by mounting the actor's Settings
// component and withdraw it when `/api/auth/me` resolves. The shell holds
// Settings during auth resolution too; this branch keeps direct route use
// equally safe when the gate is rendered independently in a test or story.
if (isLoading) {
return (
<SettingsCard aria-busy="true" role="status">
<SettingsCardHeader icon={Loader2} title={t("nav.loadingScreen")} />
</SettingsCard>
);
}
if (!inSharedRecord) return children;
const destination = classifySettingsDestination(section);
// v1.37.0 — the one destination that is record CONTENT rather than record
// configuration, and therefore the one every MANAGE holder reaches: a
// Guardian inside the profile they administer, and an adult delegate whose
// grant is MANAGE. Both post to routes that already resolve
// `requireRecordAuth("manage", "profile")`; before this the forms had no
// reachable page and the permission had no caller.
//
// Checked before the managed branches so a Guardian gets the same record
// surface an adult manager does, rather than the guardian configuration
// panel, which has no family for it.
//
// Resolved through a slug map rather than by matching the kind, and that is
// not belt-and-braces. `manage-writable` is a classification, not a
// component: a second destination joining it — a future record-content page
// — would otherwise render the allergy and family-history managers under its
// own heading, silently, because the branch matched a category where a page
// was meant. `RECORD_CONTENT_SECTIONS` says which page each slug is, a slug
// with no entry falls through to the refusal panel below, and
// `record-settings-contract.test.ts` fails if the map and the classification
// ever disagree — so a destination cannot join the kind without somebody
// deciding what it renders.
const RecordContent =
level === "manage" && destination.kind === "manage-writable"
? RECORD_CONTENT_SECTIONS[section]
: undefined;
if (RecordContent) return <RecordContent />;
if (
recordKind === "managed" &&
destination.kind === "managed-guardian" &&
section === "integrations"
) {
return <ManagedIntegrationStatus />;
}
if (
recordKind === "managed" &&
destination.kind === "managed-guardian" &&
section in MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION
) {
const family =
MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION[
section as keyof typeof MANAGED_RECORD_SETTINGS_FAMILY_BY_SECTION
];
return <ManagedRecordSettingsSection family={family} />;
}
return (
<SettingsCard aria-labelledby="shared-record-settings-unavailable-title">
<SettingsCardHeader
icon={CircleSlash}
title={t("settings.sharedRecord.unavailableTitle")}
titleId="shared-record-settings-unavailable-title"
description={t("settings.sharedRecord.unavailableDescription")}
/>
</SettingsCard>
);
}