Files
cloudflare__vinext/tests/app-page-boundary.test.ts
James Anderson fd086c4475 fix(app-router): apply x-edge-runtime header on all App Router response paths (#1603)
Extends #1565: the merged change gated the marker on the resolved segment
config, but only covered the two `buildAppPage*Response` builders. Five
other response construction paths bypassed those helpers and skipped the
header for edge-runtime routes:

- `app-page-cache.ts` — ISR HIT/STALE responses
- `app-server-action-execution.ts` — server action RSC responses
  (redirect, no-revalidate, re-render)
- `app-page-boundary.ts` — boundary RSC responses
- `app-page-execution.ts` — 200 + flight-payload redirect responses
- `app-page-stream.ts` — HTML stream responses used by the boundary path

Centralizes the gating in a single `applyEdgeRuntimeHeader` helper so
every site shares one source of truth for header name + condition, and
threads `isEdgeRuntime` through the fallback renderer + dispatch options
so each construction site receives it.

Adds positive + negative coverage for each newly-covered path.
2026-05-26 11:28:33 +01:00

287 lines
9.2 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import {
renderAppPageBoundaryResponse,
resolveAppPageErrorBoundary,
resolveAppPageHttpAccessBoundaryComponent,
resolveAppPageParentHttpAccessBoundaryModule,
wrapAppPageBoundaryElement,
} from "../packages/vinext/src/server/app-page-boundary.js";
async function readText(stream: ReadableStream<Uint8Array>): Promise<string> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let text = "";
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
text += decoder.decode(value, { stream: true });
}
return text + decoder.decode();
}
describe("app page boundary helpers", () => {
it("prefers route-specific HTTP access boundaries over root fallbacks", () => {
const component = resolveAppPageHttpAccessBoundaryComponent({
getDefaultExport(boundaryModule) {
return boundaryModule?.default ?? null;
},
rootForbiddenModule: { default: "RootForbidden" },
rootNotFoundModule: { default: "RootNotFound" },
rootUnauthorizedModule: { default: "RootUnauthorized" },
routeForbiddenModule: { default: "RouteForbidden" },
routeNotFoundModule: { default: "RouteNotFound" },
routeUnauthorizedModule: { default: "RouteUnauthorized" },
statusCode: 403,
});
expect(component).toBe("RouteForbidden");
});
it("falls back to the root not-found boundary when the route has none", () => {
const component = resolveAppPageHttpAccessBoundaryComponent({
getDefaultExport(boundaryModule) {
return boundaryModule?.default ?? null;
},
rootNotFoundModule: { default: "RootNotFound" },
statusCode: 404,
});
expect(component).toBe("RootNotFound");
});
it("selects the matching parent HTTP access boundary for layout throws", () => {
expect(
resolveAppPageParentHttpAccessBoundaryModule({
layoutIndex: 2,
rootForbiddenModule: "RootForbidden",
routeForbiddenModules: [null, "ParentForbidden", "ThrowingLayoutForbidden"],
routeNotFoundModules: [null, "ParentNotFound"],
statusCode: 403,
}),
).toBe("ParentForbidden");
expect(
resolveAppPageParentHttpAccessBoundaryModule({
layoutIndex: 2,
rootUnauthorizedModule: "RootUnauthorized",
routeNotFoundModules: [null, "ParentNotFound"],
routeUnauthorizedModules: [null, undefined, "ThrowingLayoutUnauthorized"],
statusCode: 401,
}),
).toBe("RootUnauthorized");
});
it("resolves page, layout, and global error boundaries in order", () => {
expect(
resolveAppPageErrorBoundary({
getDefaultExport(errorModule) {
return errorModule?.default ?? null;
},
layoutErrorModules: [{ default: "RootLayoutError" }, { default: "LeafLayoutError" }],
pageErrorModule: { default: "PageError" },
}),
).toEqual({
component: "PageError",
isGlobalError: false,
});
expect(
resolveAppPageErrorBoundary({
getDefaultExport(errorModule) {
return errorModule?.default ?? null;
},
globalErrorModule: { default: "GlobalError" },
layoutErrorModules: [{ default: "RootLayoutError" }, { default: "LeafLayoutError" }],
pageErrorModule: null,
}),
).toEqual({
component: "LeafLayoutError",
isGlobalError: false,
});
expect(
resolveAppPageErrorBoundary({
getDefaultExport(errorModule) {
return errorModule?.default ?? null;
},
globalErrorModule: { default: "GlobalError" },
layoutErrorModules: [null, undefined],
pageErrorModule: null,
}),
).toEqual({
component: "GlobalError",
isGlobalError: true,
});
});
it("wraps boundary elements with layouts, segment providers, and the global boundary for RSC", () => {
const wrapped = wrapAppPageBoundaryElement({
element: "Boundary",
getDefaultExport(layoutModule) {
return layoutModule?.default ?? null;
},
globalErrorComponent: "GlobalError",
includeGlobalErrorBoundary: true,
isRscRequest: true,
layoutModules: [{ default: "RootLayout" }, { default: "LeafLayout" }],
layoutTreePositions: [0, 1],
makeThenableParams(params) {
return { ...params, thenable: true };
},
matchedParams: { slug: "post" },
renderErrorBoundary(component, children) {
return `ErrorBoundary(${component})[${children}]`;
},
renderLayout(component, children, params) {
return `Layout(${component})[${children}|${JSON.stringify(params)}]`;
},
renderLayoutSegmentProvider(segmentMap, children) {
return `Segment(${String(segmentMap.children)})[${children}]`;
},
resolveChildSegments(routeSegments, treePosition, params) {
const slug = Array.isArray(params.slug) ? params.slug.join("/") : params.slug;
return `${routeSegments[treePosition] ?? "root"}:${slug}`;
},
routeSegments: ["[slug]"],
});
expect(wrapped).toBe(
'ErrorBoundary(GlobalError)[Segment([slug]:post)[Layout(RootLayout)[Segment(root:post)[Layout(LeafLayout)[Boundary|{"slug":"post","thenable":true}]]|{"thenable":true}]]]',
);
});
it("skips layout wrapping for global-error renders", () => {
const wrapped = wrapAppPageBoundaryElement({
element: "Boundary",
getDefaultExport(layoutModule) {
return layoutModule?.default ?? null;
},
globalErrorComponent: "GlobalError",
includeGlobalErrorBoundary: false,
isRscRequest: false,
layoutModules: [{ default: "RootLayout" }],
makeThenableParams(params) {
return params;
},
matchedParams: {},
renderErrorBoundary(component, children) {
return `ErrorBoundary(${component})[${children}]`;
},
renderLayout(component, children) {
return `Layout(${component})[${children}]`;
},
skipLayoutWrapping: true,
});
expect(wrapped).toBe("Boundary");
});
it("returns direct RSC responses without invoking the HTML callback", async () => {
const createHtmlResponse = vi.fn(async () => new Response("html"));
const response = await renderAppPageBoundaryResponse({
createHtmlResponse,
createRscOnErrorHandler() {
return () => null;
},
element: "RSC boundary",
isRscRequest: true,
renderToReadableStream(element) {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(String(element)));
controller.close();
},
});
},
status: 404,
});
expect(response.status).toBe(404);
expect(response.headers.get("Content-Type")).toBe("text/x-component");
expect(createHtmlResponse).not.toHaveBeenCalled();
await expect(response.text()).resolves.toBe("RSC boundary");
});
it("routes HTML boundary renders through the provided SSR callback", async () => {
const createHtmlResponse = vi.fn(
async (rscStream: ReadableStream<Uint8Array>, status: number) =>
new Response(`${status}:${await readText(rscStream)}`),
);
const response = await renderAppPageBoundaryResponse({
createHtmlResponse,
createRscOnErrorHandler() {
return () => null;
},
element: "HTML boundary",
isRscRequest: false,
renderToReadableStream(element) {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(String(element)));
controller.close();
},
});
},
status: 200,
});
expect(createHtmlResponse).toHaveBeenCalledTimes(1);
await expect(response.text()).resolves.toBe("200:HTML boundary");
});
it("emits the `x-edge-runtime: 1` marker on boundary RSC responses for edge-runtime routes", async () => {
const response = await renderAppPageBoundaryResponse({
async createHtmlResponse() {
return new Response("html");
},
createRscOnErrorHandler() {
return () => null;
},
element: "boundary",
isEdgeRuntime: true,
isRscRequest: true,
renderToReadableStream(element) {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(String(element)));
controller.close();
},
});
},
status: 404,
});
expect(response.headers.get("x-edge-runtime")).toBe("1");
});
it("omits the `x-edge-runtime` marker on boundary RSC responses for nodejs-runtime routes", async () => {
const response = await renderAppPageBoundaryResponse({
async createHtmlResponse() {
return new Response("html");
},
createRscOnErrorHandler() {
return () => null;
},
element: "boundary",
isRscRequest: true,
renderToReadableStream(element) {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(String(element)));
controller.close();
},
});
},
status: 404,
});
expect(response.headers.get("x-edge-runtime")).toBeNull();
});
});