Files
cloudflare__vinext/tests/app-page-boundary-render.test.ts
T
Nathan Nguyen 47b38a91f3 fix(app-router): recover SSR shell render errors via __next_error__ document (#1908)
* fix(plugin): resolve vinext/shims/* package subpaths to local shim files

Runtime helper modules embedded into generated entries import vinext's
own shims by package subpath (e.g. `vinext/shims/headers`), while source
checkouts alias userland `next/*` imports to the local shim files. The
two specifiers resolved to different module instances, so request-scoped
singleton state (navigation context, headers) split between the source
shim copy and the package export copy.

The violated invariant is that every shim module must be a per-request
singleton regardless of import specifier. Resolve `vinext/shims/*`
through the same plugin path as the `next/*` aliases so both forms land
on the local shim files.

Exercised by the SSR shell-error recovery browser spec, whose fixture
imports vinext from the source checkout and depends on shared
navigation state across both import forms.

* fix(app-router): recover SSR shell render errors via __next_error__ document

When the HTML (Fizz) render rejects during SSR, vinext re-rendered a
server-side global-error page whose flight payload encodes the error
tree. For an app without a custom global-error.tsx that meant the
default error card with no path back to the real page: an SSR-phase-only
throw (e.g. a client component using the "throw to opt out of server
rendering" pattern) left the browser stuck on the card even though the
client render would succeed.

The violated expectation is Next.js's shell-error semantics: a failed
HTML shell is served as the default `__next_error__` error document
carrying the ORIGINAL flight payload and the bootstrap module, and the
browser re-renders the real tree from that payload with createRoot
instead of hydrating. Local error.tsx boundaries still win — they ship
inside the flight payload and catch the re-thrown error client-side.

handleSsr now resolves to that recovery document instead of rejecting,
but only when both hold:
- the error did not originate in the RSC render (no string `digest`),
  so flight errors, redirect()/notFound(), and server-component throws
  keep driving the existing rejection-based boundary machinery, and
- the app has no custom global-error.tsx (the generated entry knows at
  build time and threads hasCustomGlobalError through dispatch/render
  options); apps with one keep the server-rendered boundary re-render.

The browser entry switches from hydrateRoot to createRoot when the
document root carries id="__next_error__", dropping the error-shell
styles first.

Covered by the new ssr-error-shell-recovery browser spec (recovery to
real content, local error.tsx for SSR-only and unconditional client
throws) and the existing tests/nextjs-compat/global-error.test.ts
boundary-semantics suite.

* refactor(review): address PR 1908 non-blocking notes

- Add static prerender no-boundary recovery regression test to
  ssr-error-shell-recovery.browser.spec.ts
- Document broad __next_error__ browser marker in app-browser-entry.ts
- Extract stripJsExtension to utils/path.ts and wire into all shim
  resolution sites (vinext/shims/* + react-server shims)

Non-functional: targeted regression coverage + code documentation
+ minor resolver hardening per reviewer feedback.

* refactor(review): clarify shell recovery assumptions

* fix(ssr): cancel abandoned prerender streams

* refactor(ssr): clarify error shell root options

* fix(isr): recover shell errors during regeneration

* fix(ssr): scope client recovery to marked shells

* fix(plugin): explicitly filter vinext shim subpaths

* fix(plugin): retain null-prefixed shim resolution

* test(nextjs-compat): cover no-boundary shell recovery fallback

An unconditional client throw during SSR shell recovery without a local error boundary must still land on the default global-error card. Without a regression test, a future change could tear down the recovery shell and leave a blank document.\n\nAdd a production browser case that exercises the no-boundary route and asserts the default error UI after the client re-render throws again.

* fix(app-router): preserve shell recovery error semantics

* test(app-router): harden shell recovery cache semantics

* fix(app-router): mark global error responses uncacheable

* test(app-router): align error response status expectations

* fix(build): preserve null-prefixed og resolution

* fix(cache): delegate cache header cleanup to adapters

---------

Co-authored-by: James <james@eli.cx>
2026-06-14 22:28:25 +00:00

728 lines
24 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";
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 {
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}`);
}
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;
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("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" });
});
});