mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
cda19e1c58
* fix(app-router): preserve recent segment state with Activity BFCache Fresh App Router navigations were reusing the active React tree when moving between dynamic segment values. That leaked client state into a newly entered segment and made the upstream back-forward-cache suite fail before it even reached browser traversal assertions. The root cause was that vinext had history-entry bfcacheId plumbing but no separate segment state-key cache. Next.js treats those identities separately: render state is keyed by segment identity/pathname, while useRouter().bfcacheId identifies a concrete history entry. Add state-key maps for app router slots, render retained entries through React.Activity when cacheComponents is enabled, keep the active one-entry keyed reset path otherwise, and port focused Next.js regression coverage for the three-entry BFCache behavior. * fix(bfcache): store stateKeyMap in retained slot entries and re-provide on render Review feedback requested two changes: 1. Add stateKeyMap to BfcacheSlotEntry so each retained BFCache entry carries the state-key map that belonged to it when active, rather than borrowing the current navigation's map. This prevents nested hidden entries from being rendered with old elements but new pathname-derived state keys — an identity corruption bug that upstream Next.js avoids by rendering each retained entry from its own state. 2. Add a nested dynamic regression test: /nested/[section]/item/[id] with client counters in both the [section] layout and [id] page. The existing flat /page/[n] tests do not exercise nested retained-entry identity, which is exactly where the missing state-key-map bug lives. Changes: - BfcacheSlotEntry gains stateKeyMap?: Readonly<Record<string, string>> - updateBfcacheSlotEntries accepts and stores an optional stateKeyMap argument - BfcacheSlotBoundary stores the current stateKeyMap alongside elements when caching the active entry - Both the Activity visible/hidden path and the non-cacheComponents single-entry path now wrap rendered entries in BfcacheStateKeyMapContext.Provider using the entry's own stateKeyMap (falling back to current map for forward compatibility) - New e2e test verifies nested layout + page counter preservation across segment navigations * refactor(bfcache): extract shared provider stack into BfcacheEntryProviders BfcacheSlotBoundary rendered the BfcacheStateKeyMapContext / ElementsContext / SegmentContext provider stack in two places: the one-entry keyed reset path and the per-entry Activity retention list. The two stacks were identical except for where the React key sat, so the retained-entry bookkeeping and its render shape were tangled together and harder to audit than needed. Extract BfcacheEntryProviders as a pure presentational helper and render it from both paths. The keyed-reset path keeps its remount semantics: the key now sits on the BfcacheEntryProviders element, so a fresh active stateKey still remounts the whole provider subtree. Hook ordering is unchanged. All hooks stay above the early returns in BfcacheSlotBoundary, so the boundary still satisfies the Rules of Hooks across the activeStateKey undefined-to-defined transition. Covered by the slot BFCache entry-state suite in tests/shims.test.ts. * test(bfcache): isolate cacheComponents BFCache coverage in a dedicated fixture The back/forward-cache e2e suite ran against the shared app-basic fixture with cacheComponents: true. app-basic backs the single app-router Playwright server that every app-router spec uses, so enabling cacheComponents globally changed a core DOM invariant for unrelated tests: inactive route trees stay mounted as hidden Activity DOM. The contamination already leaked a :visible workaround into the interception-modal bfcacheId spec, which had nothing to do with Activity. cacheComponents is an opt-in semantic mode, not default app-router behaviour, so it does not belong on the shared baseline fixture. Move the BFCache routes into a dedicated tests/fixtures/app-bfcache fixture with cacheComponents: true, served by a new app-router-bfcache Playwright project on port 4183, and run the back-forward-cache spec there against the project baseURL. The :visible locators stay in that spec, where duplicate hidden DOM is the expected contract. Remove cacheComponents and the BFCache routes from app-basic so the shared fixture models default behaviour again, and revert the :visible workaround in use-router-bfcache-id.spec.ts now that the modal route no longer retains hidden DOM. Fix the nested item page import while moving it: nested/[section]/item/[id]/ page.tsx imported ../counter, but the counter lives two levels up at nested/[section]/counter.tsx, so it must be ../../counter. Validation: PLAYWRIGHT_PROJECT=app-router-bfcache (5 passed) and the full use-router-bfcache-id spec under app-router (11 passed), plus the slot BFCache unit suite (1172 passed) and vp check (formatting + lint + types clean). * ci: run the app-router-bfcache e2e project in the matrix The dedicated app-router-bfcache Playwright project was added to playwright.config.ts but not to the CI E2E matrix, so the back/forward-cache specs never ran in CI. Add it to the matrix project list so the BFCache suite runs on every push. It uses the default chromium browser, so the existing setup and browser-cache steps apply unchanged. * fix(bfcache): keep persisted slots mounted when cacheComponents is off Without cacheComponents there is no Activity retention, yet BfcacheSlotBoundary still keyed the active entry by its segment stateKey. That stateKey tracks the pathname (createBfcacheSegmentIdentity), so any slot whose identity moves with the URL (shared layouts, interception source slots) was remounted on navigation, discarding client state that survives a normal route change. The baseline router reconciles these slots in place. This regressed seven existing app-router e2e tests once cacheComponents was no longer masking it on the shared fixture: interception source-context and modal preservation in advanced.spec.ts, and layout-persistence.spec.ts dynamic-segment layout counter survival. Fresh-entry reset is driven by userland bfcacheId keying in the fixtures, not by remounting the slot subtree, so the keying was pure overreach in the non-cacheComponents path. Render the single active entry unkeyed when cacheComponents is off so the boundary reconciles in place like the baseline. The keyed Activity retention path is unchanged for cacheComponents. Validation: full app-router project 436 passed / 0 failed (the seven regressions now pass); use-router-bfcache-id 11 passed (fresh-push reset intact); app-bfcache 5 passed (Activity retention intact); shims unit suite 1024 passed. * docs(test): point bfcacheId spec comment at the app-router-bfcache project The header noted that Activity-backed preservation is covered in back-forward-cache.spec.ts, but that spec moved to the dedicated app-bfcache fixture under the app-router-bfcache project. Name the project and path so the cross-reference stays accurate. * docs: align cacheComponents comment with unkeyed active-segment behavior * test(bfcache): assert fresh bfcacheId after Activity-restored return * style: collapse locator chains per oxfmt * refactor(bfcache): split slot entry order from snapshots * fix(bfcache): keep non-cacheComponents slot path baseline * fix(bfcache): use string cacheComponents define * test: remove external fetch from revalidate tag fixture * fix(bfcache): harden Activity state key retention * docs(bfcache): document cacheComponents Activity caveats * fix(bfcache): preserve navigation context for fallback SSR Fallback boundary rendering can outlive the original request-scoped navigation context. That made app SSR fail fast with a missing navigation context when loading-boundary digest routes rendered their HTTP fallback shell. The SSR boundary contract still requires a navigation context for real request renders, but boundary renderers now synthesize the same typed shape from the request URL and matched params when request scope has already been cleared. * refactor(bfcache): normalize pathname in state-key map, document invariant callers Moves hydration-pathname normalization into createBfcacheSegmentStateKeyMap so SSR and client always produce byte-identical Activity keys. Documents the requireNavigationContext guaranteeing callers per review feedback. * fix(bfcache): tolerate malformed state-key pathnames * docs(bfcache): document known Next.js divergence and dead-branch contract * chore(deps): update lockfile for vite 0.1.24 * fix(app-router): preserve encoded delimiters in bfcache keys * docs(bfcache): track router-owned retention follow-up * chore: retrigger ci * fix(app-router): preserve redirect bridge fallback
685 lines
24 KiB
TypeScript
685 lines
24 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", 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: 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"');
|
||
});
|
||
});
|
||
|
||
// 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(200);
|
||
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(200);
|
||
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"');
|
||
});
|
||
});
|