mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
bc31644649
* fix(app-router): match streaming metadata error responses Vinext currently serializes metadata-originated redirects into a Flight payload for document requests. That response is not an HTML page, so direct browser visits cannot follow the streamed redirect and html-limited bots receive 200 instead of the blocking redirect. The dispatcher also renders HTTP access fallback head metadata from layouts only, so not-found boundary metadata is omitted when generateMetadata() throws notFound(). Use Next.js's split response model: RSC requests keep the Flight redirect digest, streaming-capable document requests receive the refresh meta tag HTML, and html-limited bots fall through to the HTTP redirect. Thread the selected HTTP access boundary module into fallback rendering so boundary metadata is resolved exactly once. * test(app-router): cover metadata RSC redirects The unit CI failure came from a strict dispatch test receiving optional fallback fields with undefined or null values. Those fields are only meaningful when a boundary module is actually selected. Build fallback option objects without absent boundary fields, preserving the existing call shape for ordinary page-level HTTP access fallbacks. Add focused coverage for fromMetadata redirects on RSC requests so metadata document refresh handling cannot accidentally consume the client navigation Flight path. * docs(app-router): update metadata redirect transport docs and drop dead boundary fallback Address review feedback on the metadata streaming error split: - Update the stale APP_PAGE_METADATA_ERROR_MARKER docblock to describe the new three-way transport split (flight digest / HTML refresh meta tag / 307). - Note the intentional minimal-stub HTML divergence from Next.js's full-document insertion in buildMetadataRedirectHtmlResponse. - Drop the redundant resolveAppPageHttpAccessBoundaryComponent fallback in renderAppPageHttpAccessFallback (boundaryModule already covers both paths). * docs(app-router): fix stale metadata redirect docs in head resolver and test reference Address remaining bonk re-review nits: - Update resolveModuleMetadata docblock in app-page-head.ts to describe the three-way transport split (it still claimed redirects ride in the flight payload even for document SSR). - Point the APP_PAGE_METADATA_ERROR_MARKER Next.js test reference at metadata-streaming, which is where the behavioural contracts are ported from. --------- Co-authored-by: James <james@eli.cx>
1145 lines
38 KiB
TypeScript
1145 lines
38 KiB
TypeScript
import { describe, expect, it, vi } from "vite-plus/test";
|
|
import {
|
|
buildAppPageFontLinkHeader,
|
|
buildAppPageSpecialErrorResponse,
|
|
probeAppPageComponent,
|
|
probeAppPageLayouts,
|
|
resolveAppPageSpecialError,
|
|
teeAppPageRscStreamForCapture,
|
|
} from "../packages/vinext/src/server/app-page-execution.js";
|
|
import { readStreamAsText } from "../packages/vinext/src/utils/text-stream.js";
|
|
|
|
function createStream(chunks: string[]): ReadableStream<Uint8Array> {
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
for (const chunk of chunks) {
|
|
controller.enqueue(new TextEncoder().encode(chunk));
|
|
}
|
|
controller.close();
|
|
},
|
|
});
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
describe("app page execution helpers", () => {
|
|
it("parses redirect and access-fallback digests", () => {
|
|
expect(
|
|
resolveAppPageSpecialError({
|
|
digest: "NEXT_REDIRECT;replace;%2Fredirected;308",
|
|
}),
|
|
).toEqual({
|
|
kind: "redirect",
|
|
location: "/redirected",
|
|
statusCode: 308,
|
|
});
|
|
|
|
expect(
|
|
resolveAppPageSpecialError({
|
|
digest: "NEXT_HTTP_ERROR_FALLBACK;403",
|
|
}),
|
|
).toEqual({
|
|
kind: "http-access-fallback",
|
|
statusCode: 403,
|
|
});
|
|
|
|
expect(resolveAppPageSpecialError({ digest: "not-special" })).toBeNull();
|
|
});
|
|
|
|
it("builds redirect and fallback responses while preserving fallback context behavior", async () => {
|
|
const clearRequestContext = vi.fn();
|
|
|
|
const redirectResponse = await buildAppPageSpecialErrorResponse({
|
|
clearRequestContext,
|
|
isRscRequest: false,
|
|
middlewareContext: createMiddlewareContext(),
|
|
request: new Request("https://example.com/start"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/redirected",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(redirectResponse.status).toBe(307);
|
|
expect(redirectResponse.headers.get("location")).toBe("https://example.com/redirected");
|
|
expect(redirectResponse.headers.get("x-middleware-security")).toBe("present");
|
|
expect(redirectResponse.headers.get("vary")).toBe("x-auth-state");
|
|
expect(redirectResponse.headers.getSetCookie()).toContain("session=rotated; Path=/; HttpOnly");
|
|
expect(clearRequestContext).toHaveBeenCalledTimes(1);
|
|
|
|
clearRequestContext.mockClear();
|
|
|
|
const fallbackResponse = await buildAppPageSpecialErrorResponse({
|
|
clearRequestContext,
|
|
isRscRequest: false,
|
|
middlewareContext: createMiddlewareContext(),
|
|
renderFallbackPage(statusCode) {
|
|
return Promise.resolve(
|
|
new Response(`fallback:${statusCode}`, {
|
|
headers: { Vary: "RSC, Accept" },
|
|
status: statusCode,
|
|
}),
|
|
);
|
|
},
|
|
request: new Request("https://example.com/start"),
|
|
specialError: {
|
|
kind: "http-access-fallback",
|
|
statusCode: 404,
|
|
},
|
|
});
|
|
|
|
expect(fallbackResponse.status).toBe(404);
|
|
expect(fallbackResponse.headers.get("x-middleware-security")).toBe("present");
|
|
expect(fallbackResponse.headers.get("vary")).toBe("RSC, Accept, x-auth-state");
|
|
expect(fallbackResponse.headers.getSetCookie()).toContain("session=rotated; Path=/; HttpOnly");
|
|
expect(
|
|
fallbackResponse.headers
|
|
.getSetCookie()
|
|
.filter((cookie) => cookie === "session=rotated; Path=/; HttpOnly"),
|
|
).toHaveLength(1);
|
|
await expect(fallbackResponse.text()).resolves.toBe("fallback:404");
|
|
expect(clearRequestContext).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prefixes redirect Location with basePath for app-internal paths", async () => {
|
|
// Mirrors Next.js's `addPathPrefix(getURLFromRedirectError(err), basePath)`.
|
|
// `redirect("/about")` from a page mounted under basePath "/blog" should
|
|
// produce `Location: https://example.com/blog/about`, not the raw "/about".
|
|
const clearRequestContext = vi.fn();
|
|
|
|
const internalRedirect = await buildAppPageSpecialErrorResponse({
|
|
basePath: "/blog",
|
|
clearRequestContext,
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/blog/protected"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/about",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(internalRedirect.headers.get("location")).toBe("https://example.com/blog/about");
|
|
|
|
// External redirects (different origin) must NOT be prefixed — they're
|
|
// outside the app's basePath scope.
|
|
const externalRedirect = await buildAppPageSpecialErrorResponse({
|
|
basePath: "/blog",
|
|
clearRequestContext,
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/blog/protected"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "https://other.example/foo",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(externalRedirect.headers.get("location")).toBe("https://other.example/foo");
|
|
|
|
// Targets that already include the basePath prefix must be left alone
|
|
// (caller already did the work or middleware-driven redirect).
|
|
const alreadyPrefixed = await buildAppPageSpecialErrorResponse({
|
|
basePath: "/blog",
|
|
clearRequestContext,
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/blog/protected"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/blog/about",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(alreadyPrefixed.headers.get("location")).toBe("https://example.com/blog/about");
|
|
|
|
const alreadyPrefixedRootWithQuery = await buildAppPageSpecialErrorResponse({
|
|
basePath: "/blog",
|
|
clearRequestContext,
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/blog/protected"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/blog?from=checkout",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(alreadyPrefixedRootWithQuery.headers.get("location")).toBe(
|
|
"https://example.com/blog?from=checkout",
|
|
);
|
|
|
|
const alreadyPrefixedRootWithHash = await buildAppPageSpecialErrorResponse({
|
|
basePath: "/blog",
|
|
clearRequestContext,
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/blog/protected"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/blog#top",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(alreadyPrefixedRootWithHash.headers.get("location")).toBe(
|
|
"https://example.com/blog#top",
|
|
);
|
|
|
|
// No basePath configured → behavior unchanged (resolves against the
|
|
// request URL as before).
|
|
const unconfigured = await buildAppPageSpecialErrorResponse({
|
|
clearRequestContext,
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/protected"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/about",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(unconfigured.headers.get("location")).toBe("https://example.com/about");
|
|
|
|
// Redirect to root ("/") under basePath should land on the basePath itself,
|
|
// not "/blog/" with a trailing slash artifact.
|
|
const rootRedirect = await buildAppPageSpecialErrorResponse({
|
|
basePath: "/blog",
|
|
clearRequestContext,
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/blog/protected"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(rootRedirect.headers.get("location")).toBe("https://example.com/blog");
|
|
});
|
|
|
|
it("appends pending cookies (cookies().set during render) to redirect responses", async () => {
|
|
// Mirrors Next.js's `appendMutableCookies(headers, requestStore.mutableCookies)`
|
|
// in app-render.tsx. An auth flow that does
|
|
// cookies().set("session", "...");
|
|
// redirect("/dashboard");
|
|
// must keep the Set-Cookie on the 307 — otherwise the redirected
|
|
// request lands without the just-issued session and the user bounces
|
|
// back to login.
|
|
const clearRequestContext = vi.fn();
|
|
const getAndClearPendingCookies = vi.fn(() => [
|
|
"session=fresh; Path=/; HttpOnly",
|
|
"csrf=abc; Path=/",
|
|
]);
|
|
|
|
const redirectWithCookies = await buildAppPageSpecialErrorResponse({
|
|
clearRequestContext,
|
|
getAndClearPendingCookies,
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/login"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/dashboard",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(redirectWithCookies.status).toBe(307);
|
|
expect(redirectWithCookies.headers.get("location")).toBe("https://example.com/dashboard");
|
|
const setCookies = redirectWithCookies.headers.getSetCookie();
|
|
expect(setCookies).toContain("session=fresh; Path=/; HttpOnly");
|
|
expect(setCookies).toContain("csrf=abc; Path=/");
|
|
expect(getAndClearPendingCookies).toHaveBeenCalledTimes(1);
|
|
|
|
// No accumulated cookies → no Set-Cookie header (and the accumulator
|
|
// is still consulted exactly once).
|
|
getAndClearPendingCookies.mockReturnValue([]);
|
|
const redirectWithoutCookies = await buildAppPageSpecialErrorResponse({
|
|
clearRequestContext,
|
|
getAndClearPendingCookies,
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/login"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/dashboard",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(redirectWithoutCookies.headers.getSetCookie()).toEqual([]);
|
|
|
|
// Pending cookies must NOT bleed onto http-access-fallback responses —
|
|
// those cookies belong to the rendered boundary, not the bare 401/403/404.
|
|
// Matches Next.js, which only calls appendMutableCookies in the redirect
|
|
// branch.
|
|
getAndClearPendingCookies.mockReturnValue(["should-not-appear=1; Path=/"]);
|
|
const fallbackResponse = await buildAppPageSpecialErrorResponse({
|
|
clearRequestContext,
|
|
getAndClearPendingCookies,
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/protected"),
|
|
specialError: {
|
|
kind: "http-access-fallback",
|
|
statusCode: 401,
|
|
},
|
|
});
|
|
|
|
expect(fallbackResponse.headers.getSetCookie()).toEqual([]);
|
|
});
|
|
|
|
it("falls back to a plain status response when no fallback page is available", async () => {
|
|
const clearRequestContext = vi.fn();
|
|
|
|
const response = await buildAppPageSpecialErrorResponse({
|
|
clearRequestContext,
|
|
isRscRequest: false,
|
|
middlewareContext: createMiddlewareContext(),
|
|
renderFallbackPage() {
|
|
return Promise.resolve(null);
|
|
},
|
|
request: new Request("https://example.com/start"),
|
|
specialError: {
|
|
kind: "http-access-fallback",
|
|
statusCode: 401,
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.headers.get("x-middleware-security")).toBe("present");
|
|
expect(response.headers.get("vary")).toBe("x-auth-state");
|
|
expect(response.headers.getSetCookie()).toContain("session=rotated; Path=/; HttpOnly");
|
|
await expect(response.text()).resolves.toBe("Unauthorized");
|
|
expect(clearRequestContext).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("encodes redirect digest in flight payload + 200 when buildRscRedirectFlightStream is provided (RSC request)", async () => {
|
|
// Mirrors Next.js's `generateDynamicFlightRenderResult` path: when a
|
|
// server component throws redirect() during RSC rendering, the redirect
|
|
// digest is serialized into the flight stream and the response stays
|
|
// 200. The status line never carries the redirect.
|
|
//
|
|
// See: https://github.com/cloudflare/vinext/issues/1347
|
|
const buildRscRedirectFlightStream = vi.fn(
|
|
(options: { digest: string }) =>
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode(`E:${options.digest}`));
|
|
controller.close();
|
|
},
|
|
}),
|
|
);
|
|
|
|
const response = await buildAppPageSpecialErrorResponse({
|
|
buildRscRedirectFlightStream,
|
|
clearRequestContext: vi.fn(),
|
|
isRscRequest: true,
|
|
request: new Request("https://example.com/start.rsc"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/redirected",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toMatch(/^text\/x-component/);
|
|
expect(response.headers.get("location")).toBeNull();
|
|
expect(buildRscRedirectFlightStream).toHaveBeenCalledTimes(1);
|
|
expect(buildRscRedirectFlightStream).toHaveBeenCalledWith({
|
|
digest: "NEXT_REDIRECT;replace;/redirected;307;",
|
|
});
|
|
await expect(response.text()).resolves.toBe("E:NEXT_REDIRECT;replace;/redirected;307;");
|
|
});
|
|
|
|
it("keeps metadata-originated RSC redirects on the Flight digest path", async () => {
|
|
// Ported from Next.js:
|
|
// test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
|
|
// "should trigger redirection when call redirect"
|
|
//
|
|
// Document requests and RSC navigation requests intentionally diverge:
|
|
// the document path streams a refresh meta tag, while the client router
|
|
// still consumes the redirect digest from the Flight stream.
|
|
const buildRscRedirectFlightStream = vi.fn(
|
|
(options: { digest: string }) =>
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode(`E:${options.digest}`));
|
|
controller.close();
|
|
},
|
|
}),
|
|
);
|
|
|
|
const response = await buildAppPageSpecialErrorResponse({
|
|
buildRscRedirectFlightStream,
|
|
clearRequestContext: vi.fn(),
|
|
isRscRequest: true,
|
|
request: new Request("https://example.com/start.rsc"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/redirected",
|
|
statusCode: 307,
|
|
fromMetadata: true,
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toMatch(/^text\/x-component/);
|
|
expect(response.headers.get("location")).toBeNull();
|
|
expect(buildRscRedirectFlightStream).toHaveBeenCalledTimes(1);
|
|
expect(buildRscRedirectFlightStream).toHaveBeenCalledWith({
|
|
digest: "NEXT_REDIRECT;replace;/redirected;307;",
|
|
});
|
|
await expect(response.text()).resolves.toBe("E:NEXT_REDIRECT;replace;/redirected;307;");
|
|
});
|
|
|
|
it("renders a metadata-originated document redirect as an HTML refresh when metadata streams", async () => {
|
|
// Ported from Next.js:
|
|
// test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
|
|
// "should trigger redirection when call redirect"
|
|
//
|
|
// Next.js captures redirect() thrown by generateMetadata() as a streamed
|
|
// server error and injects the canonical refresh meta tag through
|
|
// make-get-server-inserted-html.tsx. The document status stays 200, but it
|
|
// must remain an HTML document so a browser direct visit can follow it.
|
|
const buildRscRedirectFlightStream = vi.fn(
|
|
() =>
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("flight-payload"));
|
|
controller.close();
|
|
},
|
|
}),
|
|
);
|
|
|
|
const response = await buildAppPageSpecialErrorResponse({
|
|
buildRscRedirectFlightStream,
|
|
clearRequestContext: vi.fn(),
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/start"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/redirected",
|
|
statusCode: 307,
|
|
fromMetadata: true,
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toContain("text/html");
|
|
expect(buildRscRedirectFlightStream).not.toHaveBeenCalled();
|
|
await expect(response.text()).resolves.toContain(
|
|
'<meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/redirected"/>',
|
|
);
|
|
});
|
|
|
|
it("uses an HTTP redirect for metadata-originated redirects when metadata streaming is disabled", async () => {
|
|
// Ported from Next.js:
|
|
// test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts
|
|
// "should render blocking 307 response status when html limited bots access redirect"
|
|
const buildRscRedirectFlightStream = vi.fn(
|
|
() =>
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("flight-payload"));
|
|
controller.close();
|
|
},
|
|
}),
|
|
);
|
|
|
|
const response = await buildAppPageSpecialErrorResponse({
|
|
buildRscRedirectFlightStream,
|
|
clearRequestContext: vi.fn(),
|
|
isRscRequest: false,
|
|
request: new Request("https://example.com/start"),
|
|
serveStreamingMetadata: false,
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/redirected",
|
|
statusCode: 307,
|
|
fromMetadata: true,
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(307);
|
|
expect(response.headers.get("location")).toBe("https://example.com/redirected");
|
|
expect(buildRscRedirectFlightStream).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves the 307/308 status code in the encoded digest (permanentRedirect)", async () => {
|
|
const captured: { digest: string }[] = [];
|
|
const buildRscRedirectFlightStream = (opts: { digest: string }) => {
|
|
captured.push(opts);
|
|
return new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.close();
|
|
},
|
|
});
|
|
};
|
|
|
|
await buildAppPageSpecialErrorResponse({
|
|
buildRscRedirectFlightStream,
|
|
clearRequestContext: vi.fn(),
|
|
isRscRequest: true,
|
|
request: new Request("https://example.com/start.rsc"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/permanent-target",
|
|
statusCode: 308,
|
|
},
|
|
});
|
|
|
|
expect(captured).toEqual([{ digest: "NEXT_REDIRECT;replace;/permanent-target;308;" }]);
|
|
});
|
|
|
|
it("falls back to 307 when buildRscRedirectFlightStream is absent (backward compat)", async () => {
|
|
// Callers that don't yet plumb the flight-stream builder keep the legacy
|
|
// behavior: HTTP 307 with a Location header. Keeps non-app-router callers
|
|
// (and any test helpers) working without changes.
|
|
const response = await buildAppPageSpecialErrorResponse({
|
|
clearRequestContext: vi.fn(),
|
|
isRscRequest: true,
|
|
request: new Request("https://example.com/start.rsc"),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/redirected",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(307);
|
|
expect(response.headers.get("location")).toMatch(/redirected\?_rsc/);
|
|
});
|
|
|
|
it("canonicalizes same-origin RSC redirect locations to Next-style RSC URLs", async () => {
|
|
const response = await buildAppPageSpecialErrorResponse({
|
|
clearRequestContext: vi.fn(),
|
|
isRscRequest: true,
|
|
request: new Request("https://example.com/start.rsc", {
|
|
headers: {
|
|
Accept: "text/x-component",
|
|
RSC: "1",
|
|
"Next-Router-State-Tree": "tree",
|
|
},
|
|
}),
|
|
specialError: {
|
|
kind: "redirect",
|
|
location: "/redirected?tab=1",
|
|
statusCode: 307,
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(307);
|
|
expect(response.headers.get("location")).toMatch(
|
|
/^https:\/\/example\.com\/redirected\?tab=1&_rsc=/,
|
|
);
|
|
});
|
|
|
|
it("probes layouts from inner to outer and stops on a handled special response", async () => {
|
|
const probedLayouts: number[] = [];
|
|
|
|
const result = await probeAppPageLayouts({
|
|
layoutCount: 3,
|
|
async onLayoutError(error, layoutIndex) {
|
|
expect(error).toBeInstanceOf(Error);
|
|
return layoutIndex === 1 ? new Response("layout-fallback", { status: 404 }) : null;
|
|
},
|
|
probeLayoutAt(layoutIndex) {
|
|
probedLayouts.push(layoutIndex);
|
|
if (layoutIndex === 1) {
|
|
throw new Error("layout failed");
|
|
}
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
});
|
|
|
|
expect(probedLayouts).toEqual([2, 1]);
|
|
expect(result.response?.status).toBe(404);
|
|
await expect(result.response?.text()).resolves.toBe("layout-fallback");
|
|
});
|
|
|
|
it("does not await async page probes when a loading boundary is present", async () => {
|
|
const onError = vi.fn();
|
|
|
|
const response = await probeAppPageComponent({
|
|
awaitAsyncResult: false,
|
|
onError,
|
|
probePage() {
|
|
return new Promise<void>(() => {});
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
});
|
|
|
|
expect(response).toBeNull();
|
|
expect(onError).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("produces fused ssrStream + sideStream when capturing (#981)", async () => {
|
|
const capture = teeAppPageRscStreamForCapture(createStream(["flight-", "chunk"]), true);
|
|
|
|
// ssrStream is for createFromReadableStream (SSR)
|
|
expect(capture.ssrStream).toBeInstanceOf(ReadableStream);
|
|
// sideStream is for embed+capture
|
|
expect(capture.sideStream).toBeInstanceOf(ReadableStream);
|
|
|
|
// Both streams should contain identical data (teed from same source)
|
|
const ssrText = await readStreamAsText(capture.ssrStream);
|
|
const sideText = await readStreamAsText(capture.sideStream!);
|
|
expect(ssrText).toBe("flight-chunk");
|
|
expect(sideText).toBe("flight-chunk");
|
|
});
|
|
|
|
it("bypasses tee when not capturing (#981)", async () => {
|
|
const stream = createStream(["no-capture"]);
|
|
const capture = teeAppPageRscStreamForCapture(stream, false);
|
|
|
|
// When not capturing, no tee — the original stream is returned as ssrStream
|
|
expect(capture.ssrStream).toBe(stream);
|
|
expect(capture.sideStream).toBeUndefined();
|
|
});
|
|
|
|
it("tracks per-layout dynamic usage when classification options are provided", async () => {
|
|
const result = await probeAppPageLayouts({
|
|
layoutCount: 3,
|
|
onLayoutError() {
|
|
return Promise.resolve(null);
|
|
},
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
buildTimeClassifications: new Map([
|
|
[0, "static"],
|
|
[2, "dynamic"],
|
|
]),
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/blog", "layout:/blog/post"][layoutIndex];
|
|
},
|
|
runWithIsolatedDynamicScope(fn) {
|
|
return Promise.resolve({ result: fn(), dynamicDetected: false });
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.response).toBeNull();
|
|
// Layout 0 is build-time static, layout 2 is build-time dynamic
|
|
// Layout 1 has no build-time classification, probed with no dynamic detected
|
|
expect(result.layoutFlags).toEqual({
|
|
"layout:/": "s",
|
|
"layout:/blog": "s",
|
|
"layout:/blog/post": "d",
|
|
});
|
|
});
|
|
|
|
it("detects dynamic usage per-layout through isolated scope", async () => {
|
|
let probeCallCount = 0;
|
|
const result = await probeAppPageLayouts({
|
|
layoutCount: 2,
|
|
onLayoutError() {
|
|
return Promise.resolve(null);
|
|
},
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/dashboard"][layoutIndex];
|
|
},
|
|
runWithIsolatedDynamicScope(fn) {
|
|
probeCallCount++;
|
|
const result = fn();
|
|
// Simulate: second probe call (layout 0, since we iterate inner-to-outer)
|
|
// detects dynamic usage
|
|
return Promise.resolve({
|
|
result,
|
|
dynamicDetected: probeCallCount === 2,
|
|
});
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.response).toBeNull();
|
|
expect(result.layoutFlags).toEqual({
|
|
"layout:/": "d",
|
|
"layout:/dashboard": "s",
|
|
});
|
|
});
|
|
|
|
it("returns empty layoutFlags when classification options are absent (backward compat)", async () => {
|
|
const result = await probeAppPageLayouts({
|
|
layoutCount: 2,
|
|
onLayoutError() {
|
|
return Promise.resolve(null);
|
|
},
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
});
|
|
|
|
expect(result.response).toBeNull();
|
|
expect(result.layoutFlags).toEqual({});
|
|
});
|
|
|
|
it("defaults to dynamic flag when probe throws a non-special error", async () => {
|
|
const result = await probeAppPageLayouts({
|
|
layoutCount: 2,
|
|
onLayoutError() {
|
|
// Non-special error — return null (don't short-circuit)
|
|
return Promise.resolve(null);
|
|
},
|
|
probeLayoutAt(layoutIndex) {
|
|
if (layoutIndex === 1) throw new Error("use() outside render");
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/dashboard"][layoutIndex];
|
|
},
|
|
runWithIsolatedDynamicScope(fn) {
|
|
// Re-throw so the catch path in probeAppPageLayouts fires
|
|
return Promise.resolve(fn()).then((result) => ({ result, dynamicDetected: false }));
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.response).toBeNull();
|
|
// Layout 1 threw → conservatively flagged as dynamic
|
|
expect(result.layoutFlags["layout:/dashboard"]).toBe("d");
|
|
// Layout 0 probed successfully
|
|
expect(result.layoutFlags["layout:/"]).toBe("s");
|
|
});
|
|
|
|
it("isolates dynamic usage across throwing layout probes", async () => {
|
|
let dynamicUsageDetected = false;
|
|
|
|
const result = await probeAppPageLayouts({
|
|
layoutCount: 2,
|
|
onLayoutError() {
|
|
return Promise.resolve(null);
|
|
},
|
|
probeLayoutAt(layoutIndex) {
|
|
if (layoutIndex === 1) {
|
|
dynamicUsageDetected = true;
|
|
throw new Error("layout failed after headers()");
|
|
}
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/dashboard"][layoutIndex];
|
|
},
|
|
async runWithIsolatedDynamicScope(fn) {
|
|
const priorDynamic = dynamicUsageDetected;
|
|
dynamicUsageDetected = false;
|
|
try {
|
|
const result = await fn();
|
|
const detectedInScope = dynamicUsageDetected;
|
|
dynamicUsageDetected = false;
|
|
return { result, dynamicDetected: detectedInScope };
|
|
} finally {
|
|
dynamicUsageDetected = false;
|
|
if (priorDynamic) dynamicUsageDetected = true;
|
|
}
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.response).toBeNull();
|
|
expect(result.layoutFlags).toEqual({
|
|
"layout:/": "s",
|
|
"layout:/dashboard": "d",
|
|
});
|
|
});
|
|
|
|
it("skips probe for build-time classified layouts", async () => {
|
|
let probeCalls = 0;
|
|
const result = await probeAppPageLayouts({
|
|
layoutCount: 2,
|
|
onLayoutError() {
|
|
return Promise.resolve(null);
|
|
},
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
buildTimeClassifications: new Map([
|
|
[0, "static"],
|
|
[1, "dynamic"],
|
|
]),
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/admin"][layoutIndex];
|
|
},
|
|
runWithIsolatedDynamicScope(fn) {
|
|
probeCalls++;
|
|
return Promise.resolve({ result: fn(), dynamicDetected: false });
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(probeCalls).toBe(0);
|
|
expect(result.layoutFlags).toEqual({
|
|
"layout:/": "s",
|
|
"layout:/admin": "d",
|
|
});
|
|
});
|
|
|
|
it("returns special error response when build-time classified layout throws during error probe", async () => {
|
|
const layoutError = new Error("layout failed");
|
|
const specialResponse = new Response("layout-fallback", { status: 404 });
|
|
|
|
const result = await probeAppPageLayouts({
|
|
layoutCount: 2,
|
|
onLayoutError(error) {
|
|
return Promise.resolve(error === layoutError ? specialResponse : null);
|
|
},
|
|
probeLayoutAt(layoutIndex) {
|
|
if (layoutIndex === 1) throw layoutError;
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
buildTimeClassifications: new Map([
|
|
[0, "static"],
|
|
[1, "static"],
|
|
]),
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/admin"][layoutIndex];
|
|
},
|
|
runWithIsolatedDynamicScope() {
|
|
throw new Error("isolated scope must not run for build-time classified layouts");
|
|
},
|
|
},
|
|
});
|
|
|
|
// The special-error response from the throwing layout short-circuits the
|
|
// loop. The flag for layout 1 is still recorded (set before the error
|
|
// probe runs), and layout 0 is never reached.
|
|
expect(result.response).toBe(specialResponse);
|
|
expect(result.layoutFlags).toEqual({ "layout:/admin": "s" });
|
|
});
|
|
|
|
it("does not read build-time reasons when debugClassification is absent", async () => {
|
|
const throwingReasons = {
|
|
get() {
|
|
throw new Error("build-time reasons should stay dormant when debug is disabled");
|
|
},
|
|
} as unknown as ReadonlyMap<number, { layer: "segment-config"; key: "dynamic"; value: string }>;
|
|
|
|
await probeAppPageLayouts({
|
|
layoutCount: 2,
|
|
onLayoutError() {
|
|
return Promise.resolve(null);
|
|
},
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
buildTimeClassifications: new Map([
|
|
[0, "static"],
|
|
[1, "dynamic"],
|
|
]),
|
|
buildTimeReasons: throwingReasons,
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/admin"][layoutIndex];
|
|
},
|
|
runWithIsolatedDynamicScope(fn) {
|
|
return Promise.resolve({ result: fn(), dynamicDetected: false });
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("emits a debug reason per layout when debugClassification is provided with build-time reasons", async () => {
|
|
const calls: Array<{ layoutId: string; reason: unknown }> = [];
|
|
|
|
await probeAppPageLayouts({
|
|
layoutCount: 3,
|
|
onLayoutError() {
|
|
return Promise.resolve(null);
|
|
},
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
buildTimeClassifications: new Map([
|
|
[0, "static"],
|
|
[1, "dynamic"],
|
|
[2, "static"],
|
|
]),
|
|
buildTimeReasons: new Map([
|
|
[0, { layer: "segment-config", key: "dynamic", value: "force-static" }],
|
|
[1, { layer: "segment-config", key: "dynamic", value: "force-dynamic" }],
|
|
[2, { layer: "module-graph", result: "static" }],
|
|
]),
|
|
debugClassification(layoutId, reason) {
|
|
calls.push({ layoutId, reason });
|
|
},
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/admin", "layout:/admin/posts"][layoutIndex];
|
|
},
|
|
runWithIsolatedDynamicScope(fn) {
|
|
return Promise.resolve({ result: fn(), dynamicDetected: false });
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(calls).toHaveLength(3);
|
|
const byId = Object.fromEntries(calls.map((c) => [c.layoutId, c.reason]));
|
|
expect(byId["layout:/"]).toEqual({
|
|
layer: "segment-config",
|
|
key: "dynamic",
|
|
value: "force-static",
|
|
});
|
|
expect(byId["layout:/admin"]).toEqual({
|
|
layer: "segment-config",
|
|
key: "dynamic",
|
|
value: "force-dynamic",
|
|
});
|
|
expect(byId["layout:/admin/posts"]).toEqual({
|
|
layer: "module-graph",
|
|
result: "static",
|
|
});
|
|
});
|
|
|
|
it("emits runtime-probe reason for layouts resolved by the Layer 3 probe", async () => {
|
|
const calls: Array<{ layoutId: string; reason: unknown }> = [];
|
|
let probeCalls = 0;
|
|
|
|
await probeAppPageLayouts({
|
|
layoutCount: 2,
|
|
onLayoutError() {
|
|
return Promise.resolve(null);
|
|
},
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
// No buildTimeClassifications → every layout takes the runtime path.
|
|
debugClassification(layoutId, reason) {
|
|
calls.push({ layoutId, reason });
|
|
},
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/dashboard"][layoutIndex];
|
|
},
|
|
runWithIsolatedDynamicScope(fn) {
|
|
probeCalls++;
|
|
// probeAppPageLayouts iterates inner-to-outer:
|
|
// first call → layout 1 (dashboard) → dynamic
|
|
// second call → layout 0 (root) → static
|
|
return Promise.resolve({ result: fn(), dynamicDetected: probeCalls === 1 });
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(calls).toHaveLength(2);
|
|
const byId = Object.fromEntries(calls.map((c) => [c.layoutId, c.reason]));
|
|
expect(byId["layout:/dashboard"]).toEqual({
|
|
layer: "runtime-probe",
|
|
outcome: "dynamic",
|
|
});
|
|
expect(byId["layout:/"]).toEqual({
|
|
layer: "runtime-probe",
|
|
outcome: "static",
|
|
});
|
|
});
|
|
|
|
it("flips a build-time static layout to dynamic when a runtime observation is unsafe for static reuse", async () => {
|
|
// Parity guard for the observation override: a layout the build classified
|
|
// `static` is downgraded to `"d"` at request time when
|
|
// `isLayoutObservationDynamic` reports the observed render is unsafe for
|
|
// static reuse (finite revalidate, cache tags, request APIs, param scope,
|
|
// etc.). The emitted debug reason switches from the stale build-time reason
|
|
// to `runtime-probe`/`dynamic` so the flag and reason stay in agreement.
|
|
const calls: Array<{ layoutId: string; reason: unknown }> = [];
|
|
|
|
const result = await probeAppPageLayouts({
|
|
layoutCount: 2,
|
|
onLayoutError() {
|
|
return Promise.resolve(null);
|
|
},
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
buildTimeClassifications: new Map([
|
|
[0, "static"],
|
|
[1, "static"],
|
|
]),
|
|
buildTimeReasons: new Map([
|
|
[0, { layer: "segment-config", key: "dynamic", value: "force-static" }],
|
|
[1, { layer: "segment-config", key: "dynamic", value: "force-static" }],
|
|
]),
|
|
debugClassification(layoutId, reason) {
|
|
calls.push({ layoutId, reason });
|
|
},
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/dashboard"][layoutIndex];
|
|
},
|
|
// Only the dashboard layout's observation is unsafe for static reuse.
|
|
isLayoutObservationDynamic(layoutId) {
|
|
return layoutId === "layout:/dashboard";
|
|
},
|
|
runWithIsolatedDynamicScope() {
|
|
throw new Error("isolated scope must not run for build-time classified layouts");
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(result.response).toBeNull();
|
|
// dashboard: build-time static, but the observation flips it to dynamic.
|
|
// root: build-time static with a safe observation stays static.
|
|
expect(result.layoutFlags).toEqual({
|
|
"layout:/": "s",
|
|
"layout:/dashboard": "d",
|
|
});
|
|
|
|
const byId = Object.fromEntries(calls.map((c) => [c.layoutId, c.reason]));
|
|
expect(byId["layout:/dashboard"]).toEqual({
|
|
layer: "runtime-probe",
|
|
outcome: "dynamic",
|
|
});
|
|
// The unflipped layout keeps reporting its build-time reason.
|
|
expect(byId["layout:/"]).toEqual({
|
|
layer: "segment-config",
|
|
key: "dynamic",
|
|
value: "force-static",
|
|
});
|
|
});
|
|
|
|
it("emits runtime-probe reason with the error message when the probe throws", async () => {
|
|
const calls: Array<{ layoutId: string; reason: unknown }> = [];
|
|
|
|
await probeAppPageLayouts({
|
|
layoutCount: 2,
|
|
onLayoutError() {
|
|
return Promise.resolve(null);
|
|
},
|
|
probeLayoutAt(layoutIndex) {
|
|
if (layoutIndex === 1) throw new Error("headers() outside render");
|
|
return null;
|
|
},
|
|
runWithSuppressedHookWarning(probe) {
|
|
return probe();
|
|
},
|
|
classification: {
|
|
debugClassification(layoutId, reason) {
|
|
calls.push({ layoutId, reason });
|
|
},
|
|
getLayoutId(layoutIndex) {
|
|
return ["layout:/", "layout:/dashboard"][layoutIndex];
|
|
},
|
|
runWithIsolatedDynamicScope(fn) {
|
|
return Promise.resolve(fn()).then((result) => ({ result, dynamicDetected: false }));
|
|
},
|
|
},
|
|
});
|
|
|
|
const byId = Object.fromEntries(calls.map((c) => [c.layoutId, c.reason]));
|
|
expect(byId["layout:/dashboard"]).toEqual({
|
|
layer: "runtime-probe",
|
|
outcome: "dynamic",
|
|
error: "headers() outside render",
|
|
});
|
|
expect(byId["layout:/"]).toEqual({
|
|
layer: "runtime-probe",
|
|
outcome: "static",
|
|
});
|
|
});
|
|
|
|
it("builds Link headers for preloaded app-page fonts", () => {
|
|
expect(
|
|
buildAppPageFontLinkHeader([
|
|
{ href: "/font-a.woff2", type: "font/woff2" },
|
|
{ href: "/font-b.woff2", type: "font/woff2" },
|
|
]),
|
|
).toBe(
|
|
"</font-a.woff2>; rel=preload; as=font; type=font/woff2; crossorigin, </font-b.woff2>; rel=preload; as=font; type=font/woff2; crossorigin",
|
|
);
|
|
});
|
|
|
|
it("emits the `x-edge-runtime: 1` marker on RSC redirect flight responses for edge-runtime routes", async () => {
|
|
const response = await buildAppPageSpecialErrorResponse({
|
|
buildRscRedirectFlightStream: ({ digest }) =>
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode(`E:${digest}`));
|
|
controller.close();
|
|
},
|
|
}),
|
|
clearRequestContext: vi.fn(),
|
|
isEdgeRuntime: true,
|
|
isRscRequest: true,
|
|
request: new Request("https://example.com/start.rsc"),
|
|
specialError: { kind: "redirect", location: "/redirected", statusCode: 307 },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("x-edge-runtime")).toBe("1");
|
|
});
|
|
|
|
it("omits the `x-edge-runtime` marker on RSC redirect flight responses for nodejs-runtime routes", async () => {
|
|
const response = await buildAppPageSpecialErrorResponse({
|
|
buildRscRedirectFlightStream: ({ digest }) =>
|
|
new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode(`E:${digest}`));
|
|
controller.close();
|
|
},
|
|
}),
|
|
clearRequestContext: vi.fn(),
|
|
isRscRequest: true,
|
|
request: new Request("https://example.com/start.rsc"),
|
|
specialError: { kind: "redirect", location: "/redirected", statusCode: 307 },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("x-edge-runtime")).toBeNull();
|
|
});
|
|
});
|