mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
69165d4557
Wraps the RSC response body in dev mode to check for invalid dynamic usage errors (e.g. cookies()/headers() inside "use cache") after the stream is fully consumed. This mirrors Next.js behavior where workStore.invalidDynamicUsageError is surfaced via the Flight pipeline so errors caught by user try/catch don't silently disappear. - Dedups against React's Flight error chunk using digest presence - Only applies to RSC responses (client-side navigations) in dev - HTML path intentionally deferred; error still flows through rscErrorTracker - Ported from Next.js: https://github.com/vercel/next.js/commit/f5e54c06726b571a042fce67417e40a29f6b8689 Closes #1195
1210 lines
39 KiB
TypeScript
1210 lines
39 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,
|
|
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";
|
|
import type { CachedAppPageValue } from "../packages/vinext/src/shims/cache.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" };
|
|
},
|
|
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,
|
|
},
|
|
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("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,
|
|
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,
|
|
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("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,
|
|
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),
|
|
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 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,
|
|
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("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();
|
|
});
|
|
});
|