Files
cloudflare__vinext/tests/app-fallback-renderer.test.ts
T
James Anderson c6e8bda55e feat(app-router): support app/global-not-found.tsx (Next.js 16 parity) (#1301)
When users define app/global-not-found.tsx, route-miss 404s should render
that module standalone — it provides its own <html>/<body> and replaces
the root layout, matching the behavior of Next.js 16's
experimental.globalNotFound feature.

  https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-not-found
  https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/app-render.tsx#L495-L520

Implementation:

- Discover app/global-not-found.{tsx,ts,jsx,js} alongside the existing
  global-error scan (src/index.ts).
- Thread the path through generateRscEntry via AppRouterConfig and
  buildAppRscManifestCode so the generated entry imports it and binds
  it as globalNotFoundModule.
- Pass the module into createAppFallbackRenderer; renderHttpAccessFallback
  switches to the global-not-found document when statusCode === 404, the
  route is null (route-miss), and no boundary override was supplied.
- Add a skipLayoutWrapping flag to renderAppPageHttpAccessFallback so
  the standalone document isn't wrapped in the root layout, and so the
  RSC payload omits layout-id entries that would otherwise refer to a
  layout tree the markup doesn't contain.

Page-triggered notFound() calls keep the existing path: they still
resolve through the regular not-found.tsx boundary inside layouts —
global-not-found.tsx only takes over for route-miss 404s.

Tests:

- tests/app-fallback-renderer.test.ts adds four unit tests covering
  the new branch (route-miss with global-not-found, page-call with
  global-not-found, absent global-not-found, 403/401 unaffected).
- tests/entry-templates.test.ts adds four codegen assertions covering
  the manifest import and the generated-entry wiring in both directions
  (present and absent).
- tests/nextjs-compat/global-not-found.test.ts ports the basic Next.js
  fixture as an integration test against a dev server, asserting that
  the route-miss 404 renders global-not-found's <html> standalone with
  exactly one <html>/<body>, and that page-call notFound() does not
  use the global-not-found document.
2026-05-18 11:48:04 +01:00

468 lines
16 KiB
TypeScript

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;
rootLayoutModules?: readonly ({ default: React.ComponentType<any> } | null | undefined)[];
rootNotFoundModule?: { default: React.ComponentType<any> } | null;
}) {
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" };
},
globalErrorModule: null,
globalNotFoundModule: overrides?.globalNotFoundModule ?? null,
makeThenableParams<T>(params: T) {
return params;
},
metadataRoutes: [],
resolveChildSegments() {
return [];
},
rootBoundaries: {
rootForbiddenModule: null,
rootLayouts: overrides?.rootLayoutModules ?? [],
rootNotFoundModule: overrides?.rootNotFoundModule ?? null,
rootUnauthorizedModule: null,
},
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("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("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"');
});
});
// 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. With no
// root notFoundModule either, the renderer returns null and the caller
// falls back to the framework's 404 Response.
const { renderer } = createRenderer({
globalNotFoundModule: null,
rootLayoutModules: [rootLayoutModule],
});
const request = new Request("https://example.com/does-not-exist");
const response = await renderer.renderNotFound(null, false, request, undefined, undefined, {
headers: null,
status: null,
});
// No boundary component to render -> renderer returns null.
expect(response).toBeNull();
});
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"');
});
});