mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
47b38a91f3
* 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>
749 lines
26 KiB
TypeScript
749 lines
26 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;
|
||
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,
|
||
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. With no
|
||
// user-defined root notFoundModule either, vinext renders its built-in
|
||
// default not-found component (parity with Next.js's packaged
|
||
// not-found.tsx — "This page could not be found." with trailing period).
|
||
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,
|
||
});
|
||
|
||
expect(response?.status).toBe(404);
|
||
const html = await response?.text();
|
||
expect(html).toContain("This page could not be found.");
|
||
});
|
||
|
||
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"');
|
||
});
|
||
});
|