Merge branch 'fix/374-share-render' into release/v1.25.0

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 14:42:26 +02:00
commit 32885d1218
13 files changed
+158 -36

No files matched your search

+26
View File
@@ -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<string, unknown> {
return (out as { props: Record<string, unknown> }).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<string, unknown>;
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 () => {
+11 -1
View File
@@ -82,7 +82,17 @@ export default async function ClinicianSharePage({
if (!verifyUnlockValue(unlockValue, gate.tokenHash)) {
const locale = await resolveLocale();
const { t } = getServerTranslator(locale);
return <ShareUnlockGate t={(key, vars) => 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 <ShareUnlockGate strings={strings} token={token} />;
}
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
/**
* Error boundary for the public clinician share route (`/c/<token>`).
*
* 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 (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
<div className="border-border bg-card space-y-4 rounded-lg border p-6 text-center">
<h1 className="text-lg font-semibold">
{t("clinicianView.loadError.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("clinicianView.loadError.description")}
</p>
<Button
onClick={() => reset()}
className="min-h-11 w-full sm:min-h-9"
variant="outline"
>
{t("clinicianView.loadError.retry")}
</Button>
</div>
</main>
);
}
+1 -1
View File
@@ -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";
+19 -17
View File
@@ -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, string | number>,
) => 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) {
}}
>
<div className="space-y-1.5">
<h1 className="text-lg font-semibold">
{t("clinicianView.unlock.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("clinicianView.unlock.description")}
</p>
<h1 className="text-lg font-semibold">{strings.title}</h1>
<p className="text-muted-foreground text-sm">{strings.description}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="share-passphrase">
{t("clinicianView.unlock.label")}
</Label>
<Label htmlFor="share-passphrase">{strings.label}</Label>
<Input
id="share-passphrase"
value={passphrase}
@@ -113,7 +115,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
{error && (
<p role="alert" className="text-destructive text-sm">
{t("clinicianView.unlock.error")}
{strings.error}
</p>
)}
@@ -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}
</Button>
</form>
</main>
+6 -17
View File
@@ -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<string, string> = {
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<string, string | null> = {
WEIGHT: "kg",
+24
View File
@@ -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<string, string> = {
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",
};