mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
47b38a91f3
* fix(plugin): resolve vinext/shims/* package subpaths to local shim files Runtime helper modules embedded into generated entries import vinext's own shims by package subpath (e.g. `vinext/shims/headers`), while source checkouts alias userland `next/*` imports to the local shim files. The two specifiers resolved to different module instances, so request-scoped singleton state (navigation context, headers) split between the source shim copy and the package export copy. The violated invariant is that every shim module must be a per-request singleton regardless of import specifier. Resolve `vinext/shims/*` through the same plugin path as the `next/*` aliases so both forms land on the local shim files. Exercised by the SSR shell-error recovery browser spec, whose fixture imports vinext from the source checkout and depends on shared navigation state across both import forms. * fix(app-router): recover SSR shell render errors via __next_error__ document When the HTML (Fizz) render rejects during SSR, vinext re-rendered a server-side global-error page whose flight payload encodes the error tree. For an app without a custom global-error.tsx that meant the default error card with no path back to the real page: an SSR-phase-only throw (e.g. a client component using the "throw to opt out of server rendering" pattern) left the browser stuck on the card even though the client render would succeed. The violated expectation is Next.js's shell-error semantics: a failed HTML shell is served as the default `__next_error__` error document carrying the ORIGINAL flight payload and the bootstrap module, and the browser re-renders the real tree from that payload with createRoot instead of hydrating. Local error.tsx boundaries still win — they ship inside the flight payload and catch the re-thrown error client-side. handleSsr now resolves to that recovery document instead of rejecting, but only when both hold: - the error did not originate in the RSC render (no string `digest`), so flight errors, redirect()/notFound(), and server-component throws keep driving the existing rejection-based boundary machinery, and - the app has no custom global-error.tsx (the generated entry knows at build time and threads hasCustomGlobalError through dispatch/render options); apps with one keep the server-rendered boundary re-render. The browser entry switches from hydrateRoot to createRoot when the document root carries id="__next_error__", dropping the error-shell styles first. Covered by the new ssr-error-shell-recovery browser spec (recovery to real content, local error.tsx for SSR-only and unconditional client throws) and the existing tests/nextjs-compat/global-error.test.ts boundary-semantics suite. * refactor(review): address PR 1908 non-blocking notes - Add static prerender no-boundary recovery regression test to ssr-error-shell-recovery.browser.spec.ts - Document broad __next_error__ browser marker in app-browser-entry.ts - Extract stripJsExtension to utils/path.ts and wire into all shim resolution sites (vinext/shims/* + react-server shims) Non-functional: targeted regression coverage + code documentation + minor resolver hardening per reviewer feedback. * refactor(review): clarify shell recovery assumptions * fix(ssr): cancel abandoned prerender streams * refactor(ssr): clarify error shell root options * fix(isr): recover shell errors during regeneration * fix(ssr): scope client recovery to marked shells * fix(plugin): explicitly filter vinext shim subpaths * fix(plugin): retain null-prefixed shim resolution * test(nextjs-compat): cover no-boundary shell recovery fallback An unconditional client throw during SSR shell recovery without a local error boundary must still land on the default global-error card. Without a regression test, a future change could tear down the recovery shell and leave a blank document.\n\nAdd a production browser case that exercises the no-boundary route and asserts the default error UI after the client re-render throws again. * fix(app-router): preserve shell recovery error semantics * test(app-router): harden shell recovery cache semantics * fix(app-router): mark global error responses uncacheable * test(app-router): align error response status expectations * fix(build): preserve null-prefixed og resolution * fix(cache): delegate cache header cleanup to adapters --------- Co-authored-by: James <james@eli.cx>
1877 lines
66 KiB
TypeScript
1877 lines
66 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 {
|
|
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 { 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 {
|
|
runWithExecutionContext,
|
|
type ExecutionContextLike,
|
|
} from "../packages/vinext/src/shims/request-context.js";
|
|
import {
|
|
consumeDynamicUsage,
|
|
consumeRenderRequestApiUsage,
|
|
markDynamicUsage,
|
|
markRenderRequestApiUsage,
|
|
} 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 } | 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"];
|
|
findIntercept?: DispatchOptions["findIntercept"];
|
|
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"];
|
|
probeLayoutAt?: DispatchOptions["probeLayoutAt"];
|
|
probePage?: DispatchOptions["probePage"];
|
|
renderedConcreteUrlPaths?: DispatchOptions["renderedConcreteUrlPaths"];
|
|
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,
|
|
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,
|
|
probeLayoutAt: overrides.probeLayoutAt ?? createLayoutParamProbe(route, params, []),
|
|
probePage: overrides.probePage ?? (() => null),
|
|
renderedConcreteUrlPaths: overrides.renderedConcreteUrlPaths,
|
|
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,
|
|
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 reuse a speculative connection() page probe", 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).toHaveBeenCalledTimes(1);
|
|
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 { options } = createDispatchOptions({
|
|
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")).toBeNull();
|
|
expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate");
|
|
await expect(response.text()).resolves.toBe("<html>page</html>");
|
|
});
|
|
|
|
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("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,
|
|
) => {
|
|
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,
|
|
},
|
|
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("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");
|
|
});
|
|
|
|
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("regenerates stale intercepted RSC cache entries from the source route", 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")).toBe("STALE");
|
|
await expect(response.text()).resolves.toBe("stale-flight");
|
|
expect(typeof scheduledRender).toBe("function");
|
|
if (typeof scheduledRender !== "function") {
|
|
throw new Error("expected stale intercepted RSC response to schedule regeneration");
|
|
}
|
|
|
|
await scheduledRender();
|
|
|
|
const [routeArg, paramsArg, optsArg, searchParamsArg] = buildPageElement.mock.calls[0];
|
|
expect(resolveRouteFetchCacheMode).toHaveBeenCalledWith(sourceRoute);
|
|
expect(routeArg).toBe(sourceRoute);
|
|
expect(paramsArg).toEqual({});
|
|
expect(searchParamsArg.toString()).toBe("");
|
|
expect(optsArg).toMatchObject({
|
|
interceptionContext: "/feed",
|
|
interceptParams: { id: "123" },
|
|
interceptSlotId: "slot:modal:/feed",
|
|
interceptSlotKey: "modal@app/feed/@modal",
|
|
interceptSourceMatchedUrl: "/feed",
|
|
});
|
|
expect(options.isrSet).toHaveBeenCalledWith(
|
|
"rsc:/photos/123:slot:modal:/feed:/feed",
|
|
expect.objectContaining({ kind: "APP_PAGE" }),
|
|
60,
|
|
expect.arrayContaining(["/photos/123", "_N_T_/feed/page"]),
|
|
undefined,
|
|
);
|
|
});
|
|
|
|
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("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("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")).toBe("STALE");
|
|
expect(typeof scheduledRender).toBe("function");
|
|
if (typeof scheduledRender !== "function") {
|
|
throw new Error("expected stale response to schedule regeneration");
|
|
}
|
|
|
|
await scheduledRender();
|
|
|
|
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")).toBe("MISS");
|
|
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");
|
|
});
|
|
});
|