mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
cc41b6680a
commit
be37a87fa7
3 files changed
+56
-18
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user