mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
3079 lines
110 KiB
TypeScript
3079 lines
110 KiB
TypeScript
import React from "react";
|
|
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
|
import {
|
|
APP_ROOT_LAYOUT_KEY,
|
|
APP_ROUTE_KEY,
|
|
AppElementsWire,
|
|
} from "../packages/vinext/src/server/app-elements.js";
|
|
import { dispatchAppPage } from "../packages/vinext/src/server/app-page-dispatch.js";
|
|
import { createClientReuseManifestHeaderFromVisibleAppState } from "../packages/vinext/src/server/app-browser-client-reuse-manifest.js";
|
|
import type { AppLayoutParamAccessTracker } from "../packages/vinext/src/server/app-layout-param-observation.js";
|
|
import {
|
|
buildPageElements,
|
|
type AppPageBuildRoute,
|
|
} from "../packages/vinext/src/server/app-page-element-builder.js";
|
|
import { probeAppPage } from "../packages/vinext/src/server/app-page-probe.js";
|
|
import {
|
|
resolveAppPageSegmentParamScopeKeys,
|
|
resolveAppPageSegmentParams,
|
|
} from "../packages/vinext/src/server/app-page-params.js";
|
|
import { createAppPageTreePath } from "../packages/vinext/src/server/app-page-route-wiring.js";
|
|
import {
|
|
createArtifactCompatibilityEnvelope,
|
|
createArtifactCompatibilityGraphVersion,
|
|
} from "../packages/vinext/src/server/artifact-compatibility.js";
|
|
import {
|
|
parseClientReuseManifestHeader,
|
|
type ClientReuseManifestParseResult,
|
|
} from "../packages/vinext/src/server/client-reuse-manifest.js";
|
|
import {
|
|
buildRenderObservation,
|
|
buildRenderRequestApiObservations,
|
|
type RenderObservation,
|
|
} from "../packages/vinext/src/server/cache-proof.js";
|
|
import { APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL } from "../packages/vinext/src/server/app-rsc-render-mode.js";
|
|
import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js";
|
|
import { connection } from "../packages/vinext/src/shims/server.js";
|
|
import type { AppPageMiddlewareContext } from "../packages/vinext/src/server/app-page-response.js";
|
|
import type { ISRCacheEntry } from "../packages/vinext/src/server/isr-cache.js";
|
|
import type { CachedAppPageValue } from "../packages/vinext/src/shims/cache.js";
|
|
import { markAppPprDynamicFallbackShellHtml } from "../packages/vinext/src/server/app-ppr-fallback-shell.js";
|
|
import { appPagePprRuntime } from "../packages/vinext/src/server/app-page-ppr-runtime.js";
|
|
import {
|
|
runWithExecutionContext,
|
|
type ExecutionContextLike,
|
|
} from "../packages/vinext/src/shims/request-context.js";
|
|
import {
|
|
consumeDynamicUsage,
|
|
consumeRenderRequestApiUsage,
|
|
draftMode,
|
|
getHeadersContext,
|
|
markDynamicUsage,
|
|
markRenderRequestApiUsage,
|
|
setHeadersContext,
|
|
} from "../packages/vinext/src/shims/headers.js";
|
|
import { isPromiseLike } from "../packages/vinext/src/utils/promise.js";
|
|
import { isUnknownRecord } from "../packages/vinext/src/utils/record.js";
|
|
|
|
type TestRoute = {
|
|
__buildTimeClassifications?: ReadonlyMap<number, "static" | "dynamic"> | null;
|
|
error?: { default?: unknown } | null;
|
|
errors?: readonly ({ default?: unknown } | null | undefined)[];
|
|
forbiddens?: readonly ({ default?: unknown } | null | undefined)[];
|
|
isDynamic: boolean;
|
|
layouts: readonly { default?: unknown; dynamic?: unknown; revalidate?: unknown }[];
|
|
layoutTreePositions?: readonly number[];
|
|
loading?: { default?: unknown } | null;
|
|
notFounds?: readonly ({ default?: unknown } | null | undefined)[];
|
|
params: readonly string[];
|
|
pattern: string;
|
|
routeSegments: readonly string[];
|
|
slots?: Readonly<
|
|
Record<
|
|
string,
|
|
{
|
|
default?: { default?: unknown } | null;
|
|
page?: { default?: unknown; generateMetadata?: unknown } | null;
|
|
slotParamNames?: readonly string[] | null;
|
|
slotPatternParts?: readonly string[] | null;
|
|
}
|
|
>
|
|
>;
|
|
unauthorizeds?: readonly ({ default?: unknown } | null | undefined)[];
|
|
};
|
|
type DispatchOptions = Parameters<typeof dispatchAppPage<TestRoute>>[0];
|
|
|
|
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 captureRecord(value: unknown): Record<string, unknown> {
|
|
if (value && typeof value === "object" && !React.isValidElement(value) && !Array.isArray(value)) {
|
|
return value as Record<string, unknown>;
|
|
}
|
|
throw new Error("Expected AppElements record payload");
|
|
}
|
|
|
|
function isCachedAppPageValue(value: unknown): value is CachedAppPageValue {
|
|
return isUnknownRecord(value) && value.kind === "APP_PAGE";
|
|
}
|
|
|
|
function isQueryRecord(value: unknown): value is Record<string, string | string[] | undefined> {
|
|
return isUnknownRecord(value);
|
|
}
|
|
|
|
function isDispatchReactNode(value: unknown): value is React.ReactNode {
|
|
if (
|
|
value === null ||
|
|
value === undefined ||
|
|
typeof value === "boolean" ||
|
|
typeof value === "string" ||
|
|
typeof value === "number" ||
|
|
typeof value === "bigint"
|
|
) {
|
|
return true;
|
|
}
|
|
if (React.isValidElement(value)) {
|
|
return true;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return value.every(isDispatchReactNode);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function toDispatchElementRecord(
|
|
elements: Readonly<Record<string, unknown>>,
|
|
): Readonly<Record<string, React.ReactNode>> {
|
|
const dispatchElements: Record<string, React.ReactNode> = {};
|
|
for (const [key, value] of Object.entries(elements)) {
|
|
if (isDispatchReactNode(value)) {
|
|
dispatchElements[key] = value;
|
|
}
|
|
}
|
|
return dispatchElements;
|
|
}
|
|
|
|
function findPageElement(payload: unknown): React.ReactElement | null {
|
|
const record = captureRecord(payload);
|
|
for (const [key, value] of Object.entries(record)) {
|
|
if (key.startsWith("page:") && React.isValidElement(value)) {
|
|
return value;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function renderReactNodeText(node: unknown): Promise<string> {
|
|
if (node === null || node === undefined || typeof node === "boolean") return "";
|
|
if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
|
|
return String(node);
|
|
}
|
|
if (isPromiseLike(node)) {
|
|
return renderReactNodeText(await node);
|
|
}
|
|
if (Array.isArray(node)) {
|
|
const rendered = await Promise.all(node.map((child) => renderReactNodeText(child)));
|
|
return rendered.join("");
|
|
}
|
|
if (!React.isValidElement<{ children?: unknown }>(node)) return "";
|
|
|
|
if (node.type === React.Fragment || typeof node.type === "string") {
|
|
return renderReactNodeText(node.props.children);
|
|
}
|
|
if (typeof node.type === "function") {
|
|
return renderReactNodeText(Reflect.apply(node.type, undefined, [node.props]));
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function renderPagePayloadToStream(payload: unknown): ReadableStream<Uint8Array> {
|
|
let didRender = false;
|
|
return new ReadableStream({
|
|
async pull(controller) {
|
|
if (didRender) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
didRender = true;
|
|
const pageElement = findPageElement(payload);
|
|
const text = pageElement ? await renderReactNodeText(pageElement) : "";
|
|
controller.enqueue(new TextEncoder().encode(text));
|
|
controller.close();
|
|
},
|
|
});
|
|
}
|
|
|
|
function buildISRCacheEntry(value: CachedAppPageValue, isStale = false): ISRCacheEntry {
|
|
return {
|
|
isStale,
|
|
value: {
|
|
lastModified: Date.now(),
|
|
value,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildCachedAppPageValue(
|
|
html: string,
|
|
rscData?: ArrayBuffer,
|
|
status?: number,
|
|
renderObservation?: RenderObservation,
|
|
): CachedAppPageValue {
|
|
const value: CachedAppPageValue = {
|
|
kind: "APP_PAGE",
|
|
html,
|
|
rscData,
|
|
headers: undefined,
|
|
postponed: undefined,
|
|
status,
|
|
};
|
|
if (renderObservation) {
|
|
value.renderObservation = renderObservation;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function expectCachedAppPageSearchParamsObservation(value: unknown, html: string): void {
|
|
expect(isCachedAppPageValue(value)).toBe(true);
|
|
if (!isCachedAppPageValue(value)) {
|
|
throw new Error("Expected an APP_PAGE cache value");
|
|
}
|
|
expect(value.html).toBe(html);
|
|
expect(
|
|
value.renderObservation?.requestApis.find((requestApi) => requestApi.kind === "searchParams")
|
|
?.status,
|
|
).toBe("observed");
|
|
}
|
|
|
|
function buildQueryInvariantRenderObservation(): RenderObservation {
|
|
return buildRenderObservation({
|
|
boundaryOutcome: { kind: "success" },
|
|
cacheability: "public",
|
|
cacheTags: [],
|
|
completeness: "complete",
|
|
dynamicFetches: [],
|
|
output: {
|
|
kind: "app-html",
|
|
renderEpoch: null,
|
|
rootBoundaryId: null,
|
|
routeId: "route:/posts/[slug]",
|
|
},
|
|
pathTags: [],
|
|
requestApis: buildRenderRequestApiObservations({
|
|
completeness: "complete",
|
|
observed: [],
|
|
}),
|
|
});
|
|
}
|
|
|
|
function createRoute(overrides: Partial<TestRoute> = {}): TestRoute {
|
|
return {
|
|
isDynamic: false,
|
|
layouts: [],
|
|
params: [],
|
|
pattern: "/posts/[slug]",
|
|
routeSegments: ["posts", "[slug]"],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
type CreateDispatchOptionsOverrides = {
|
|
buildPageElement?: DispatchOptions["buildPageElement"];
|
|
cleanPathname?: string;
|
|
clearRequestContext?: DispatchOptions["clearRequestContext"];
|
|
dynamicConfig?: DispatchOptions["dynamicConfig"];
|
|
dynamicParamsConfig?: DispatchOptions["dynamicParamsConfig"];
|
|
findIntercept?: DispatchOptions["findIntercept"];
|
|
ensureRouteLoaded?: DispatchOptions["ensureRouteLoaded"];
|
|
generateStaticParams?: DispatchOptions["generateStaticParams"];
|
|
hasCustomGlobalError?: DispatchOptions["hasCustomGlobalError"];
|
|
formState?: DispatchOptions["formState"];
|
|
getSourceRoute?: DispatchOptions["getSourceRoute"];
|
|
getNavigationContext?: DispatchOptions["getNavigationContext"];
|
|
actionError?: DispatchOptions["actionError"];
|
|
actionFailed?: boolean;
|
|
interceptionContext?: string | null;
|
|
isProgressiveActionRender?: DispatchOptions["isProgressiveActionRender"];
|
|
isProduction?: boolean;
|
|
isRscRequest?: boolean;
|
|
isrRscKey?: DispatchOptions["isrRscKey"];
|
|
isrGet?: DispatchOptions["isrGet"];
|
|
isrSet?: DispatchOptions["isrSet"];
|
|
clientReuseManifest?: ClientReuseManifestParseResult;
|
|
loadSsrHandler?: DispatchOptions["loadSsrHandler"];
|
|
middlewareContext?: AppPageMiddlewareContext;
|
|
mountedSlotsHeader?: string | null;
|
|
params?: Record<string, string | string[]>;
|
|
pprFallbackCacheShells?: DispatchOptions["pprFallbackCacheShells"];
|
|
pprRuntime?: DispatchOptions["pprRuntime"];
|
|
probeLayoutAt?: DispatchOptions["probeLayoutAt"];
|
|
probePage?: DispatchOptions["probePage"];
|
|
renderedConcreteUrlPaths?: DispatchOptions["renderedConcreteUrlPaths"];
|
|
renderMode?: DispatchOptions["renderMode"];
|
|
renderToReadableStream?: DispatchOptions["renderToReadableStream"];
|
|
request?: Request;
|
|
revalidateSeconds?: number | null;
|
|
resolveRouteFetchCacheMode?: DispatchOptions["resolveRouteFetchCacheMode"];
|
|
resolveRouteDynamicConfig?: DispatchOptions["resolveRouteDynamicConfig"];
|
|
route?: TestRoute;
|
|
scheduleBackgroundRegeneration?: DispatchOptions["scheduleBackgroundRegeneration"];
|
|
searchParams?: URLSearchParams;
|
|
setNavigationContext?: DispatchOptions["setNavigationContext"];
|
|
};
|
|
|
|
function createDispatchOptions(overrides: CreateDispatchOptionsOverrides = {}) {
|
|
const route = overrides.route ?? createRoute();
|
|
const buildPageElement =
|
|
overrides.buildPageElement ?? (async () => React.createElement("main", null, "page"));
|
|
const clearRequestContext = overrides.clearRequestContext ?? (() => {});
|
|
const isrGet = overrides.isrGet ?? (async () => null);
|
|
const params = overrides.params ?? { slug: "hello" };
|
|
const setNavigationContext = overrides.setNavigationContext ?? (() => {});
|
|
const renderToReadableStream: DispatchOptions["renderToReadableStream"] =
|
|
overrides.renderToReadableStream ?? (() => createStream(["flight"]));
|
|
const loadSsrHandler: DispatchOptions["loadSsrHandler"] =
|
|
overrides.loadSsrHandler ??
|
|
(async () => ({
|
|
async handleSsr() {
|
|
return createStream(["<html>page</html>"]);
|
|
},
|
|
}));
|
|
const options: DispatchOptions = {
|
|
buildPageElement,
|
|
cleanPathname: overrides.cleanPathname ?? "/posts/hello",
|
|
clearRequestContext,
|
|
createRscOnErrorHandler() {
|
|
return () => null;
|
|
},
|
|
draftModeSecret: "draft-secret",
|
|
dynamicConfig: overrides.dynamicConfig,
|
|
dynamicParamsConfig: overrides.dynamicParamsConfig,
|
|
ensureRouteLoaded: overrides.ensureRouteLoaded,
|
|
findIntercept: overrides.findIntercept ?? (() => null),
|
|
generateStaticParams: overrides.generateStaticParams ?? null,
|
|
getFontLinks() {
|
|
return [];
|
|
},
|
|
getFontPreloads() {
|
|
return [];
|
|
},
|
|
getFontStyles() {
|
|
return [];
|
|
},
|
|
getNavigationContext:
|
|
overrides.getNavigationContext ??
|
|
(() => ({
|
|
pathname: "/posts/hello",
|
|
searchParams: new URLSearchParams(),
|
|
params: { slug: "hello" },
|
|
})),
|
|
getSourceRoute: overrides.getSourceRoute ?? (() => undefined),
|
|
hasGenerateStaticParams: typeof overrides.generateStaticParams === "function",
|
|
hasCustomGlobalError: overrides.hasCustomGlobalError,
|
|
hasPageDefaultExport: true,
|
|
hasPageModule: true,
|
|
handlerStart: 10,
|
|
formState: overrides.formState,
|
|
actionError: overrides.actionError,
|
|
actionFailed: overrides.actionFailed,
|
|
interceptionContext: overrides.interceptionContext ?? null,
|
|
isProgressiveActionRender: overrides.isProgressiveActionRender,
|
|
isProduction: overrides.isProduction ?? false,
|
|
isRscRequest: overrides.isRscRequest ?? false,
|
|
isrGet,
|
|
isrHtmlKey(pathname: string) {
|
|
return `html:${pathname}`;
|
|
},
|
|
isrRscKey:
|
|
overrides.isrRscKey ??
|
|
((pathname: string, mountedSlotsHeader?: string | null) =>
|
|
mountedSlotsHeader ? `rsc:${pathname}:${mountedSlotsHeader}` : `rsc:${pathname}`),
|
|
isrSet: overrides.isrSet ?? vi.fn(async () => {}),
|
|
loadSsrHandler,
|
|
clientReuseManifest: overrides.clientReuseManifest ?? { kind: "absent" },
|
|
middlewareContext: overrides.middlewareContext ?? {
|
|
headers: null,
|
|
status: null,
|
|
},
|
|
mountedSlotsHeader: overrides.mountedSlotsHeader,
|
|
params,
|
|
pprFallbackCacheShells: overrides.pprFallbackCacheShells,
|
|
pprRuntime: overrides.pprRuntime,
|
|
probeLayoutAt: overrides.probeLayoutAt ?? createLayoutParamProbe(route, params, []),
|
|
probePage: overrides.probePage ?? (() => null),
|
|
renderedConcreteUrlPaths: overrides.renderedConcreteUrlPaths,
|
|
renderMode: overrides.renderMode,
|
|
renderErrorBoundaryPage: vi.fn(async () => null),
|
|
renderHttpAccessFallbackPage: vi.fn(async () => null),
|
|
renderToReadableStream,
|
|
request: overrides.request ?? new Request("https://example.test/posts/hello"),
|
|
revalidateSeconds: overrides.revalidateSeconds ?? null,
|
|
resolveRouteFetchCacheMode: overrides.resolveRouteFetchCacheMode,
|
|
resolveRouteDynamicConfig: overrides.resolveRouteDynamicConfig,
|
|
route,
|
|
runWithSuppressedHookWarning<T>(probe: () => Promise<T>) {
|
|
return probe();
|
|
},
|
|
scheduleBackgroundRegeneration: overrides.scheduleBackgroundRegeneration ?? vi.fn(),
|
|
searchParams: overrides.searchParams ?? new URLSearchParams(),
|
|
setNavigationContext,
|
|
};
|
|
|
|
return {
|
|
buildPageElement,
|
|
clearRequestContext,
|
|
isrGet,
|
|
route,
|
|
setNavigationContext,
|
|
options,
|
|
};
|
|
}
|
|
|
|
const pprBlogFallbackShells = [
|
|
{
|
|
fallbackParamNames: ["slug"],
|
|
params: { locale: "en", slug: "[slug]" },
|
|
pathname: "/en/blog/[slug]",
|
|
},
|
|
] satisfies NonNullable<DispatchOptions["pprFallbackCacheShells"]>;
|
|
|
|
function createPprBlogRoute(): TestRoute {
|
|
return createRoute({
|
|
isDynamic: true,
|
|
params: ["locale", "slug"],
|
|
pattern: "/:locale/blog/:slug",
|
|
routeSegments: ["[locale]", "blog", "[slug]"],
|
|
});
|
|
}
|
|
|
|
function createParamTextPageElement(prefix = "element") {
|
|
return vi.fn(
|
|
async (
|
|
_route: TestRoute,
|
|
params: Record<string, string | string[]>,
|
|
_opts: Parameters<DispatchOptions["buildPageElement"]>[2],
|
|
searchParams: URLSearchParams,
|
|
) => `${prefix}:${JSON.stringify(params)}${searchParams.size > 0 ? `?${searchParams}` : ""}`,
|
|
);
|
|
}
|
|
|
|
function createPprBlogDispatchOptions(overrides: CreateDispatchOptionsOverrides = {}) {
|
|
return createDispatchOptions({
|
|
cleanPathname: "/en/blog/new-post",
|
|
isProduction: true,
|
|
params: { locale: "en", slug: "new-post" },
|
|
pprFallbackCacheShells: pprBlogFallbackShells,
|
|
pprRuntime: appPagePprRuntime,
|
|
revalidateSeconds: 60,
|
|
route: createPprBlogRoute(),
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function createPprBlogFallbackShellGetter(stale: boolean) {
|
|
return vi.fn(async (key: string) => {
|
|
if (key === "html:/en/blog/[slug]") {
|
|
return buildISRCacheEntry(
|
|
buildCachedAppPageValue("<html><head></head><body>Locale: en</body></html>"),
|
|
stale,
|
|
);
|
|
}
|
|
return null;
|
|
});
|
|
}
|
|
|
|
function createFreshBodySsrHandler(body: string): DispatchOptions["loadSsrHandler"] {
|
|
return async () => ({
|
|
async handleSsr() {
|
|
return createStream([`<html><head></head><body>${body}</body></html>`]);
|
|
},
|
|
});
|
|
}
|
|
|
|
function createVerifiedStaticLayoutManifest(input: {
|
|
deploymentVersion: string;
|
|
layoutId?: string;
|
|
layoutIds?: readonly string[];
|
|
rootBoundaryId: string;
|
|
routeId: string;
|
|
routePattern: string;
|
|
}): ClientReuseManifestParseResult {
|
|
const layoutIds = input.layoutIds ?? (input.layoutId ? [input.layoutId] : []);
|
|
if (layoutIds.length === 0) {
|
|
throw new Error("Expected at least one static layout manifest entry");
|
|
}
|
|
const artifactCompatibility = createArtifactCompatibilityEnvelope({
|
|
deploymentVersion: input.deploymentVersion,
|
|
graphVersion: createArtifactCompatibilityGraphVersion({
|
|
routePattern: input.routePattern,
|
|
rootBoundaryId: input.rootBoundaryId,
|
|
}),
|
|
rootBoundaryId: input.rootBoundaryId,
|
|
});
|
|
const retainedLayouts = Object.fromEntries(
|
|
layoutIds.map((layoutId) => [layoutId, `retained-${layoutId}`]),
|
|
);
|
|
const layoutFlags: Record<string, "s"> = {};
|
|
for (const layoutId of layoutIds) {
|
|
layoutFlags[layoutId] = "s";
|
|
}
|
|
const header = createClientReuseManifestHeaderFromVisibleAppState({
|
|
elements: {
|
|
...AppElementsWire.createMetadataEntries({
|
|
interceptionContext: null,
|
|
layoutIds,
|
|
rootLayoutTreePath: input.rootBoundaryId,
|
|
routeId: input.routeId,
|
|
}),
|
|
[AppElementsWire.keys.artifactCompatibility]: artifactCompatibility,
|
|
[AppElementsWire.keys.layoutFlags]: layoutFlags,
|
|
...retainedLayouts,
|
|
},
|
|
visibleCommitVersion: 1,
|
|
});
|
|
if (header === null) {
|
|
throw new Error("Expected retained static layout manifest");
|
|
}
|
|
return parseClientReuseManifestHeader(header);
|
|
}
|
|
|
|
type LayoutParamProbeReader = (params: unknown) => unknown;
|
|
|
|
function createLayoutParamProbe(
|
|
route: TestRoute,
|
|
matchedParams: Record<string, string | string[]>,
|
|
readers: readonly (LayoutParamProbeReader | null | undefined)[],
|
|
): DispatchOptions["probeLayoutAt"] {
|
|
return (layoutIndex, layoutParamAccess) => {
|
|
const treePath = createAppPageTreePath(
|
|
route.routeSegments,
|
|
route.layoutTreePositions?.[layoutIndex] ?? 0,
|
|
);
|
|
const layoutId = AppElementsWire.encodeLayoutId(treePath);
|
|
const runProbe = (tracker: AppLayoutParamAccessTracker | undefined) => {
|
|
const segmentParams = resolveAppPageSegmentParams(
|
|
route.routeSegments,
|
|
route.layoutTreePositions?.[layoutIndex] ?? 0,
|
|
matchedParams,
|
|
);
|
|
tracker?.recordLayoutParamScope(
|
|
layoutId,
|
|
resolveAppPageSegmentParamScopeKeys(
|
|
route.routeSegments,
|
|
route.layoutTreePositions?.[layoutIndex] ?? 0,
|
|
),
|
|
);
|
|
const revalidate = route.layouts[layoutIndex]?.revalidate;
|
|
if (typeof revalidate === "number" && Number.isFinite(revalidate) && revalidate > 0) {
|
|
tracker?.recordLayoutFiniteRevalidate(layoutId, revalidate);
|
|
}
|
|
const params = makeThenableParams(
|
|
segmentParams,
|
|
tracker?.createThenableParamsObserver(layoutId),
|
|
);
|
|
return readers[layoutIndex]?.(params) ?? null;
|
|
};
|
|
|
|
return layoutParamAccess
|
|
? layoutParamAccess.runLayoutProbe(layoutId, () => runProbe(layoutParamAccess))
|
|
: runProbe(undefined);
|
|
};
|
|
}
|
|
|
|
describe("app page dispatch", () => {
|
|
it("does not run a speculative connection() page probe for HTML renders", async () => {
|
|
const probePage = vi.fn(async () => {
|
|
await connection();
|
|
});
|
|
const { options } = createDispatchOptions({ probePage });
|
|
|
|
const response = await Promise.race([
|
|
dispatchAppPage(options),
|
|
new Promise<never>((_, reject) => {
|
|
setTimeout(() => reject(new Error("dispatch timed out")), 250);
|
|
}),
|
|
]);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(probePage).not.toHaveBeenCalled();
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
});
|
|
|
|
it.each([
|
|
["force-dynamic", { dynamicConfig: "force-dynamic" as const }],
|
|
[
|
|
"draft mode",
|
|
{
|
|
request: new Request("https://example.test/posts/hello", {
|
|
headers: { Cookie: "__prerender_bypass=draft-secret" },
|
|
}),
|
|
},
|
|
],
|
|
["progressive actions", { isProgressiveActionRender: true }],
|
|
["revalidate zero", { revalidateSeconds: 0 }],
|
|
])("does not probe queryless production HTML for %s", async (_name, overrides) => {
|
|
const probePage = vi.fn(() => null);
|
|
const { options } = createDispatchOptions({
|
|
isProduction: true,
|
|
probePage,
|
|
revalidateSeconds: 60,
|
|
...overrides,
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(probePage).not.toHaveBeenCalled();
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
});
|
|
|
|
afterEach(() => {
|
|
consumeDynamicUsage();
|
|
consumeRenderRequestApiUsage();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("serves cached production HTML instead of revalidating params or rendering", async () => {
|
|
const probePage = vi.fn(() => {
|
|
throw new Error("cache hit must not execute page code");
|
|
});
|
|
const { options } = createDispatchOptions({
|
|
async buildPageElement() {
|
|
throw new Error("cache hit should not render the page");
|
|
},
|
|
async generateStaticParams() {
|
|
throw new Error("cache hit should not validate static params");
|
|
},
|
|
isProduction: true,
|
|
isrGet: vi.fn(async () => buildISRCacheEntry(buildCachedAppPageValue("<html>cached</html>"))),
|
|
probePage,
|
|
revalidateSeconds: 60,
|
|
route: createRoute({ isDynamic: true, params: ["slug"] }),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(probePage).not.toHaveBeenCalled();
|
|
expect(response.headers.get("x-vinext-cache")).toBe("HIT");
|
|
await expect(response.text()).resolves.toBe("<html>cached</html>");
|
|
});
|
|
|
|
it("treats unproofed cached production HTML as a miss for query-bearing requests", async () => {
|
|
const isrGet = vi.fn(async () =>
|
|
buildISRCacheEntry(buildCachedAppPageValue("<html>cached empty query</html>")),
|
|
);
|
|
const probePage = vi.fn(() => null);
|
|
const { options } = createDispatchOptions({
|
|
isProduction: true,
|
|
isrGet,
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr() {
|
|
return new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
markDynamicUsage();
|
|
markRenderRequestApiUsage("searchParams");
|
|
controller.enqueue(new TextEncoder().encode("<html>page</html>"));
|
|
controller.close();
|
|
},
|
|
});
|
|
},
|
|
}),
|
|
probePage,
|
|
revalidateSeconds: 60,
|
|
searchParams: new URLSearchParams("search=hello"),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet).toHaveBeenCalled();
|
|
expect(probePage).not.toHaveBeenCalled();
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
});
|
|
|
|
it("caches fresh query-bearing HTML when the page probe does not read searchParams", async () => {
|
|
const probePage = vi.fn(() => null);
|
|
const isrSet = vi.fn<DispatchOptions["isrSet"]>(async () => {});
|
|
const waitUntilPromises: Promise<unknown>[] = [];
|
|
const executionContext = {
|
|
waitUntil(promise) {
|
|
waitUntilPromises.push(promise);
|
|
},
|
|
} satisfies ExecutionContextLike;
|
|
const { options } = createDispatchOptions({
|
|
isProduction: true,
|
|
isrSet,
|
|
probePage,
|
|
revalidateSeconds: 60,
|
|
searchParams: new URLSearchParams("utm_source=google"),
|
|
});
|
|
|
|
const response = await runWithExecutionContext(executionContext, () =>
|
|
dispatchAppPage(options),
|
|
);
|
|
|
|
expect(probePage).not.toHaveBeenCalled();
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
await Promise.all(waitUntilPromises.splice(0));
|
|
expect(isrSet).toHaveBeenCalledTimes(1);
|
|
const [cacheKey, cacheValue, revalidateSeconds, tags, expireSeconds] = isrSet.mock.calls[0]!;
|
|
expect(cacheKey).toBe("html:/posts/hello");
|
|
expect(cacheValue).toMatchObject({
|
|
kind: "APP_PAGE",
|
|
renderObservation: {
|
|
requestApis: expect.arrayContaining([{ kind: "searchParams", status: "notObserved" }]),
|
|
},
|
|
});
|
|
expect(revalidateSeconds).toBe(60);
|
|
expect(tags).toEqual(expect.arrayContaining(["_N_T_/posts/hello"]));
|
|
expect(expireSeconds).toBeUndefined();
|
|
});
|
|
|
|
it("does not reuse queryless HTML when the page reads searchParams", async () => {
|
|
let pageExecutions = 0;
|
|
async function Page(props: Record<string, unknown>): Promise<React.ReactNode> {
|
|
pageExecutions += 1;
|
|
const query = isPromiseLike(props.searchParams) ? await props.searchParams : {};
|
|
if (!isQueryRecord(query)) {
|
|
throw new Error("Expected searchParams to resolve to a query record");
|
|
}
|
|
return React.createElement("h1", null, query.q ?? "empty");
|
|
}
|
|
|
|
const route = createRoute({ pattern: "/query-proof", routeSegments: ["query-proof"] });
|
|
const cache = new Map<string, ISRCacheEntry>();
|
|
const isrGet = vi.fn(async (key: string) => cache.get(key) ?? null);
|
|
const isrSet = vi.fn<DispatchOptions["isrSet"]>(async (key, data) => {
|
|
cache.set(key, {
|
|
isStale: false,
|
|
value: { lastModified: Date.now(), value: data },
|
|
});
|
|
});
|
|
const waitUntilPromises: Promise<unknown>[] = [];
|
|
const executionContext = {
|
|
waitUntil(promise) {
|
|
waitUntilPromises.push(promise);
|
|
},
|
|
} satisfies ExecutionContextLike;
|
|
const buildPageElement: DispatchOptions["buildPageElement"] = (
|
|
_route,
|
|
params,
|
|
_opts,
|
|
searchParams,
|
|
layoutParamAccess,
|
|
buildOptions,
|
|
) =>
|
|
buildPageElements({
|
|
layoutParamAccess,
|
|
metadataRoutes: [],
|
|
params,
|
|
pageRequest: {
|
|
isRscRequest: false,
|
|
mountedSlotsHeader: null,
|
|
observeMetadataSearchParamsAccess:
|
|
buildOptions?.observeMetadataSearchParamsAccess === true,
|
|
observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess === true,
|
|
opts: undefined,
|
|
request: new Request(`https://example.test/query-proof?${searchParams}`),
|
|
searchParams,
|
|
},
|
|
route: {
|
|
layouts: [],
|
|
page: { default: Page },
|
|
pattern: "/query-proof",
|
|
routeSegments: ["query-proof"],
|
|
},
|
|
routePath: "/query-proof",
|
|
}).then(toDispatchElementRecord);
|
|
const loadSsrHandler: DispatchOptions["loadSsrHandler"] = async () => ({
|
|
async handleSsr(rscStream, _navigationContext, _fontData, captureOptions) {
|
|
void captureOptions?.sideStream?.cancel().catch(() => {});
|
|
const renderedText = await new Response(rscStream).text();
|
|
return createStream([`<html>${renderedText}</html>`]);
|
|
},
|
|
});
|
|
const probePage = vi.fn((searchParams: URLSearchParams) =>
|
|
probeAppPage({
|
|
asyncRouteParams: makeThenableParams({}),
|
|
pageComponent: Page,
|
|
searchParams,
|
|
}),
|
|
);
|
|
|
|
async function request(searchParams: URLSearchParams): Promise<{
|
|
response: Response;
|
|
text: string;
|
|
}> {
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/query-proof",
|
|
isProduction: true,
|
|
isrGet,
|
|
isrSet,
|
|
loadSsrHandler,
|
|
probePage: () => probePage(searchParams),
|
|
renderToReadableStream: renderPagePayloadToStream,
|
|
revalidateSeconds: 60,
|
|
route,
|
|
searchParams,
|
|
});
|
|
const response = await runWithExecutionContext(executionContext, () =>
|
|
dispatchAppPage(options),
|
|
);
|
|
const text = await response.text();
|
|
await Promise.all(waitUntilPromises.splice(0));
|
|
return { response, text };
|
|
}
|
|
|
|
const queryless = await request(new URLSearchParams());
|
|
expect(queryless.response.headers.get("x-vinext-cache")).not.toBe("HIT");
|
|
expect(queryless.text).toBe("<html>empty</html>");
|
|
expect(cache.has("html:/query-proof")).toBe(false);
|
|
expect(probePage).not.toHaveBeenCalled();
|
|
expect(pageExecutions).toBe(1);
|
|
|
|
const withQuery = await request(new URLSearchParams({ q: "hello" }));
|
|
expect(withQuery.response.headers.get("x-vinext-cache")).not.toBe("HIT");
|
|
expect(withQuery.text).toBe("<html>hello</html>");
|
|
expect(probePage).not.toHaveBeenCalled();
|
|
expect(pageExecutions).toBe(2);
|
|
});
|
|
|
|
it.each(["generateMetadata", "generateViewport"] as const)(
|
|
"does not reuse queryless HTML when %s reads searchParams",
|
|
async (headGenerator) => {
|
|
const readSearchParams = async (props: {
|
|
searchParams: Promise<Record<string, unknown>>;
|
|
}) => {
|
|
const query = await props.searchParams;
|
|
return typeof query.q === "string" ? query.q : "empty";
|
|
};
|
|
const pageModule = {
|
|
default: () => React.createElement("h1", null, "static body"),
|
|
...(headGenerator === "generateMetadata"
|
|
? {
|
|
async generateMetadata(props: { searchParams: Promise<Record<string, unknown>> }) {
|
|
return { title: await readSearchParams(props) };
|
|
},
|
|
}
|
|
: {
|
|
async generateViewport(props: { searchParams: Promise<Record<string, unknown>> }) {
|
|
return { themeColor: await readSearchParams(props) };
|
|
},
|
|
}),
|
|
};
|
|
const route = createRoute({ pattern: "/head-proof", routeSegments: ["head-proof"] });
|
|
const cache = new Map<string, ISRCacheEntry>();
|
|
const isrGet = vi.fn(async (key: string) => cache.get(key) ?? null);
|
|
const isrSet = vi.fn<DispatchOptions["isrSet"]>(async (key, data) => {
|
|
cache.set(key, {
|
|
isStale: false,
|
|
value: { lastModified: Date.now(), value: data },
|
|
});
|
|
});
|
|
const waitUntilPromises: Promise<unknown>[] = [];
|
|
const executionContext = {
|
|
waitUntil(promise) {
|
|
waitUntilPromises.push(promise);
|
|
},
|
|
} satisfies ExecutionContextLike;
|
|
const buildPageElement: DispatchOptions["buildPageElement"] = (
|
|
_route,
|
|
params,
|
|
_opts,
|
|
searchParams,
|
|
layoutParamAccess,
|
|
buildOptions,
|
|
) =>
|
|
buildPageElements({
|
|
layoutParamAccess,
|
|
metadataRoutes: [],
|
|
params,
|
|
pageRequest: {
|
|
isRscRequest: false,
|
|
mountedSlotsHeader: null,
|
|
observeMetadataSearchParamsAccess:
|
|
buildOptions?.observeMetadataSearchParamsAccess === true,
|
|
observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess === true,
|
|
opts: undefined,
|
|
request: new Request(`https://example.test/head-proof?${searchParams}`),
|
|
searchParams,
|
|
},
|
|
route: {
|
|
layouts: [],
|
|
page: pageModule,
|
|
pattern: "/head-proof",
|
|
routeSegments: ["head-proof"],
|
|
},
|
|
routePath: "/head-proof",
|
|
}).then(toDispatchElementRecord);
|
|
const loadSsrHandler: DispatchOptions["loadSsrHandler"] = async () => ({
|
|
async handleSsr(rscStream, _navigationContext, _fontData, captureOptions) {
|
|
void captureOptions?.sideStream?.cancel().catch(() => {});
|
|
const renderedText = await new Response(rscStream).text();
|
|
return createStream([`<html>${renderedText}</html>`]);
|
|
},
|
|
});
|
|
const probePage = vi.fn(() => null);
|
|
|
|
async function request(searchParams: URLSearchParams): Promise<Response> {
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/head-proof",
|
|
isProduction: true,
|
|
isrGet,
|
|
isrSet,
|
|
loadSsrHandler,
|
|
probePage,
|
|
renderToReadableStream: renderPagePayloadToStream,
|
|
revalidateSeconds: 60,
|
|
route,
|
|
searchParams,
|
|
});
|
|
const response = await runWithExecutionContext(executionContext, () =>
|
|
dispatchAppPage(options),
|
|
);
|
|
await response.text();
|
|
await Promise.all(waitUntilPromises.splice(0));
|
|
return response;
|
|
}
|
|
|
|
const queryless = await request(new URLSearchParams());
|
|
expect(queryless.headers.get("x-vinext-cache")).not.toBe("HIT");
|
|
expect(cache.has("html:/head-proof")).toBe(false);
|
|
expect(probePage).not.toHaveBeenCalled();
|
|
|
|
const withQuery = await request(new URLSearchParams({ q: "hello" }));
|
|
expect(withQuery.headers.get("x-vinext-cache")).not.toBe("HIT");
|
|
},
|
|
);
|
|
|
|
it.each(["primary", "parallel"] as const)(
|
|
"does not reuse queryless RSC when %s generateMetadata reads searchParams",
|
|
async (metadataOwner) => {
|
|
const metadataPage = {
|
|
default: () => React.createElement("h1", null, `${metadataOwner} body`),
|
|
async generateMetadata(props: { searchParams: Promise<Record<string, unknown>> }) {
|
|
const query = await props.searchParams;
|
|
return { title: typeof query.q === "string" ? query.q : "empty" };
|
|
},
|
|
};
|
|
const route = createRoute({
|
|
pattern: "/rsc-metadata-proof",
|
|
routeSegments: ["rsc-metadata-proof"],
|
|
...(metadataOwner === "parallel"
|
|
? {
|
|
slots: {
|
|
sidebar: {
|
|
page: metadataPage,
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
const cache = new Map<string, ISRCacheEntry>();
|
|
const isrGet = vi.fn(async (key: string) => cache.get(key) ?? null);
|
|
const isrSet = vi.fn<DispatchOptions["isrSet"]>(async (key, data) => {
|
|
cache.set(key, {
|
|
isStale: false,
|
|
value: { lastModified: Date.now(), value: data },
|
|
});
|
|
});
|
|
const waitUntilPromises: Promise<unknown>[] = [];
|
|
const executionContext = {
|
|
waitUntil(promise) {
|
|
waitUntilPromises.push(promise);
|
|
},
|
|
} satisfies ExecutionContextLike;
|
|
const buildPageElement: DispatchOptions["buildPageElement"] = (
|
|
_route,
|
|
params,
|
|
_opts,
|
|
searchParams,
|
|
layoutParamAccess,
|
|
buildOptions,
|
|
) => {
|
|
const buildRoute: AppPageBuildRoute = {
|
|
layouts: [],
|
|
page:
|
|
metadataOwner === "primary"
|
|
? metadataPage
|
|
: { default: () => React.createElement("h1", null, "primary body") },
|
|
pattern: "/rsc-metadata-proof",
|
|
routeSegments: ["rsc-metadata-proof"],
|
|
...(metadataOwner === "parallel"
|
|
? {
|
|
slots: {
|
|
sidebar: {
|
|
layoutIndex: -1,
|
|
name: "sidebar",
|
|
page: metadataPage,
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
return buildPageElements({
|
|
layoutParamAccess,
|
|
metadataRoutes: [],
|
|
params,
|
|
pageRequest: {
|
|
isRscRequest: true,
|
|
mountedSlotsHeader: null,
|
|
observeMetadataSearchParamsAccess:
|
|
buildOptions?.observeMetadataSearchParamsAccess === true,
|
|
observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess === true,
|
|
opts: undefined,
|
|
request: new Request(`https://example.test/rsc-metadata-proof?${searchParams}`),
|
|
searchParams,
|
|
},
|
|
route: buildRoute,
|
|
routePath: "/rsc-metadata-proof",
|
|
}).then(toDispatchElementRecord);
|
|
};
|
|
|
|
async function request(searchParams: URLSearchParams): Promise<Response> {
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/rsc-metadata-proof",
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
isrGet,
|
|
isrSet,
|
|
probePage() {
|
|
return null;
|
|
},
|
|
renderToReadableStream: renderPagePayloadToStream,
|
|
revalidateSeconds: 60,
|
|
route,
|
|
searchParams,
|
|
});
|
|
const response = await runWithExecutionContext(executionContext, () =>
|
|
dispatchAppPage(options),
|
|
);
|
|
await response.text();
|
|
await Promise.all(waitUntilPromises.splice(0));
|
|
return response;
|
|
}
|
|
|
|
const queryless = await request(new URLSearchParams());
|
|
expect(queryless.headers.get("x-vinext-cache")).not.toBe("HIT");
|
|
expect(cache.has("rsc:/rsc-metadata-proof")).toBe(false);
|
|
|
|
const withQuery = await request(new URLSearchParams({ q: "hello" }));
|
|
expect(withQuery.headers.get("x-vinext-cache")).not.toBe("HIT");
|
|
},
|
|
);
|
|
|
|
it("does not reuse loading-boundary RSC when the page reads searchParams", async () => {
|
|
async function Page(props: Record<string, unknown>): Promise<React.ReactNode> {
|
|
const query = isPromiseLike(props.searchParams) ? await props.searchParams : {};
|
|
return React.createElement(
|
|
"h1",
|
|
null,
|
|
isQueryRecord(query) && typeof query.q === "string" ? query.q : "empty",
|
|
);
|
|
}
|
|
const route = createRoute({
|
|
loading: { default: () => null },
|
|
pattern: "/rsc-loading-proof",
|
|
routeSegments: ["rsc-loading-proof"],
|
|
});
|
|
const cache = new Map<string, ISRCacheEntry>();
|
|
const waitUntilPromises: Promise<unknown>[] = [];
|
|
const executionContext = {
|
|
waitUntil(promise) {
|
|
waitUntilPromises.push(promise);
|
|
},
|
|
} satisfies ExecutionContextLike;
|
|
const buildPageElement: DispatchOptions["buildPageElement"] = (
|
|
_route,
|
|
params,
|
|
_opts,
|
|
searchParams,
|
|
layoutParamAccess,
|
|
buildOptions,
|
|
) =>
|
|
buildPageElements({
|
|
layoutParamAccess,
|
|
metadataRoutes: [],
|
|
params,
|
|
pageRequest: {
|
|
isRscRequest: true,
|
|
mountedSlotsHeader: null,
|
|
observeMetadataSearchParamsAccess:
|
|
buildOptions?.observeMetadataSearchParamsAccess === true,
|
|
observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess === true,
|
|
opts: undefined,
|
|
request: new Request(`https://example.test/rsc-loading-proof?${searchParams}`),
|
|
searchParams,
|
|
},
|
|
route: {
|
|
layouts: [],
|
|
loading: { default: () => null },
|
|
page: { default: Page },
|
|
pattern: "/rsc-loading-proof",
|
|
routeSegments: ["rsc-loading-proof"],
|
|
},
|
|
routePath: "/rsc-loading-proof",
|
|
}).then(toDispatchElementRecord);
|
|
|
|
async function request(q: string): Promise<string> {
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/rsc-loading-proof",
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
isrGet: async (key) => cache.get(key) ?? null,
|
|
isrSet: async (key, value) => {
|
|
cache.set(key, {
|
|
isStale: false,
|
|
value: { lastModified: Date.now(), value },
|
|
});
|
|
},
|
|
params: {},
|
|
probePage() {
|
|
throw new Error("loading.tsx should skip the eager page probe");
|
|
},
|
|
renderToReadableStream: renderPagePayloadToStream,
|
|
revalidateSeconds: 60,
|
|
route,
|
|
searchParams: new URLSearchParams({ q }),
|
|
});
|
|
const response = await runWithExecutionContext(executionContext, () =>
|
|
dispatchAppPage(options),
|
|
);
|
|
const text = await response.text();
|
|
await Promise.all(waitUntilPromises.splice(0));
|
|
expect(response.headers.get("x-vinext-cache")).not.toBe("HIT");
|
|
return text;
|
|
}
|
|
|
|
await expect(request("first")).resolves.toBe("first");
|
|
await expect(request("second")).resolves.toBe("second");
|
|
expect(cache.has("rsc:/rsc-loading-proof")).toBe(false);
|
|
});
|
|
|
|
it("serves cached production HTML when searchParams is only mentioned but not accessed", async () => {
|
|
const isrGet = vi.fn(async () =>
|
|
buildISRCacheEntry(
|
|
buildCachedAppPageValue(
|
|
"<html>cached static page</html>",
|
|
undefined,
|
|
undefined,
|
|
buildQueryInvariantRenderObservation(),
|
|
),
|
|
),
|
|
);
|
|
const probePage = vi.fn(() => {
|
|
const unusedCommentOnlySearchParams = "searchParams";
|
|
expect(unusedCommentOnlySearchParams).toBe("searchParams");
|
|
return null;
|
|
});
|
|
const { options } = createDispatchOptions({
|
|
isProduction: true,
|
|
isrGet,
|
|
probePage,
|
|
revalidateSeconds: 60,
|
|
searchParams: new URLSearchParams("search=hello"),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet).toHaveBeenCalled();
|
|
expect(probePage).not.toHaveBeenCalled();
|
|
expect(response.headers.get("x-vinext-cache")).toBe("HIT");
|
|
await expect(response.text()).resolves.toBe("<html>cached static page</html>");
|
|
});
|
|
|
|
it("lets force-static override observed searchParams access", async () => {
|
|
const isrGet = vi.fn(async () =>
|
|
buildISRCacheEntry(buildCachedAppPageValue("<html>cached force static</html>")),
|
|
);
|
|
const { options } = createDispatchOptions({
|
|
dynamicConfig: "force-static",
|
|
isProduction: true,
|
|
isrGet,
|
|
probePage() {
|
|
markDynamicUsage();
|
|
markRenderRequestApiUsage("searchParams");
|
|
return null;
|
|
},
|
|
revalidateSeconds: 60,
|
|
searchParams: new URLSearchParams("search=hello"),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet).toHaveBeenCalled();
|
|
expect(response.headers.get("x-vinext-cache")).toBe("HIT");
|
|
await expect(response.text()).resolves.toBe("<html>cached force static</html>");
|
|
});
|
|
|
|
it("renders prefetch dynamic shells with empty page searchParams", async () => {
|
|
// Ported from Next.js:
|
|
// test/e2e/app-dir/segment-cache/search-params/segment-cache-search-params.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/segment-cache/search-params/segment-cache-search-params.test.ts
|
|
const buildQueries: string[] = [];
|
|
const probeQueries: string[] = [];
|
|
const setNavigationContext = vi.fn<DispatchOptions["setNavigationContext"]>();
|
|
const buildPageElement = vi.fn<DispatchOptions["buildPageElement"]>(
|
|
async (_route, _params, _opts, searchParams) => {
|
|
buildQueries.push(searchParams.toString());
|
|
return searchParams.get("searchParam") ?? "empty";
|
|
},
|
|
);
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
isRscRequest: true,
|
|
probePage(searchParams) {
|
|
probeQueries.push(searchParams?.get("searchParam") ?? "empty");
|
|
return null;
|
|
},
|
|
renderMode: APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL,
|
|
renderToReadableStream(element) {
|
|
if (typeof element !== "string") {
|
|
throw new Error("Expected string dynamic shell test element");
|
|
}
|
|
return createStream([element]);
|
|
},
|
|
searchParams: new URLSearchParams("searchParam=a_PPR"),
|
|
setNavigationContext,
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
await expect(response.text()).resolves.toBe("empty");
|
|
expect(buildQueries).toEqual([""]);
|
|
expect(probeQueries).toEqual(["empty"]);
|
|
const navigationContext = setNavigationContext.mock.calls.at(-1)?.[0];
|
|
expect(navigationContext?.searchParams.toString()).toBe("");
|
|
});
|
|
|
|
it.each([
|
|
{ isDraftMode: false, isRscRequest: false },
|
|
{ isDraftMode: false, isRscRequest: true },
|
|
{ isDraftMode: true, isRscRequest: false },
|
|
{ isDraftMode: true, isRscRequest: true },
|
|
])(
|
|
"passes empty request APIs to force-static page and head execution (RSC: $isRscRequest, draft: $isDraftMode)",
|
|
async ({ isDraftMode, isRscRequest }) => {
|
|
// Matches Next.js's force-static searchParams behavior:
|
|
// packages/next/src/server/request/search-params.ts
|
|
const metadataQueries: string[] = [];
|
|
const viewportQueries: string[] = [];
|
|
const pageHeaders: Array<string | null> = [];
|
|
const pageDraftModes: boolean[] = [];
|
|
async function readUser(searchParams: PromiseLike<Record<string, unknown>>): Promise<string> {
|
|
const query = await searchParams;
|
|
return typeof query.user === "string" ? query.user : "empty";
|
|
}
|
|
async function Page(props: Record<string, unknown>): Promise<React.ReactNode> {
|
|
pageHeaders.push(getHeadersContext()?.headers.get("x-request-value") ?? null);
|
|
pageDraftModes.push((await draftMode()).isEnabled);
|
|
const searchParams = props.searchParams;
|
|
if (!isPromiseLike(searchParams)) {
|
|
throw new Error("Expected page searchParams to be thenable");
|
|
}
|
|
return React.createElement(
|
|
"h1",
|
|
null,
|
|
await readUser(searchParams as PromiseLike<Record<string, unknown>>),
|
|
);
|
|
}
|
|
const pageModule = {
|
|
default: Page,
|
|
async generateMetadata(props: { searchParams: Promise<Record<string, unknown>> }) {
|
|
metadataQueries.push(await readUser(props.searchParams));
|
|
return null;
|
|
},
|
|
async generateViewport(props: { searchParams: Promise<Record<string, unknown>> }) {
|
|
viewportQueries.push(await readUser(props.searchParams));
|
|
return null;
|
|
},
|
|
};
|
|
const route = createRoute({
|
|
pattern: "/force-static-query",
|
|
routeSegments: ["force-static-query"],
|
|
});
|
|
const buildPageElement: DispatchOptions["buildPageElement"] = (
|
|
_route,
|
|
params,
|
|
_opts,
|
|
searchParams,
|
|
layoutParamAccess,
|
|
buildOptions,
|
|
) =>
|
|
buildPageElements({
|
|
layoutParamAccess,
|
|
metadataRoutes: [],
|
|
params,
|
|
pageRequest: {
|
|
isRscRequest,
|
|
mountedSlotsHeader: null,
|
|
observeMetadataSearchParamsAccess:
|
|
buildOptions?.observeMetadataSearchParamsAccess === true,
|
|
observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess === true,
|
|
opts: undefined,
|
|
request: new Request("https://example.test/force-static-query?user=alice"),
|
|
searchParams,
|
|
},
|
|
route: {
|
|
layouts: [],
|
|
page: pageModule,
|
|
pattern: "/force-static-query",
|
|
routeSegments: ["force-static-query"],
|
|
},
|
|
routePath: "/force-static-query",
|
|
}).then(toDispatchElementRecord);
|
|
const probeQueries: string[] = [];
|
|
const setNavigationContext = vi.fn<DispatchOptions["setNavigationContext"]>();
|
|
const request = new Request("https://example.test/force-static-query?user=alice", {
|
|
headers: {
|
|
...(isDraftMode ? { cookie: "__prerender_bypass=draft-secret" } : {}),
|
|
"x-request-value": "present",
|
|
},
|
|
});
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/force-static-query",
|
|
dynamicConfig: "force-static",
|
|
isRscRequest,
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr(rscStream, _navigationContext, _fontData, captureOptions) {
|
|
void captureOptions?.sideStream?.cancel().catch(() => {});
|
|
return createStream([`<html>${await new Response(rscStream).text()}</html>`]);
|
|
},
|
|
}),
|
|
probePage(searchParams) {
|
|
probeQueries.push(searchParams?.get("user") ?? "empty");
|
|
return null;
|
|
},
|
|
request,
|
|
renderToReadableStream: renderPagePayloadToStream,
|
|
route,
|
|
searchParams: new URLSearchParams("user=alice"),
|
|
setNavigationContext,
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
await expect(response.text()).resolves.toBe(isRscRequest ? "empty" : "<html>empty</html>");
|
|
expect(metadataQueries).toEqual(["empty"]);
|
|
expect(viewportQueries).toEqual(["empty"]);
|
|
expect(probeQueries).toEqual(isRscRequest ? ["empty"] : []);
|
|
expect(pageHeaders).toEqual([null]);
|
|
expect(pageDraftModes).toEqual([isDraftMode]);
|
|
const navigationContext = setNavigationContext.mock.calls.at(-1)?.[0];
|
|
expect(navigationContext?.searchParams.toString()).toBe("");
|
|
},
|
|
);
|
|
|
|
it("preserves dynamic-error searchParams values but throws when they are accessed", async () => {
|
|
// Ported from Next.js: test/e2e/app-dir/dynamic-data/dynamic-data.test.ts
|
|
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/dynamic-data/dynamic-data.test.ts
|
|
const receivedQueries: string[] = [];
|
|
async function Page(props: Record<string, unknown>): Promise<React.ReactNode> {
|
|
const searchParams = props.searchParams;
|
|
if (!isPromiseLike(searchParams)) {
|
|
throw new Error("Expected page searchParams to be thenable");
|
|
}
|
|
const query = await searchParams;
|
|
if (!isQueryRecord(query)) {
|
|
throw new Error("Expected searchParams to resolve to a query record");
|
|
}
|
|
receivedQueries.push(typeof query.user === "string" ? query.user : "empty");
|
|
return React.createElement("h1", null, "unexpected");
|
|
}
|
|
const route = createRoute({ pattern: "/dynamic-error", routeSegments: ["dynamic-error"] });
|
|
const buildPageElement: DispatchOptions["buildPageElement"] = (
|
|
_route,
|
|
params,
|
|
_opts,
|
|
searchParams,
|
|
layoutParamAccess,
|
|
buildOptions,
|
|
) =>
|
|
buildPageElements({
|
|
layoutParamAccess,
|
|
metadataRoutes: [],
|
|
params,
|
|
pageRequest: {
|
|
isRscRequest: true,
|
|
mountedSlotsHeader: null,
|
|
observeMetadataSearchParamsAccess:
|
|
buildOptions?.observeMetadataSearchParamsAccess === true,
|
|
observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess === true,
|
|
opts: undefined,
|
|
request: new Request("https://example.test/dynamic-error?user=alice"),
|
|
searchParams,
|
|
},
|
|
route: {
|
|
layouts: [],
|
|
page: { default: Page },
|
|
pattern: "/dynamic-error",
|
|
routeSegments: ["dynamic-error"],
|
|
},
|
|
routePath: "/dynamic-error",
|
|
}).then(toDispatchElementRecord);
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/dynamic-error",
|
|
dynamicConfig: "error",
|
|
isRscRequest: true,
|
|
renderToReadableStream: renderPagePayloadToStream,
|
|
route,
|
|
searchParams: new URLSearchParams("user=alice"),
|
|
});
|
|
|
|
try {
|
|
const response = await dispatchAppPage(options);
|
|
|
|
await expect(response.text()).rejects.toThrow('Page with `dynamic = "error"`');
|
|
expect(receivedQueries).toEqual([]);
|
|
} finally {
|
|
setHeadersContext(null);
|
|
}
|
|
});
|
|
|
|
it("does not write query-invariant cache entries when loading-boundary render awaits searchParams", async () => {
|
|
async function Page(props: Record<string, unknown>): Promise<React.ReactNode> {
|
|
const query = isPromiseLike(props.searchParams) ? await props.searchParams : {};
|
|
if (!isQueryRecord(query)) {
|
|
throw new Error("Expected searchParams to resolve to a query record");
|
|
}
|
|
return React.createElement("h1", null, query.q ?? "");
|
|
}
|
|
|
|
const route = createRoute({
|
|
loading: { default: () => null },
|
|
pattern: "/loading-search",
|
|
routeSegments: ["loading-search"],
|
|
});
|
|
const cache = new Map<string, ISRCacheEntry>();
|
|
const isrGet = vi.fn(async (key: string) => cache.get(key) ?? null);
|
|
const isrSet = vi.fn<DispatchOptions["isrSet"]>(async (key, data) => {
|
|
cache.set(key, {
|
|
isStale: false,
|
|
value: {
|
|
lastModified: Date.now(),
|
|
value: data,
|
|
},
|
|
});
|
|
});
|
|
const waitUntilPromises: Promise<unknown>[] = [];
|
|
const executionContext = {
|
|
waitUntil(promise) {
|
|
waitUntilPromises.push(promise);
|
|
},
|
|
} satisfies ExecutionContextLike;
|
|
const buildPageElement: DispatchOptions["buildPageElement"] = (
|
|
_route,
|
|
params,
|
|
_opts,
|
|
searchParams,
|
|
layoutParamAccess?: AppLayoutParamAccessTracker,
|
|
buildOptions?: Parameters<DispatchOptions["buildPageElement"]>[5],
|
|
) => {
|
|
const buildRoute: AppPageBuildRoute = {
|
|
layouts: [],
|
|
loading: { default: () => null },
|
|
page: { default: Page },
|
|
pattern: "/loading-search",
|
|
routeSegments: ["loading-search"],
|
|
};
|
|
return buildPageElements({
|
|
layoutParamAccess,
|
|
metadataRoutes: [],
|
|
params,
|
|
pageRequest: {
|
|
isRscRequest: false,
|
|
mountedSlotsHeader: null,
|
|
opts: undefined,
|
|
request: new Request(`https://example.test/loading-search?${searchParams}`),
|
|
searchParams,
|
|
observeMetadataSearchParamsAccess: buildOptions?.observeMetadataSearchParamsAccess,
|
|
observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess,
|
|
},
|
|
route: buildRoute,
|
|
routePath: "/loading-search",
|
|
}).then(toDispatchElementRecord);
|
|
};
|
|
const loadSsrHandler: DispatchOptions["loadSsrHandler"] = async () => ({
|
|
async handleSsr(rscStream, _navigationContext, _fontData, captureOptions) {
|
|
void captureOptions?.sideStream?.cancel().catch(() => {});
|
|
const renderedText = await new Response(rscStream).text();
|
|
return createStream([`<html>${renderedText}</html>`]);
|
|
},
|
|
});
|
|
|
|
async function requestWithQuery(q: string): Promise<Response> {
|
|
waitUntilPromises.length = 0;
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/loading-search",
|
|
isProduction: true,
|
|
isrGet,
|
|
isrSet,
|
|
loadSsrHandler,
|
|
probePage() {
|
|
throw new Error("loading.tsx should skip the eager page probe");
|
|
},
|
|
renderToReadableStream: renderPagePayloadToStream,
|
|
revalidateSeconds: 60,
|
|
route,
|
|
searchParams: new URLSearchParams({ q }),
|
|
});
|
|
|
|
const response = await runWithExecutionContext(executionContext, () =>
|
|
dispatchAppPage(options),
|
|
);
|
|
await Promise.all(waitUntilPromises.splice(0));
|
|
return response;
|
|
}
|
|
|
|
const firstResponse = await requestWithQuery("first");
|
|
expect(firstResponse.headers.get("x-vinext-cache")).not.toBe("HIT");
|
|
expect(firstResponse.headers.get("cache-control")).toContain("no-store");
|
|
await expect(firstResponse.text()).resolves.toBe("<html>first</html>");
|
|
const firstCachedValue = cache.get("html:/loading-search")?.value.value;
|
|
if (firstCachedValue) {
|
|
expectCachedAppPageSearchParamsObservation(firstCachedValue, "<html>first</html>");
|
|
} else {
|
|
expect(isrSet).not.toHaveBeenCalled();
|
|
}
|
|
|
|
const secondResponse = await requestWithQuery("second");
|
|
expect(secondResponse.headers.get("x-vinext-cache")).not.toBe("HIT");
|
|
await expect(secondResponse.text()).resolves.toBe("<html>second</html>");
|
|
expect(isrGet).toHaveBeenCalledTimes(2);
|
|
const secondCachedValue = cache.get("html:/loading-search")?.value.value;
|
|
if (secondCachedValue) {
|
|
expectCachedAppPageSearchParamsObservation(secondCachedValue, "<html>second</html>");
|
|
}
|
|
});
|
|
|
|
it("caches queryless loading-boundary HTML with a complete searchParams proof", async () => {
|
|
const route = createRoute({
|
|
loading: { default: () => null },
|
|
pattern: "/loading-static",
|
|
routeSegments: ["loading-static"],
|
|
});
|
|
const cache = new Map<string, ISRCacheEntry>();
|
|
const waitUntilPromises: Promise<unknown>[] = [];
|
|
const executionContext = {
|
|
waitUntil(promise) {
|
|
waitUntilPromises.push(promise);
|
|
},
|
|
} satisfies ExecutionContextLike;
|
|
const buildPageElement: DispatchOptions["buildPageElement"] = (
|
|
_route,
|
|
params,
|
|
_opts,
|
|
searchParams,
|
|
layoutParamAccess,
|
|
) =>
|
|
buildPageElements({
|
|
layoutParamAccess,
|
|
metadataRoutes: [],
|
|
params,
|
|
pageRequest: {
|
|
isRscRequest: false,
|
|
mountedSlotsHeader: null,
|
|
opts: undefined,
|
|
request: new Request(`https://example.test/loading-static?${searchParams}`),
|
|
searchParams,
|
|
},
|
|
route: {
|
|
layouts: [],
|
|
loading: { default: () => null },
|
|
page: { default: () => React.createElement("h1", null, "static") },
|
|
pattern: "/loading-static",
|
|
routeSegments: ["loading-static"],
|
|
},
|
|
routePath: "/loading-static",
|
|
}).then(toDispatchElementRecord);
|
|
|
|
async function request(searchParams: URLSearchParams): Promise<Response> {
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/loading-static",
|
|
isProduction: true,
|
|
isrGet: async (key) => cache.get(key) ?? null,
|
|
isrSet: async (key, value) => {
|
|
cache.set(key, {
|
|
isStale: false,
|
|
value: { lastModified: Date.now(), value },
|
|
});
|
|
},
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr(rscStream, _navigationContext, _fontData, captureOptions) {
|
|
void captureOptions?.sideStream?.cancel().catch(() => {});
|
|
return createStream([`<html>${await new Response(rscStream).text()}</html>`]);
|
|
},
|
|
}),
|
|
probePage() {
|
|
throw new Error("loading.tsx should skip the eager page probe");
|
|
},
|
|
params: {},
|
|
renderToReadableStream: renderPagePayloadToStream,
|
|
revalidateSeconds: 60,
|
|
route,
|
|
searchParams,
|
|
});
|
|
const response = await runWithExecutionContext(executionContext, () =>
|
|
dispatchAppPage(options),
|
|
);
|
|
await response.text();
|
|
await Promise.all(waitUntilPromises.splice(0));
|
|
return response;
|
|
}
|
|
|
|
const queryless = await request(new URLSearchParams());
|
|
expect(queryless.headers.get("cache-control")).toContain("no-store");
|
|
const cached = cache.get("html:/loading-static")?.value.value;
|
|
expect(isCachedAppPageValue(cached)).toBe(true);
|
|
if (!isCachedAppPageValue(cached)) throw new Error("expected cached loading page");
|
|
expect(cached.renderObservation?.completeness).toBe("complete");
|
|
expect(
|
|
cached.renderObservation?.requestApis.find((api) => api.kind === "searchParams")?.status,
|
|
).toBe("notObserved");
|
|
|
|
const queried = await request(new URLSearchParams({ q: "hello" }));
|
|
expect(queried.headers.get("x-vinext-cache")).toBe("HIT");
|
|
});
|
|
|
|
it("bypasses cached production HTML when draft mode is enabled", async () => {
|
|
const isrGet = vi.fn(async () =>
|
|
buildISRCacheEntry(buildCachedAppPageValue("<html>stale</html>")),
|
|
);
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement: vi.fn(async () => React.createElement("main", null, "draft page")),
|
|
isProduction: true,
|
|
isrGet,
|
|
request: new Request("https://example.test/posts/hello", {
|
|
headers: { Cookie: "__prerender_bypass=draft-secret" },
|
|
}),
|
|
revalidateSeconds: 60,
|
|
renderToReadableStream() {
|
|
return createStream(["flight"]);
|
|
},
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet).not.toHaveBeenCalled();
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
});
|
|
|
|
it("bypasses cached production HTML when rendering action form state", async () => {
|
|
const formState = ["action-result", "key-path", "reference-id", 1] as never;
|
|
const isrGet = vi.fn(async () =>
|
|
buildISRCacheEntry(buildCachedAppPageValue("<html>cached initial state</html>")),
|
|
);
|
|
const { options } = createDispatchOptions({
|
|
formState,
|
|
isProgressiveActionRender: true,
|
|
isProduction: true,
|
|
isrGet,
|
|
revalidateSeconds: 60,
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet).not.toHaveBeenCalled();
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
});
|
|
|
|
it("bypasses cached production HTML when a progressive action returns no form state", async () => {
|
|
const isrGet = vi.fn(async () =>
|
|
buildISRCacheEntry(buildCachedAppPageValue("<html>cached initial state</html>")),
|
|
);
|
|
const { options } = createDispatchOptions({
|
|
isProgressiveActionRender: true,
|
|
isProduction: true,
|
|
isrGet,
|
|
revalidateSeconds: 60,
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet).not.toHaveBeenCalled();
|
|
expect(options.isrSet).not.toHaveBeenCalled();
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
});
|
|
|
|
it("renders not-found HTML when a progressive action calls notFound()", async () => {
|
|
const buildPageElement = vi.fn(async () => React.createElement("main", null, "page"));
|
|
const renderHttpAccessFallbackPage = vi.fn(
|
|
async () => new Response("<html>not found</html>", { status: 404 }),
|
|
);
|
|
const { options } = createDispatchOptions({
|
|
actionError: { digest: "NEXT_HTTP_ERROR_FALLBACK;404" },
|
|
actionFailed: true,
|
|
buildPageElement,
|
|
isProgressiveActionRender: true,
|
|
});
|
|
options.renderHttpAccessFallbackPage = renderHttpAccessFallbackPage;
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.status).toBe(404);
|
|
await expect(response.text()).resolves.toBe("<html>not found</html>");
|
|
expect(buildPageElement).not.toHaveBeenCalled();
|
|
expect(renderHttpAccessFallbackPage).toHaveBeenCalledWith(
|
|
404,
|
|
{ matchedParams: { slug: "hello" } },
|
|
null,
|
|
);
|
|
});
|
|
|
|
it("does not bypass cached production HTML for arbitrary draft cookie values", async () => {
|
|
const isrGet = vi.fn(async () =>
|
|
buildISRCacheEntry(buildCachedAppPageValue("<html>cached</html>")),
|
|
);
|
|
const { options } = createDispatchOptions({
|
|
async buildPageElement() {
|
|
throw new Error("invalid draft cookie should still use the page cache");
|
|
},
|
|
isProduction: true,
|
|
isrGet,
|
|
request: new Request("https://example.test/posts/hello", {
|
|
headers: { Cookie: "__prerender_bypass=wrong-secret" },
|
|
}),
|
|
revalidateSeconds: 60,
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet).toHaveBeenCalledOnce();
|
|
expect(response.headers.get("x-vinext-cache")).toBe("HIT");
|
|
await expect(response.text()).resolves.toBe("<html>cached</html>");
|
|
});
|
|
|
|
it("serves cached production HTML for indefinite revalidate=false pages", async () => {
|
|
const { options } = createDispatchOptions({
|
|
async buildPageElement() {
|
|
throw new Error("revalidate=false cache hit should not render the page");
|
|
},
|
|
isProduction: true,
|
|
isrGet: vi.fn(async () =>
|
|
buildISRCacheEntry(buildCachedAppPageValue("<html>static cached</html>")),
|
|
),
|
|
revalidateSeconds: Infinity,
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("cache-control")).toBe("s-maxage=31536000, stale-while-revalidate");
|
|
expect(response.headers.get("x-vinext-cache")).toBe("HIT");
|
|
await expect(response.text()).resolves.toBe("<html>static cached</html>");
|
|
});
|
|
|
|
it("returns method policy responses instead of rendering unsupported methods", async () => {
|
|
const { options } = createDispatchOptions({
|
|
async buildPageElement() {
|
|
throw new Error("unsupported methods should not render the page");
|
|
},
|
|
request: new Request("https://example.test/posts/hello", { method: "POST" }),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.status).toBe(405);
|
|
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
|
});
|
|
|
|
it("uses a verified client reuse manifest to omit static layouts only from RSC transport", async () => {
|
|
const originalBuildId = process.env.__VINEXT_BUILD_ID;
|
|
process.env.__VINEXT_BUILD_ID = "deploy-test";
|
|
const sourceRouteId = "route:/dashboard/settings";
|
|
const sourceRoutePattern = "/dashboard/settings";
|
|
const targetRouteId = "route:/dashboard/profile";
|
|
const targetRoutePattern = "/dashboard/profile";
|
|
const rootBoundaryId = "/";
|
|
const layoutId = AppElementsWire.encodeLayoutId("/");
|
|
const pageId = AppElementsWire.encodePageId("/dashboard/profile", null);
|
|
const element = {
|
|
[APP_ROUTE_KEY]: targetRouteId,
|
|
[APP_ROOT_LAYOUT_KEY]: rootBoundaryId,
|
|
[layoutId]: "root-layout",
|
|
[pageId]: "profile-page",
|
|
};
|
|
const route = createRoute({
|
|
__buildTimeClassifications: new Map([[0, "static"]]),
|
|
layoutTreePositions: [0],
|
|
layouts: [{ default() {} }],
|
|
pattern: targetRoutePattern,
|
|
});
|
|
const clientReuseManifest = createVerifiedStaticLayoutManifest({
|
|
deploymentVersion: "deploy-test",
|
|
layoutId,
|
|
rootBoundaryId,
|
|
routeId: sourceRouteId,
|
|
routePattern: sourceRoutePattern,
|
|
});
|
|
const capturedRscPayloads: Record<string, unknown>[] = [];
|
|
const capturedHtmlPayloads: Record<string, unknown>[] = [];
|
|
const waitUntilPromises: Promise<unknown>[] = [];
|
|
const executionContext = {
|
|
waitUntil(promise) {
|
|
waitUntilPromises.push(promise);
|
|
},
|
|
} satisfies ExecutionContextLike;
|
|
|
|
try {
|
|
const { options: rscOptions } = createDispatchOptions({
|
|
buildPageElement: async () => element,
|
|
clientReuseManifest,
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
revalidateSeconds: 60,
|
|
renderToReadableStream(payload) {
|
|
capturedRscPayloads.push(captureRecord(payload));
|
|
return createStream(["flight"]);
|
|
},
|
|
route,
|
|
});
|
|
|
|
const rscResponse = await runWithExecutionContext(executionContext, () =>
|
|
dispatchAppPage(rscOptions),
|
|
);
|
|
|
|
expect(rscResponse.status).toBe(200);
|
|
expect(rscResponse.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
expect(rscResponse.headers.get("x-vinext-cache")).toBeNull();
|
|
expect(waitUntilPromises).toHaveLength(0);
|
|
expect(capturedRscPayloads).toHaveLength(1);
|
|
expect(Object.hasOwn(capturedRscPayloads[0], layoutId)).toBe(false);
|
|
expect(capturedRscPayloads[0][pageId]).toBe("profile-page");
|
|
|
|
const capturedDynamicPayloads: Record<string, unknown>[] = [];
|
|
const { options: dynamicOptions } = createDispatchOptions({
|
|
buildPageElement: async () => element,
|
|
clientReuseManifest,
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
renderToReadableStream(payload) {
|
|
capturedDynamicPayloads.push(captureRecord(payload));
|
|
return createStream(["flight"]);
|
|
},
|
|
route: createRoute({
|
|
__buildTimeClassifications: new Map([[0, "dynamic"]]),
|
|
layoutTreePositions: [0],
|
|
layouts: [{ default() {} }],
|
|
pattern: targetRoutePattern,
|
|
}),
|
|
});
|
|
|
|
const dynamicResponse = await dispatchAppPage(dynamicOptions);
|
|
|
|
expect(dynamicResponse.status).toBe(200);
|
|
expect(capturedDynamicPayloads).toHaveLength(1);
|
|
expect(capturedDynamicPayloads[0][layoutId]).toBe("root-layout");
|
|
expect(capturedDynamicPayloads[0][pageId]).toBe("profile-page");
|
|
|
|
const { options: htmlOptions } = createDispatchOptions({
|
|
buildPageElement: async () => element,
|
|
clientReuseManifest,
|
|
isProduction: true,
|
|
isRscRequest: false,
|
|
renderToReadableStream(payload) {
|
|
capturedHtmlPayloads.push(captureRecord(payload));
|
|
return createStream(["flight"]);
|
|
},
|
|
route,
|
|
});
|
|
|
|
const htmlResponse = await dispatchAppPage(htmlOptions);
|
|
|
|
expect(htmlResponse.status).toBe(200);
|
|
expect(capturedHtmlPayloads).toHaveLength(1);
|
|
expect(capturedHtmlPayloads[0][layoutId]).toBe("root-layout");
|
|
expect(capturedHtmlPayloads[0][pageId]).toBe("profile-page");
|
|
} finally {
|
|
if (originalBuildId === undefined) {
|
|
delete process.env.__VINEXT_BUILD_ID;
|
|
} else {
|
|
process.env.__VINEXT_BUILD_ID = originalBuildId;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("returns not found for dynamicParams=false paths outside generated params", async () => {
|
|
const { options } = createDispatchOptions({
|
|
async buildPageElement() {
|
|
throw new Error("unknown static params should not render the page");
|
|
},
|
|
async generateStaticParams() {
|
|
return [{ slug: "known" }];
|
|
},
|
|
route: createRoute({ isDynamic: true, params: ["slug"] }),
|
|
});
|
|
|
|
const response = await dispatchAppPage({
|
|
...options,
|
|
dynamicParamsConfig: false,
|
|
});
|
|
|
|
expect(response.status).toBe(404);
|
|
await expect(response.text()).resolves.toBe("This page could not be found");
|
|
});
|
|
|
|
// Ported from Next.js: test/e2e/app-dir/app-prefetch-static/app-prefetch-static.test.ts
|
|
// https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/app-prefetch-static/app-prefetch-static.test.ts
|
|
it("admits generated params using default case-insensitive route matching", async () => {
|
|
const buildPageElement = vi.fn(async () => React.createElement("main", null, "page"));
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
async generateStaticParams() {
|
|
return [{ region: "SE" }, { region: "DE" }];
|
|
},
|
|
params: { region: "se" },
|
|
route: createRoute({ isDynamic: true, params: ["region"] }),
|
|
});
|
|
|
|
const response = await dispatchAppPage({
|
|
...options,
|
|
dynamicParamsConfig: false,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(buildPageElement).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
{ region: "se" },
|
|
undefined,
|
|
expect.any(URLSearchParams),
|
|
expect.anything(),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("admits generated catch-all params using default case-insensitive route matching", async () => {
|
|
const buildPageElement = vi.fn(async () => React.createElement("main", null, "page"));
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
async generateStaticParams() {
|
|
return [{ slug: ["Docs", "Getting-Started"] }];
|
|
},
|
|
params: { slug: ["docs", "getting-started"] },
|
|
route: createRoute({ isDynamic: true, params: ["slug"] }),
|
|
});
|
|
|
|
const response = await dispatchAppPage({
|
|
...options,
|
|
dynamicParamsConfig: false,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(buildPageElement).toHaveBeenCalled();
|
|
});
|
|
|
|
it('renders dynamic = "error" routes without generateStaticParams', async () => {
|
|
const buildPageElement = vi.fn(async () => React.createElement("main", null, "page"));
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
dynamicConfig: "error",
|
|
route: createRoute({ isDynamic: true, params: ["slug"] }),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(buildPageElement).toHaveBeenCalled();
|
|
});
|
|
|
|
for (const dynamicParamsConfig of [undefined, true] as const) {
|
|
it(`renders unknown generated params under dynamic = "error" when dynamicParams is ${
|
|
dynamicParamsConfig === undefined ? "implicit" : "true"
|
|
}`, async () => {
|
|
const buildPageElement = vi.fn(async () => React.createElement("main", null, "page"));
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
dynamicConfig: "error",
|
|
dynamicParamsConfig,
|
|
async generateStaticParams() {
|
|
return [{ slug: "known" }];
|
|
},
|
|
route: createRoute({ isDynamic: true, params: ["slug"] }),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(buildPageElement).toHaveBeenCalled();
|
|
});
|
|
}
|
|
|
|
it('returns not found for unknown generated params under dynamic = "error" when dynamicParams is false', async () => {
|
|
const { options } = createDispatchOptions({
|
|
async buildPageElement() {
|
|
throw new Error("unknown static params should not render the page");
|
|
},
|
|
dynamicConfig: "error",
|
|
dynamicParamsConfig: false,
|
|
async generateStaticParams() {
|
|
return [{ slug: "known" }];
|
|
},
|
|
route: createRoute({ isDynamic: true, params: ["slug"] }),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.status).toBe(404);
|
|
await expect(response.text()).resolves.toBe("This page could not be found");
|
|
});
|
|
|
|
it("serves intercepted RSC source-route payloads with middleware response state", async () => {
|
|
const sourceRoute = createRoute({
|
|
params: [],
|
|
pattern: "/feed",
|
|
routeSegments: ["feed"],
|
|
slots: {
|
|
"modal@app/feed/@modal": {
|
|
page: { default: "modal-page" },
|
|
slotParamNames: ["id"],
|
|
slotPatternParts: ["photos", ":id"],
|
|
},
|
|
"sidebar@app/feed/@sidebar": {
|
|
page: { default: "sidebar-page" },
|
|
slotParamNames: ["catchAll"],
|
|
slotPatternParts: [":catchAll+"],
|
|
},
|
|
},
|
|
});
|
|
const currentRoute = createRoute({ params: ["id"], pattern: "/photos/[id]" });
|
|
const middlewareHeaders = new Headers({ "x-from-middleware": "yes" });
|
|
const setNavigationContext = vi.fn();
|
|
const { options } = createDispatchOptions({
|
|
cleanPathname: "/photos/123",
|
|
async buildPageElement(route, params, opts) {
|
|
return `${route.pattern}:${JSON.stringify(params)}:${opts?.interceptSlotKey ?? "direct"}`;
|
|
},
|
|
isRscRequest: true,
|
|
middlewareContext: {
|
|
headers: middlewareHeaders,
|
|
status: 202,
|
|
},
|
|
renderToReadableStream(element) {
|
|
if (typeof element !== "string") {
|
|
throw new Error("expected intercepted payload to be rendered from a string element");
|
|
}
|
|
return createStream([element]);
|
|
},
|
|
route: currentRoute,
|
|
searchParams: new URLSearchParams("from=feed"),
|
|
setNavigationContext,
|
|
});
|
|
|
|
const response = await dispatchAppPage({
|
|
...options,
|
|
findIntercept() {
|
|
return {
|
|
matchedParams: { id: "123" },
|
|
page: { default: "modal-page" },
|
|
slotKey: "modal@app/feed/@modal",
|
|
sourceRouteIndex: 1,
|
|
};
|
|
},
|
|
getSourceRoute(sourceRouteIndex) {
|
|
return sourceRouteIndex === 1 ? sourceRoute : undefined;
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(202);
|
|
expect(response.headers.get("content-type")).toBe("text/x-component");
|
|
expect(response.headers.get("x-from-middleware")).toBe("yes");
|
|
await expect(response.text()).resolves.toBe("/feed:{}:modal@app/feed/@modal");
|
|
expect(setNavigationContext).toHaveBeenLastCalledWith({
|
|
params: { id: "123", catchAll: ["photos", "123"] },
|
|
pathname: "/photos/123",
|
|
searchParams: new URLSearchParams("from=feed"),
|
|
});
|
|
});
|
|
|
|
it("fresh-renders mounted-slot intercepted RSC requests without persistent cache reuse", async () => {
|
|
const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] });
|
|
const currentRoute = createRoute({
|
|
params: ["id"],
|
|
pattern: "/photos/[id]",
|
|
routeSegments: ["photos", "[id]"],
|
|
});
|
|
const staleRscData = new TextEncoder().encode("stale-flight").buffer;
|
|
const buildPageElement = vi.fn(
|
|
async (
|
|
route: TestRoute,
|
|
params: Record<string, string | string[]>,
|
|
opts: Parameters<DispatchOptions["buildPageElement"]>[2],
|
|
searchParams: URLSearchParams,
|
|
) =>
|
|
JSON.stringify({
|
|
params,
|
|
route: route.pattern,
|
|
search: searchParams.toString(),
|
|
slot: opts?.interceptSlotKey ?? "direct",
|
|
}),
|
|
);
|
|
let scheduledRender: unknown = null;
|
|
const scheduleBackgroundRegeneration: DispatchOptions["scheduleBackgroundRegeneration"] = (
|
|
_key,
|
|
renderFn,
|
|
) => {
|
|
scheduledRender = renderFn;
|
|
};
|
|
const resolveRouteFetchCacheMode = vi.fn((route: TestRoute) =>
|
|
route === sourceRoute ? "force-cache" : null,
|
|
);
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/photos/123",
|
|
findIntercept: () => ({
|
|
matchedParams: { id: "123" },
|
|
page: { default: "modal-page" },
|
|
slotId: "slot:modal:/feed",
|
|
slotKey: "modal@app/feed/@modal",
|
|
sourceRouteIndex: 1,
|
|
}),
|
|
getSourceRoute(sourceRouteIndex) {
|
|
return sourceRouteIndex === 1 ? sourceRoute : undefined;
|
|
},
|
|
interceptionContext: "/feed",
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
isrGet: vi.fn(async () =>
|
|
buildISRCacheEntry(
|
|
buildCachedAppPageValue(
|
|
"",
|
|
staleRscData,
|
|
undefined,
|
|
buildQueryInvariantRenderObservation(),
|
|
),
|
|
true,
|
|
),
|
|
),
|
|
isrRscKey(pathname, mountedSlotsHeader, _renderMode, interceptionContext) {
|
|
return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}:${interceptionContext ?? "none"}`;
|
|
},
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) {
|
|
if (captureOptions?.capturedRscDataRef) {
|
|
captureOptions.capturedRscDataRef.value = Promise.resolve(
|
|
new TextEncoder().encode("fresh-intercepted-flight").buffer,
|
|
);
|
|
}
|
|
void captureOptions?.sideStream?.cancel().catch(() => {});
|
|
return createStream(["<html>fresh</html>"]);
|
|
},
|
|
}),
|
|
mountedSlotsHeader: "slot:modal:/feed",
|
|
revalidateSeconds: 60,
|
|
resolveRouteFetchCacheMode,
|
|
route: currentRoute,
|
|
scheduleBackgroundRegeneration,
|
|
searchParams: new URLSearchParams("tab=popular"),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
await expect(response.text()).resolves.toBe("flight");
|
|
expect(scheduledRender).toBeNull();
|
|
|
|
const [routeArg, paramsArg, optsArg, searchParamsArg] = buildPageElement.mock.calls[0];
|
|
expect(resolveRouteFetchCacheMode).toHaveBeenCalledWith(sourceRoute);
|
|
expect(routeArg).toBe(sourceRoute);
|
|
expect(paramsArg).toEqual({});
|
|
expect(searchParamsArg.toString()).toBe("tab=popular");
|
|
expect(optsArg).toMatchObject({
|
|
interceptionContext: "/feed",
|
|
interceptParams: { id: "123" },
|
|
interceptSlotId: "slot:modal:/feed",
|
|
interceptSlotKey: "modal@app/feed/@modal",
|
|
interceptSourceMatchedUrl: "/feed",
|
|
});
|
|
expect(options.isrGet).not.toHaveBeenCalled();
|
|
expect(options.isrSet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolves the intercept source route's dynamic config for force-dynamic fetch defaults", async () => {
|
|
// When the current route is not force-dynamic but the intercepted source route is,
|
|
// the dispatch must resolve the source route's dynamic config so that fetch
|
|
// defaults come from the source route, not the current route.
|
|
const sourceRoute = createRoute({
|
|
params: [],
|
|
pattern: "/feed",
|
|
routeSegments: ["feed"],
|
|
layouts: [{ default: () => null, dynamic: "force-dynamic" }],
|
|
});
|
|
const currentRoute = createRoute({
|
|
params: ["id"],
|
|
pattern: "/photos/[id]",
|
|
routeSegments: ["photos", "[id]"],
|
|
});
|
|
|
|
const resolveRouteDynamicConfig = vi.fn((route: TestRoute) =>
|
|
route === sourceRoute ? "force-dynamic" : undefined,
|
|
);
|
|
|
|
const { options } = createDispatchOptions({
|
|
async buildPageElement(route, params, opts) {
|
|
return `${route.pattern}:${JSON.stringify(params)}:${opts?.interceptSlotKey ?? "direct"}`;
|
|
},
|
|
isRscRequest: true,
|
|
route: currentRoute,
|
|
resolveRouteDynamicConfig,
|
|
});
|
|
|
|
const response = await dispatchAppPage({
|
|
...options,
|
|
findIntercept() {
|
|
return {
|
|
matchedParams: { id: "123" },
|
|
page: { default: "modal-page" },
|
|
slotKey: "modal@app/feed/@modal",
|
|
sourceRouteIndex: 1,
|
|
};
|
|
},
|
|
getSourceRoute(sourceRouteIndex) {
|
|
return sourceRouteIndex === 1 ? sourceRoute : undefined;
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(sourceRoute);
|
|
});
|
|
|
|
it("does not leak the current route's force-dynamic config into the intercept source route", async () => {
|
|
// When the current route is force-dynamic but the intercepted source route is not,
|
|
// the dispatch must resolve the source route's dynamic config so that fetch
|
|
// defaults do NOT leak from the current route into the source route.
|
|
const sourceRoute = createRoute({
|
|
params: [],
|
|
pattern: "/feed",
|
|
routeSegments: ["feed"],
|
|
});
|
|
const currentRoute = createRoute({
|
|
params: ["id"],
|
|
pattern: "/photos/[id]",
|
|
routeSegments: ["photos", "[id]"],
|
|
layouts: [{ default: () => null, dynamic: "force-dynamic" }],
|
|
});
|
|
|
|
const resolveRouteDynamicConfig = vi.fn((route: TestRoute) =>
|
|
route === currentRoute ? "force-dynamic" : undefined,
|
|
);
|
|
|
|
const { options } = createDispatchOptions({
|
|
async buildPageElement(route, params, opts) {
|
|
return `${route.pattern}:${JSON.stringify(params)}:${opts?.interceptSlotKey ?? "direct"}`;
|
|
},
|
|
dynamicConfig: "force-dynamic",
|
|
isRscRequest: true,
|
|
route: currentRoute,
|
|
resolveRouteDynamicConfig,
|
|
});
|
|
|
|
const response = await dispatchAppPage({
|
|
...options,
|
|
findIntercept() {
|
|
return {
|
|
matchedParams: { id: "123" },
|
|
page: { default: "modal-page" },
|
|
slotKey: "modal@app/feed/@modal",
|
|
sourceRouteIndex: 1,
|
|
};
|
|
},
|
|
getSourceRoute(sourceRouteIndex) {
|
|
return sourceRouteIndex === 1 ? sourceRoute : undefined;
|
|
},
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(sourceRoute);
|
|
});
|
|
|
|
it("passes empty searchParams to a force-static intercept source route", async () => {
|
|
const sourceRoute = createRoute({
|
|
params: [],
|
|
pattern: "/feed",
|
|
routeSegments: ["feed"],
|
|
});
|
|
const currentRoute = createRoute({
|
|
params: ["id"],
|
|
pattern: "/photos/[id]",
|
|
routeSegments: ["photos", "[id]"],
|
|
});
|
|
const buildPageElement = vi.fn<DispatchOptions["buildPageElement"]>(
|
|
async (_route, _params, _opts, searchParams) => searchParams.get("tab") ?? "empty",
|
|
);
|
|
const setNavigationContext = vi.fn<DispatchOptions["setNavigationContext"]>();
|
|
const resolveRouteDynamicConfig = vi.fn((route: TestRoute) =>
|
|
route === sourceRoute && route.layouts.length > 0 ? "force-static" : undefined,
|
|
);
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/photos/123",
|
|
ensureRouteLoaded(route) {
|
|
if (route === sourceRoute) {
|
|
sourceRoute.layouts = [{ default: () => null, dynamic: "force-static" }];
|
|
}
|
|
},
|
|
interceptionContext: "/feed",
|
|
isRscRequest: true,
|
|
renderToReadableStream(element) {
|
|
return createStream([typeof element === "string" ? element : "unexpected-element"]);
|
|
},
|
|
resolveRouteDynamicConfig,
|
|
route: currentRoute,
|
|
searchParams: new URLSearchParams("tab=popular"),
|
|
setNavigationContext,
|
|
});
|
|
|
|
const response = await dispatchAppPage({
|
|
...options,
|
|
findIntercept() {
|
|
return {
|
|
matchedParams: { id: "123" },
|
|
page: { default: "modal-page" },
|
|
slotKey: "modal@app/feed/@modal",
|
|
sourceRouteIndex: 1,
|
|
};
|
|
},
|
|
getSourceRoute(sourceRouteIndex) {
|
|
return sourceRouteIndex === 1 ? sourceRoute : undefined;
|
|
},
|
|
});
|
|
|
|
await expect(response.text()).resolves.toBe("empty");
|
|
expect(buildPageElement.mock.calls[0]?.[3].toString()).toBe("");
|
|
expect(setNavigationContext).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ searchParams: expect.any(URLSearchParams) }),
|
|
);
|
|
const navigationContext = setNavigationContext.mock.calls.at(-1)?.[0];
|
|
expect(navigationContext?.searchParams.toString()).toBe("");
|
|
expect(resolveRouteDynamicConfig).toHaveBeenCalledTimes(1);
|
|
expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(sourceRoute);
|
|
});
|
|
|
|
it("observes searchParams access for a dynamic-error intercept source route", async () => {
|
|
const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] });
|
|
const currentRoute = createRoute({
|
|
params: ["id"],
|
|
pattern: "/photos/[id]",
|
|
routeSegments: ["photos", "[id]"],
|
|
});
|
|
const buildOptions: Array<{
|
|
observeMetadataSearchParamsAccess?: boolean;
|
|
observePageSearchParamsAccess?: boolean;
|
|
}> = [];
|
|
const buildPageElement = vi.fn<DispatchOptions["buildPageElement"]>(
|
|
async (_route, _params, _opts, searchParams, _layoutParamAccess, options) => {
|
|
buildOptions.push(options ?? {});
|
|
return searchParams.get("tab") ?? "empty";
|
|
},
|
|
);
|
|
const resolveRouteDynamicConfig = vi.fn((route: TestRoute) =>
|
|
route === sourceRoute && route.layouts.length > 0 ? "error" : undefined,
|
|
);
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/photos/123",
|
|
ensureRouteLoaded(route) {
|
|
if (route === sourceRoute) {
|
|
sourceRoute.layouts = [{ default: () => null, dynamic: "error" }];
|
|
}
|
|
},
|
|
interceptionContext: "/feed",
|
|
isRscRequest: true,
|
|
renderToReadableStream(element) {
|
|
return createStream([typeof element === "string" ? element : "unexpected-element"]);
|
|
},
|
|
resolveRouteDynamicConfig,
|
|
route: currentRoute,
|
|
searchParams: new URLSearchParams("tab=popular"),
|
|
});
|
|
|
|
const response = await dispatchAppPage({
|
|
...options,
|
|
findIntercept() {
|
|
return {
|
|
matchedParams: { id: "123" },
|
|
page: { default: "modal-page" },
|
|
slotKey: "modal@app/feed/@modal",
|
|
sourceRouteIndex: 1,
|
|
};
|
|
},
|
|
getSourceRoute(sourceRouteIndex) {
|
|
return sourceRouteIndex === 1 ? sourceRoute : undefined;
|
|
},
|
|
});
|
|
|
|
await expect(response.text()).resolves.toBe("popular");
|
|
expect(buildOptions).toEqual([
|
|
{ observeMetadataSearchParamsAccess: true, observePageSearchParamsAccess: true },
|
|
]);
|
|
});
|
|
|
|
it("preserves request headers for an ordinary intercept source route", async () => {
|
|
const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] });
|
|
const currentRoute = createRoute({
|
|
params: ["id"],
|
|
pattern: "/photos/[id]",
|
|
routeSegments: ["photos", "[id]"],
|
|
});
|
|
const requestHeadersContext = {
|
|
headers: new Headers({ "x-request-value": "preserved" }),
|
|
cookies: new Map(),
|
|
};
|
|
setHeadersContext(requestHeadersContext);
|
|
try {
|
|
const { options } = createDispatchOptions({
|
|
async buildPageElement() {
|
|
return getHeadersContext()?.headers.get("x-request-value") ?? "missing";
|
|
},
|
|
cleanPathname: "/photos/123",
|
|
interceptionContext: "/feed",
|
|
isRscRequest: true,
|
|
renderToReadableStream(element) {
|
|
return createStream([typeof element === "string" ? element : "unexpected-element"]);
|
|
},
|
|
route: currentRoute,
|
|
searchParams: new URLSearchParams("tab=popular"),
|
|
});
|
|
|
|
const response = await dispatchAppPage({
|
|
...options,
|
|
findIntercept() {
|
|
return {
|
|
matchedParams: { id: "123" },
|
|
page: { default: "modal-page" },
|
|
slotKey: "modal@app/feed/@modal",
|
|
sourceRouteIndex: 1,
|
|
};
|
|
},
|
|
getSourceRoute(sourceRouteIndex) {
|
|
return sourceRouteIndex === 1 ? sourceRoute : undefined;
|
|
},
|
|
});
|
|
|
|
await expect(response.text()).resolves.toBe("preserved");
|
|
} finally {
|
|
setHeadersContext(null);
|
|
}
|
|
});
|
|
|
|
it("regenerates stale HTML cache entries with waitForAllReady so suspense fallbacks never leak into the cache", async () => {
|
|
// Stale-while-revalidate regeneration must await React's `allReady` before
|
|
// transforming/buffering the HTML stream — same guarantee as the prerender
|
|
// path. Without `waitForAllReady: true`, Suspense fallback content could be
|
|
// written to the regenerated cache entry instead of the resolved content.
|
|
const route = createRoute({ pattern: "/posts/[slug]", routeSegments: ["posts", "[slug]"] });
|
|
let scheduledRender: unknown = null;
|
|
const scheduleBackgroundRegeneration: DispatchOptions["scheduleBackgroundRegeneration"] = (
|
|
_key,
|
|
renderFn,
|
|
) => {
|
|
scheduledRender = renderFn;
|
|
};
|
|
let capturedWaitForAllReady: boolean | undefined;
|
|
let capturedFallbackToErrorDocument: boolean | undefined;
|
|
const isrSet = vi.fn(async () => {});
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement: async () => React.createElement("main", null, "fresh"),
|
|
cleanPathname: "/posts/hello",
|
|
isProduction: true,
|
|
isrGet: vi.fn(async () =>
|
|
buildISRCacheEntry(buildCachedAppPageValue("<html>stale</html>"), true),
|
|
),
|
|
isrSet,
|
|
hasCustomGlobalError: false,
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) {
|
|
capturedWaitForAllReady = captureOptions?.waitForAllReady;
|
|
capturedFallbackToErrorDocument = captureOptions?.fallbackToErrorDocumentOnShellError;
|
|
if (captureOptions?.capturedRscDataRef) {
|
|
captureOptions.capturedRscDataRef.value = Promise.resolve(
|
|
new TextEncoder().encode("fresh-flight").buffer,
|
|
);
|
|
}
|
|
void captureOptions?.sideStream?.cancel().catch(() => {});
|
|
return createStream(["<html>fresh</html>"]);
|
|
},
|
|
}),
|
|
renderToReadableStream() {
|
|
return createStream(["flight"]);
|
|
},
|
|
revalidateSeconds: 60,
|
|
route,
|
|
scheduleBackgroundRegeneration,
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.headers.get("x-vinext-cache")).toBe("STALE");
|
|
await expect(response.text()).resolves.toBe("<html>stale</html>");
|
|
expect(typeof scheduledRender).toBe("function");
|
|
if (typeof scheduledRender !== "function") {
|
|
throw new Error("expected stale HTML response to schedule regeneration");
|
|
}
|
|
|
|
await scheduledRender();
|
|
|
|
expect(capturedWaitForAllReady).toBe(true);
|
|
expect(capturedFallbackToErrorDocument).toBeUndefined();
|
|
expect(isrSet).toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["page", "metadata"] as const)(
|
|
"records searchParams access when stale regeneration reads them in %s",
|
|
async (reader) => {
|
|
async function Page(props: Record<string, unknown>): Promise<React.ReactNode> {
|
|
if (reader !== "page") return React.createElement("main", null, "static body");
|
|
const query = isPromiseLike(props.searchParams) ? await props.searchParams : {};
|
|
return React.createElement(
|
|
"main",
|
|
null,
|
|
isQueryRecord(query) ? (query.q ?? "empty") : "invalid",
|
|
);
|
|
}
|
|
const pageModule = {
|
|
default: Page,
|
|
...(reader === "metadata"
|
|
? {
|
|
async generateMetadata(props: {
|
|
searchParams: Promise<Record<string, string | string[]>>;
|
|
}) {
|
|
const query = await props.searchParams;
|
|
return { title: typeof query.q === "string" ? query.q : "empty" };
|
|
},
|
|
}
|
|
: {}),
|
|
};
|
|
const route = createRoute({ pattern: "/regen-proof", routeSegments: ["regen-proof"] });
|
|
let scheduledRender: unknown = null;
|
|
const written: CachedAppPageValue[] = [];
|
|
const buildPageElement = vi.fn<DispatchOptions["buildPageElement"]>(
|
|
(_route, params, _opts, searchParams, layoutParamAccess, buildOptions) =>
|
|
buildPageElements({
|
|
layoutParamAccess,
|
|
metadataRoutes: [],
|
|
params,
|
|
pageRequest: {
|
|
isRscRequest: false,
|
|
mountedSlotsHeader: null,
|
|
opts: undefined,
|
|
request: new Request("https://example.test/regen-proof"),
|
|
searchParams,
|
|
observeMetadataSearchParamsAccess: buildOptions?.observeMetadataSearchParamsAccess,
|
|
observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess,
|
|
},
|
|
route: {
|
|
layouts: [],
|
|
page: pageModule,
|
|
pattern: "/regen-proof",
|
|
routeSegments: ["regen-proof"],
|
|
},
|
|
routePath: "/regen-proof",
|
|
}).then(toDispatchElementRecord),
|
|
);
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/regen-proof",
|
|
isProduction: true,
|
|
isrGet: vi.fn(async () =>
|
|
buildISRCacheEntry(
|
|
buildCachedAppPageValue(
|
|
"<html>stale</html>",
|
|
undefined,
|
|
undefined,
|
|
buildQueryInvariantRenderObservation(),
|
|
),
|
|
true,
|
|
),
|
|
),
|
|
isrSet: vi.fn(async (_key, value) => {
|
|
written.push(value);
|
|
}),
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr(rscStream, _navigationContext, _fontData, captureOptions) {
|
|
if (captureOptions?.capturedRscDataRef) {
|
|
captureOptions.capturedRscDataRef.value = Promise.resolve(
|
|
new TextEncoder().encode("fresh-flight").buffer,
|
|
);
|
|
}
|
|
void captureOptions?.sideStream?.cancel().catch(() => {});
|
|
return createStream([`<html>${await new Response(rscStream).text()}</html>`]);
|
|
},
|
|
}),
|
|
renderToReadableStream: renderPagePayloadToStream,
|
|
revalidateSeconds: 60,
|
|
route,
|
|
scheduleBackgroundRegeneration(_key, renderFn) {
|
|
scheduledRender = renderFn;
|
|
},
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
await response.text();
|
|
expect(typeof scheduledRender).toBe("function");
|
|
if (typeof scheduledRender !== "function") {
|
|
throw new Error("expected stale response to schedule regeneration");
|
|
}
|
|
|
|
await scheduledRender();
|
|
|
|
expect(
|
|
written.map(
|
|
(value) =>
|
|
value.renderObservation?.requestApis.find((api) => api.kind === "searchParams")?.status,
|
|
),
|
|
).toEqual(["observed", "observed"]);
|
|
},
|
|
);
|
|
|
|
it("preserves stale HTML when SSR shell rendering fails during regeneration", async () => {
|
|
const route = createRoute({ pattern: "/posts/[slug]", routeSegments: ["posts", "[slug]"] });
|
|
let scheduledRender: unknown = null;
|
|
const scheduleBackgroundRegeneration: DispatchOptions["scheduleBackgroundRegeneration"] = (
|
|
_key,
|
|
renderFn,
|
|
) => {
|
|
scheduledRender = renderFn;
|
|
};
|
|
const isrSet = vi.fn(async () => {});
|
|
const shellError = new Error("SSR shell failed");
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement: async () => React.createElement("main", null, "fresh"),
|
|
cleanPathname: "/posts/hello",
|
|
isProduction: true,
|
|
isrGet: vi.fn(async () =>
|
|
buildISRCacheEntry(buildCachedAppPageValue("<html>stale</html>"), true),
|
|
),
|
|
isrSet,
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr() {
|
|
throw shellError;
|
|
},
|
|
}),
|
|
renderToReadableStream() {
|
|
return createStream(["flight"]);
|
|
},
|
|
revalidateSeconds: 60,
|
|
route,
|
|
scheduleBackgroundRegeneration,
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.headers.get("x-vinext-cache")).toBe("STALE");
|
|
await expect(response.text()).resolves.toBe("<html>stale</html>");
|
|
expect(typeof scheduledRender).toBe("function");
|
|
if (typeof scheduledRender !== "function") {
|
|
throw new Error("expected stale HTML response to schedule regeneration");
|
|
}
|
|
|
|
await expect(scheduledRender()).rejects.toBe(shellError);
|
|
expect(isrSet).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolves the revalidation target route's dynamic config for force-dynamic fetch defaults", async () => {
|
|
// When regenerating a stale cache entry for a target route that is force-dynamic,
|
|
// the dispatch must resolve the target route's dynamic config so that fetch
|
|
// defaults come from the target route, not the current route.
|
|
const targetRoute = createRoute({
|
|
pattern: "/feed",
|
|
routeSegments: ["feed"],
|
|
layouts: [{ default: () => null, dynamic: "force-dynamic" }],
|
|
});
|
|
const currentRoute = createRoute({
|
|
params: ["id"],
|
|
pattern: "/photos/[id]",
|
|
routeSegments: ["photos", "[id]"],
|
|
});
|
|
|
|
let scheduledRender: unknown = null;
|
|
const scheduleBackgroundRegeneration: DispatchOptions["scheduleBackgroundRegeneration"] = (
|
|
_key,
|
|
renderFn,
|
|
) => {
|
|
scheduledRender = renderFn;
|
|
};
|
|
|
|
const resolveRouteDynamicConfig = vi.fn((route: TestRoute) =>
|
|
route === targetRoute ? "force-dynamic" : undefined,
|
|
);
|
|
|
|
const buildPageElement = vi.fn(
|
|
async (
|
|
route: TestRoute,
|
|
params: Record<string, string | string[]>,
|
|
opts: Parameters<DispatchOptions["buildPageElement"]>[2],
|
|
searchParams: URLSearchParams,
|
|
) =>
|
|
JSON.stringify({
|
|
params,
|
|
route: route.pattern,
|
|
search: searchParams.toString(),
|
|
slot: opts?.interceptSlotKey ?? "direct",
|
|
}),
|
|
);
|
|
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/photos/123",
|
|
findIntercept() {
|
|
return {
|
|
matchedParams: { id: "123" },
|
|
page: { default: "modal-page" },
|
|
slotKey: "modal@app/feed/@modal",
|
|
sourceRouteIndex: 1,
|
|
};
|
|
},
|
|
getSourceRoute(sourceRouteIndex) {
|
|
return sourceRouteIndex === 1 ? targetRoute : undefined;
|
|
},
|
|
interceptionContext: "/feed",
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
isrGet: vi.fn(async () =>
|
|
buildISRCacheEntry(
|
|
buildCachedAppPageValue(
|
|
"",
|
|
new TextEncoder().encode("stale-flight").buffer,
|
|
undefined,
|
|
buildQueryInvariantRenderObservation(),
|
|
),
|
|
true,
|
|
),
|
|
),
|
|
isrRscKey(pathname, mountedSlotsHeader, _renderMode, interceptionContext) {
|
|
return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}:${interceptionContext ?? "none"}`;
|
|
},
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) {
|
|
if (captureOptions?.capturedRscDataRef) {
|
|
captureOptions.capturedRscDataRef.value = Promise.resolve(
|
|
new TextEncoder().encode("fresh-flight").buffer,
|
|
);
|
|
}
|
|
void captureOptions?.sideStream?.cancel().catch(() => {});
|
|
return createStream(["<html>fresh</html>"]);
|
|
},
|
|
}),
|
|
mountedSlotsHeader: "slot:modal:/feed",
|
|
revalidateSeconds: 60,
|
|
resolveRouteDynamicConfig,
|
|
route: currentRoute,
|
|
scheduleBackgroundRegeneration,
|
|
searchParams: new URLSearchParams("tab=popular"),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
await expect(response.text()).resolves.toBe("flight");
|
|
expect(scheduledRender).toBeNull();
|
|
expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(targetRoute);
|
|
const [routeArg] = buildPageElement.mock.calls[0];
|
|
expect(routeArg).toBe(targetRoute);
|
|
});
|
|
|
|
it("does not leak the current route's force-dynamic config into the revalidation target route", async () => {
|
|
// When regenerating a stale cache entry for a target route that is NOT force-dynamic,
|
|
// the dispatch must resolve the target route's dynamic config so that fetch
|
|
// defaults do NOT leak from the current route into the target route.
|
|
const targetRoute = createRoute({
|
|
pattern: "/feed",
|
|
routeSegments: ["feed"],
|
|
});
|
|
const currentRoute = createRoute({
|
|
params: ["id"],
|
|
pattern: "/photos/[id]",
|
|
routeSegments: ["photos", "[id]"],
|
|
layouts: [{ default: () => null, dynamic: "force-dynamic" }],
|
|
});
|
|
|
|
const resolveRouteDynamicConfig = vi.fn((route: TestRoute) =>
|
|
route === currentRoute ? "force-dynamic" : undefined,
|
|
);
|
|
|
|
const buildPageElement = vi.fn(
|
|
async (
|
|
route: TestRoute,
|
|
params: Record<string, string | string[]>,
|
|
opts: Parameters<DispatchOptions["buildPageElement"]>[2],
|
|
searchParams: URLSearchParams,
|
|
) =>
|
|
JSON.stringify({
|
|
params,
|
|
route: route.pattern,
|
|
search: searchParams.toString(),
|
|
slot: opts?.interceptSlotKey ?? "direct",
|
|
}),
|
|
);
|
|
|
|
const { options } = createDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/photos/123",
|
|
dynamicConfig: "force-dynamic",
|
|
findIntercept() {
|
|
return {
|
|
matchedParams: { id: "123" },
|
|
page: { default: "modal-page" },
|
|
slotKey: "modal@app/feed/@modal",
|
|
sourceRouteIndex: 1,
|
|
};
|
|
},
|
|
getSourceRoute(sourceRouteIndex) {
|
|
return sourceRouteIndex === 1 ? targetRoute : undefined;
|
|
},
|
|
interceptionContext: "/feed",
|
|
isProduction: true,
|
|
isRscRequest: true,
|
|
isrGet: vi.fn(async () =>
|
|
buildISRCacheEntry(
|
|
buildCachedAppPageValue(
|
|
"",
|
|
new TextEncoder().encode("stale-flight").buffer,
|
|
undefined,
|
|
buildQueryInvariantRenderObservation(),
|
|
),
|
|
true,
|
|
),
|
|
),
|
|
isrRscKey(pathname, mountedSlotsHeader, _renderMode, interceptionContext) {
|
|
return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}:${interceptionContext ?? "none"}`;
|
|
},
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) {
|
|
if (captureOptions?.capturedRscDataRef) {
|
|
captureOptions.capturedRscDataRef.value = Promise.resolve(
|
|
new TextEncoder().encode("fresh-flight").buffer,
|
|
);
|
|
}
|
|
void captureOptions?.sideStream?.cancel().catch(() => {});
|
|
return createStream(["<html>fresh</html>"]);
|
|
},
|
|
}),
|
|
mountedSlotsHeader: "slot:modal:/feed",
|
|
revalidateSeconds: 60,
|
|
resolveRouteDynamicConfig,
|
|
route: currentRoute,
|
|
searchParams: new URLSearchParams("tab=popular"),
|
|
});
|
|
|
|
// A force-dynamic current route skips the cache read entirely, so there is no
|
|
// revalidation path. The intercept path is still exercised, and it must resolve
|
|
// the target route's dynamic config instead of inheriting the current route's.
|
|
const response = await dispatchAppPage(options);
|
|
expect(response.status).toBe(200);
|
|
expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(targetRoute);
|
|
});
|
|
|
|
it("serves exact cache HIT instead of fallback shell", async () => {
|
|
const buildPageElement = createParamTextPageElement();
|
|
const isrGet = vi.fn(async (key: string) => {
|
|
if (key === "html:/en/blog/known-post") {
|
|
return buildISRCacheEntry(
|
|
buildCachedAppPageValue("<html><head></head><body>exact HIT</body></html>"),
|
|
false,
|
|
);
|
|
}
|
|
return null;
|
|
});
|
|
const { options } = createPprBlogDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/en/blog/known-post",
|
|
isrGet,
|
|
params: { locale: "en", slug: "known-post" },
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.headers.get("x-vinext-cache")).toBe("HIT");
|
|
await expect(response.text()).resolves.toBe("<html><head></head><body>exact HIT</body></html>");
|
|
expect(buildPageElement).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("static params validation rejects unknown params before shell probing", async () => {
|
|
const generateStaticParams = vi.fn(async () => [{ locale: "en", slug: "hello-world" }]);
|
|
const buildPageElement = createParamTextPageElement();
|
|
const isrGet = vi.fn(async () => null);
|
|
const { options } = createPprBlogDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/en/blog/unknown-post",
|
|
generateStaticParams,
|
|
isrGet,
|
|
params: { locale: "en", slug: "unknown-post" },
|
|
});
|
|
|
|
const response = await dispatchAppPage({
|
|
...options,
|
|
dynamicParamsConfig: false,
|
|
});
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(isrGet).not.toHaveBeenCalledWith("html:/en/blog/[slug]");
|
|
expect(buildPageElement).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("serves fallback shell HTML for an unknown child param after the exact cache misses", async () => {
|
|
const buildPageElement = createParamTextPageElement();
|
|
const isrGet = createPprBlogFallbackShellGetter(false);
|
|
const { options } = createPprBlogDispatchOptions({
|
|
buildPageElement,
|
|
isrGet,
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet.mock.calls.map(([key]) => key)).toEqual([
|
|
"html:/en/blog/new-post",
|
|
"html:/en/blog/[slug]",
|
|
]);
|
|
expect(response.headers.get("x-vinext-cache")).toBe("HIT");
|
|
await expect(response.text()).resolves.toContain("Locale: en");
|
|
expect(buildPageElement).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not serve fallback shell HTML for an unknown child param when the request has search params", async () => {
|
|
const buildPageElement = createParamTextPageElement("fresh");
|
|
const isrGet = createPprBlogFallbackShellGetter(false);
|
|
const { options } = createPprBlogDispatchOptions({
|
|
buildPageElement,
|
|
isrGet,
|
|
loadSsrHandler: createFreshBodySsrHandler("fresh render"),
|
|
request: new Request("https://example.test/en/blog/new-post?preview=1"),
|
|
searchParams: new URLSearchParams("preview=1"),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet.mock.calls.map(([key]) => key)).toEqual(["html:/en/blog/new-post"]);
|
|
expect(isrGet).not.toHaveBeenCalledWith("html:/en/blog/[slug]");
|
|
expect(response.headers.get("x-vinext-cache")).toBeNull();
|
|
await expect(response.text()).resolves.toContain("fresh render");
|
|
expect(buildPageElement).toHaveBeenCalled();
|
|
});
|
|
|
|
it("serves stale static PPR fallback-shell HTML without regenerating the shell key", async () => {
|
|
const buildPageElement = createParamTextPageElement();
|
|
const isrGet = createPprBlogFallbackShellGetter(true);
|
|
const { options } = createPprBlogDispatchOptions({
|
|
buildPageElement,
|
|
isrGet,
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet.mock.calls.map(([key]) => key)).toEqual([
|
|
"html:/en/blog/new-post",
|
|
"html:/en/blog/[slug]",
|
|
]);
|
|
expect(response.headers.get("x-vinext-cache")).toBe("STALE");
|
|
await expect(response.text()).resolves.toContain("Locale: en");
|
|
expect(buildPageElement).not.toHaveBeenCalled();
|
|
expect(options.scheduleBackgroundRegeneration).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("falls through to a fresh render when the cached fallback shell requires resume", async () => {
|
|
const buildPageElement = createParamTextPageElement("fresh");
|
|
const isrGet = vi.fn(async (key: string) => {
|
|
if (key === "html:/en/blog/[slug]") {
|
|
return buildISRCacheEntry(
|
|
buildCachedAppPageValue(
|
|
markAppPprDynamicFallbackShellHtml(
|
|
"<html><head></head><body>fallback only</body></html>",
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return null;
|
|
});
|
|
const { options } = createPprBlogDispatchOptions({
|
|
buildPageElement,
|
|
isrGet,
|
|
loadSsrHandler: createFreshBodySsrHandler("fresh new-post content"),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(response.headers.get("x-vinext-cache")).toBe("MISS");
|
|
await expect(response.text()).resolves.toContain("fresh new-post content");
|
|
expect(buildPageElement).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not serve the fallback shell for a known pregenerated route whose exact cache is absent", async () => {
|
|
const buildPageElement = createParamTextPageElement("fresh");
|
|
const isrGet = createPprBlogFallbackShellGetter(false);
|
|
const { options } = createPprBlogDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/en/blog/known-post",
|
|
isrGet,
|
|
loadSsrHandler: createFreshBodySsrHandler(
|
|
`fresh:${JSON.stringify({ locale: "en", slug: "known-post" })}`,
|
|
),
|
|
params: { locale: "en", slug: "known-post" },
|
|
renderedConcreteUrlPaths: new Set(["/en/blog/known-post"]),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet.mock.calls.map(([key]) => key)).toEqual(["html:/en/blog/known-post"]);
|
|
expect(isrGet).not.toHaveBeenCalledWith("html:/en/blog/[slug]");
|
|
expect(buildPageElement).toHaveBeenCalled();
|
|
expect(response.headers.get("x-vinext-cache")).toBe("MISS");
|
|
});
|
|
|
|
it("does not serve the fallback shell for an encoded known pregenerated route whose exact cache is absent", async () => {
|
|
const buildPageElement = createParamTextPageElement("fresh");
|
|
const isrGet = createPprBlogFallbackShellGetter(false);
|
|
const { options } = createPprBlogDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/en/blog/hello world",
|
|
isrGet,
|
|
loadSsrHandler: createFreshBodySsrHandler(
|
|
`fresh:${JSON.stringify({ locale: "en", slug: "hello world" })}`,
|
|
),
|
|
params: { locale: "en", slug: "hello world" },
|
|
renderedConcreteUrlPaths: new Set(["/en/blog/hello world"]),
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet.mock.calls.map(([key]) => key)).toEqual(["html:/en/blog/hello world"]);
|
|
expect(isrGet).not.toHaveBeenCalledWith("html:/en/blog/[slug]");
|
|
expect(buildPageElement).toHaveBeenCalled();
|
|
expect(response.headers.get("x-vinext-cache")).toBe("MISS");
|
|
});
|
|
|
|
it("does not serve the fallback shell when concrete paths come from the Worker global registry", async () => {
|
|
const { getRenderedConcreteUrlPathsForRoute, initPregeneratedPathsFromGlobals } =
|
|
await import("../packages/vinext/src/server/pregenerated-concrete-paths.js");
|
|
|
|
globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = [
|
|
["/:locale/blog/:slug", ["/en/blog/worker-known"]],
|
|
];
|
|
initPregeneratedPathsFromGlobals();
|
|
delete globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS;
|
|
const concretePaths = getRenderedConcreteUrlPathsForRoute("/:locale/blog/:slug");
|
|
|
|
const buildPageElement = createParamTextPageElement("fresh");
|
|
const isrGet = createPprBlogFallbackShellGetter(false);
|
|
const { options } = createPprBlogDispatchOptions({
|
|
buildPageElement,
|
|
cleanPathname: "/en/blog/worker-known",
|
|
isrGet,
|
|
loadSsrHandler: createFreshBodySsrHandler(
|
|
`fresh:${JSON.stringify({ locale: "en", slug: "worker-known" })}`,
|
|
),
|
|
params: { locale: "en", slug: "worker-known" },
|
|
renderedConcreteUrlPaths: concretePaths,
|
|
});
|
|
|
|
const response = await dispatchAppPage(options);
|
|
|
|
expect(isrGet.mock.calls.map(([key]) => key)).toEqual(["html:/en/blog/worker-known"]);
|
|
expect(isrGet).not.toHaveBeenCalledWith("html:/en/blog/[slug]");
|
|
expect(buildPageElement).toHaveBeenCalled();
|
|
expect(response.headers.get("x-vinext-cache")).toBe("MISS");
|
|
});
|
|
});
|