Files
cloudflare__vinext/tests/app-page-boundary-render.test.ts
T
James Anderson 7b21c429c0 fix(app-router): preserve global error details (#2249)
* fix(app-router): preserve global error details

* fix(app-router): preserve default global error semantics
2026-06-22 23:29:40 +01:00

789 lines
26 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import React from "react";
import ReactDOMServer from "react-dom/server";
import {
renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback,
} from "../packages/vinext/src/server/app-page-boundary-render.js";
import type { AppElements } from "../packages/vinext/src/server/app-elements.js";
import type { MetadataFileRoute } from "../packages/vinext/src/server/metadata-routes.js";
import { VINEXT_RSC_VARY_HEADER } from "../packages/vinext/src/server/app-rsc-cache-busting.js";
import { applyFileBasedMetadata } from "../packages/vinext/src/server/file-based-metadata.js";
function createStreamFromMarkup(markup: string): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(markup));
controller.close();
},
});
}
function renderElementToStream(element: React.ReactNode | AppElements): ReadableStream<Uint8Array> {
if (element !== null && typeof element === "object" && !React.isValidElement(element)) {
// Flat map payload — extract the route element and render it to HTML
// (mirrors what the real SSR entry does after deserializing the Flight stream)
const record = element as Record<string, unknown>;
const routeId = record.__route;
if (typeof routeId === "string" && React.isValidElement(record[routeId])) {
return createStreamFromMarkup(
ReactDOMServer.renderToStaticMarkup(record[routeId] as React.ReactNode),
);
}
return createStreamFromMarkup(JSON.stringify(element));
}
return createStreamFromMarkup(ReactDOMServer.renderToStaticMarkup(element));
}
function renderWirePayloadToStream(payload: unknown): ReadableStream<Uint8Array> {
return createStreamFromMarkup(JSON.stringify(payload));
}
function createCommonOptions() {
const clearRequestContext = vi.fn();
const loadSsrHandler = vi.fn(async () => ({
async handleSsr(rscStream: ReadableStream<Uint8Array>) {
return rscStream;
},
}));
return {
applyFileBasedMetadata,
buildFontLinkHeader(preloads: readonly { href: string; type: string }[] | null | undefined) {
if (!preloads || preloads.length === 0) {
return "";
}
return preloads.map((preload) => `<${preload.href}>; rel=preload`).join(", ");
},
clearRequestContext,
createRscOnErrorHandler() {
return () => null;
},
getFontLinks() {
return ["/styles.css"];
},
getFontPreloads() {
return [{ href: "/font.woff2", type: "font/woff2" }];
},
getFontStyles() {
return [".font { font-family: Test; }"];
},
getNavigationContext() {
return { pathname: "/posts/missing", searchParams: new URLSearchParams(), params: {} };
},
isRscRequest: false,
loadSsrHandler,
makeThenableParams<T>(params: T) {
return params;
},
middlewareContext: {
headers: null,
status: null,
},
metadataRoutes: [],
renderToReadableStream: renderElementToStream,
requestUrl: "https://example.com/posts/missing",
resolveChildSegments() {
return [];
},
rootLayouts: EMPTY_ROOT_LAYOUTS,
};
}
function createMiddlewareContext() {
const headers = new Headers();
headers.set("x-middleware-security", "present");
headers.append("set-cookie", "session=rotated; Path=/; HttpOnly");
headers.set("vary", "x-auth-state");
return {
headers,
status: 299,
};
}
function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: { slug?: string };
}) {
return React.createElement("div", { "data-layout": "root", "data-slug": params.slug }, children);
}
function LeafLayout({
children,
params,
}: {
children: React.ReactNode;
params: { slug?: string };
}) {
return React.createElement(
"section",
{ "data-layout": "leaf", "data-slug": params.slug },
children,
);
}
function NotFoundBoundary() {
return React.createElement("p", { "data-boundary": "not-found" }, "Missing page");
}
function RouteErrorBoundary({ error }: { error: Error }) {
return React.createElement("p", { "data-boundary": "route-error" }, `route:${error.message}`);
}
function GlobalErrorBoundary({ error }: { error: Error }) {
return React.createElement("p", { "data-boundary": "global-error" }, `global:${error.message}`);
}
function GlobalErrorBoundaryWithStack({ error }: { error: Error }) {
return React.createElement("p", { "data-boundary": "global-error" }, error.stack);
}
type TestModule = {
default: React.ComponentType<any>;
metadata?: { description?: string; title?: string };
viewport?: { themeColor: string };
};
const rootLayoutModule = {
default: RootLayout,
metadata: { description: "Root layout description" },
viewport: { themeColor: "#111111" },
} satisfies TestModule;
const leafLayoutModule = {
default: LeafLayout,
} satisfies TestModule;
const notFoundModule = {
default: NotFoundBoundary,
} satisfies TestModule;
const notFoundModuleWithMetadata = {
default: NotFoundBoundary,
metadata: { title: "notfound title" },
} satisfies TestModule;
const routeErrorModule = {
default: RouteErrorBoundary,
} satisfies TestModule;
const globalErrorModule = {
default: GlobalErrorBoundary,
} satisfies TestModule;
const globalErrorModuleWithStack = {
default: GlobalErrorBoundaryWithStack,
} satisfies TestModule;
function ThrowingGlobalErrorBoundary(): React.ReactNode {
throw new Error("global-error boom");
}
const throwingGlobalErrorModule = {
default: ThrowingGlobalErrorBoundary,
} satisfies TestModule;
function SignalThrowingGlobalErrorBoundary(): React.ReactNode {
// Mimics notFound() called from inside global-error: a navigation signal that
// must propagate rather than being degraded to a built-in 200.
const signal = Object.assign(new Error("NEXT_NOT_FOUND"), { digest: "NEXT_NOT_FOUND" });
throw signal;
}
const signalThrowingGlobalErrorModule = {
default: SignalThrowingGlobalErrorBoundary,
} satisfies TestModule;
const EMPTY_ROOT_LAYOUTS: readonly TestModule[] = [];
describe("app page boundary render helpers", () => {
it("returns null when no HTTP access fallback boundary exists", async () => {
const common = createCommonOptions();
const response = await renderAppPageHttpAccessFallback<TestModule>({
...common,
matchedParams: { slug: "missing" },
route: {
pattern: "/posts/[slug]",
params: { slug: "missing" },
},
statusCode: 404,
});
expect(response).toBeNull();
expect(common.loadSsrHandler).not.toHaveBeenCalled();
expect(common.clearRequestContext).not.toHaveBeenCalled();
});
it("renders HTTP access fallbacks with layout metadata and wrapped HTML", async () => {
const common = createCommonOptions();
const response = await renderAppPageHttpAccessFallback<TestModule>({
...common,
matchedParams: { slug: "missing" },
rootLayouts: [rootLayoutModule],
route: {
layoutTreePositions: [0, 1],
layouts: [rootLayoutModule, leafLayoutModule],
notFound: notFoundModule,
params: { slug: "missing" },
pattern: "/posts/[slug]",
routeSegments: ["posts", "[slug]"],
},
statusCode: 404,
});
expect(common.loadSsrHandler).toHaveBeenCalledTimes(1);
expect(response?.status).toBe(404);
expect(response?.headers.get("link")).toContain("/font.woff2");
const html = await response?.text();
expect(common.clearRequestContext).toHaveBeenCalledTimes(1);
expect(html).toContain('data-layout="root"');
expect(html).toContain('data-layout="leaf"');
expect(html).toContain('data-boundary="not-found"');
expect(html).toContain('content="Root layout description"');
expect(html).toContain('name="viewport"');
expect(html).toContain('name="theme-color" content="#111111"');
expect(html).toContain('name="robots"');
expect(html).toContain('content="noindex"');
});
it("renders not-found boundary metadata exactly once for HTTP access fallbacks", async () => {
// Ported from Next.js:
// test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
// "should not duplicate metadata with navigation API"
//
// The upstream fixture calls notFound() from generateMetadata() and expects
// the rendered not-found boundary's metadata (`not-found.tsx` static
// metadata) to produce a single title in the document.
const common = createCommonOptions();
const response = await renderAppPageHttpAccessFallback<TestModule>({
...common,
matchedParams: { slug: "missing" },
rootLayouts: [rootLayoutModule],
route: {
layoutTreePositions: [0],
layouts: [rootLayoutModule],
notFound: notFoundModuleWithMetadata,
params: { slug: "missing" },
pattern: "/posts/[slug]",
routeSegments: ["posts", "[slug]"],
},
statusCode: 404,
});
expect(response?.status).toBe(404);
const html = await response?.text();
expect(html?.match(/<title>/g) ?? []).toHaveLength(1);
expect(html).toContain("<title>notfound title</title>");
});
it("does not inject child route file metadata into layout-level HTTP access fallbacks", async () => {
const common = createCommonOptions();
const metadataRoutes: MetadataFileRoute[] = [
{
type: "opengraph-image",
isDynamic: false,
filePath: "/tmp/app/posts/[slug]/opengraph-image.png",
routePrefix: "/posts/[slug]",
routeSegments: ["posts", "[slug]"],
servedUrl: "/posts/-/opengraph-image.png",
contentType: "image/png",
headData: {
kind: "openGraph",
href: "/posts/-/opengraph-image.png?hash",
type: "image/png",
width: 1200,
height: 630,
},
},
];
const response = await renderAppPageHttpAccessFallback<TestModule>({
...common,
layoutModules: [rootLayoutModule],
matchedParams: { slug: "missing" },
metadataRoutes,
route: {
layoutTreePositions: [0, 1],
layouts: [rootLayoutModule, leafLayoutModule],
notFound: notFoundModule,
params: { slug: "missing" },
pattern: "/posts/[slug]",
routeSegments: ["posts", "[slug]"],
},
statusCode: 404,
});
expect(response?.status).toBe(404);
const html = await response?.text();
expect(html).toContain('content="Root layout description"');
expect(html).not.toContain("opengraph-image");
});
it("preserves middleware headers on HTTP access fallback HTML responses", async () => {
const common = createCommonOptions();
const response = await renderAppPageHttpAccessFallback<TestModule>({
...common,
matchedParams: { slug: "missing" },
middlewareContext: createMiddlewareContext(),
route: {
layouts: [rootLayoutModule],
notFound: notFoundModule,
params: { slug: "missing" },
pattern: "/posts/[slug]",
},
statusCode: 404,
});
expect(response?.status).toBe(404);
expect(response?.headers.get("x-middleware-security")).toBe("present");
expect(response?.headers.get("vary")).toBe(`${VINEXT_RSC_VARY_HEADER}, x-auth-state`);
expect(response?.headers.getSetCookie()).toContain("session=rotated; Path=/; HttpOnly");
});
it("renders HTTP access fallback RSC responses as flat payloads", async () => {
const common = createCommonOptions();
const response = await renderAppPageHttpAccessFallback<TestModule>({
...common,
isRscRequest: true,
matchedParams: { slug: "missing" },
renderToReadableStream: renderWirePayloadToStream,
rootLayouts: [rootLayoutModule],
route: {
layoutTreePositions: [0, 1],
layouts: [rootLayoutModule, leafLayoutModule],
notFound: notFoundModule,
params: { slug: "missing" },
pattern: "/posts/[slug]",
routeSegments: ["posts", "[slug]"],
},
statusCode: 404,
});
expect(response?.status).toBe(404);
expect(response?.headers.get("Content-Type")).toBe("text/x-component");
const payload = JSON.parse((await response?.text()) ?? "{}") as Record<string, unknown>;
expect(payload.__route).toBe("route:/posts/missing");
expect(payload.__layoutIds).toEqual(["layout:/", "layout:/posts"]);
expect(payload.__rootLayout).toBe("/");
expect(payload.__sourcePage).toBe("/posts/[slug]/page");
expect(payload["route:/posts/missing"]).toBeTruthy();
});
it("derives HTTP access fallback layout metadata from the rendered layout subset", async () => {
const common = createCommonOptions();
const response = await renderAppPageHttpAccessFallback<TestModule>({
...common,
isRscRequest: true,
layoutModules: [rootLayoutModule],
matchedParams: { slug: "missing" },
renderToReadableStream: renderWirePayloadToStream,
route: {
layoutTreePositions: [0, 1],
layouts: [rootLayoutModule, leafLayoutModule],
notFound: notFoundModule,
params: { slug: "missing" },
pattern: "/posts/[slug]",
routeSegments: ["posts", "[slug]"],
},
statusCode: 404,
});
expect(response?.status).toBe(404);
const payload = JSON.parse((await response?.text()) ?? "{}") as Record<string, unknown>;
expect(payload.__layoutIds).toEqual(["layout:/"]);
expect(payload.__rootLayout).toBe("/");
expect(payload["route:/posts/missing"]).toBeTruthy();
});
it("uses unknown root metadata when no route layout is rendered for an HTTP access fallback", async () => {
const common = createCommonOptions();
const response = await renderAppPageHttpAccessFallback<TestModule>({
...common,
isRscRequest: true,
layoutModules: [],
matchedParams: { slug: "missing" },
renderToReadableStream: renderWirePayloadToStream,
route: {
layoutTreePositions: [0, 1],
layouts: [rootLayoutModule, leafLayoutModule],
notFound: notFoundModule,
params: { slug: "missing" },
pattern: "/posts/[slug]",
routeSegments: ["posts", "[slug]"],
},
statusCode: 404,
});
expect(response?.status).toBe(404);
const payload = JSON.parse((await response?.text()) ?? "{}") as Record<string, unknown>;
expect(payload.__layoutIds).toEqual([]);
expect(payload.__rootLayout).toBeNull();
expect(payload["route:/posts/missing"]).toBeTruthy();
});
it("preserves middleware headers on HTTP access fallback RSC responses", async () => {
const common = createCommonOptions();
const response = await renderAppPageHttpAccessFallback<TestModule>({
...common,
isRscRequest: true,
matchedParams: { slug: "missing" },
middlewareContext: createMiddlewareContext(),
renderToReadableStream: renderWirePayloadToStream,
route: {
notFound: notFoundModule,
params: { slug: "missing" },
pattern: "/posts/[slug]",
},
statusCode: 404,
});
expect(response?.status).toBe(404);
expect(response?.headers.get("x-middleware-security")).toBe("present");
expect(response?.headers.get("vary")).toBe(`${VINEXT_RSC_VARY_HEADER}, x-auth-state`);
expect(response?.headers.getSetCookie()).toContain("session=rotated; Path=/; HttpOnly");
});
it("uses null root layout metadata when a boundary payload has no route context", async () => {
const common = createCommonOptions();
const response = await renderAppPageHttpAccessFallback<TestModule>({
...common,
boundaryComponent: NotFoundBoundary,
isRscRequest: true,
matchedParams: { slug: "missing" },
renderToReadableStream: renderWirePayloadToStream,
rootLayouts: [rootLayoutModule],
route: null,
statusCode: 404,
});
expect(response?.status).toBe(404);
const payload = JSON.parse((await response?.text()) ?? "{}") as Record<string, unknown>;
expect(payload.__route).toBe("route:/posts/missing");
expect(payload.__layoutIds).toEqual([]);
expect(payload.__rootLayout).toBeNull();
expect(payload.__sourcePage).toBeUndefined();
expect(payload["route:/posts/missing"]).toBeTruthy();
});
it("omits source-page metadata when route segments are unavailable", async () => {
const common = createCommonOptions();
const response = await renderAppPageHttpAccessFallback<TestModule>({
...common,
isRscRequest: true,
matchedParams: {},
renderToReadableStream: renderWirePayloadToStream,
route: {
notFound: notFoundModule,
pattern: "/posts/missing",
},
statusCode: 404,
});
const payload = JSON.parse((await response?.text()) ?? "{}") as Record<string, unknown>;
expect(payload.__sourcePage).toBeUndefined();
});
it("renders route error boundaries with sanitized errors inside layouts", async () => {
const common = createCommonOptions();
const sanitizeErrorForClient = vi.fn((error: Error) => new Error(`safe:${error.message}`));
const response = await renderAppPageErrorBoundary<TestModule>({
...common,
error: new Error("secret"),
matchedParams: { slug: "post" },
route: {
error: routeErrorModule,
layouts: [rootLayoutModule],
params: { slug: "post" },
pattern: "/posts/[slug]",
},
sanitizeErrorForClient,
});
expect(response?.status).toBe(200);
expect(sanitizeErrorForClient).toHaveBeenCalledTimes(1);
const html = await response?.text();
expect(common.clearRequestContext).toHaveBeenCalledTimes(1);
expect(html).toContain('data-layout="root"');
expect(html).toContain('data-boundary="route-error"');
expect(html).toContain("route:safe:secret");
});
it("does not serialize production SSR stacks into the global error payload", async () => {
vi.stubEnv("NODE_ENV", "production");
const common = createCommonOptions();
const error = new Error("client page error");
error.stack = "/private/deployment/server.js:42";
try {
const response = await renderAppPageErrorBoundary<TestModule>({
...common,
error,
errorOrigin: "ssr",
globalErrorModule,
sanitizeErrorForClient(value: Error) {
return value;
},
});
const html = await response?.text();
expect(html).toContain("client page error");
expect(html).not.toContain("/private/deployment/server.js:42");
} finally {
vi.unstubAllEnvs();
}
});
it("preserves development RSC stacks in the global error payload", async () => {
vi.stubEnv("NODE_ENV", "development");
const common = createCommonOptions();
const error = Object.assign(new Error("server page error"), {
digest: "12345",
stack: "/app/rsc/page.tsx:2",
});
try {
const response = await renderAppPageErrorBoundary<TestModule>({
...common,
error,
errorOrigin: "rsc",
globalErrorModule: globalErrorModuleWithStack,
sanitizeErrorForClient(value: Error) {
return value;
},
});
const html = await response?.text();
expect(html).toContain("/app/rsc/page.tsx:2");
} finally {
vi.unstubAllEnvs();
}
});
it("preserves middleware headers on error boundary responses", async () => {
const common = createCommonOptions();
const response = await renderAppPageErrorBoundary<TestModule>({
...common,
error: new Error("secret"),
matchedParams: { slug: "post" },
middlewareContext: createMiddlewareContext(),
route: {
error: routeErrorModule,
params: { slug: "post" },
pattern: "/posts/[slug]",
},
sanitizeErrorForClient(error: Error) {
return error;
},
});
expect(response?.status).toBe(200);
expect(response?.headers.get("x-middleware-security")).toBe("present");
expect(response?.headers.get("vary")).toBe(`${VINEXT_RSC_VARY_HEADER}, x-auth-state`);
expect(response?.headers.getSetCookie()).toContain("session=rotated; Path=/; HttpOnly");
});
it("renders error boundaries when dynamic file metadata resolution fails", async () => {
const error = new Error("metadata boom");
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const common = createCommonOptions();
const metadataRoutes: MetadataFileRoute[] = [
{
type: "opengraph-image",
isDynamic: true,
filePath: "/tmp/app/posts/[slug]/opengraph-image.tsx",
routePrefix: "/posts/[slug]",
routeSegments: ["posts", "[slug]"],
servedUrl: "/posts/[slug]/opengraph-image",
contentType: "image/png",
contentHash: "hash",
module: {
generateImageMetadata() {
throw error;
},
},
},
];
try {
const response = await renderAppPageErrorBoundary<TestModule>({
...common,
error: new Error("secret"),
matchedParams: { slug: "post" },
metadataRoutes,
route: {
error: routeErrorModule,
layoutTreePositions: [0],
layouts: [rootLayoutModule],
params: { slug: "post" },
pattern: "/posts/[slug]",
routeSegments: ["posts", "[slug]"],
},
sanitizeErrorForClient(error: Error) {
return error;
},
});
expect(response?.status).toBe(200);
expect(consoleError).toHaveBeenCalledWith(
"[vinext] File-based metadata resolution failed while rendering error boundary for /posts/[slug]:",
error,
);
const html = await response?.text();
expect(html).toContain('data-boundary="route-error"');
expect(html).toContain("route:secret");
expect(html).toContain('content="Root layout description"');
expect(html).not.toContain("opengraph-image");
} finally {
consoleError.mockRestore();
}
});
it("renders error boundary RSC responses as flat payloads", async () => {
const common = createCommonOptions();
const response = await renderAppPageErrorBoundary<TestModule>({
...common,
error: new Error("secret"),
isRscRequest: true,
matchedParams: { slug: "missing" },
renderToReadableStream: renderWirePayloadToStream,
route: {
error: routeErrorModule,
layoutTreePositions: [0],
layouts: [rootLayoutModule],
params: { slug: "missing" },
pattern: "/posts/[slug]",
routeSegments: ["posts", "[slug]"],
},
sanitizeErrorForClient(error: Error) {
return new Error(`safe:${error.message}`);
},
});
expect(response?.status).toBe(200);
expect(response?.headers.get("Content-Type")).toBe("text/x-component");
const payload = JSON.parse((await response?.text()) ?? "{}") as Record<string, unknown>;
expect(payload.__route).toBe("route:/posts/missing");
expect(payload.__layoutIds).toEqual(["layout:/"]);
expect(payload.__rootLayout).toBe("/");
expect(payload.__sourcePage).toBe("/posts/[slug]/page");
expect(payload["route:/posts/missing"]).toBeTruthy();
});
it("renders global-error boundaries without layout wrapping", async () => {
const common = createCommonOptions();
const response = await renderAppPageErrorBoundary<TestModule>({
...common,
error: new Error("boom"),
globalErrorModule,
matchedParams: { slug: "post" },
route: {
layouts: [rootLayoutModule],
params: { slug: "post" },
pattern: "/posts/[slug]",
},
sanitizeErrorForClient(error: Error) {
return error;
},
});
expect(response?.status).toBe(500);
expect(response?.headers.get("cache-control")).toBe(
"private, no-cache, no-store, max-age=0, must-revalidate",
);
const html = await response?.text();
expect(html).toContain('data-boundary="global-error"');
expect(html).toContain("global:boom");
expect(html).not.toContain('data-layout="root"');
expect(html).not.toContain("Root layout description");
expect(html).not.toContain('name="viewport"');
});
it("falls back to the built-in default global-error when the user's global-error throws", async () => {
// When the resolved global-error boundary itself throws while rendering, the
// SSR render rejects; renderAppPageErrorBoundary catches it and re-renders
// with the built-in default global-error so the request still produces a
// usable error document with the original HTTP 500 semantics. Locks in the server-side
// retry directly (the integration test in tests/nextjs-compat/global-error
// exercises the same path through the dev/preview server). Fixes #1548.
const common = createCommonOptions();
const response = await renderAppPageErrorBoundary<TestModule>({
...common,
error: new Error("boom"),
globalErrorModule: throwingGlobalErrorModule,
matchedParams: { slug: "post" },
route: {
layouts: [rootLayoutModule],
params: { slug: "post" },
pattern: "/posts/[slug]",
},
sanitizeErrorForClient(error: Error) {
return error;
},
});
expect(response?.status).toBe(500);
expect(response?.headers.get("cache-control")).toBe(
"private, no-cache, no-store, max-age=0, must-revalidate",
);
const html = await response?.text();
// The built-in default global-error UI from
// packages/vinext/src/shims/default-global-error.tsx.
expect(html).toContain("This page couldn");
// The user's throwing boundary contributed no markup.
expect(html).not.toContain("global-error boom");
});
it("re-throws navigation signals from a throwing global-error instead of degrading to the built-in fallback", async () => {
// A redirect()/notFound() thrown from inside global-error must propagate,
// not be swallowed into a built-in default 200.
const common = createCommonOptions();
await expect(
renderAppPageErrorBoundary<TestModule>({
...common,
error: new Error("boom"),
globalErrorModule: signalThrowingGlobalErrorModule,
matchedParams: { slug: "post" },
route: {
layouts: [rootLayoutModule],
params: { slug: "post" },
pattern: "/posts/[slug]",
},
sanitizeErrorForClient(error: Error) {
return error;
},
}),
).rejects.toMatchObject({ digest: "NEXT_NOT_FOUND" });
});
});