From cc41b6680a363409b884706686aba45f1c2bd221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 11:53:39 +0200 Subject: [PATCH 1/3] refactor(doctor-report): split the type-label keys out of the PDF core Move DOCTOR_REPORT_TYPE_LABEL_KEYS into a jsPDF-free module so leaf consumers can import the map without dragging jspdf + jspdf-autotable into their graph. The PDF core re-exports the map, keeping a single source of truth and its existing import surface intact. Point the clinician share view at the new module so the public share route no longer pulls the PDF renderer into its server graph. --- src/components/clinician/clinician-view.tsx | 2 +- src/lib/doctor-report-pdf-core.ts | 23 ++++++-------------- src/lib/doctor-report/type-label-keys.ts | 24 +++++++++++++++++++++ 3 files changed, 31 insertions(+), 18 deletions(-) create mode 100644 src/lib/doctor-report/type-label-keys.ts diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 2b06593a2..1850a2b64 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -10,7 +10,7 @@ * a FENCED, muted wellness card carrying the load-bearing "descriptive, not a * clinical assessment / not a diagnosis" disclaimer. */ -import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report-pdf-core"; +import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys"; import type { DoctorReportData } from "@/lib/doctor-report-data"; import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs"; diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 79d3ade8b..54f2895bb 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -11,6 +11,7 @@ */ import { jsPDF } from "jspdf"; import autoTable from "jspdf-autotable"; +import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "./doctor-report/type-label-keys"; import { makeFormatters } from "./format-locale"; import type { Locale } from "./i18n/config"; import { convertGlucose, resolveGlucoseUnit } from "./glucose"; @@ -150,23 +151,11 @@ export interface DoctorReportRenderOptions { aiSummary?: string | null; } -/** - * Per-vital label keys. Exported for coverage tests (issue #109 / phase P0) - * so a future enum addition is caught by a unit test rather than reaching - * production as a raw enum string in the PDF. - */ -export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record = { - WEIGHT: "doctorReport.typeWeight", - BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys", - BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia", - PULSE: "doctorReport.typePulse", - BODY_FAT: "doctorReport.typeBodyFat", - SLEEP_DURATION: "doctorReport.typeSleep", - ACTIVITY_STEPS: "doctorReport.typeSteps", - TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater", - BONE_MASS: "doctorReport.typeBoneMass", - OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation", -}; +// Per-vital label keys live in a jsPDF-free module so leaf consumers (the +// public clinician share view) can import the map without dragging the PDF +// renderer into their graph. Re-exported here so the PDF core stays the +// single import surface its existing callers already use. +export { DOCTOR_REPORT_TYPE_LABEL_KEYS }; export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { WEIGHT: "kg", diff --git a/src/lib/doctor-report/type-label-keys.ts b/src/lib/doctor-report/type-label-keys.ts new file mode 100644 index 000000000..9123e57b9 --- /dev/null +++ b/src/lib/doctor-report/type-label-keys.ts @@ -0,0 +1,24 @@ +/** + * Per-vital i18n label keys for the doctor report. + * + * Extracted from `doctor-report-pdf-core` so consumers that only need the + * label map (e.g. the public clinician share view, a pure server component) + * do not pull the jsPDF renderer into their module graph. The PDF core + * re-exports this map so there is a single source of truth. + * + * Exported for coverage tests (issue #109 / phase P0) so a future enum + * addition is caught by a unit test rather than reaching production as a raw + * enum string in the PDF. + */ +export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record = { + WEIGHT: "doctorReport.typeWeight", + BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys", + BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia", + PULSE: "doctorReport.typePulse", + BODY_FAT: "doctorReport.typeBodyFat", + SLEEP_DURATION: "doctorReport.typeSleep", + ACTIVITY_STEPS: "doctorReport.typeSteps", + TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater", + BONE_MASS: "doctorReport.typeBoneMass", + OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation", +}; From be37a87fa7c3d69ea6460e57a3c254c0342769b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 11:53:46 +0200 Subject: [PATCH 2/3] fix(sharing): resolve gate strings on the server so the share view renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The passphrase gate is a client component, but the page passed it a `t` translator function. A function cannot cross the server→client boundary, so a fresh (passphrase-protected) link threw a generic server-components render error instead of painting the unlock form. Pre-resolve the gate copy on the server into a plain, serializable `strings` object and pass that instead. Locale resolution stays server-authoritative. The page test mocked the gate away and only checked the token prop, so the regression slipped through. Tighten it to assert the gate receives a serializable contract: no prop value is a function, and the copy arrives as a pre-resolved plain string object. --- src/app/c/[token]/__tests__/page.test.tsx | 26 ++++++++++++++ src/app/c/[token]/page.tsx | 12 ++++++- .../clinician/share-unlock-gate.tsx | 36 ++++++++++--------- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/app/c/[token]/__tests__/page.test.tsx b/src/app/c/[token]/__tests__/page.test.tsx index 53dacacc1..911a7f956 100644 --- a/src/app/c/[token]/__tests__/page.test.tsx +++ b/src/app/c/[token]/__tests__/page.test.tsx @@ -72,6 +72,15 @@ function isGate(out: unknown): boolean { ); } +/** + * Returns the props the page handed to the gate element. A client component + * boundary can only carry serializable props — issue #374 was a `t` FUNCTION + * crossing it, which throws at render. + */ +function gateProps(out: unknown): Record { + return (out as { props: Record }).props; +} + function resolvedContext() { return { shareLinkId: "link-1", @@ -126,6 +135,23 @@ describe("clinician share page", () => { expect(isGate(out)).toBe(true); expect(resolve).not.toHaveBeenCalled(); expect(loadData).not.toHaveBeenCalled(); + + // Issue #374 contract: every prop crossing the server→client boundary must + // be serializable. NO prop value may be a function (a `t` function here + // throws "Server Components render error"), and the copy must arrive as a + // pre-resolved plain object. + const props = gateProps(out); + for (const value of Object.values(props)) { + expect(typeof value).not.toBe("function"); + } + expect(typeof props.token).toBe("string"); + const strings = props.strings as Record; + expect(strings).toBeTypeOf("object"); + expect(strings).not.toBeNull(); + expect(Object.values(strings).length).toBeGreaterThan(0); + for (const value of Object.values(strings)) { + expect(typeof value).toBe("string"); + } }); it("renders a protected link WITH a valid unlock cookie", async () => { diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx index 38633ec14..986836f8a 100644 --- a/src/app/c/[token]/page.tsx +++ b/src/app/c/[token]/page.tsx @@ -82,7 +82,17 @@ export default async function ClinicianSharePage({ if (!verifyUnlockValue(unlockValue, gate.tokenHash)) { const locale = await resolveLocale(); const { t } = getServerTranslator(locale); - return t(key, vars)} token={token} />; + // Resolve the gate copy on the server (it owns the locale) into a plain, + // serializable object. A `t` function cannot cross the RSC boundary into + // the client gate — passing one throws at render (issue #374). + const strings = { + title: t("clinicianView.unlock.title"), + description: t("clinicianView.unlock.description"), + label: t("clinicianView.unlock.label"), + error: t("clinicianView.unlock.error"), + submit: t("clinicianView.unlock.submit"), + }; + return ; } } diff --git a/src/components/clinician/share-unlock-gate.tsx b/src/components/clinician/share-unlock-gate.tsx index a4f613e27..36547d00d 100644 --- a/src/components/clinician/share-unlock-gate.tsx +++ b/src/components/clinician/share-unlock-gate.tsx @@ -25,18 +25,26 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { apiPost } from "@/lib/api/api-fetch"; -type Translate = ( - key: string, - vars?: Record, -) => string; +/** + * Plain, serializable copy for the gate. Resolved on the server (which owns the + * locale) and passed across the RSC boundary as data — never a `t` function, as + * functions cannot be serialized to a client component. + */ +export interface ShareUnlockGateStrings { + title: string; + description: string; + label: string; + error: string; + submit: string; +} interface ShareUnlockGateProps { - t: Translate; + strings: ShareUnlockGateStrings; /** The raw `hls_` token from the path — used only to build the verify URL. */ token: string; } -export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) { +export function ShareUnlockGate({ strings, token }: ShareUnlockGateProps) { const [passphrase, setPassphrase] = useState(""); const [pending, setPending] = useState(false); const [error, setError] = useState(false); @@ -86,18 +94,12 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) { }} >
-

- {t("clinicianView.unlock.title")} -

-

- {t("clinicianView.unlock.description")} -

+

{strings.title}

+

{strings.description}

- + - {t("clinicianView.unlock.error")} + {strings.error}

)} @@ -122,7 +124,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) { disabled={pending || !passphrase.trim()} className="min-h-11 w-full sm:min-h-9" > - {t("clinicianView.unlock.submit")} + {strings.submit} From ee2e9a356389b83ec63e94f901d480e9338bf328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 11:53:52 +0200 Subject: [PATCH 3/3] feat(sharing): add an error boundary to the public share route A render failure on `/c/` previously fell through to the full-app error screen, which carries app chrome the public share view never wants. Add a route-scoped, chrome-free error boundary that shows a localised, stack-free message and a single retry. Add the matching copy across all six locales. --- messages/de.json | 5 +++++ messages/en.json | 5 +++++ messages/es.json | 5 +++++ messages/fr.json | 5 +++++ messages/it.json | 5 +++++ messages/pl.json | 5 +++++ src/app/c/error.tsx | 41 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 71 insertions(+) create mode 100644 src/app/c/error.tsx diff --git a/messages/de.json b/messages/de.json index ce28bb09c..745402814 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6358,6 +6358,11 @@ "label": "Passphrase", "error": "Diese Passphrase ist nicht korrekt.", "submit": "Entsperren" + }, + "loadError": { + "title": "Diese geteilte Ansicht konnte nicht geladen werden", + "description": "Beim Öffnen dieser geteilten Gesundheitsdaten ist ein Fehler aufgetreten. Der Link ist möglicherweise abgelaufen, oder es lag ein vorübergehendes Problem vor. Versuche es erneut oder bitte die Person, die den Link geteilt hat, um einen neuen.", + "retry": "Erneut versuchen" } }, "cycle": { diff --git a/messages/en.json b/messages/en.json index 14fa8b085..2123b06ff 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6358,6 +6358,11 @@ "label": "Passphrase", "error": "That passphrase is not correct.", "submit": "Unlock" + }, + "loadError": { + "title": "This shared view could not be loaded", + "description": "Something went wrong while opening this shared health record. The link may have expired, or there was a temporary problem. Try again, or ask the person who shared it for a new link.", + "retry": "Try again" } }, "cycle": { diff --git a/messages/es.json b/messages/es.json index ce2ee6de9..9c486bcb6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6358,6 +6358,11 @@ "label": "Frase de acceso", "error": "Esa frase de acceso no es correcta.", "submit": "Desbloquear" + }, + "loadError": { + "title": "No se pudo cargar esta vista compartida", + "description": "Se produjo un error al abrir este registro de salud compartido. Puede que el enlace haya caducado o que haya habido un problema temporal. Inténtalo de nuevo o pide un enlace nuevo a la persona que lo compartió.", + "retry": "Intentar de nuevo" } }, "cycle": { diff --git a/messages/fr.json b/messages/fr.json index 69f085b74..4ac28d1b6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6358,6 +6358,11 @@ "label": "Phrase secrète", "error": "Cette phrase secrète est incorrecte.", "submit": "Déverrouiller" + }, + "loadError": { + "title": "Impossible de charger cette vue partagée", + "description": "Une erreur s'est produite lors de l'ouverture de ce dossier de santé partagé. Le lien a peut-être expiré, ou un problème temporaire est survenu. Réessayez ou demandez un nouveau lien à la personne qui l'a partagé.", + "retry": "Réessayer" } }, "cycle": { diff --git a/messages/it.json b/messages/it.json index 60e5288d4..abfc66c93 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6358,6 +6358,11 @@ "label": "Passphrase", "error": "La passphrase non è corretta.", "submit": "Sblocca" + }, + "loadError": { + "title": "Impossibile caricare questa vista condivisa", + "description": "Si è verificato un errore durante l'apertura di questo referto sanitario condiviso. Il link potrebbe essere scaduto oppure si è verificato un problema temporaneo. Riprova o chiedi un nuovo link alla persona che lo ha condiviso.", + "retry": "Riprova" } }, "cycle": { diff --git a/messages/pl.json b/messages/pl.json index 5631b6f90..9a72becb6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6358,6 +6358,11 @@ "label": "Hasło", "error": "To hasło jest nieprawidłowe.", "submit": "Odblokuj" + }, + "loadError": { + "title": "Nie udało się załadować udostępnionego widoku", + "description": "Podczas otwierania udostępnionej dokumentacji zdrowotnej wystąpił błąd. Link mógł wygasnąć lub wystąpił chwilowy problem. Spróbuj ponownie albo poproś osobę, która go udostępniła, o nowy link.", + "retry": "Spróbuj ponownie" } }, "cycle": { diff --git a/src/app/c/error.tsx b/src/app/c/error.tsx new file mode 100644 index 000000000..27700b74f --- /dev/null +++ b/src/app/c/error.tsx @@ -0,0 +1,41 @@ +"use client"; + +/** + * Error boundary for the public clinician share route (`/c/`). + * + * The share view carries no app chrome and no session, so a render failure must + * not fall through to the full-app error screen. This renders a clean, locale- + * aware, stack-free message and offers a single retry. No record data, no + * digest, no stack — a probe learns nothing from a failed render. + */ +import { useTranslations } from "@/lib/i18n/context"; +import { Button } from "@/components/ui/button"; + +export default function ClinicianShareError({ + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const { t } = useTranslations(); + + return ( +
+
+

+ {t("clinicianView.loadError.title")} +

+

+ {t("clinicianView.loadError.description")} +

+ +
+
+ ); +}