From 13d48b3ada6205c79267ee4cae6981387a483260 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 3 Aug 2026 13:00:42 +0000 Subject: [PATCH 1/2] fix(webapp): report message catalogs ride the registry, not an import side effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The health catalog registered itself at import time, reachable only through a bare side-effect import — which the production SSR bundle tree-shakes away under "sideEffects": false, so GET /api/v1/reports/health threw 'no catalog registered for report "health"' in production while working in dev. Verified on the built server bundle: main's lacks the catalog, this branch's carries it. Catalogs are now values on the report registry entries and the resolver reads them from there; the mutable register-at-import registry is gone, so the class of bug is structurally impossible. --- .../report-messages-not-treeshaken.md | 6 ++++++ .../v3/reports/health/health-messages.ts | 4 +--- .../app/presenters/v3/reports/health/health.ts | 1 - .../presenters/v3/reports/report-messages.ts | 17 ++++++++--------- .../presenters/v3/reports/report-registry.ts | 11 +++++++++++ 5 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 .server-changes/report-messages-not-treeshaken.md diff --git a/.server-changes/report-messages-not-treeshaken.md b/.server-changes/report-messages-not-treeshaken.md new file mode 100644 index 00000000000..b599e84f1ce --- /dev/null +++ b/.server-changes/report-messages-not-treeshaken.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fix the health report failing with an internal error when requested through the API. diff --git a/apps/webapp/app/presenters/v3/reports/health/health-messages.ts b/apps/webapp/app/presenters/v3/reports/health/health-messages.ts index 888b2b54b73..2475118a685 100644 --- a/apps/webapp/app/presenters/v3/reports/health/health-messages.ts +++ b/apps/webapp/app/presenters/v3/reports/health/health-messages.ts @@ -8,7 +8,7 @@ * metrics / evidence — meaning lives here, numbers stay facts. */ -import { registerReportMessages, type ReportMessages } from "../report-messages"; +import { type ReportMessages } from "../report-messages"; import { type ReasonCode, type Severity } from "../report-view-model"; /** Metric id -> expanded display label. */ @@ -166,5 +166,3 @@ export const healthMessages: ReportMessages = { statementMessage, actionMessage: (code) => ACTIONS[code] ?? code, }; - -registerReportMessages("health", healthMessages); diff --git a/apps/webapp/app/presenters/v3/reports/health/health.ts b/apps/webapp/app/presenters/v3/reports/health/health.ts index 08beae3894c..fa56e7b630e 100644 --- a/apps/webapp/app/presenters/v3/reports/health/health.ts +++ b/apps/webapp/app/presenters/v3/reports/health/health.ts @@ -26,7 +26,6 @@ import { applyFlowPolicy, buildFlowRead, interpretFlow } from "./flow"; import { interpretLiveness } from "./liveness"; // Registers the "health" message catalog (side effect) so the renderer resolves this report's // codes. Kept here — the health report's entry module — so loading it always registers its prose. -import "./health-messages"; // Re-exported so the data layer + tests keep a single import path (`./health`). export { HEALTH_THRESHOLDS, isPendingIncreasing, type HealthInput } from "./health-core"; diff --git a/apps/webapp/app/presenters/v3/reports/report-messages.ts b/apps/webapp/app/presenters/v3/reports/report-messages.ts index 2d90ea016ed..09b47201413 100644 --- a/apps/webapp/app/presenters/v3/reports/report-messages.ts +++ b/apps/webapp/app/presenters/v3/reports/report-messages.ts @@ -5,6 +5,7 @@ * No report vocabulary here — that would re-couple the renderer to a specific report. */ +import { REPORT_REGISTRY } from "./report-registry"; import { type ReasonCode, type Severity } from "./report-view-model"; /** @@ -22,16 +23,14 @@ export type ReportMessages = { actionMessage(code: ReasonCode): string; }; -const catalogs = new Map(); - -/** Register a report's catalog under its title (e.g. "health"). Called for its side effect. */ -export function registerReportMessages(title: string, messages: ReportMessages): void { - catalogs.set(title, messages); -} - -/** Look up a report's catalog by `vm.title`. Throws if the report never registered one. */ +/** + * Look up a report's catalog by `vm.title`. Catalogs live as values on the + * report registry entries — there is deliberately no register-at-import-time + * step: a side-effect registration is exactly what the production bundle + * tree-shakes away under `"sideEffects": false`. + */ export function reportMessages(title: string): ReportMessages { - const messages = catalogs.get(title); + const messages = REPORT_REGISTRY[title]?.messages; if (!messages) { throw new Error(`report-messages: no catalog registered for report "${title}"`); } diff --git a/apps/webapp/app/presenters/v3/reports/report-registry.ts b/apps/webapp/app/presenters/v3/reports/report-registry.ts index a0209dcfeda..20fef9609ea 100644 --- a/apps/webapp/app/presenters/v3/reports/report-registry.ts +++ b/apps/webapp/app/presenters/v3/reports/report-registry.ts @@ -10,11 +10,21 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { interpret as interpretHealth } from "./health/health"; import { loadHealthInput } from "./health/health-data"; +import { healthMessages } from "./health/health-messages"; +import { type ReportMessages } from "./report-messages"; import { type ReportViewModel } from "./report-view-model"; export type ReportLoader = { load: (env: AuthenticatedEnvironment, period: string) => Promise; interpret: (input: TInput) => ReportViewModel; + /** + * The report's message catalog, carried BY VALUE on the registry entry. It + * used to be registered as a side effect of importing the catalog module — + * which the production SSR bundle tree-shook away (`"sideEffects": false`), + * leaving `GET /api/v1/reports/health` throwing "no catalog registered". + * A value on the entry cannot be dropped. + */ + messages: ReportMessages; }; function defineReport(loader: ReportLoader): ReportLoader { @@ -25,6 +35,7 @@ export const REPORT_REGISTRY: Record> = { health: defineReport({ load: (env, period) => loadHealthInput(env, period), interpret: interpretHealth, + messages: healthMessages, }), }; From f761eac9628cfd997c327429f9dcc15e30452bb6 Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 3 Aug 2026 13:19:48 +0000 Subject: [PATCH 2/2] fix(webapp): catalogs live in their own IO-free module Reading them off the registry dragged the loaders (and so env.server and the engine singleton) into the pure renderer and its unit test. The catalogs-by-value map now imports only the per-report messages files. --- .../v3/reports/report-message-catalogs.ts | 11 +++++++++++ .../app/presenters/v3/reports/report-messages.ts | 13 +++++-------- .../app/presenters/v3/reports/report-registry.ts | 11 ----------- 3 files changed, 16 insertions(+), 19 deletions(-) create mode 100644 apps/webapp/app/presenters/v3/reports/report-message-catalogs.ts diff --git a/apps/webapp/app/presenters/v3/reports/report-message-catalogs.ts b/apps/webapp/app/presenters/v3/reports/report-message-catalogs.ts new file mode 100644 index 00000000000..eca36e7e95a --- /dev/null +++ b/apps/webapp/app/presenters/v3/reports/report-message-catalogs.ts @@ -0,0 +1,11 @@ +/** + * Catalogs by value, in a module that imports ONLY the per-report `*-messages` + * files — no loaders, no IO. Presentation stays decoupled from the data layer, + * and a value import can't be tree-shaken away. + */ +import { healthMessages } from "./health/health-messages"; +import { type ReportMessages } from "./report-messages"; + +export const REPORT_MESSAGE_CATALOGS: Record = { + health: healthMessages, +}; diff --git a/apps/webapp/app/presenters/v3/reports/report-messages.ts b/apps/webapp/app/presenters/v3/reports/report-messages.ts index 09b47201413..1c549f78a1c 100644 --- a/apps/webapp/app/presenters/v3/reports/report-messages.ts +++ b/apps/webapp/app/presenters/v3/reports/report-messages.ts @@ -5,7 +5,7 @@ * No report vocabulary here — that would re-couple the renderer to a specific report. */ -import { REPORT_REGISTRY } from "./report-registry"; +import { REPORT_MESSAGE_CATALOGS } from "./report-message-catalogs"; import { type ReasonCode, type Severity } from "./report-view-model"; /** @@ -23,14 +23,11 @@ export type ReportMessages = { actionMessage(code: ReasonCode): string; }; -/** - * Look up a report's catalog by `vm.title`. Catalogs live as values on the - * report registry entries — there is deliberately no register-at-import-time - * step: a side-effect registration is exactly what the production bundle - * tree-shakes away under `"sideEffects": false`. - */ +/** Look up a report's catalog by `vm.title`. Catalogs are values, never + * registered at import time — side-effect registration is what the production + * bundle tree-shakes away. */ export function reportMessages(title: string): ReportMessages { - const messages = REPORT_REGISTRY[title]?.messages; + const messages = REPORT_MESSAGE_CATALOGS[title]; if (!messages) { throw new Error(`report-messages: no catalog registered for report "${title}"`); } diff --git a/apps/webapp/app/presenters/v3/reports/report-registry.ts b/apps/webapp/app/presenters/v3/reports/report-registry.ts index 20fef9609ea..a0209dcfeda 100644 --- a/apps/webapp/app/presenters/v3/reports/report-registry.ts +++ b/apps/webapp/app/presenters/v3/reports/report-registry.ts @@ -10,21 +10,11 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { interpret as interpretHealth } from "./health/health"; import { loadHealthInput } from "./health/health-data"; -import { healthMessages } from "./health/health-messages"; -import { type ReportMessages } from "./report-messages"; import { type ReportViewModel } from "./report-view-model"; export type ReportLoader = { load: (env: AuthenticatedEnvironment, period: string) => Promise; interpret: (input: TInput) => ReportViewModel; - /** - * The report's message catalog, carried BY VALUE on the registry entry. It - * used to be registered as a side effect of importing the catalog module — - * which the production SSR bundle tree-shook away (`"sideEffects": false`), - * leaving `GET /api/v1/reports/health` throwing "no catalog registered". - * A value on the entry cannot be dropped. - */ - messages: ReportMessages; }; function defineReport(loader: ReportLoader): ReportLoader { @@ -35,7 +25,6 @@ export const REPORT_REGISTRY: Record> = { health: defineReport({ load: (env, period) => loadHealthInput(env, period), interpret: interpretHealth, - messages: healthMessages, }), };