diff --git a/web/src/app/(app)/layout.legal-consent.hosted.test.tsx b/web/src/app/(app)/layout.legal-consent.hosted.test.tsx
index 12983f6f3..c115c5c8f 100644
--- a/web/src/app/(app)/layout.legal-consent.hosted.test.tsx
+++ b/web/src/app/(app)/layout.legal-consent.hosted.test.tsx
@@ -12,6 +12,12 @@ process.env.NEXT_PUBLIC_TERMS_URL = "/terms";
import { render, screen, within } from "@testing-library/react";
import AppLayout from "./AppLayoutClient";
+// Legal links must never render through next/link's — see the
+// comment on LegalAnchor in SignInConsent.tsx for why (same-origin legal
+// paths can resolve outside this Next app, and prefetches the
+// route's RSC payload regardless). This mock tags anything that DOES go
+// through with a marker attribute so a Link-rendered legal link is
+// distinguishable from a plain even though both render as tags.
jest.mock("next/link", () => {
return function MockLink({
href,
@@ -23,7 +29,7 @@ jest.mock("next/link", () => {
[k: string]: unknown;
}) {
return (
-
+
{children}
);
@@ -57,12 +63,18 @@ describe("(app) layout — sign-in consent (hosted build)", () => {
expect(consent).toHaveTextContent(
"By signing in you agree to the Terms and Privacy policy.",
);
- expect(within(consent).getByRole("link", { name: "Terms" })).toHaveAttribute(
- "href",
- "/terms",
- );
- expect(
- within(consent).getByRole("link", { name: "Privacy policy" }),
- ).toHaveAttribute("href", "/privacy");
+ const termsLink = within(consent).getByRole("link", { name: "Terms" });
+ expect(termsLink).toHaveAttribute("href", "/terms");
+ const privacyLink = within(consent).getByRole("link", {
+ name: "Privacy policy",
+ });
+ expect(privacyLink).toHaveAttribute("href", "/privacy");
+
+ // Same-origin on paper ("/terms", "/privacy"), but on the hosted
+ // deployment these resolve to static Caddy routes, not Next pages.
+ // would prefetch that route's RSC payload and 404 — regression
+ // coverage for the #1051 follow-up bug.
+ expect(termsLink).not.toHaveAttribute("data-next-link");
+ expect(privacyLink).not.toHaveAttribute("data-next-link");
});
});
diff --git a/web/src/app/components/LegalFooterLinks.test.tsx b/web/src/app/components/LegalFooterLinks.test.tsx
index c424fcc4e..e64cff0af 100644
--- a/web/src/app/components/LegalFooterLinks.test.tsx
+++ b/web/src/app/components/LegalFooterLinks.test.tsx
@@ -11,6 +11,12 @@ export {};
const PRIVACY_ENV_KEY = "NEXT_PUBLIC_PRIVACY_URL";
const TERMS_ENV_KEY = "NEXT_PUBLIC_TERMS_URL";
+// Legal links must never render through next/link's — see the
+// comment on legalFooterLinks() in lib/site.ts for why (same-origin legal
+// paths can resolve outside this Next app, and prefetches the
+// route's RSC payload regardless). This mock tags anything that DOES go
+// through with a marker attribute so a Link-rendered legal link is
+// distinguishable from a plain even though both render as tags.
jest.mock("next/link", () => {
return function MockLink({
href,
@@ -22,7 +28,7 @@ jest.mock("next/link", () => {
[key: string]: unknown;
}) {
return (
-
+
{children}
);
@@ -72,4 +78,21 @@ describe("LegalFooterLinks", () => {
"/terms",
);
});
+
+ it("renders same-origin Privacy/Terms as plain anchors, never next/link's ", () => {
+ // A same-origin legal path (e.g. "/privacy") may resolve outside this
+ // Next app on the hosted deployment (Caddy serves it as a static page,
+ // not a Next route). Rendering it with would prefetch a route
+ // this app doesn't own and 404. Regression coverage for that bug.
+ process.env[PRIVACY_ENV_KEY] = "/privacy";
+ process.env[TERMS_ENV_KEY] = "/terms";
+ const { LegalFooterLinks } = loadLegalFooterLinks();
+ render();
+ expect(screen.getByRole("link", { name: "Privacy" })).not.toHaveAttribute(
+ "data-next-link",
+ );
+ expect(screen.getByRole("link", { name: "Terms" })).not.toHaveAttribute(
+ "data-next-link",
+ );
+ });
});
diff --git a/web/src/app/components/LegalFooterLinks.tsx b/web/src/app/components/LegalFooterLinks.tsx
index d3d3b4efe..2b37521c0 100644
--- a/web/src/app/components/LegalFooterLinks.tsx
+++ b/web/src/app/components/LegalFooterLinks.tsx
@@ -1,4 +1,3 @@
-import Link from "next/link";
import { legalFooterLinks } from "../../lib/site";
// Privacy/Terms links for a public-page footer that doesn't already carry
@@ -6,6 +5,11 @@ import { legalFooterLinks } from "../../lib/site";
// legalFooterLinks() straight into its own FOOTER_LINKS). Renders nothing
// when neither PRIVACY_URL nor TERMS_URL is configured, so a self-host or
// staging footer looks exactly as it did before this existed.
+//
+// Every entry from legalFooterLinks() is `plain` — always a bare , never
+// next/link's — because these are operator-supplied URLs that may
+// resolve outside this Next app (see the comment on legalFooterLinks in
+// lib/site.ts). Only `external` varies the target/rel.
export function LegalFooterLinks({
className,
linkStyle,
@@ -17,23 +21,17 @@ export function LegalFooterLinks({
if (links.length === 0) return null;
return (