Files
cloudflare__vinext/tests/app-fallback-renderer.test.ts
T
James Anderson e4f5a0e56b fix(app-router): honor global not found opt-in (#2340)
* fix(app-router): honor global not found opt-in

* fix(app-router): render default global 404 standalone

* test(app-router): avoid fixture workspace lockfile entry

* test(app-router): keep fixture esm outside workspace

* fix(app-router): add default global 404 shell

* test(app-router): cover client not found boundary

* fix(app-router): gate global not found discovery
2026-06-26 21:34:25 +01:00

784 lines
27 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi } from "vite-plus/test";
import React from "react";
import ReactDOMServer from "react-dom/server";
import { createAppFallbackRenderer } from "../packages/vinext/src/server/app-fallback-renderer.js";
import type { AppElements } from "../packages/vinext/src/server/app-elements.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)) {
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 createRenderer(overrides?: {
createRscOnErrorHandler?: (
request: Request,
pathname: string,
routePath: string,
) => (error: unknown, requestInfo: unknown, errorContext: unknown) => unknown;
sanitizeErrorForClient?: (error: Error) => Error;
globalNotFoundModule?: { default: React.ComponentType<any> } | null;
globalNotFoundEnabled?: boolean;
rootLayoutModules?: readonly ({ default: React.ComponentType<any> } | null | undefined)[];
rootNotFoundModule?: { default: React.ComponentType<any> } | null;
rscRenderer?: (
element: React.ReactNode | AppElements,
options: { onError: (error: unknown, requestInfo: unknown, errorContext: unknown) => unknown },
) => ReadableStream<Uint8Array>;
}) {
const clearRequestContext = vi.fn();
const ssrLoader = vi.fn(async () => ({
async handleSsr(rscStream: ReadableStream<Uint8Array>) {
return rscStream;
},
}));
return {
clearRequestContext,
renderer: createAppFallbackRenderer({
clearRequestContext,
createRscOnErrorHandler: overrides?.createRscOnErrorHandler ?? (() => () => null),
fontProviders: {
buildFontLinkHeader(
preloads: readonly { href: string; type: string }[] | null | undefined,
) {
if (!preloads || preloads.length === 0) return "";
return preloads.map((p) => `<${p.href}>; rel=preload`).join(", ");
},
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: {} };
},
globalErrorModule: null,
globalNotFoundEnabled:
overrides?.globalNotFoundEnabled ?? overrides?.globalNotFoundModule != null,
loadGlobalNotFoundModule: overrides?.globalNotFoundModule
? async () => overrides.globalNotFoundModule ?? null
: null,
makeThenableParams<T>(params: T) {
return params;
},
metadataRoutes: [],
resolveChildSegments() {
return [];
},
rootBoundaries: {
rootForbiddenModule: null,
rootLayouts: overrides?.rootLayoutModules ?? [],
rootNotFoundModule: overrides?.rootNotFoundModule ?? null,
rootUnauthorizedModule: null,
},
rscRenderer: overrides?.rscRenderer ?? renderElementToStream,
sanitizer: overrides?.sanitizeErrorForClient ?? ((error: Error) => error),
ssrLoader,
}),
ssrLoader,
};
}
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 ParamsLayout({
children,
params,
}: {
children: React.ReactNode;
params: { slug?: string };
}) {
return React.createElement("div", { "data-params-slug": params.slug ?? "none" }, children);
}
function OverrideLayout({ children }: { children: React.ReactNode }) {
return React.createElement("div", { "data-layout": "override" }, children);
}
type TestModule = {
default: React.ComponentType<any>;
};
const notFoundModule = { default: NotFoundBoundary } satisfies TestModule;
const routeErrorModule = { default: RouteErrorBoundary } satisfies TestModule;
const paramsLayoutModule = { default: ParamsLayout } satisfies TestModule;
const overrideLayoutModule = { default: OverrideLayout } satisfies TestModule;
describe("app fallback renderer factory", () => {
it("constructs once and passes request at call site", async () => {
const { renderer } = createRenderer();
const requestA = new Request("https://example.com/a");
const requestB = new Request("https://example.com/b");
// Both calls succeed with different requests — proves request is not
// captured in the factory closure but passed per-call.
const responseA = await renderer.renderNotFound(
{
notFound: notFoundModule,
params: {},
pattern: "/a",
},
false,
requestA,
undefined,
undefined,
{ headers: null, status: null },
);
const responseB = await renderer.renderNotFound(
{
notFound: notFoundModule,
params: {},
pattern: "/b",
},
false,
requestB,
undefined,
undefined,
{ headers: null, status: null },
);
expect(responseA?.status).toBe(404);
expect(responseB?.status).toBe(404);
});
it("delegates renderNotFound to renderHttpAccessFallback with status 404", async () => {
const { renderer, ssrLoader } = createRenderer();
const request = new Request("https://example.com/posts/missing");
const response = await renderer.renderNotFound(
{
notFound: notFoundModule,
params: { slug: "missing" },
pattern: "/posts/[slug]",
},
false,
request,
{ slug: "missing" },
undefined,
{ headers: null, status: null },
);
expect(response?.status).toBe(404);
expect(ssrLoader).toHaveBeenCalledTimes(1);
const html = await response?.text();
expect(html).toContain('data-boundary="not-found"');
});
it("renders error boundaries with sanitized errors", async () => {
const sanitizer = vi.fn((error: Error) => new Error(`safe:${error.message}`));
const { renderer, ssrLoader, clearRequestContext } = createRenderer({
sanitizeErrorForClient: sanitizer,
});
const request = new Request("https://example.com/posts/boom");
const response = await renderer.renderErrorBoundary(
{
error: routeErrorModule,
layouts: [],
params: { slug: "boom" },
pattern: "/posts/[slug]",
},
new Error("secret"),
false,
request,
{ slug: "boom" },
undefined,
{ headers: null, status: null },
);
expect(response?.status).toBe(200);
expect(sanitizer).toHaveBeenCalledTimes(1);
expect(ssrLoader).toHaveBeenCalledTimes(1);
const html = await response?.text();
// clearRequestContext is deferred until the stream is consumed.
expect(clearRequestContext).toHaveBeenCalledTimes(1);
expect(html).toContain('data-boundary="route-error"');
expect(html).toContain("route:safe:secret");
});
it("preserves sibling-intercept source pages in error boundary RSC payloads", async () => {
const { renderer } = createRenderer({
rscRenderer(element) {
return createStreamFromMarkup(JSON.stringify(element));
},
});
const response = await renderer.renderErrorBoundary(
{
error: routeErrorModule,
layouts: [],
params: {},
pattern: "/foo/bar",
routeSegments: ["foo", "bar"],
},
new Error("secret"),
true,
new Request("https://example.com/hoge"),
{},
undefined,
{ headers: null, status: null },
{ sourcePageSegments: ["foo", "bar", "(..)(..)hoge"] },
);
const payload = JSON.parse((await response?.text()) ?? "{}") as Record<string, unknown>;
expect(payload.__sourcePage).toBe("/foo/bar/(..)(..)hoge/page");
});
it("passes request to createRscOnErrorHandler at call time", async () => {
const createRscOnErrorHandler = vi.fn(
(_request: Request, _pathname: string, _routePath: string) => () => null,
);
const { renderer } = createRenderer({ createRscOnErrorHandler });
const request = new Request("https://example.com/posts/boom");
await renderer.renderErrorBoundary(
{
error: routeErrorModule,
layouts: [],
params: { slug: "boom" },
pattern: "/posts/[slug]",
},
new Error("boom"),
false,
request,
{ slug: "boom" },
undefined,
{ headers: null, status: null },
);
expect(createRscOnErrorHandler).toHaveBeenCalledTimes(1);
expect(createRscOnErrorHandler).toHaveBeenCalledWith(request, "/posts/boom", "/posts/[slug]");
});
it("uses empty middleware context when none is provided", async () => {
const { renderer } = createRenderer();
const request = new Request("https://example.com/posts/missing");
// Pass undefined middlewareContext — factory should fall back to empty.
const response = await renderer.renderNotFound(
{
notFound: notFoundModule,
params: { slug: "missing" },
pattern: "/posts/[slug]",
},
false,
request,
{ slug: "missing" },
undefined,
undefined as unknown as { headers: null; status: null },
);
expect(response?.status).toBe(404);
});
it("falls back matchedParams to route.params when not provided", async () => {
const { renderer } = createRenderer();
const request = new Request("https://example.com/posts/missing");
// Call without matchedParams in opts — factory should fall back to route.params.
// Provide routeSegments + layoutTreePositions so the layout receives the slug param.
const response = await renderer.renderHttpAccessFallback(
{
layouts: [paramsLayoutModule],
layoutTreePositions: [1],
notFound: notFoundModule,
params: { slug: "from-route" },
pattern: "/[slug]",
routeSegments: ["[slug]"],
},
404,
false,
request,
{}, // no matchedParams, no boundaryComponent override
undefined,
{ headers: null, status: null },
);
const html = await response?.text();
expect(html).toContain('data-params-slug="from-route"');
expect(html).toContain('data-boundary="not-found"');
});
it("preserves sibling-intercept source pages in HTTP fallback RSC payloads", async () => {
const { renderer } = createRenderer({
rscRenderer(element) {
return createStreamFromMarkup(JSON.stringify(element));
},
});
const response = await renderer.renderHttpAccessFallback(
{
layouts: [],
notFound: notFoundModule,
params: {},
pattern: "/foo/bar",
routeSegments: ["foo", "bar"],
},
404,
true,
new Request("https://example.com/hoge"),
{ matchedParams: {} },
undefined,
{ headers: null, status: null },
{ sourcePageSegments: ["foo", "bar", "(..)(..)hoge"] },
);
const payload = JSON.parse((await response?.text()) ?? "{}") as Record<string, unknown>;
expect(payload.__sourcePage).toBe("/foo/bar/(..)(..)hoge/page");
});
it("uses opts.layouts override instead of route.layouts", async () => {
const { renderer } = createRenderer();
const request = new Request("https://example.com/posts/missing");
const response = await renderer.renderHttpAccessFallback(
{
layouts: [paramsLayoutModule], // route layout — should NOT appear
layoutTreePositions: [1],
notFound: notFoundModule,
params: { slug: "missing" },
pattern: "/[slug]",
routeSegments: ["[slug]"],
},
404,
false,
request,
{
layouts: [overrideLayoutModule], // override — SHOULD appear
matchedParams: { slug: "missing" },
},
undefined,
{ headers: null, status: null },
);
const html = await response?.text();
expect(html).toContain('data-layout="override"');
expect(html).not.toContain("data-params-slug");
expect(html).toContain('data-boundary="not-found"');
});
});
// Ported from Next.js: test/e2e/app-dir/default-error-page-ui/default-error-page-ui.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/default-error-page-ui/default-error-page-ui.test.ts
//
// When the app does not define `error.tsx`, `global-error.tsx`, etc., vinext
// must still render the same default error UI Next.js ships, including the
// 32x32 warning icon, the "This page couldn't load" heading, a "Reload"
// button, a "Back" button (client errors only), and an "ERROR <digest>"
// footer (server errors only). Without this, the renderer used to return
// null and the request bubbled up as a generic 500.
describe("app fallback renderer default global error UI", () => {
it("renders the built-in default global error UI for client errors", async () => {
const { renderer } = createRenderer();
const request = new Request("https://example.com/trigger-error");
const response = await renderer.renderErrorBoundary(
{
// No `error` module on the route — falls back to the default global error.
layouts: [],
params: {},
pattern: "/trigger-error",
},
new Error("Test client error"),
false,
request,
{},
undefined,
{ headers: null, status: null },
);
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();
// 32x32 SVG warning icon (matches the test's `expect width/height === 32`).
expect(html).toContain('width="32"');
expect(html).toContain('height="32"');
// Heading + curly apostrophe ("couldn’t").
expect(html).toContain("<h1");
expect(html).toContain("This page couldn’t load");
// Client error message + "Reload" + "Back" buttons.
expect(html).toContain("Reload to try again, or go back");
expect(html).toContain(">Reload<");
expect(html).toContain(">Back<");
// Theme CSS is inlined so the test's color assertions still resolve.
expect(html).toContain("--next-error-title");
// No digest -> no "ERROR <digest>" footer.
expect(html).not.toMatch(/ERROR\s+\w+/);
});
it("renders the server error variant with an ERROR <digest> footer", async () => {
const { renderer } = createRenderer();
const request = new Request("https://example.com/server-error");
// Use a sanitizer that preserves the digest so the footer is emitted.
const serverError = Object.assign(new Error("Test server error"), {
digest: "1234567890",
});
const response = await renderer.renderErrorBoundary(
{
layouts: [],
params: {},
pattern: "/server-error",
},
serverError,
false,
request,
{},
undefined,
{ headers: null, status: null },
);
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();
// Server errors still render the same heading.
expect(html).toContain("This page couldn’t load");
// Server error variant of the message.
expect(html).toContain("A server error occurred");
// Server errors do not render the "Back" button.
expect(html).not.toContain(">Back<");
// Digest footer "ERROR <digest>" — the test uses /ERROR \w+/.
expect(html).toMatch(/ERROR\s+1234567890/);
});
it("prefers a user-defined global error module over the default", async () => {
function UserGlobalError({ error }: { error: { message?: string } }) {
return React.createElement(
"html",
null,
React.createElement(
"body",
null,
React.createElement(
"h1",
{ "data-user-global-error": "true" },
`user-global-error:${error.message ?? ""}`,
),
),
);
}
const userGlobalErrorModule = { default: UserGlobalError } satisfies TestModule;
const { renderer } = createRenderer();
// Re-create the renderer with a user-supplied global error module. The
// createRenderer helper does not expose globalErrorModule directly, so
// call createAppFallbackRenderer here instead via the same overrides path.
// We instead test by inspecting that the default UI is replaced when a
// user module is configured. Since the helper does not currently allow
// overriding globalErrorModule, this assertion is encoded by re-creating
// the renderer locally.
const { createAppFallbackRenderer } =
await import("../packages/vinext/src/server/app-fallback-renderer.js");
const localRenderer = createAppFallbackRenderer({
basePath: "",
clearRequestContext() {},
createRscOnErrorHandler: () => () => null,
fontProviders: {
buildFontLinkHeader: () => "",
getFontLinks: () => [],
getFontPreloads: () => [],
getFontStyles: () => [],
},
getNavigationContext: () => ({
pathname: "/server-error",
searchParams: new URLSearchParams(),
params: {},
}),
globalErrorModule: userGlobalErrorModule,
loadGlobalNotFoundModule: null,
makeThenableParams: (p) => p,
metadataRoutes: [],
resolveChildSegments: () => [],
rootBoundaries: {
rootForbiddenModule: null,
rootLayouts: [],
rootNotFoundModule: null,
rootUnauthorizedModule: null,
},
rscRenderer: renderElementToStream,
sanitizer: (error) => error,
ssrLoader: async () => ({
async handleSsr(rscStream: ReadableStream<Uint8Array>) {
return rscStream;
},
}),
});
void renderer;
const request = new Request("https://example.com/server-error");
const response = await localRenderer.renderErrorBoundary(
{
layouts: [],
params: {},
pattern: "/server-error",
},
new Error("from-user"),
false,
request,
{},
undefined,
{ headers: null, status: null },
);
const html = await response?.text();
expect(html).toContain('data-user-global-error="true"');
expect(html).toContain("user-global-error:from-user");
// The default UI must NOT leak through.
expect(html).not.toContain("This page couldn’t load");
});
});
// Regression for #1454 — default App Router 404 must match Next.js's built-in
// not-found component ("This page could not be found." with trailing period).
describe("app fallback renderer default not-found UI", () => {
it("renders the canonical 'This page could not be found.' body when no not-found.tsx exists", async () => {
const { renderer } = createRenderer();
const request = new Request("https://example.com/missing");
const response = await renderer.renderNotFound(null, false, request, undefined, undefined, {
headers: null,
status: null,
});
expect(response?.status).toBe(404);
const html = await response?.text();
// Canonical message must contain the trailing period to match Next.js
// (see .nextjs-ref/packages/next/src/client/components/builtin/not-found.tsx).
expect(html).toContain("This page could not be found.");
// Status code is surfaced as the <h1>.
expect(html).toContain("404");
// Old vinext default ("404 - Page not found") must NOT leak through.
expect(html).not.toContain("404 - Page not found");
});
it("prefers a user-defined root not-found.tsx over the default", async () => {
const { renderer } = createRenderer({ rootNotFoundModule: notFoundModule });
const request = new Request("https://example.com/missing");
const response = await renderer.renderNotFound(null, false, request, undefined, undefined, {
headers: null,
status: null,
});
expect(response?.status).toBe(404);
const html = await response?.text();
// The user-defined boundary wins.
expect(html).toContain('data-boundary="not-found"');
expect(html).toContain("Missing page");
// The default not-found body must NOT leak through.
expect(html).not.toContain("This page could not be found.");
});
});
// Mirrors Next.js 16 experimental.globalNotFound behavior.
// Ported from Next.js: test/e2e/app-dir/global-not-found/{basic,both-present,not-present}.
// Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-not-found
// In Next.js, `app/global-not-found.tsx` replaces the root layout for
// route-miss 404s — see createNotFoundLoaderTree in
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/app-render.tsx#L495-L520
describe("app fallback renderer with globalNotFoundModule", () => {
function RootLayout({ children }: { children: React.ReactNode }) {
return React.createElement("html", { lang: "en" }, React.createElement("body", null, children));
}
const rootLayoutModule = { default: RootLayout } satisfies TestModule;
function GlobalNotFound() {
return React.createElement(
"html",
{ "data-global-not-found": "true" },
React.createElement(
"body",
null,
React.createElement("h1", { id: "global-error-title" }, "global-not-found"),
),
);
}
const globalNotFoundModule = { default: GlobalNotFound } satisfies TestModule;
it("renders global-not-found for route-miss 404 without root layout wrapping", async () => {
// Mirrors test/e2e/app-dir/global-not-found/basic:
// visiting /does-not-exist should produce global-not-found's own
// <html data-global-not-found="true"> document, not the root layout's.
const { renderer } = createRenderer({
globalNotFoundModule,
rootLayoutModules: [rootLayoutModule],
});
const request = new Request("https://example.com/does-not-exist");
// route = null mirrors the route-miss path in app-rsc-handler.ts:504.
const response = await renderer.renderNotFound(null, false, request, undefined, undefined, {
headers: null,
status: null,
});
expect(response?.status).toBe(404);
const html = await response?.text();
expect(html).toContain('data-global-not-found="true"');
expect(html).toContain('id="global-error-title"');
expect(html).toContain("global-not-found");
// Root layout was NOT applied — it would have rendered <html lang="en">.
expect(html).not.toContain('lang="en"');
});
it("uses the route's not-found.tsx boundary when notFound() is called from a page", async () => {
// Mirrors test/e2e/app-dir/global-not-found/basic, /call-not-found case:
// when notFound() is called inside a matched page, the regular
// not-found.tsx boundary should render inside the root layout, NOT the
// global-not-found.tsx document.
const { renderer } = createRenderer({
globalNotFoundModule,
rootLayoutModules: [rootLayoutModule],
rootNotFoundModule: notFoundModule,
});
const request = new Request("https://example.com/call-not-found");
// route is non-null: simulates a matched page that called notFound().
const response = await renderer.renderNotFound(
{
layouts: [rootLayoutModule],
notFound: notFoundModule,
params: {},
pattern: "/call-not-found",
},
false,
request,
undefined,
undefined,
{ headers: null, status: null },
);
expect(response?.status).toBe(404);
const html = await response?.text();
// Should render the regular not-found boundary inside the root layout.
expect(html).toContain('data-boundary="not-found"');
expect(html).toContain('lang="en"');
// Should NOT render the global-not-found document.
expect(html).not.toContain('data-global-not-found="true"');
});
it("falls back to default not-found when global-not-found is absent and route is null", async () => {
// Mirrors test/e2e/app-dir/global-not-found/not-present: when the user
// opted into experimental.globalNotFound but never created the file,
// route-miss 404s should still serve the default 404 response, even when
// the app defines a root not-found.tsx for explicit notFound() calls.
const { renderer } = createRenderer({
globalNotFoundModule: null,
globalNotFoundEnabled: true,
rootLayoutModules: [rootLayoutModule],
rootNotFoundModule: notFoundModule,
});
const request = new Request("https://example.com/does-not-exist");
const response = await renderer.renderNotFound(null, false, request, undefined, undefined, {
headers: null,
status: null,
});
expect(response?.status).toBe(404);
const html = await response?.text();
expect(html).toContain("This page could not be found.");
expect(html).not.toContain('data-boundary="not-found"');
expect(html).not.toContain('lang="en"');
expect((html?.match(/<html/gi) ?? []).length).toBe(1);
expect((html?.match(/<body/gi) ?? []).length).toBe(1);
});
it("keeps root not-found for explicit notFound when global-not-found is absent", async () => {
const { renderer } = createRenderer({
globalNotFoundEnabled: true,
rootLayoutModules: [rootLayoutModule],
rootNotFoundModule: notFoundModule,
});
const request = new Request("https://example.com/call-not-found");
const response = await renderer.renderNotFound(
{
layouts: [rootLayoutModule],
notFound: notFoundModule,
params: {},
pattern: "/call-not-found",
},
false,
request,
undefined,
undefined,
{ headers: null, status: null },
);
expect(response?.status).toBe(404);
const html = await response?.text();
expect(html).toContain('data-boundary="not-found"');
expect(html).toContain('lang="en"');
});
it("does not use global-not-found for non-404 access fallbacks (403, 401)", async () => {
// global-not-found only applies to 404. Other HTTP access fallbacks
// (forbidden / unauthorized) keep their normal behavior.
function ForbiddenBoundary() {
return React.createElement("p", { "data-boundary": "forbidden" }, "Forbidden");
}
const forbiddenModule = { default: ForbiddenBoundary } satisfies TestModule;
const { renderer } = createRenderer({
globalNotFoundModule,
rootLayoutModules: [rootLayoutModule],
});
const request = new Request("https://example.com/admin");
// statusCode 403 with route = null. Should NOT trigger global-not-found.
// With no boundary configured, the renderer returns null.
const response = await renderer.renderHttpAccessFallback(
null,
403,
false,
request,
{ boundaryComponent: forbiddenModule.default },
undefined,
{ headers: null, status: null },
);
expect(response?.status).toBe(403);
const html = await response?.text();
expect(html).toContain('data-boundary="forbidden"');
// 403 wraps in the root layout (not global-not-found).
expect(html).toContain('lang="en"');
expect(html).not.toContain('data-global-not-found="true"');
});
});