fix(sharing): resolve gate strings on the server so the share view renders

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 11:53:46 +02:00
1 parent cc41b6680a
commit be37a87fa7
3 files changed
+56 -18

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} />;
}
}
+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>