mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
cda19e1c58
* fix(app-router): preserve recent segment state with Activity BFCache Fresh App Router navigations were reusing the active React tree when moving between dynamic segment values. That leaked client state into a newly entered segment and made the upstream back-forward-cache suite fail before it even reached browser traversal assertions. The root cause was that vinext had history-entry bfcacheId plumbing but no separate segment state-key cache. Next.js treats those identities separately: render state is keyed by segment identity/pathname, while useRouter().bfcacheId identifies a concrete history entry. Add state-key maps for app router slots, render retained entries through React.Activity when cacheComponents is enabled, keep the active one-entry keyed reset path otherwise, and port focused Next.js regression coverage for the three-entry BFCache behavior. * fix(bfcache): store stateKeyMap in retained slot entries and re-provide on render Review feedback requested two changes: 1. Add stateKeyMap to BfcacheSlotEntry so each retained BFCache entry carries the state-key map that belonged to it when active, rather than borrowing the current navigation's map. This prevents nested hidden entries from being rendered with old elements but new pathname-derived state keys — an identity corruption bug that upstream Next.js avoids by rendering each retained entry from its own state. 2. Add a nested dynamic regression test: /nested/[section]/item/[id] with client counters in both the [section] layout and [id] page. The existing flat /page/[n] tests do not exercise nested retained-entry identity, which is exactly where the missing state-key-map bug lives. Changes: - BfcacheSlotEntry gains stateKeyMap?: Readonly<Record<string, string>> - updateBfcacheSlotEntries accepts and stores an optional stateKeyMap argument - BfcacheSlotBoundary stores the current stateKeyMap alongside elements when caching the active entry - Both the Activity visible/hidden path and the non-cacheComponents single-entry path now wrap rendered entries in BfcacheStateKeyMapContext.Provider using the entry's own stateKeyMap (falling back to current map for forward compatibility) - New e2e test verifies nested layout + page counter preservation across segment navigations * refactor(bfcache): extract shared provider stack into BfcacheEntryProviders BfcacheSlotBoundary rendered the BfcacheStateKeyMapContext / ElementsContext / SegmentContext provider stack in two places: the one-entry keyed reset path and the per-entry Activity retention list. The two stacks were identical except for where the React key sat, so the retained-entry bookkeeping and its render shape were tangled together and harder to audit than needed. Extract BfcacheEntryProviders as a pure presentational helper and render it from both paths. The keyed-reset path keeps its remount semantics: the key now sits on the BfcacheEntryProviders element, so a fresh active stateKey still remounts the whole provider subtree. Hook ordering is unchanged. All hooks stay above the early returns in BfcacheSlotBoundary, so the boundary still satisfies the Rules of Hooks across the activeStateKey undefined-to-defined transition. Covered by the slot BFCache entry-state suite in tests/shims.test.ts. * test(bfcache): isolate cacheComponents BFCache coverage in a dedicated fixture The back/forward-cache e2e suite ran against the shared app-basic fixture with cacheComponents: true. app-basic backs the single app-router Playwright server that every app-router spec uses, so enabling cacheComponents globally changed a core DOM invariant for unrelated tests: inactive route trees stay mounted as hidden Activity DOM. The contamination already leaked a :visible workaround into the interception-modal bfcacheId spec, which had nothing to do with Activity. cacheComponents is an opt-in semantic mode, not default app-router behaviour, so it does not belong on the shared baseline fixture. Move the BFCache routes into a dedicated tests/fixtures/app-bfcache fixture with cacheComponents: true, served by a new app-router-bfcache Playwright project on port 4183, and run the back-forward-cache spec there against the project baseURL. The :visible locators stay in that spec, where duplicate hidden DOM is the expected contract. Remove cacheComponents and the BFCache routes from app-basic so the shared fixture models default behaviour again, and revert the :visible workaround in use-router-bfcache-id.spec.ts now that the modal route no longer retains hidden DOM. Fix the nested item page import while moving it: nested/[section]/item/[id]/ page.tsx imported ../counter, but the counter lives two levels up at nested/[section]/counter.tsx, so it must be ../../counter. Validation: PLAYWRIGHT_PROJECT=app-router-bfcache (5 passed) and the full use-router-bfcache-id spec under app-router (11 passed), plus the slot BFCache unit suite (1172 passed) and vp check (formatting + lint + types clean). * ci: run the app-router-bfcache e2e project in the matrix The dedicated app-router-bfcache Playwright project was added to playwright.config.ts but not to the CI E2E matrix, so the back/forward-cache specs never ran in CI. Add it to the matrix project list so the BFCache suite runs on every push. It uses the default chromium browser, so the existing setup and browser-cache steps apply unchanged. * fix(bfcache): keep persisted slots mounted when cacheComponents is off Without cacheComponents there is no Activity retention, yet BfcacheSlotBoundary still keyed the active entry by its segment stateKey. That stateKey tracks the pathname (createBfcacheSegmentIdentity), so any slot whose identity moves with the URL (shared layouts, interception source slots) was remounted on navigation, discarding client state that survives a normal route change. The baseline router reconciles these slots in place. This regressed seven existing app-router e2e tests once cacheComponents was no longer masking it on the shared fixture: interception source-context and modal preservation in advanced.spec.ts, and layout-persistence.spec.ts dynamic-segment layout counter survival. Fresh-entry reset is driven by userland bfcacheId keying in the fixtures, not by remounting the slot subtree, so the keying was pure overreach in the non-cacheComponents path. Render the single active entry unkeyed when cacheComponents is off so the boundary reconciles in place like the baseline. The keyed Activity retention path is unchanged for cacheComponents. Validation: full app-router project 436 passed / 0 failed (the seven regressions now pass); use-router-bfcache-id 11 passed (fresh-push reset intact); app-bfcache 5 passed (Activity retention intact); shims unit suite 1024 passed. * docs(test): point bfcacheId spec comment at the app-router-bfcache project The header noted that Activity-backed preservation is covered in back-forward-cache.spec.ts, but that spec moved to the dedicated app-bfcache fixture under the app-router-bfcache project. Name the project and path so the cross-reference stays accurate. * docs: align cacheComponents comment with unkeyed active-segment behavior * test(bfcache): assert fresh bfcacheId after Activity-restored return * style: collapse locator chains per oxfmt * refactor(bfcache): split slot entry order from snapshots * fix(bfcache): keep non-cacheComponents slot path baseline * fix(bfcache): use string cacheComponents define * test: remove external fetch from revalidate tag fixture * fix(bfcache): harden Activity state key retention * docs(bfcache): document cacheComponents Activity caveats * fix(bfcache): preserve navigation context for fallback SSR Fallback boundary rendering can outlive the original request-scoped navigation context. That made app SSR fail fast with a missing navigation context when loading-boundary digest routes rendered their HTTP fallback shell. The SSR boundary contract still requires a navigation context for real request renders, but boundary renderers now synthesize the same typed shape from the request URL and matched params when request scope has already been cleared. * refactor(bfcache): normalize pathname in state-key map, document invariant callers Moves hydration-pathname normalization into createBfcacheSegmentStateKeyMap so SSR and client always produce byte-identical Activity keys. Documents the requireNavigationContext guaranteeing callers per review feedback. * fix(bfcache): tolerate malformed state-key pathnames * docs(bfcache): document known Next.js divergence and dead-branch contract * chore(deps): update lockfile for vite 0.1.24 * fix(app-router): preserve encoded delimiters in bfcache keys * docs(bfcache): track router-owned retention follow-up * chore: retrigger ci * fix(app-router): preserve redirect bridge fallback
1199 lines
42 KiB
TypeScript
1199 lines
42 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 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 {
|
|
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[];
|
|
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,
|
|
};
|
|
}
|
|
|
|
function createDispatchOptions(
|
|
overrides: {
|
|
buildPageElement?: DispatchOptions["buildPageElement"];
|
|
cleanPathname?: string;
|
|
clearRequestContext?: DispatchOptions["clearRequestContext"];
|
|
dynamicConfig?: DispatchOptions["dynamicConfig"];
|
|
findIntercept?: DispatchOptions["findIntercept"];
|
|
generateStaticParams?: DispatchOptions["generateStaticParams"];
|
|
formState?: DispatchOptions["formState"];
|
|
getSourceRoute?: DispatchOptions["getSourceRoute"];
|
|
actionError?: DispatchOptions["actionError"];
|
|
actionFailed?: DispatchOptions["actionFailed"];
|
|
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[]>;
|
|
probeLayoutAt?: DispatchOptions["probeLayoutAt"];
|
|
probePage?: DispatchOptions["probePage"];
|
|
renderToReadableStream?: DispatchOptions["renderToReadableStream"];
|
|
request?: Request;
|
|
revalidateSeconds?: number | null;
|
|
resolveRouteFetchCacheMode?: DispatchOptions["resolveRouteFetchCacheMode"];
|
|
route?: TestRoute;
|
|
scheduleBackgroundRegeneration?: DispatchOptions["scheduleBackgroundRegeneration"];
|
|
searchParams?: URLSearchParams;
|
|
setNavigationContext?: DispatchOptions["setNavigationContext"];
|
|
} = {},
|
|
) {
|
|
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() {
|
|
return {
|
|
pathname: "/posts/hello",
|
|
searchParams: new URLSearchParams(),
|
|
params: { slug: "hello" },
|
|
};
|
|
},
|
|
getSourceRoute: overrides.getSourceRoute ?? (() => undefined),
|
|
hasGenerateStaticParams: typeof overrides.generateStaticParams === "function",
|
|
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,
|
|
probeLayoutAt: overrides.probeLayoutAt ?? createLayoutParamProbe(route, params, []),
|
|
probePage: overrides.probePage ?? (() => null),
|
|
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,
|
|
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,
|
|
};
|
|
}
|
|
|
|
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", () => {
|
|
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"] });
|
|
const currentRoute = createRoute({ params: ["id"], pattern: "/photos/[id]" });
|
|
const middlewareHeaders = new Headers({ "x-from-middleware": "yes" });
|
|
const { options } = createDispatchOptions({
|
|
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"),
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
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("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;
|
|
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,
|
|
loadSsrHandler: async () => ({
|
|
async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) {
|
|
capturedWaitForAllReady = captureOptions?.waitForAllReady;
|
|
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(isrSet).toHaveBeenCalled();
|
|
});
|
|
});
|