mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
15c8079604
* feat(app-router): add artifact compatibility envelope App Router payloads now carry a parsed compatibility envelope at the existing payload metadata boundary. Without this skeleton, future cache and skip work would have no typed place to record graph, deployment, schema, root-boundary, or render-epoch proof. Add a small constructor/parser pair, attach a default unknown-proof envelope to outgoing record payloads, and reject malformed envelope metadata when reading payload metadata. Tests cover explicit envelopes, legacy missing envelopes, malformed envelopes, and non-mutating payload writes. * test(app-router): cover future artifact schema rejection The review correctly identified that the envelope parser had malformed-field coverage but no direct test for the version guard path. Add the future-schema-version case and document why the Wave01 skeleton emits separate schema versions and an unknown-proof envelope without yet splitting mismatch from corruption behavior. * feat(app-router): attach artifact compatibility metadata App Router payloads previously carried only the COMPAT-01 unknown envelope unless a caller manually supplied metadata. That left COMPAT-02/03 without concrete graph, deployment, root-boundary, and fallback behavior in the PR. The render lifecycle now attaches compatibility metadata for record payloads from route/root/deployment facts, while renderEpoch remains unknown until an epoch owner exists. The compatibility evaluator returns explicit compatible, unknown, or incompatible decisions so unknown proof always falls back to render fresh instead of becoming reuse proof. Tests cover render-boundary metadata attachment and unknown graph, deployment, root-boundary, and render-epoch decisions. * fix(app-router): tolerate unused compatibility metadata parse failures App Router metadata parsing crashed when __artifactCompatibility was malformed or from a future schema, even though current render and navigation callers do not consume that field yet. The parser treated compatibility scaffolding like required route metadata. That made an unused proof capable of failing initial mount, SSR, and client navigation paths. Default invalid compatibility metadata to the unknown proof for Wave01, document the planned hard-fail point for #726-COMPAT-04/05, and cover malformed, future-version, and non-object payloads.
904 lines
29 KiB
TypeScript
904 lines
29 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { describe, expect, it, vi } from "vite-plus/test";
|
|
import React from "react";
|
|
import {
|
|
APP_ARTIFACT_COMPATIBILITY_KEY,
|
|
APP_LAYOUT_FLAGS_KEY,
|
|
APP_ROOT_LAYOUT_KEY,
|
|
isAppElementsRecord,
|
|
type AppOutgoingElements,
|
|
} from "../packages/vinext/src/server/app-elements.js";
|
|
import {
|
|
APP_ELEMENTS_SCHEMA_VERSION,
|
|
ARTIFACT_COMPATIBILITY_SCHEMA_VERSION,
|
|
createArtifactCompatibilityGraphVersion,
|
|
RSC_PAYLOAD_SCHEMA_VERSION,
|
|
} from "../packages/vinext/src/server/artifact-compatibility.js";
|
|
import type { LayoutClassificationOptions } from "../packages/vinext/src/server/app-page-execution.js";
|
|
import { renderAppPageLifecycle } from "../packages/vinext/src/server/app-page-render.js";
|
|
|
|
function captureRecord(value: ReactNode | AppOutgoingElements): Record<string, unknown> {
|
|
if (!isAppElementsRecord(value)) {
|
|
throw new Error("Expected captured element to be a plain record");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
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 createDeferred<T = void>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
resolve = promiseResolve;
|
|
reject = promiseReject;
|
|
});
|
|
return { promise, reject, resolve };
|
|
}
|
|
|
|
function createCommonOptions() {
|
|
const waitUntilPromises: Promise<void>[] = [];
|
|
const renderToReadableStream = vi.fn(() => createStream(["flight-data"]));
|
|
const loadSsrHandler = vi.fn(async () => ({
|
|
async handleSsr(
|
|
_rscStream: ReadableStream<Uint8Array>,
|
|
_navContext: unknown,
|
|
_fontData: unknown,
|
|
options?: {
|
|
scriptNonce?: string;
|
|
sideStream?: ReadableStream<Uint8Array>;
|
|
capturedRscDataRef?: { value: Promise<ArrayBuffer> | null };
|
|
},
|
|
) {
|
|
// Fill capturedRscDataRef so the ISR cache write path can verify paired
|
|
// HTML + RSC writes. The embed transform accumulates raw bytes; simulate
|
|
// that by providing a resolved promise with test fixture data.
|
|
if (options?.capturedRscDataRef) {
|
|
options.capturedRscDataRef.value = Promise.resolve(
|
|
new TextEncoder().encode("flight-data").buffer,
|
|
);
|
|
// Consume the sideStream so the stream is not left hanging
|
|
if (options.sideStream) {
|
|
void options.sideStream.getReader().cancel();
|
|
}
|
|
}
|
|
return createStream(["<html>page</html>"]);
|
|
},
|
|
}));
|
|
const renderErrorBoundaryResponse = vi.fn(async (error: unknown) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Response(`boundary:${message}`, { status: 200 });
|
|
});
|
|
const renderLayoutSpecialError = vi.fn(
|
|
async (specialError) =>
|
|
new Response(`layout:${specialError.statusCode}`, {
|
|
status: specialError.statusCode,
|
|
}),
|
|
);
|
|
const renderPageSpecialError = vi.fn(
|
|
async (specialError) =>
|
|
new Response(`page:${specialError.statusCode}`, {
|
|
status: specialError.statusCode,
|
|
}),
|
|
);
|
|
const isrSet = vi.fn(async () => {});
|
|
|
|
return {
|
|
isrSet,
|
|
loadSsrHandler,
|
|
renderErrorBoundaryResponse,
|
|
renderLayoutSpecialError,
|
|
renderPageSpecialError,
|
|
renderToReadableStream,
|
|
waitUntilPromises,
|
|
options: {
|
|
cleanPathname: "/posts/post",
|
|
clearRequestContext() {},
|
|
consumeDynamicUsage: vi.fn(() => false),
|
|
createRscOnErrorHandler() {
|
|
return () => null;
|
|
},
|
|
element: React.createElement("div", null, "page"),
|
|
getDraftModeCookieHeader() {
|
|
return null;
|
|
},
|
|
getFontLinks() {
|
|
return [];
|
|
},
|
|
getFontPreloads() {
|
|
return [];
|
|
},
|
|
getFontStyles() {
|
|
return [];
|
|
},
|
|
getNavigationContext() {
|
|
return { pathname: "/posts/post" };
|
|
},
|
|
getPageTags() {
|
|
return ["_N_T_/posts/post"];
|
|
},
|
|
getRequestCacheLife() {
|
|
return null;
|
|
},
|
|
handlerStart: 10,
|
|
hasLoadingBoundary: false,
|
|
isDynamicError: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: false,
|
|
isProduction: false,
|
|
isRscRequest: false,
|
|
isrHtmlKey(pathname: string) {
|
|
return `html:${pathname}`;
|
|
},
|
|
isrRscKey(pathname: string) {
|
|
return `rsc:${pathname}`;
|
|
},
|
|
isrSet,
|
|
layoutCount: 0,
|
|
loadSsrHandler,
|
|
middlewareContext: {
|
|
headers: null,
|
|
status: null,
|
|
},
|
|
params: { slug: "post" },
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
probePage() {
|
|
return null;
|
|
},
|
|
revalidateSeconds: null,
|
|
renderErrorBoundaryResponse,
|
|
renderLayoutSpecialError,
|
|
renderPageSpecialError,
|
|
renderToReadableStream,
|
|
routeHasLocalBoundary: false,
|
|
routePattern: "/posts/[slug]",
|
|
runWithSuppressedHookWarning<T>(probe: () => Promise<T>) {
|
|
return probe();
|
|
},
|
|
waitUntil(promise: Promise<void>) {
|
|
waitUntilPromises.push(promise);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("clearRequestContext timing — issue #660", () => {
|
|
// Regression test: clearRequestContext() must not be called before the HTML
|
|
// stream is fully consumed. Calling it synchronously after receiving the
|
|
// stream handle races the lazy RSC/SSR pipeline on warm module-cache loads,
|
|
// causing headers()/cookies() to see a null context mid-stream.
|
|
it("does not call clearRequestContext before the HTML stream body is consumed", async () => {
|
|
const common = createCommonOptions();
|
|
const contextCleared: string[] = [];
|
|
|
|
// Record when the context is cleared relative to stream reads.
|
|
const clearRequestContext = vi.fn(() => {
|
|
contextCleared.push("cleared");
|
|
});
|
|
|
|
// The SSR handler produces a stream that records when each chunk is read.
|
|
const loadSsrHandler = vi.fn(async () => ({
|
|
async handleSsr() {
|
|
return new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("<html>page</html>"));
|
|
controller.close();
|
|
},
|
|
});
|
|
},
|
|
}));
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
clearRequestContext,
|
|
loadSsrHandler,
|
|
});
|
|
|
|
// Context must NOT be cleared yet — stream hasn't been consumed.
|
|
expect(contextCleared).toHaveLength(0);
|
|
|
|
// Consume the stream (simulates the HTTP response being sent to the client).
|
|
await response.text();
|
|
|
|
// Context must be cleared after the stream is fully consumed.
|
|
expect(contextCleared).toHaveLength(1);
|
|
});
|
|
|
|
it("does not call clearRequestContext before the ISR-cacheable HTML stream body is consumed", async () => {
|
|
const common = createCommonOptions();
|
|
const contextCleared: string[] = [];
|
|
|
|
const clearRequestContext = vi.fn(() => {
|
|
contextCleared.push("cleared");
|
|
});
|
|
|
|
const loadSsrHandler = vi.fn(async () => ({
|
|
async handleSsr() {
|
|
return new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("<html>cached</html>"));
|
|
controller.close();
|
|
},
|
|
});
|
|
},
|
|
}));
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
clearRequestContext,
|
|
isProduction: true,
|
|
loadSsrHandler,
|
|
revalidateSeconds: 30,
|
|
});
|
|
|
|
// Context must NOT be cleared yet — stream hasn't been consumed.
|
|
expect(contextCleared).toHaveLength(0);
|
|
|
|
// Consume the stream.
|
|
await response.text();
|
|
|
|
// Context must be cleared after the stream is fully consumed.
|
|
expect(contextCleared).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("app page render lifecycle", () => {
|
|
it("returns pre-render special responses before starting the render stream", async () => {
|
|
const common = createCommonOptions();
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
probePage() {
|
|
throw { digest: "NEXT_NOT_FOUND" };
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(404);
|
|
await expect(response.text()).resolves.toBe("page:404");
|
|
expect(common.renderToReadableStream).not.toHaveBeenCalled();
|
|
expect(common.renderPageSpecialError).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns RSC responses and schedules an ISR cache write through waitUntil", async () => {
|
|
const common = createCommonOptions();
|
|
const consumeDynamicUsage = vi.fn(() => false);
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
consumeDynamicUsage,
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
revalidateSeconds: 60,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toBe("text/x-component; charset=utf-8");
|
|
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
expect(response.headers.get("x-vinext-cache")).toBe("MISS");
|
|
await expect(response.text()).resolves.toBe("flight-data");
|
|
|
|
expect(common.waitUntilPromises).toHaveLength(1);
|
|
await Promise.all(common.waitUntilPromises);
|
|
expect(common.isrSet).toHaveBeenCalledTimes(1);
|
|
expect(common.isrSet).toHaveBeenCalledWith(
|
|
"rsc:/posts/post",
|
|
expect.objectContaining({ kind: "APP_PAGE" }),
|
|
60,
|
|
["_N_T_/posts/post"],
|
|
undefined,
|
|
);
|
|
expect(consumeDynamicUsage).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not wait for the full captured RSC payload before returning production RSC responses", async () => {
|
|
const common = createCommonOptions();
|
|
const releaseRsc = createDeferred();
|
|
|
|
const responsePromise = renderAppPageLifecycle({
|
|
...common.options,
|
|
getRequestCacheLife() {
|
|
return { revalidate: 7, expire: 11 };
|
|
},
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
renderToReadableStream() {
|
|
let sent = false;
|
|
return new ReadableStream<Uint8Array>({
|
|
async pull(controller) {
|
|
if (sent) {
|
|
await releaseRsc.promise;
|
|
controller.close();
|
|
return;
|
|
}
|
|
sent = true;
|
|
controller.enqueue(new TextEncoder().encode("flight"));
|
|
},
|
|
});
|
|
},
|
|
revalidateSeconds: null,
|
|
});
|
|
|
|
await expect(
|
|
Promise.race([responsePromise.then(() => "returned"), releaseRsc.promise.then(() => "done")]),
|
|
).resolves.toBe("returned");
|
|
|
|
const response = await responsePromise;
|
|
expect(response.headers.get("cache-control")).toBeNull();
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(common.waitUntilPromises).toHaveLength(1);
|
|
|
|
releaseRsc.resolve();
|
|
await expect(response.text()).resolves.toBe("flight");
|
|
await Promise.all(common.waitUntilPromises);
|
|
expect(common.isrSet).toHaveBeenCalledWith(
|
|
"rsc:/posts/post",
|
|
expect.objectContaining({ kind: "APP_PAGE" }),
|
|
7,
|
|
["_N_T_/posts/post"],
|
|
11,
|
|
);
|
|
});
|
|
|
|
it("rerenders HTML responses with the error boundary when a global RSC error was captured", async () => {
|
|
const common = createCommonOptions();
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
renderToReadableStream(_element, { onError }) {
|
|
onError(new Error("boom"), null, null);
|
|
return createStream(["flight-data"]);
|
|
},
|
|
});
|
|
|
|
expect(common.renderErrorBoundaryResponse).toHaveBeenCalledTimes(1);
|
|
await expect(response.text()).resolves.toBe("boundary:boom");
|
|
});
|
|
|
|
it("writes paired HTML and RSC cache entries for cacheable HTML responses", async () => {
|
|
const common = createCommonOptions();
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
getDraftModeCookieHeader() {
|
|
return "draft=1; Path=/";
|
|
},
|
|
isProduction: true,
|
|
revalidateSeconds: 30,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
expect(response.headers.get("x-vinext-cache")).toBe("MISS");
|
|
expect(response.headers.get("set-cookie")).toBe("draft=1; Path=/");
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
|
|
expect(common.waitUntilPromises).toHaveLength(1);
|
|
await Promise.all(common.waitUntilPromises);
|
|
expect(common.isrSet).toHaveBeenCalledTimes(2);
|
|
expect(common.isrSet).toHaveBeenNthCalledWith(
|
|
1,
|
|
"html:/posts/post",
|
|
expect.objectContaining({ kind: "APP_PAGE" }),
|
|
30,
|
|
["_N_T_/posts/post"],
|
|
undefined,
|
|
);
|
|
expect(common.isrSet).toHaveBeenNthCalledWith(
|
|
2,
|
|
"rsc:/posts/post",
|
|
expect.objectContaining({ kind: "APP_PAGE" }),
|
|
30,
|
|
["_N_T_/posts/post"],
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
it("does not wait for cacheLife-only RSC capture before returning production HTML responses", async () => {
|
|
const common = createCommonOptions();
|
|
const releaseRsc = createDeferred();
|
|
|
|
const responsePromise = renderAppPageLifecycle({
|
|
...common.options,
|
|
getRequestCacheLife() {
|
|
return { revalidate: 5, expire: 9 };
|
|
},
|
|
isProduction: true,
|
|
renderToReadableStream() {
|
|
let sent = false;
|
|
return new ReadableStream<Uint8Array>({
|
|
async pull(controller) {
|
|
if (sent) {
|
|
await releaseRsc.promise;
|
|
controller.close();
|
|
return;
|
|
}
|
|
sent = true;
|
|
controller.enqueue(new TextEncoder().encode("flight"));
|
|
},
|
|
});
|
|
},
|
|
revalidateSeconds: null,
|
|
});
|
|
|
|
await expect(
|
|
Promise.race([responsePromise.then(() => "returned"), releaseRsc.promise.then(() => "done")]),
|
|
).resolves.toBe("returned");
|
|
|
|
const response = await responsePromise;
|
|
expect(response.headers.get("cache-control")).toBeNull();
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(common.waitUntilPromises).toHaveLength(1);
|
|
|
|
releaseRsc.resolve();
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
await Promise.all(common.waitUntilPromises);
|
|
expect(common.isrSet).toHaveBeenNthCalledWith(
|
|
1,
|
|
"html:/posts/post",
|
|
expect.objectContaining({ kind: "APP_PAGE" }),
|
|
5,
|
|
["_N_T_/posts/post"],
|
|
9,
|
|
);
|
|
expect(common.isrSet).toHaveBeenNthCalledWith(
|
|
2,
|
|
"rsc:/posts/post",
|
|
expect.objectContaining({ kind: "APP_PAGE" }),
|
|
5,
|
|
["_N_T_/posts/post"],
|
|
9,
|
|
);
|
|
});
|
|
|
|
it("preserves original production RSC response headers when speculative cacheLife never appears", async () => {
|
|
const common = createCommonOptions();
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
revalidateSeconds: null,
|
|
});
|
|
|
|
expect(response.headers.get("cache-control")).toBeNull();
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(common.waitUntilPromises).toHaveLength(1);
|
|
|
|
await expect(response.text()).resolves.toBe("flight-data");
|
|
await Promise.all(common.waitUntilPromises);
|
|
expect(common.isrSet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves original production HTML response headers when speculative cacheLife never appears", async () => {
|
|
const common = createCommonOptions();
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
isProduction: true,
|
|
revalidateSeconds: null,
|
|
});
|
|
|
|
expect(response.headers.get("cache-control")).toBeNull();
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(common.waitUntilPromises).toHaveLength(1);
|
|
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
await Promise.all(common.waitUntilPromises);
|
|
expect(common.isrSet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("captures prerender cache metadata before building non-production HTML responses", async () => {
|
|
const common = createCommonOptions();
|
|
let requestCacheLife: { revalidate: number; expire: number } | null = null;
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
getRequestCacheLife() {
|
|
const value = requestCacheLife;
|
|
requestCacheLife = null;
|
|
return value;
|
|
},
|
|
isPrerender: true,
|
|
isProduction: false,
|
|
renderToReadableStream() {
|
|
let sent = false;
|
|
return new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (sent) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
requestCacheLife = { revalidate: 1, expire: 3 };
|
|
controller.enqueue(new TextEncoder().encode("flight-data"));
|
|
sent = true;
|
|
},
|
|
});
|
|
},
|
|
revalidateSeconds: 1,
|
|
});
|
|
|
|
expect(response.headers.get("cache-control")).toBe("s-maxage=1, stale-while-revalidate=2");
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
expect(common.waitUntilPromises).toHaveLength(0);
|
|
expect(common.isrSet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("captures prerender cache metadata when cacheLife provides the only revalidate value", async () => {
|
|
const common = createCommonOptions();
|
|
let requestCacheLife: { revalidate: number; expire: number } | null = null;
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
getRequestCacheLife() {
|
|
const value = requestCacheLife;
|
|
requestCacheLife = null;
|
|
return value;
|
|
},
|
|
isPrerender: true,
|
|
isProduction: false,
|
|
renderToReadableStream() {
|
|
let sent = false;
|
|
return new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (sent) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
requestCacheLife = { revalidate: 1, expire: 3 };
|
|
controller.enqueue(new TextEncoder().encode("flight-data"));
|
|
sent = true;
|
|
},
|
|
});
|
|
},
|
|
revalidateSeconds: null,
|
|
});
|
|
|
|
expect(response.headers.get("cache-control")).toBe("s-maxage=1, stale-while-revalidate=2");
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
expect(common.isrSet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves prerender cache metadata for the manifest writer after shaping headers", async () => {
|
|
const common = createCommonOptions();
|
|
let requestCacheLife: { revalidate: number; expire: number } | null = null;
|
|
const consumeRequestCacheLife = () => {
|
|
const value = requestCacheLife;
|
|
requestCacheLife = null;
|
|
return value;
|
|
};
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
getRequestCacheLife: consumeRequestCacheLife,
|
|
isPrerender: true,
|
|
isProduction: false,
|
|
peekRequestCacheLife() {
|
|
return requestCacheLife;
|
|
},
|
|
renderToReadableStream() {
|
|
let sent = false;
|
|
return new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (sent) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
requestCacheLife = { revalidate: 1, expire: 1 };
|
|
controller.enqueue(new TextEncoder().encode("flight-data"));
|
|
sent = true;
|
|
},
|
|
});
|
|
},
|
|
revalidateSeconds: null,
|
|
});
|
|
|
|
expect(response.headers.get("cache-control")).toBe("s-maxage=1");
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
expect(consumeRequestCacheLife()).toEqual({ revalidate: 1, expire: 1 });
|
|
});
|
|
|
|
it("preserves prerender cache metadata headers in production mode without ISR writes", async () => {
|
|
const common = createCommonOptions();
|
|
let requestCacheLife: { revalidate: number; expire: number } | null = null;
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
getRequestCacheLife() {
|
|
const value = requestCacheLife;
|
|
requestCacheLife = null;
|
|
return value;
|
|
},
|
|
isPrerender: true,
|
|
isProduction: true,
|
|
renderToReadableStream() {
|
|
let sent = false;
|
|
return new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (sent) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
requestCacheLife = { revalidate: 1, expire: 3 };
|
|
controller.enqueue(new TextEncoder().encode("flight-data"));
|
|
sent = true;
|
|
},
|
|
});
|
|
},
|
|
revalidateSeconds: null,
|
|
});
|
|
|
|
expect(response.headers.get("cache-control")).toBe("s-maxage=1, stale-while-revalidate=2");
|
|
expect(response.headers.get("x-vinext-cache")).toBe("MISS");
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
expect(common.waitUntilPromises).toHaveLength(0);
|
|
expect(common.isrSet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("disables HTML ISR caching when the response carries a script nonce", async () => {
|
|
const common = createCommonOptions();
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
isProduction: true,
|
|
revalidateSeconds: 30,
|
|
scriptNonce: "vinext-test-nonce",
|
|
});
|
|
|
|
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
expect(common.waitUntilPromises).toHaveLength(0);
|
|
expect(common.isrSet).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("layoutFlags injection into RSC payload", () => {
|
|
function createRscOptions(overrides: {
|
|
element?: Record<string, ReactNode>;
|
|
layoutCount?: number;
|
|
probeLayoutAt?: (index: number) => unknown;
|
|
classification?: LayoutClassificationOptions | null;
|
|
}) {
|
|
let capturedElement: Record<string, unknown> | null = null;
|
|
|
|
const options = {
|
|
cleanPathname: "/test",
|
|
clearRequestContext: vi.fn(),
|
|
consumeDynamicUsage: vi.fn(() => false),
|
|
createRscOnErrorHandler: () => () => {},
|
|
getDraftModeCookieHeader: () => null,
|
|
getFontLinks: () => [],
|
|
getFontPreloads: () => [],
|
|
getFontStyles: () => [],
|
|
getNavigationContext: () => null,
|
|
getPageTags: () => [],
|
|
getRequestCacheLife: () => null,
|
|
handlerStart: 0,
|
|
hasLoadingBoundary: false,
|
|
isDynamicError: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: false,
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
isrHtmlKey: (p: string) => `html:${p}`,
|
|
isrRscKey: (p: string) => `rsc:${p}`,
|
|
isrSet: vi.fn().mockResolvedValue(undefined),
|
|
layoutCount: overrides.layoutCount ?? 0,
|
|
loadSsrHandler: vi.fn(),
|
|
middlewareContext: { headers: null, status: null },
|
|
params: {},
|
|
probeLayoutAt: overrides.probeLayoutAt ?? (() => null),
|
|
probePage: () => null,
|
|
revalidateSeconds: null,
|
|
renderErrorBoundaryResponse: async () => null,
|
|
renderLayoutSpecialError: async () => new Response("error", { status: 500 }),
|
|
renderPageSpecialError: async () => new Response("error", { status: 500 }),
|
|
renderToReadableStream(el: ReactNode | AppOutgoingElements) {
|
|
capturedElement = captureRecord(el);
|
|
return createStream(["flight-data"]);
|
|
},
|
|
routeHasLocalBoundary: false,
|
|
routePattern: "/test",
|
|
runWithSuppressedHookWarning: <T>(probe: () => Promise<T>) => probe(),
|
|
element: overrides.element ?? { "page:/test": "test-page" },
|
|
classification: overrides.classification,
|
|
};
|
|
|
|
return {
|
|
options,
|
|
getCapturedElement: (): Record<string, unknown> => {
|
|
if (capturedElement === null) {
|
|
throw new Error("renderToReadableStream was not called");
|
|
}
|
|
return capturedElement;
|
|
},
|
|
};
|
|
}
|
|
|
|
it("injects __layoutFlags with 's' when classification detects a static layout", async () => {
|
|
const { options, getCapturedElement } = createRscOptions({
|
|
element: { "layout:/": "root-layout", "page:/test": "test-page" },
|
|
layoutCount: 1,
|
|
probeLayoutAt: () => null,
|
|
classification: {
|
|
getLayoutId: () => "layout:/",
|
|
buildTimeClassifications: null,
|
|
async runWithIsolatedDynamicScope(fn) {
|
|
const result = await fn();
|
|
return { result, dynamicDetected: false };
|
|
},
|
|
},
|
|
});
|
|
|
|
await renderAppPageLifecycle(options);
|
|
expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ "layout:/": "s" });
|
|
});
|
|
|
|
it("injects __layoutFlags with 'd' for dynamic layouts", async () => {
|
|
const { options, getCapturedElement } = createRscOptions({
|
|
element: { "layout:/": "root-layout", "page:/test": "test-page" },
|
|
layoutCount: 1,
|
|
probeLayoutAt: () => null,
|
|
classification: {
|
|
getLayoutId: () => "layout:/",
|
|
buildTimeClassifications: null,
|
|
async runWithIsolatedDynamicScope(fn) {
|
|
const result = await fn();
|
|
return { result, dynamicDetected: true };
|
|
},
|
|
},
|
|
});
|
|
|
|
await renderAppPageLifecycle(options);
|
|
expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ "layout:/": "d" });
|
|
});
|
|
|
|
it("injects empty __layoutFlags when classification is not provided (backward compat)", async () => {
|
|
const { options, getCapturedElement } = createRscOptions({
|
|
element: { "layout:/": "root-layout", "page:/test": "test-page" },
|
|
layoutCount: 1,
|
|
probeLayoutAt: () => null,
|
|
});
|
|
|
|
await renderAppPageLifecycle(options);
|
|
expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({});
|
|
});
|
|
|
|
it("injects concrete artifact compatibility metadata from the render boundary", async () => {
|
|
const originalBuildId = process.env.__VINEXT_BUILD_ID;
|
|
process.env.__VINEXT_BUILD_ID = "deploy-test";
|
|
const { options, getCapturedElement } = createRscOptions({
|
|
element: {
|
|
[APP_ROOT_LAYOUT_KEY]: "/(shop)",
|
|
"layout:/(shop)": "shop-layout",
|
|
"page:/shop": "shop-page",
|
|
},
|
|
});
|
|
|
|
try {
|
|
await renderAppPageLifecycle(options);
|
|
} finally {
|
|
if (originalBuildId === undefined) {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
} else {
|
|
process.env.__VINEXT_BUILD_ID = originalBuildId;
|
|
}
|
|
}
|
|
|
|
expect(getCapturedElement()[APP_ARTIFACT_COMPATIBILITY_KEY]).toEqual({
|
|
schemaVersion: ARTIFACT_COMPATIBILITY_SCHEMA_VERSION,
|
|
graphVersion: createArtifactCompatibilityGraphVersion({
|
|
routePattern: "/test",
|
|
rootBoundaryId: "/(shop)",
|
|
}),
|
|
deploymentVersion: "deploy-test",
|
|
appElementsSchemaVersion: APP_ELEMENTS_SCHEMA_VERSION,
|
|
rscPayloadSchemaVersion: RSC_PAYLOAD_SCHEMA_VERSION,
|
|
rootBoundaryId: "/(shop)",
|
|
renderEpoch: null,
|
|
});
|
|
});
|
|
|
|
it("injects __layoutFlags for multiple independently classified layouts", async () => {
|
|
let callCount = 0;
|
|
const { options, getCapturedElement } = createRscOptions({
|
|
element: {
|
|
"layout:/": "root-layout",
|
|
"layout:/blog": "blog-layout",
|
|
"page:/blog/post": "post-page",
|
|
},
|
|
layoutCount: 2,
|
|
probeLayoutAt: () => null,
|
|
classification: {
|
|
getLayoutId: (index: number) => (index === 0 ? "layout:/" : "layout:/blog"),
|
|
buildTimeClassifications: null,
|
|
async runWithIsolatedDynamicScope(fn) {
|
|
callCount++;
|
|
const result = await fn();
|
|
// probeAppPageLayouts iterates from layoutCount-1 down to 0:
|
|
// call 1 → layout index 1 (blog) → dynamic
|
|
// call 2 → layout index 0 (root) → static
|
|
return { result, dynamicDetected: callCount === 1 };
|
|
},
|
|
},
|
|
});
|
|
|
|
await renderAppPageLifecycle(options);
|
|
expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({
|
|
"layout:/": "s",
|
|
"layout:/blog": "d",
|
|
});
|
|
});
|
|
|
|
it("__layoutFlags includes flags for ALL layouts even when some are skipped", async () => {
|
|
const { options, getCapturedElement } = createRscOptions({
|
|
element: {
|
|
"layout:/": "root-layout",
|
|
"layout:/blog": "blog-layout",
|
|
"page:/blog/post": "post-page",
|
|
},
|
|
layoutCount: 2,
|
|
probeLayoutAt: () => null,
|
|
classification: {
|
|
getLayoutId: (index: number) => (index === 0 ? "layout:/" : "layout:/blog"),
|
|
buildTimeClassifications: null,
|
|
async runWithIsolatedDynamicScope(fn) {
|
|
const result = await fn();
|
|
return { result, dynamicDetected: false };
|
|
},
|
|
},
|
|
});
|
|
|
|
await renderAppPageLifecycle(options);
|
|
// layoutFlags must include ALL layout flags, even for skipped layouts
|
|
expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({
|
|
"layout:/": "s",
|
|
"layout:/blog": "s",
|
|
});
|
|
});
|
|
|
|
it("wire payload layoutFlags uses only the shorthand 's'/'d' values, never tagged reasons", async () => {
|
|
const { options, getCapturedElement } = createRscOptions({
|
|
element: {
|
|
"layout:/": "root-layout",
|
|
"layout:/admin": "admin-layout",
|
|
"page:/admin/users": "users-page",
|
|
},
|
|
layoutCount: 2,
|
|
probeLayoutAt: () => null,
|
|
classification: {
|
|
buildTimeClassifications: new Map([
|
|
[0, "static"],
|
|
[1, "dynamic"],
|
|
]),
|
|
getLayoutId: (index: number) => (index === 0 ? "layout:/" : "layout:/admin"),
|
|
async runWithIsolatedDynamicScope(fn) {
|
|
const result = await fn();
|
|
return { result, dynamicDetected: false };
|
|
},
|
|
},
|
|
});
|
|
|
|
await renderAppPageLifecycle(options);
|
|
|
|
const wireFlags = getCapturedElement()[APP_LAYOUT_FLAGS_KEY];
|
|
expect(wireFlags).toEqual({ "layout:/": "s", "layout:/admin": "d" });
|
|
|
|
for (const [_id, flag] of Object.entries(wireFlags as Record<string, unknown>)) {
|
|
expect(flag === "s" || flag === "d").toBe(true);
|
|
}
|
|
});
|
|
});
|