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] 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}