mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
a9a30a5c85
Co-authored-by: James Anderson <james@eli.cx>
2069 lines
70 KiB
TypeScript
2069 lines
70 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_RENDER_OBSERVATION_KEY,
|
|
APP_ROOT_LAYOUT_KEY,
|
|
APP_SKIPPED_LAYOUT_IDS_KEY,
|
|
AppElementsWire,
|
|
isAppElementsRecord,
|
|
type AppOutgoingElements,
|
|
} from "../packages/vinext/src/server/app-elements.js";
|
|
import {
|
|
APP_ELEMENTS_SCHEMA_VERSION,
|
|
ARTIFACT_COMPATIBILITY_SCHEMA_VERSION,
|
|
createArtifactCompatibilityEnvelope,
|
|
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 { createClientReuseManifestHeaderFromVisibleAppState } from "../packages/vinext/src/server/app-browser-client-reuse-manifest.js";
|
|
import { createAppLayoutParamAccessTracker } from "../packages/vinext/src/server/app-layout-param-observation.js";
|
|
import { renderAppPageLifecycle } from "../packages/vinext/src/server/app-page-render.js";
|
|
import {
|
|
parseClientReuseManifestHeader,
|
|
type ClientReuseManifestParseResult,
|
|
type ClientReuseManifestSkipDisposition,
|
|
} from "../packages/vinext/src/server/client-reuse-manifest.js";
|
|
import {
|
|
VINEXT_DYNAMIC_STALE_TIME_HEADER,
|
|
VINEXT_PRERENDER_CACHE_LIFE_HEADER,
|
|
} from "../packages/vinext/src/server/headers.js";
|
|
import type { CachedAppPageValue } from "../packages/vinext/src/shims/cache.js";
|
|
import {
|
|
DefaultCdnCacheAdapter,
|
|
setCdnCacheAdapter,
|
|
} from "../packages/vinext/src/shims/cdn-cache.js";
|
|
import { markDynamicUsage } from "../packages/vinext/src/shims/headers.js";
|
|
import {
|
|
createRequestContext,
|
|
runWithRequestContext,
|
|
} from "../packages/vinext/src/shims/unified-request-context.js";
|
|
import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.runtime.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?: {
|
|
formState?: unknown;
|
|
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 (
|
|
_key: string,
|
|
_data: CachedAppPageValue,
|
|
_revalidateSeconds: number,
|
|
_tags: string[],
|
|
_expireSeconds?: number,
|
|
) => {},
|
|
);
|
|
|
|
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",
|
|
searchParams: new URLSearchParams(),
|
|
params: { slug: "post" },
|
|
};
|
|
},
|
|
getPageTags() {
|
|
return ["_N_T_/posts/post"];
|
|
},
|
|
getRequestCacheLife() {
|
|
return null;
|
|
},
|
|
handlerStart: 10,
|
|
hasLoadingBoundary: false,
|
|
isDynamicError: false,
|
|
isDraftMode: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: false,
|
|
isProgressiveActionRender: 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,
|
|
},
|
|
navigationParams: { slug: "post" },
|
|
params: { slug: "post" },
|
|
probeLayoutAt() {
|
|
return null;
|
|
},
|
|
probePage() {
|
|
return null;
|
|
},
|
|
revalidateSeconds: null,
|
|
renderErrorBoundaryResponse,
|
|
renderLayoutSpecialError,
|
|
renderPageSpecialError,
|
|
renderToReadableStream,
|
|
routePattern: "/posts/[slug]",
|
|
runWithSuppressedHookWarning<T>(probe: () => Promise<T>) {
|
|
return probe();
|
|
},
|
|
waitUntil(promise: Promise<void>) {
|
|
waitUntilPromises.push(promise);
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createVerifiedStaticLayoutManifest(input: {
|
|
deploymentVersion: string;
|
|
layoutId: string;
|
|
rootBoundaryId: string;
|
|
routeId: string;
|
|
routePattern: string;
|
|
}): ClientReuseManifestParseResult {
|
|
const artifactCompatibility = createArtifactCompatibilityEnvelope({
|
|
deploymentVersion: input.deploymentVersion,
|
|
graphVersion: createArtifactCompatibilityGraphVersion({
|
|
routePattern: input.routePattern,
|
|
rootBoundaryId: input.rootBoundaryId,
|
|
}),
|
|
rootBoundaryId: input.rootBoundaryId,
|
|
});
|
|
|
|
const header = createClientReuseManifestHeaderFromVisibleAppState({
|
|
elements: {
|
|
...AppElementsWire.createMetadataEntries({
|
|
interceptionContext: null,
|
|
layoutIds: [input.layoutId],
|
|
rootLayoutTreePath: input.rootBoundaryId,
|
|
routeId: input.routeId,
|
|
}),
|
|
[AppElementsWire.keys.artifactCompatibility]: artifactCompatibility,
|
|
[AppElementsWire.keys.layoutFlags]: { [input.layoutId]: "s" },
|
|
[input.layoutId]: `retained-${input.layoutId}`,
|
|
},
|
|
visibleCommitVersion: 1,
|
|
});
|
|
if (header === null) {
|
|
throw new Error("Expected retained static layout manifest");
|
|
}
|
|
|
|
return parseClientReuseManifestHeader(header);
|
|
}
|
|
|
|
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("SSR shell error recovery", () => {
|
|
it("returns an uncached 500 response for a recovered dynamic shell error", async () => {
|
|
setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
|
|
const common = createCommonOptions();
|
|
try {
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
isProduction: true,
|
|
middlewareContext: {
|
|
headers: new Headers({
|
|
"Cache-Control": "public, max-age=3600",
|
|
"CDN-Cache-Control": "public, max-age=3600",
|
|
"Cloudflare-CDN-Cache-Control": "public, max-age=3600",
|
|
"Cache-Tag": "shell-error",
|
|
}),
|
|
status: null,
|
|
},
|
|
revalidateSeconds: 30,
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr() {
|
|
return {
|
|
htmlStream: createStream(['<html id="__next_error__"></html>']),
|
|
metadataReady: Promise.resolve(),
|
|
capturedRscData: null,
|
|
shellErrorRecovered: true,
|
|
};
|
|
},
|
|
}),
|
|
});
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.headers.get("cache-control")).toBe(
|
|
"private, no-cache, no-store, max-age=0, must-revalidate",
|
|
);
|
|
expect(response.headers.get("cdn-cache-control")).toBeNull();
|
|
expect(response.headers.get("cloudflare-cdn-cache-control")).toBeNull();
|
|
expect(response.headers.get("cache-tag")).toBeNull();
|
|
await expect(response.text()).resolves.toContain("__next_error__");
|
|
expect(common.isrSet).not.toHaveBeenCalled();
|
|
} finally {
|
|
setCdnCacheAdapter(new DefaultCdnCacheAdapter());
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("form state rendering", () => {
|
|
it("passes action form state to SSR and disables HTML cache writes", async () => {
|
|
const common = createCommonOptions();
|
|
const formState = ["action-result", "key-path", "reference-id", 1] as never;
|
|
const loadSsrHandler = vi.fn(async () => ({
|
|
async handleSsr(
|
|
_rscStream: ReadableStream<Uint8Array>,
|
|
_navContext: unknown,
|
|
_fontData: unknown,
|
|
options?: { formState?: unknown },
|
|
) {
|
|
expect(options?.formState).toBe(formState);
|
|
return createStream(["<html>action state</html>"]);
|
|
},
|
|
}));
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
formState,
|
|
isProgressiveActionRender: true,
|
|
isProduction: true,
|
|
loadSsrHandler,
|
|
revalidateSeconds: 60,
|
|
});
|
|
|
|
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
expect(common.isrSet).not.toHaveBeenCalled();
|
|
await expect(response.text()).resolves.toBe("<html>action state</html>");
|
|
});
|
|
});
|
|
|
|
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,
|
|
isRscRequest: true,
|
|
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("does not run the page probe before normal HTML rendering", async () => {
|
|
const common = createCommonOptions();
|
|
const probePage = vi.fn(() => {
|
|
throw new Error("page probe should not execute for HTML");
|
|
});
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
isRscRequest: false,
|
|
probePage,
|
|
});
|
|
|
|
expect(probePage).not.toHaveBeenCalled();
|
|
expect(common.renderToReadableStream).toHaveBeenCalledTimes(1);
|
|
expect(common.renderPageSpecialError).not.toHaveBeenCalled();
|
|
expect(response.status).toBe(200);
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
});
|
|
|
|
it("streams lazy HTML while pending dynamic usage determines the cache write", async () => {
|
|
const common = createCommonOptions();
|
|
const streamGate = createDeferred();
|
|
let dynamicUsed = false;
|
|
|
|
const responsePromise = renderAppPageLifecycle({
|
|
...common.options,
|
|
omitPendingDynamicCacheState: true,
|
|
consumeDynamicUsage() {
|
|
const value = dynamicUsed;
|
|
dynamicUsed = false;
|
|
return value;
|
|
},
|
|
isProduction: true,
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr() {
|
|
return new ReadableStream<Uint8Array>({
|
|
async pull(controller) {
|
|
await streamGate.promise;
|
|
dynamicUsed = true;
|
|
controller.enqueue(new TextEncoder().encode("<html>dynamic</html>"));
|
|
controller.close();
|
|
},
|
|
});
|
|
},
|
|
}),
|
|
revalidateSeconds: 60,
|
|
});
|
|
|
|
const timeout = Symbol("timeout");
|
|
const response = await Promise.race([
|
|
responsePromise,
|
|
new Promise<typeof timeout>((resolve) => setTimeout(() => resolve(timeout), 50)),
|
|
]);
|
|
expect(response).not.toBe(timeout);
|
|
expect(response).toBeInstanceOf(Response);
|
|
if (!(response instanceof Response)) {
|
|
throw new Error("Expected renderAppPageLifecycle to return a Response before stream pull");
|
|
}
|
|
|
|
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
streamGate.resolve();
|
|
await expect(response.text()).resolves.toBe("<html>dynamic</html>");
|
|
await Promise.all(common.waitUntilPromises.splice(0));
|
|
expect(common.isrSet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("recovers HTML page special errors from the real render when the page probe is skipped", async () => {
|
|
const common = createCommonOptions();
|
|
const notFoundError = Object.assign(new Error("NEXT_NOT_FOUND"), { digest: "NEXT_NOT_FOUND" });
|
|
let capturedOnError: ((error: unknown, ...args: unknown[]) => void) | null = null;
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
hasLoadingBoundary: false,
|
|
isRscRequest: false,
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr() {
|
|
capturedOnError?.(notFoundError, null, null);
|
|
return createStream(["<html>fallback</html>"]);
|
|
},
|
|
}),
|
|
probePage() {
|
|
throw new Error("page probe should not execute for HTML");
|
|
},
|
|
renderToReadableStream(_element, opts) {
|
|
capturedOnError = opts.onError;
|
|
return createStream(["flight-data"]);
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(common.renderPageSpecialError).toHaveBeenCalledTimes(1);
|
|
expect(common.renderPageSpecialError).toHaveBeenCalledWith({
|
|
kind: "http-access-fallback",
|
|
statusCode: 404,
|
|
});
|
|
});
|
|
|
|
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,
|
|
consumeRenderObservationState() {
|
|
return {
|
|
dynamicFetches: ["https://api.example.test/posts?token=secret"],
|
|
requestApis: ["headers"],
|
|
};
|
|
},
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
revalidateSeconds: 60,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("content-type")).toBe("text/x-component");
|
|
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,
|
|
);
|
|
const cachedValue = common.isrSet.mock.calls[0]?.[1];
|
|
expect(cachedValue?.renderObservation).toMatchObject({
|
|
boundaryOutcome: { kind: "success" },
|
|
cacheability: "public",
|
|
completeness: "complete",
|
|
output: {
|
|
kind: "app-rsc",
|
|
mountedSlotsFingerprint: null,
|
|
renderEpoch: null,
|
|
rootBoundaryId: null,
|
|
routeId: "route:/posts/[slug]",
|
|
},
|
|
requestApis: expect.arrayContaining([
|
|
{ kind: "headers", status: "observed" },
|
|
{ kind: "params", status: "observed" },
|
|
]),
|
|
});
|
|
expect(JSON.stringify(cachedValue?.renderObservation)).not.toContain("secret");
|
|
expect(consumeDynamicUsage).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("omits RSC cache state and skips cache writes when stream-time searchParams usage is dynamic", async () => {
|
|
const common = createCommonOptions();
|
|
const streamGate = createDeferred();
|
|
let dynamicUsed = false;
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
consumeDynamicUsage() {
|
|
const value = dynamicUsed;
|
|
dynamicUsed = false;
|
|
return value;
|
|
},
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
omitPendingDynamicCacheState: true,
|
|
renderToReadableStream() {
|
|
let sent = false;
|
|
return new ReadableStream<Uint8Array>({
|
|
async pull(controller) {
|
|
if (sent) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
sent = true;
|
|
await streamGate.promise;
|
|
dynamicUsed = true;
|
|
controller.enqueue(new TextEncoder().encode("flight-data"));
|
|
},
|
|
});
|
|
},
|
|
revalidateSeconds: 60,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(common.waitUntilPromises).toHaveLength(1);
|
|
|
|
streamGate.resolve();
|
|
await expect(response.text()).resolves.toBe("flight-data");
|
|
await Promise.all(common.waitUntilPromises);
|
|
expect(common.isrSet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not cache RSC responses when skip transport omits layout records", async () => {
|
|
const common = createCommonOptions();
|
|
const isrDebug = vi.fn();
|
|
let capturedElement: Record<string, unknown> | null = null;
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
element: {
|
|
[APP_ROOT_LAYOUT_KEY]: "/",
|
|
"layout:/": "root-layout",
|
|
"page:/posts/post": "post-page",
|
|
},
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
isrDebug,
|
|
renderToReadableStream(element) {
|
|
capturedElement = captureRecord(element);
|
|
return createStream(["flight-data"]);
|
|
},
|
|
revalidateSeconds: 60,
|
|
skipDisposition: {
|
|
code: "SKIP_STATIC_LAYOUT_VERIFIED",
|
|
enabled: true,
|
|
mode: "skipStaticLayout",
|
|
skippedEntryIds: ["layout:/"],
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
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("flight-data");
|
|
|
|
if (capturedElement === null) {
|
|
throw new Error("Expected renderToReadableStream to receive AppElements payload");
|
|
}
|
|
expect(Object.hasOwn(capturedElement, "layout:/")).toBe(false);
|
|
expect(capturedElement["page:/posts/post"]).toBe("post-page");
|
|
expect(common.waitUntilPromises).toHaveLength(0);
|
|
expect(common.isrSet).not.toHaveBeenCalled();
|
|
expect(isrDebug).toHaveBeenCalledWith(
|
|
"RSC cache write skipped (skip transport payload)",
|
|
"/posts/post",
|
|
);
|
|
});
|
|
|
|
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("preserves HTML responses when a post-shell RSC error may be caught by a client boundary", 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).not.toHaveBeenCalled();
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
});
|
|
|
|
it("prefers the captured RSC error over an SSR decoder error when rendering the error boundary", async () => {
|
|
const common = createCommonOptions();
|
|
const rscError = new Error("rsc-original");
|
|
const ssrError = new Error("ssr-decoder");
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
async loadSsrHandler() {
|
|
return {
|
|
async handleSsr() {
|
|
throw ssrError;
|
|
},
|
|
};
|
|
},
|
|
renderToReadableStream(_element, { onError }) {
|
|
onError(rscError, null, null);
|
|
return createStream(["flight-data"]);
|
|
},
|
|
});
|
|
|
|
expect(common.renderErrorBoundaryResponse).toHaveBeenCalledWith(rscError, "rsc");
|
|
await expect(response.text()).resolves.toBe("boundary:rsc-original");
|
|
});
|
|
|
|
it("marks uncaptured SSR errors as SSR-origin boundary failures", async () => {
|
|
const common = createCommonOptions();
|
|
const ssrError = new Error("ssr-decoder");
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
async loadSsrHandler() {
|
|
return {
|
|
async handleSsr() {
|
|
throw ssrError;
|
|
},
|
|
};
|
|
},
|
|
});
|
|
|
|
expect(common.renderErrorBoundaryResponse).toHaveBeenCalledWith(ssrError, "ssr");
|
|
await expect(response.text()).resolves.toBe("boundary:ssr-decoder");
|
|
});
|
|
|
|
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 late RSC cache metadata during speculative prerender", async () => {
|
|
const common = createCommonOptions();
|
|
let capturedWaitForAllReady: boolean | undefined;
|
|
let capturedFallbackToErrorDocumentOnShellError: boolean | undefined;
|
|
let requestCacheLife: { revalidate: number; expire: number } | null = null;
|
|
const getRequestCacheLife = vi.fn(() => {
|
|
const value = requestCacheLife;
|
|
requestCacheLife = null;
|
|
return value;
|
|
});
|
|
const releaseRscData = createDeferred<ArrayBuffer>();
|
|
|
|
const responsePromise = renderAppPageLifecycle({
|
|
...common.options,
|
|
getRequestCacheLife,
|
|
isPrerender: true,
|
|
isSpeculativePrerender: true,
|
|
loadSsrHandler: vi.fn(async () => ({
|
|
async handleSsr(
|
|
_rscStream: ReadableStream<Uint8Array>,
|
|
_navContext: unknown,
|
|
_fontData: unknown,
|
|
options?: {
|
|
capturedRscDataRef?: { value: Promise<ArrayBuffer> | null };
|
|
fallbackToErrorDocumentOnShellError?: boolean;
|
|
sideStream?: ReadableStream<Uint8Array>;
|
|
waitForAllReady?: boolean;
|
|
},
|
|
) {
|
|
capturedWaitForAllReady = options?.waitForAllReady;
|
|
capturedFallbackToErrorDocumentOnShellError =
|
|
options?.fallbackToErrorDocumentOnShellError;
|
|
if (options?.capturedRscDataRef) {
|
|
options.capturedRscDataRef.value = releaseRscData.promise;
|
|
}
|
|
if (options?.sideStream) {
|
|
void options.sideStream.getReader().cancel();
|
|
}
|
|
return createStream(["<html>speculative</html>"]);
|
|
},
|
|
})),
|
|
revalidateSeconds: 1,
|
|
});
|
|
|
|
await Promise.resolve();
|
|
expect(getRequestCacheLife).not.toHaveBeenCalled();
|
|
requestCacheLife = { revalidate: 1, expire: 3 };
|
|
releaseRscData.resolve(new ArrayBuffer(0));
|
|
const response = await responsePromise;
|
|
|
|
expect(capturedWaitForAllReady).toBe(false);
|
|
expect(capturedFallbackToErrorDocumentOnShellError).toBe(false);
|
|
expect(getRequestCacheLife).toHaveBeenCalledOnce();
|
|
expect(response.headers.get("x-vinext-prerender-cache-life")).toBe(
|
|
JSON.stringify({ revalidate: 1, expire: 3 }),
|
|
);
|
|
expect(response.headers.get("cache-control")).toBe("s-maxage=1, stale-while-revalidate=2");
|
|
await expect(response.text()).resolves.toBe("<html>speculative</html>");
|
|
});
|
|
|
|
it("does not wait for speculative prerender cache metadata after dynamic usage", async () => {
|
|
const common = createCommonOptions();
|
|
let dynamicUsed = false;
|
|
const getRequestCacheLife = vi.fn(() => null);
|
|
const pendingRscData = new Promise<ArrayBuffer>(() => {});
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
consumeDynamicUsage() {
|
|
const value = dynamicUsed;
|
|
dynamicUsed = false;
|
|
return value;
|
|
},
|
|
getRequestCacheLife,
|
|
isPrerender: true,
|
|
isSpeculativePrerender: true,
|
|
loadSsrHandler: vi.fn(async () => ({
|
|
async handleSsr(
|
|
_rscStream: ReadableStream<Uint8Array>,
|
|
_navContext: unknown,
|
|
_fontData: unknown,
|
|
options?: {
|
|
capturedRscDataRef?: { value: Promise<ArrayBuffer> | null };
|
|
sideStream?: ReadableStream<Uint8Array>;
|
|
},
|
|
) {
|
|
if (options?.capturedRscDataRef) {
|
|
options.capturedRscDataRef.value = pendingRscData;
|
|
}
|
|
if (options?.sideStream) {
|
|
void options.sideStream.getReader().cancel();
|
|
}
|
|
queueMicrotask(() => {
|
|
dynamicUsed = true;
|
|
});
|
|
return createStream(["<html>dynamic</html>"]);
|
|
},
|
|
})),
|
|
peekDynamicUsage() {
|
|
return dynamicUsed;
|
|
},
|
|
revalidateSeconds: 1,
|
|
});
|
|
|
|
expect(getRequestCacheLife).toHaveBeenCalledOnce();
|
|
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
expect(response.headers.get("x-vinext-prerender-cache-life")).toBeNull();
|
|
await expect(response.text()).resolves.toBe("<html>dynamic</html>");
|
|
});
|
|
|
|
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("captures prerender cache metadata when SSR fills RSC capture during HTML consumption", 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,
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr(
|
|
rscStream: ReadableStream<Uint8Array>,
|
|
_navContext: unknown,
|
|
_fontData: unknown,
|
|
options?: {
|
|
sideStream?: ReadableStream<Uint8Array>;
|
|
capturedRscDataRef?: { value: Promise<ArrayBuffer> | null };
|
|
},
|
|
) {
|
|
const stream = options?.sideStream ?? rscStream;
|
|
const capturedRscData = new Response(stream).arrayBuffer();
|
|
if (options?.capturedRscDataRef) {
|
|
options.capturedRscDataRef.value = capturedRscData;
|
|
}
|
|
|
|
const htmlStream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("<html>page</html>"));
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
return {
|
|
htmlStream,
|
|
metadataReady: capturedRscData.then(() => {}),
|
|
capturedRscData,
|
|
};
|
|
},
|
|
}),
|
|
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");
|
|
expect(response.headers.get(VINEXT_PRERENDER_CACHE_LIFE_HEADER)).toBe(
|
|
'{"revalidate":1,"expire":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(VINEXT_PRERENDER_CACHE_LIFE_HEADER)).toBe(
|
|
'{"revalidate":1,"expire":3}',
|
|
);
|
|
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();
|
|
});
|
|
|
|
it("emits the dynamic stale time header on RSC responses during dynamic renders", async () => {
|
|
const common = createCommonOptions();
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
dynamicStaleTimeSeconds: 60,
|
|
isRscRequest: true,
|
|
});
|
|
expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBe("60");
|
|
await expect(response.text()).resolves.toBe("flight-data");
|
|
});
|
|
|
|
it("omits the dynamic stale time header during prerender (isPrerender=true)", async () => {
|
|
const common = createCommonOptions();
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
dynamicStaleTimeSeconds: 60,
|
|
isRscRequest: true,
|
|
isPrerender: true,
|
|
});
|
|
expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBeNull();
|
|
await expect(response.text()).resolves.toBe("flight-data");
|
|
});
|
|
|
|
it("omits the dynamic stale time header during force-static renders", async () => {
|
|
const common = createCommonOptions();
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
dynamicStaleTimeSeconds: 60,
|
|
isRscRequest: true,
|
|
isForceStatic: true,
|
|
});
|
|
expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBeNull();
|
|
await expect(response.text()).resolves.toBe("flight-data");
|
|
});
|
|
|
|
it("omits the dynamic stale time header on production ISR renders captured into the cache", async () => {
|
|
// Production ISR (revalidate > 0, not force-static, not a build prerender)
|
|
// satisfies shouldCaptureRscForCacheMetadata, so the render feeds the ISR
|
|
// cache. Like Next.js's !workStore.isStaticGeneration guard, the
|
|
// authoritative per-page stale time must not be emitted on such responses.
|
|
const common = createCommonOptions();
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
dynamicStaleTimeSeconds: 60,
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
revalidateSeconds: 60,
|
|
});
|
|
expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBeNull();
|
|
await expect(response.text()).resolves.toBe("flight-data");
|
|
});
|
|
|
|
it("emits the dynamic stale time header on dynamic production default-config RSC responses", async () => {
|
|
// Ported from Next.js: test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts
|
|
// The upstream fixture pages export unstable_dynamicStaleTime and call
|
|
// connection(), so the per-page value is authoritative only once the render
|
|
// is known to be dynamic.
|
|
const common = createCommonOptions();
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
consumeDynamicUsage: vi.fn(() => true),
|
|
dynamicStaleTimeSeconds: 60,
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
revalidateSeconds: null,
|
|
});
|
|
expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBe("60");
|
|
await expect(response.text()).resolves.toBe("flight-data");
|
|
});
|
|
|
|
it("omits the dynamic stale time header on static production default-config RSC responses", async () => {
|
|
const common = createCommonOptions();
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
consumeDynamicUsage: vi.fn(() => false),
|
|
dynamicStaleTimeSeconds: 60,
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
revalidateSeconds: null,
|
|
});
|
|
expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBeNull();
|
|
await expect(response.text()).resolves.toBe("flight-data");
|
|
});
|
|
|
|
it("streams runtime HTML responses progressively without buffering the body", async () => {
|
|
const common = createCommonOptions();
|
|
const releaseSsr = createDeferred();
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
isPrerender: false,
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr() {
|
|
const htmlStream = new ReadableStream<Uint8Array>({
|
|
async pull(controller) {
|
|
controller.enqueue(new TextEncoder().encode("<html>part1</html>"));
|
|
await releaseSsr.promise;
|
|
controller.enqueue(new TextEncoder().encode("<html>part2</html>"));
|
|
controller.close();
|
|
},
|
|
});
|
|
return {
|
|
htmlStream,
|
|
metadataReady: Promise.resolve(),
|
|
capturedRscData: null,
|
|
};
|
|
},
|
|
}),
|
|
});
|
|
|
|
const reader = response.body?.getReader();
|
|
if (!reader) {
|
|
throw new Error("Expected a response body");
|
|
}
|
|
|
|
const { value: chunk1 } = await reader.read();
|
|
expect(new TextDecoder().decode(chunk1)).toBe("<html>part1</html>");
|
|
|
|
releaseSsr.resolve();
|
|
const { value: chunk2 } = await reader.read();
|
|
expect(new TextDecoder().decode(chunk2)).toBe("<html>part2</html>");
|
|
});
|
|
|
|
it("waits for metadataReady to resolve before returning the response in prerender mode", async () => {
|
|
const common = createCommonOptions();
|
|
let metadataReadyResolved = false;
|
|
const releaseMetadata = createDeferred();
|
|
|
|
const responsePromise = renderAppPageLifecycle({
|
|
...common.options,
|
|
isPrerender: true,
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr() {
|
|
return {
|
|
htmlStream: createStream(["<html>page</html>"]),
|
|
metadataReady: releaseMetadata.promise.then(() => {
|
|
metadataReadyResolved = true;
|
|
}),
|
|
capturedRscData: null,
|
|
};
|
|
},
|
|
}),
|
|
});
|
|
|
|
// Verify that the response is NOT returned yet because metadataReady is pending
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
let responseReturned = false;
|
|
void responsePromise.then(() => {
|
|
responseReturned = true;
|
|
});
|
|
expect(responseReturned).toBe(false);
|
|
|
|
// Resolve metadataReady
|
|
releaseMetadata.resolve();
|
|
const response = await responsePromise;
|
|
expect(metadataReadyResolved).toBe(true);
|
|
expect(response.status).toBe(200);
|
|
});
|
|
|
|
it("captures special errors thrown during the full prerender SSR pass and converts them to 307/404 response", async () => {
|
|
const common = createCommonOptions();
|
|
const notFoundError = Object.assign(new Error("NEXT_NOT_FOUND"), { digest: "NEXT_NOT_FOUND" });
|
|
let capturedOnError: ((error: unknown, ...args: unknown[]) => void) | null = null;
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
isPrerender: true,
|
|
hasLoadingBoundary: true,
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr(_rscStream, _navContext, _fontData, _options) {
|
|
// Trigger the captured onError callback representing a component throw
|
|
if (capturedOnError) {
|
|
capturedOnError(notFoundError, null, null);
|
|
}
|
|
return {
|
|
htmlStream: createStream(["<html>fallback</html>"]),
|
|
metadataReady: Promise.resolve(),
|
|
capturedRscData: null,
|
|
};
|
|
},
|
|
}),
|
|
renderToReadableStream(_element, opts) {
|
|
capturedOnError = opts.onError;
|
|
return createStream(["flight-data"]);
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(common.renderPageSpecialError).toHaveBeenCalledTimes(1);
|
|
expect(common.renderPageSpecialError).toHaveBeenCalledWith({
|
|
kind: "http-access-fallback",
|
|
statusCode: 404,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("layoutFlags injection into RSC payload", () => {
|
|
function createRscOptions(overrides: {
|
|
cleanPathname?: string;
|
|
clientReuseManifest?: ClientReuseManifestParseResult;
|
|
element?: Record<string, ReactNode>;
|
|
layoutParamAccess?: ReturnType<typeof createAppLayoutParamAccessTracker>;
|
|
layoutCount?: number;
|
|
probeLayoutAt?: (index: number) => unknown;
|
|
classification?: LayoutClassificationOptions | null;
|
|
routePattern?: string;
|
|
skipDisposition?: ClientReuseManifestSkipDisposition;
|
|
}) {
|
|
let capturedElement: Record<string, unknown> | null = null;
|
|
|
|
const options = {
|
|
cleanPathname: overrides.cleanPathname ?? "/test",
|
|
clearRequestContext: vi.fn(),
|
|
consumeDynamicUsage: vi.fn(() => false),
|
|
createRscOnErrorHandler: () => () => {},
|
|
clientReuseManifest: overrides.clientReuseManifest,
|
|
getDraftModeCookieHeader: () => null,
|
|
getFontLinks: () => [],
|
|
getFontPreloads: () => [],
|
|
getFontStyles: () => [],
|
|
getNavigationContext: () => null,
|
|
getPageTags: () => [],
|
|
getRequestCacheLife: () => null,
|
|
handlerStart: 0,
|
|
hasLoadingBoundary: false,
|
|
isDynamicError: false,
|
|
isDraftMode: false,
|
|
isForceDynamic: false,
|
|
isForceStatic: false,
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
isrHtmlKey: (p: string) => `html:${p}`,
|
|
isrRscKey: (p: string) => `rsc:${p}`,
|
|
isrSet: vi.fn().mockResolvedValue(undefined),
|
|
layoutParamAccess: overrides.layoutParamAccess,
|
|
layoutCount: overrides.layoutCount ?? 0,
|
|
loadSsrHandler: vi.fn(),
|
|
middlewareContext: { headers: null, status: null },
|
|
navigationParams: {},
|
|
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"]);
|
|
},
|
|
routePattern: overrides.routePattern ?? "/test",
|
|
runWithSuppressedHookWarning: <T>(probe: () => Promise<T>) => probe(),
|
|
element: overrides.element ?? { "page:/test": "test-page" },
|
|
classification: overrides.classification,
|
|
skipDisposition: overrides.skipDisposition,
|
|
};
|
|
|
|
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 partial render observation metadata into outgoing AppElements payloads", async () => {
|
|
const { options, getCapturedElement } = createRscOptions({
|
|
element: {
|
|
[APP_ROOT_LAYOUT_KEY]: "/",
|
|
"layout:/": "root-layout",
|
|
"page:/test": "test-page",
|
|
},
|
|
});
|
|
|
|
await renderAppPageLifecycle({
|
|
...options,
|
|
navigationParams: { id: "123" },
|
|
params: { id: "123" },
|
|
peekRenderObservationState() {
|
|
return {
|
|
dynamicFetches: ["https://api.example.test/posts?token=secret"],
|
|
requestApis: ["headers"],
|
|
};
|
|
},
|
|
});
|
|
|
|
const renderObservation = getCapturedElement()[APP_RENDER_OBSERVATION_KEY];
|
|
|
|
expect(renderObservation).toMatchObject({
|
|
boundaryOutcome: { kind: "unknown" },
|
|
cacheability: "unknown",
|
|
completeness: "partial",
|
|
output: {
|
|
kind: "app-rsc",
|
|
mountedSlotsFingerprint: null,
|
|
renderEpoch: null,
|
|
rootBoundaryId: "/",
|
|
routeId: "route:/test",
|
|
},
|
|
requestApis: expect.arrayContaining([
|
|
{ kind: "headers", status: "observed" },
|
|
{ kind: "params", status: "observed" },
|
|
]),
|
|
});
|
|
expect(JSON.stringify(renderObservation)).not.toContain("secret");
|
|
});
|
|
|
|
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("applies enabled static-layout skip transport after preserving all layout flags", 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 };
|
|
},
|
|
},
|
|
skipDisposition: {
|
|
code: "SKIP_STATIC_LAYOUT_VERIFIED",
|
|
enabled: true,
|
|
mode: "skipStaticLayout",
|
|
skippedEntryIds: ["layout:/blog"],
|
|
},
|
|
});
|
|
|
|
await renderAppPageLifecycle(options);
|
|
expect(getCapturedElement()["layout:/"]).toBe("root-layout");
|
|
expect(Object.hasOwn(getCapturedElement(), "layout:/blog")).toBe(false);
|
|
expect(getCapturedElement()["page:/blog/post"]).toBe("post-page");
|
|
expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({
|
|
"layout:/": "s",
|
|
"layout:/blog": "s",
|
|
});
|
|
});
|
|
|
|
it("does not apply skip transport while producing an HTML response", async () => {
|
|
const common = createCommonOptions();
|
|
let capturedElement: Record<string, unknown> | null = null;
|
|
|
|
await renderAppPageLifecycle({
|
|
...common.options,
|
|
element: {
|
|
"layout:/": "root-layout",
|
|
"layout:/blog": "blog-layout",
|
|
"page:/blog/post": "post-page",
|
|
},
|
|
layoutCount: 2,
|
|
classification: {
|
|
getLayoutId: (index: number) => (index === 0 ? "layout:/" : "layout:/blog"),
|
|
buildTimeClassifications: null,
|
|
async runWithIsolatedDynamicScope(fn) {
|
|
const result = await fn();
|
|
return { result, dynamicDetected: false };
|
|
},
|
|
},
|
|
isRscRequest: false,
|
|
renderToReadableStream(element) {
|
|
capturedElement = captureRecord(element);
|
|
return createStream(["flight-data"]);
|
|
},
|
|
skipDisposition: {
|
|
code: "SKIP_STATIC_LAYOUT_VERIFIED",
|
|
enabled: true,
|
|
mode: "skipStaticLayout",
|
|
skippedEntryIds: ["layout:/blog"],
|
|
},
|
|
});
|
|
|
|
if (capturedElement === null) {
|
|
throw new Error("Expected renderToReadableStream to be called");
|
|
}
|
|
expect(capturedElement["layout:/"]).toBe("root-layout");
|
|
expect(capturedElement["layout:/blog"]).toBe("blog-layout");
|
|
expect(capturedElement["page:/blog/post"]).toBe("post-page");
|
|
});
|
|
|
|
it("keeps the layout in the payload when final skip verification rejects dynamic usage", async () => {
|
|
const originalBuildId = process.env.__VINEXT_BUILD_ID;
|
|
process.env.__VINEXT_BUILD_ID = "deploy-test";
|
|
const layoutId = "layout:/blog";
|
|
const tracker = createAppLayoutParamAccessTracker();
|
|
const clientReuseManifest = createVerifiedStaticLayoutManifest({
|
|
deploymentVersion: "deploy-test",
|
|
layoutId,
|
|
rootBoundaryId: "/",
|
|
routeId: "route:/blog/hello",
|
|
routePattern: "/blog/[slug]",
|
|
});
|
|
const { options, getCapturedElement } = createRscOptions({
|
|
cleanPathname: "/blog/hello",
|
|
clientReuseManifest,
|
|
element: {
|
|
[APP_ROOT_LAYOUT_KEY]: "/",
|
|
[layoutId]: "blog-layout",
|
|
"page:/blog/hello": "post-page",
|
|
},
|
|
layoutCount: 1,
|
|
layoutParamAccess: tracker,
|
|
probeLayoutAt() {
|
|
return tracker.runLayoutProbe(layoutId, () => {
|
|
markDynamicUsage();
|
|
});
|
|
},
|
|
classification: {
|
|
getLayoutId: () => layoutId,
|
|
buildTimeClassifications: null,
|
|
isLayoutObservationDynamic: () => false,
|
|
async runWithIsolatedDynamicScope(fn) {
|
|
const result = await fn();
|
|
return { result, dynamicDetected: false };
|
|
},
|
|
},
|
|
routePattern: "/blog/[slug]",
|
|
});
|
|
|
|
try {
|
|
await runWithRequestContext(createRequestContext(), () => renderAppPageLifecycle(options));
|
|
} finally {
|
|
if (originalBuildId === undefined) {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
} else {
|
|
process.env.__VINEXT_BUILD_ID = originalBuildId;
|
|
}
|
|
}
|
|
|
|
expect(getCapturedElement()[layoutId]).toBe("blog-layout");
|
|
expect(getCapturedElement()["page:/blog/hello"]).toBe("post-page");
|
|
expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ [layoutId]: "s" });
|
|
expect(Object.hasOwn(getCapturedElement(), APP_SKIPPED_LAYOUT_IDS_KEY)).toBe(false);
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("dev error reporting for invalid dynamic usage — issue #1195", () => {
|
|
it("does not double-log an uncaught error that React already stamped with a digest", async () => {
|
|
const common = createCommonOptions();
|
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
const errorWithDigest = Object.assign(new Error("cookies() inside use cache"), {
|
|
digest: "DYNAMIC_SERVER_USAGE",
|
|
});
|
|
let consumedError: unknown = null;
|
|
const consumeInvalidDynamicUsageError = vi.fn(() => {
|
|
if (consumedError !== null) return null;
|
|
consumedError = errorWithDigest;
|
|
return errorWithDigest;
|
|
});
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
consumeInvalidDynamicUsageError,
|
|
isRscRequest: true,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
await response.text();
|
|
expect(consumeInvalidDynamicUsageError).toHaveBeenCalled();
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
it("logs a caught error that has no digest (React never saw it)", async () => {
|
|
const common = createCommonOptions();
|
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
const errorWithoutDigest = new Error("cookies() inside use cache");
|
|
let consumedError: unknown = null;
|
|
const consumeInvalidDynamicUsageError = vi.fn(() => {
|
|
if (consumedError !== null) return null;
|
|
consumedError = errorWithoutDigest;
|
|
return errorWithoutDigest;
|
|
});
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
consumeInvalidDynamicUsageError,
|
|
isRscRequest: true,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
await response.text();
|
|
expect(consumeInvalidDynamicUsageError).toHaveBeenCalled();
|
|
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
"[vinext] Invalid dynamic usage:",
|
|
errorWithoutDigest,
|
|
);
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
it("does not log when no invalid dynamic usage error was recorded", async () => {
|
|
const common = createCommonOptions();
|
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
const consumeInvalidDynamicUsageError = vi.fn(() => null);
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
consumeInvalidDynamicUsageError,
|
|
isRscRequest: true,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
await response.text();
|
|
expect(consumeInvalidDynamicUsageError).toHaveBeenCalled();
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
it("still dedups when the stream is cancelled before full consumption", async () => {
|
|
const common = createCommonOptions();
|
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
const errorWithDigest = Object.assign(new Error("cookies() inside use cache"), {
|
|
digest: "DYNAMIC_SERVER_USAGE",
|
|
});
|
|
let consumedError: unknown = null;
|
|
const consumeInvalidDynamicUsageError = vi.fn(() => {
|
|
if (consumedError !== null) return null;
|
|
consumedError = errorWithDigest;
|
|
return errorWithDigest;
|
|
});
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
consumeInvalidDynamicUsageError,
|
|
isRscRequest: true,
|
|
});
|
|
|
|
// Cancel the stream before consuming any bytes
|
|
if (response.body) {
|
|
await response.body.cancel("client disconnect");
|
|
}
|
|
expect(consumeInvalidDynamicUsageError).toHaveBeenCalled();
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
it("still dedups when the stream errors during pull", async () => {
|
|
const common = createCommonOptions();
|
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
const errorWithDigest = Object.assign(new Error("cookies() inside use cache"), {
|
|
digest: "DYNAMIC_SERVER_USAGE",
|
|
});
|
|
let consumedError: unknown = null;
|
|
const consumeInvalidDynamicUsageError = vi.fn(() => {
|
|
if (consumedError !== null) return null;
|
|
consumedError = errorWithDigest;
|
|
return errorWithDigest;
|
|
});
|
|
|
|
const renderToReadableStream = vi.fn(
|
|
() =>
|
|
new ReadableStream<Uint8Array>({
|
|
pull() {
|
|
throw new Error("stream blowup");
|
|
},
|
|
}),
|
|
);
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
consumeInvalidDynamicUsageError,
|
|
isRscRequest: true,
|
|
renderToReadableStream,
|
|
});
|
|
|
|
// Attempting to consume the stream will trigger the error
|
|
try {
|
|
await response.text();
|
|
} catch {
|
|
// Expected
|
|
}
|
|
expect(consumeInvalidDynamicUsageError).toHaveBeenCalled();
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
it("does not apply the wrapper in production mode", async () => {
|
|
const common = createCommonOptions();
|
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
const errorWithoutDigest = new Error("cookies() inside use cache");
|
|
const consumeInvalidDynamicUsageError = vi.fn(() => errorWithoutDigest);
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
consumeInvalidDynamicUsageError,
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
await response.text();
|
|
// In production the wrapper is skipped entirely
|
|
expect(consumeInvalidDynamicUsageError).not.toHaveBeenCalled();
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
|
|
it("does not apply the wrapper for HTML (non-RSC) requests", async () => {
|
|
const common = createCommonOptions();
|
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
const errorWithoutDigest = new Error("cookies() inside use cache");
|
|
const consumeInvalidDynamicUsageError = vi.fn(() => errorWithoutDigest);
|
|
|
|
const response = await renderAppPageLifecycle({
|
|
...common.options,
|
|
consumeInvalidDynamicUsageError,
|
|
isRscRequest: false,
|
|
});
|
|
|
|
await response.text();
|
|
// HTML path intentionally defers dev error reporting
|
|
expect(consumeInvalidDynamicUsageError).not.toHaveBeenCalled();
|
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
|
|
consoleErrorSpy.mockRestore();
|
|
});
|
|
});
|