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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-16 06:58:48 +02:00
1 parent a806676a71
commit 057fd3b49c
21 files changed
+255 -192

No files matched your search

@@ -37,7 +37,7 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
import { isScreeningReminderType } from "@/lib/measurement-reminders/screening";
import { relativeDueKey } from "@/lib/measurement-reminders/due-day";
import {
useMeasurementReminders,
@@ -92,7 +92,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MedicationCardHeader } from "@/components/medications/medication-card-header";
import { isScreeningReminderType } from "@/lib/validations/measurement-reminders";
import { isScreeningReminderType } from "@/lib/measurement-reminders/screening";
import {
useMeasurementReminders,
useMeasurementReminderMutations,
@@ -43,7 +43,7 @@ import {
} from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement";
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit";
import {
getLastUsedMeasurementType,
setLastUsedMeasurementType,
@@ -8,7 +8,7 @@
* keeps a bare URL. Kept out of the client component so the round trip stays
* trivially unit-testable without a render harness.
*/
import { measurementSourceEnum } from "@/lib/validations/measurement";
import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "./measurement-list-meta";
export interface MeasurementListFilters {
@@ -24,7 +24,7 @@ export interface MeasurementListFilters {
valueMax?: string;
}
const SOURCE_SET = new Set<string>(measurementSourceEnum.options);
const SOURCE_SET = new Set<string>(MEASUREMENT_SOURCES);
const DAY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
function dayOrUndefined(value: string | null): string | undefined {
@@ -72,7 +72,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels";
import { CUMULATIVE_DAY_SUM_TYPES } from "@/lib/measurements/cumulative-day-sum";
import { rawDisplayFractionDigits } from "@/lib/measurements/display-transform";
import { getUnitForType } from "@/lib/validations/measurement";
import { getUnitForType } from "@/lib/measurements/unit-map";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import {
convertGlucose,
@@ -85,10 +85,8 @@ import {
queryKeys,
refetchInactiveDailyReads,
} from "@/lib/query-keys";
import {
MEASUREMENT_NOTES_MAX_LENGTH,
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit";
import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -255,7 +253,7 @@ function formatMeasurementSource(
* management-list source filter. Derived from the shared Zod enum so a
* future source addition surfaces here automatically.
*/
const MEASUREMENT_SOURCE_OPTIONS = measurementSourceEnum.options;
const MEASUREMENT_SOURCE_OPTIONS = MEASUREMENT_SOURCES;
/**
* v1.15.13 — convert a `<input type="date">` value (YYYY-MM-DD) into the
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { ComponentType } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
@@ -32,6 +33,24 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
// The gate loads its two record-scoped sections on demand. A static render
// cannot wait for a lazy chunk, so the stand-in resolves each loader once,
// before the first test, and renders the resolved component synchronously.
const lazyLoads = vi.hoisted(() => [] as Promise<void>[]);
vi.mock("next/dynamic", () => ({
default: (load: () => Promise<ComponentType>) => {
let Loaded: ComponentType | null = null;
lazyLoads.push(
load().then((component) => {
Loaded = component;
}),
);
return function DynamicStandIn() {
return Loaded ? <Loaded /> : null;
};
},
}));
vi.mock("../managed-record-settings-section", () => ({
ManagedRecordSettingsSection: () => <div data-slot="managed-settings" />,
}));
@@ -55,6 +74,10 @@ function render(section: "modules" | "integrations" | "anamnesis" | "score") {
}
describe("RecordSettingsSectionGate", () => {
beforeAll(async () => {
await Promise.all(lazyLoads);
});
it("does not mount an actor Settings component while auth is unresolved", () => {
authRef.isLoading = true;
authRef.active = null;
@@ -12,7 +12,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import { queryKeys } from "@/lib/query-keys";
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings";
import { assertRecordSettingsResponseForRecord } from "@/lib/record-settings/response";
type ManagedIntegrationKey = keyof typeof INTEGRATION_DISPLAY_NAMES;
@@ -1,19 +1,36 @@
"use client";
import type { ReactNode } from "react";
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";
import { classifySettingsDestination } from "@/lib/record-settings/classification";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ManagedIntegrationStatus } from "@/components/settings/managed-integration-status";
import { ManagedRecordSettingsSection } from "@/components/settings/managed-record-settings-section";
import { RecordAnamnesisSection } from "@/components/settings/record-anamnesis-section";
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.
*
@@ -23,7 +40,7 @@ import type { SettingsSectionSlug } from "@/components/settings/section-slugs";
* is what stops a new destination inheriting this one's forms.
*/
export const RECORD_CONTENT_SECTIONS: Partial<
Record<SettingsSectionSlug, () => ReactNode>
Record<SettingsSectionSlug, ComponentType>
> = {
anamnesis: RecordAnamnesisSection,
};