Files
cloudflare__vinext/tests/app-page-request.test.ts
T
Nathan Nguyen 49d2f0966d feat(skip): plumb client reuse manifests through the app request path (#1717)
* feat(skip): plumb client reuse manifests through the app request path

The browser never sends a client reuse manifest and the server has no path
to receive one, so the static-layout skip planner (#1674) and observation
foundations (#1672) have nothing to act on. Skip transport stays fully off,
but the transport wiring those slices depend on is missing.

Add a bounded browser producer that derives a client reuse manifest from the
visible AppElements state and attaches it as X-Vinext-Client-Reuse-Manifest
only on soft navigations. Parse the header into a render/send-by-default
disposition and thread the parsed result through the RSC handler, the
generated app entry, page dispatch, and the render lifecycle. Wire the
per-layout observation tracker and segment-config classification into dispatch
so the enable-transport slice can read them.

No RSC payload is omitted and no response cache-control changes: the parsed
manifest is carried but never authorizes a skip yet. Unproven and absent-
manifest requests render and send exactly as before.

Slice 3 of 4 from #1437; enable-transport follows.

Refs #726

* fix(skip): address review feedback on manifest plumbing

- app-page-execution: defer layout debug classification until after the
  observation override so the emitted reason agrees with the final flag;
  emit a runtime-probe reason when an observation flips a build-time static
  layout to dynamic.
- app-page-dispatch: document classifyLayoutSegmentConfigFromModule as the
  request-time counterpart to the build-time classifier, noting the
  intentional revalidate===false divergence and the dev-mode classification.
- app-render-dependency: mark registerAppElementRenderDependencies as
  write-only until the enable slice's consumer lands.

* docs(skip): clarify layout classification comments per review

- app-page-dispatch: correct the classifyLayoutSegmentConfigFromModule
  docblock — the build-time classifier already normalizes revalidate=false
  to Infinity, so both agree on that case; the real difference is scope
  (request-time classifies dev-mode layouts not captured at build time).
- app-page-dispatch: document the merge precedence in
  createEffectiveLayoutClassifications — module segment config is
  authoritative over the build-time map.

* test(skip): pin observation override flipping build-time static layout to dynamic

Adds a parity assertion for the request-time behavioral change the review
flagged: isLayoutObservationDynamic downgrades a build-time `static` layout
to `d` (and emits a runtime-probe reason) when the observed render is unsafe
for static reuse, while a safe observation keeps it static. Complements the
existing children-subtree probe assertion in app-page-route-wiring.test.ts.

* fix(skip): fold probe-scoped dynamic usage into layout observation

The probe rewrite runs layout probes inside runWithIsolatedProbeDependencies,
which opens a child unified scope and resets dynamicUsageDetected. A
markDynamicUsage() during the probe that leaves no other observable trace
(e.g. "use cache: private") was therefore discarded on scope exit, so the
parent's consumeDynamicUsage() in the Layer-3 path returned false and the
layout could classify static where the pre-PR direct probe classified dynamic.

recordProbeDependencies now peeks the child scope's dynamic-usage flag before
it is reset and folds it into the per-layout observation
(dynamicUsageObserved), which isAppLayoutObservationUnsafeForStaticReuse
treats as unsafe. This keeps the observation signal a superset of the
markDynamicUsage() triggers the Layer-3 dynamicDetected path covered, so the
two signals can no longer diverge into a false-static. Adds peekDynamicUsage()
(non-resetting read) and a regression test.

* perf(skip): defer client reuse manifest production to the cache-miss path

The manifest header is excluded from VINEXT_RSC_VARY_HEADER, so it never
affects the cache-busting URL or the visited-response cache lookup. Producing
it up front ran createClientReuseManifestHeaderFromVisibleAppState (iterates
visible layout ids, builds a cache-variant proof per static layout, then
binary-searches the byte budget) on every soft navigate — including ones that
resolve entirely from the visited-response cache or a prefetch and never send
a request, where the work is pure waste.

Move production to just before the network fetch, gated on the same
`navigationKind === "navigate"` condition and computed from the captured
nav-start router state, so the emitted header is unchanged on the request path
while the cache-hit/prefetch-hit hot paths skip it entirely.

---------

Co-authored-by: James <james@eli.cx>
2026-06-02 20:25:25 +01:00

593 lines
19 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import { resolveAppPageSpecialError } from "../packages/vinext/src/server/app-page-execution.js";
import {
buildAppPageElement,
resolveAppPageActionRerenderTarget,
resolveAppPageIntercept,
resolveAppPageInterceptMatch,
resolveAppPageGenerateStaticParamsSources,
validateAppPageDynamicParams,
} from "../packages/vinext/src/server/app-page-request.js";
describe("app page request helpers", () => {
it("returns 404 when dynamicParams=false receives unknown params", async () => {
const clearRequestContext = vi.fn();
const response = await validateAppPageDynamicParams({
clearRequestContext,
enforceStaticParamsOnly: true,
async generateStaticParams() {
return [{ slug: "known-post" }];
},
isDynamicRoute: true,
params: { slug: "missing-post" },
});
expect(response?.status).toBe(404);
await expect(response?.text()).resolves.toBe("This page could not be found");
expect(clearRequestContext).toHaveBeenCalledTimes(1);
});
it("returns 404 when dynamicParams=false has no static params sources", async () => {
const clearRequestContext = vi.fn();
const response = await validateAppPageDynamicParams({
clearRequestContext,
enforceStaticParamsOnly: true,
generateStaticParams: undefined,
isDynamicRoute: true,
params: { slug: "anything" },
});
expect(response?.status).toBe(404);
await expect(response?.text()).resolves.toBe("This page could not be found");
expect(clearRequestContext).toHaveBeenCalledTimes(1);
});
it("allows matching static params, including nested parent params", async () => {
const clearRequestContext = vi.fn();
const response = await validateAppPageDynamicParams({
clearRequestContext,
enforceStaticParamsOnly: true,
async generateStaticParams() {
return [{ item: "shoe" }];
},
isDynamicRoute: true,
params: { category: "fashion", item: "shoe" },
});
expect(response).toBeNull();
expect(clearRequestContext).not.toHaveBeenCalled();
});
it("requires every segment generateStaticParams source to allow the params", async () => {
const clearRequestContext = vi.fn();
const layoutGenerateStaticParams = async () => [{ category: "docs" }];
const pageGenerateStaticParams = async () => [{ slug: "intro" }];
const response = await validateAppPageDynamicParams({
clearRequestContext,
enforceStaticParamsOnly: true,
generateStaticParams: [layoutGenerateStaticParams, pageGenerateStaticParams],
isDynamicRoute: true,
params: { category: "docs", slug: "missing" },
});
expect(response?.status).toBe(404);
expect(clearRequestContext).toHaveBeenCalledTimes(1);
});
it("passes parent-only params to each generateStaticParams source", async () => {
const clearRequestContext = vi.fn();
const categoryGenerateStaticParams = vi.fn(() => [{ category: "docs" }]);
const itemGenerateStaticParams = vi.fn(
({ params }: { params: Record<string, string | string[]> }) => {
if (params.category !== "docs" || params.slug !== undefined) {
return [];
}
return [{ slug: "intro" }];
},
);
const response = await validateAppPageDynamicParams({
clearRequestContext,
enforceStaticParamsOnly: true,
generateStaticParams: resolveAppPageGenerateStaticParamsSources({
layouts: [null, { generateStaticParams: categoryGenerateStaticParams }],
layoutTreePositions: [0, 1],
page: { generateStaticParams: itemGenerateStaticParams },
routeSegments: ["[category]", "[slug]"],
}),
isDynamicRoute: true,
params: { category: "docs", slug: "intro" },
});
expect(response).toBeNull();
expect(clearRequestContext).not.toHaveBeenCalled();
expect(categoryGenerateStaticParams).toHaveBeenCalledWith({ params: {} });
expect(itemGenerateStaticParams).toHaveBeenCalledWith({ params: { category: "docs" } });
});
// Ported from Next.js: packages/next/src/build/static-paths/app.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/static-paths/app.test.ts
it("throws when generateStaticParams throws", async () => {
const error = new Error("boom");
await expect(
validateAppPageDynamicParams({
clearRequestContext() {},
enforceStaticParamsOnly: true,
async generateStaticParams() {
throw error;
},
isDynamicRoute: true,
params: { slug: "post" },
}),
).rejects.toThrow(error);
});
// Ported from Next.js: packages/next/src/build/static-paths/app.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/static-paths/app.test.ts
it("throws when generateStaticParams rejects", async () => {
const error = new Error("async boom");
await expect(
validateAppPageDynamicParams({
clearRequestContext() {},
enforceStaticParamsOnly: true,
async generateStaticParams() {
return Promise.reject(error);
},
isDynamicRoute: true,
params: { slug: "post" },
}),
).rejects.toThrow(error);
});
// Ported from Next.js: packages/next/src/build/static-paths/app.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/build/static-paths/app.test.ts
it("does not check remaining sources when an earlier generateStaticParams source throws", async () => {
const clearRequestContext = vi.fn();
const throwsSource = vi.fn(() => {
throw new Error("source 1 failed");
});
const rejectsSource = vi.fn(async () => [{ slug: "other" }]);
await expect(
validateAppPageDynamicParams({
clearRequestContext,
enforceStaticParamsOnly: true,
generateStaticParams: [throwsSource, rejectsSource],
isDynamicRoute: true,
params: { slug: "target" },
}),
).rejects.toThrow("source 1 failed");
expect(clearRequestContext).not.toHaveBeenCalled();
expect(throwsSource).toHaveBeenCalledTimes(1);
expect(rejectsSource).not.toHaveBeenCalled();
});
it("returns 404 when generateStaticParams excludes the requested params", async () => {
const clearRequestContext = vi.fn();
const response = await validateAppPageDynamicParams({
clearRequestContext,
enforceStaticParamsOnly: true,
generateStaticParams: async () => [{ slug: "other" }],
isDynamicRoute: true,
params: { slug: "target" },
});
expect(response?.status).toBe(404);
expect(clearRequestContext).toHaveBeenCalledTimes(1);
});
it("renders intercepted source routes on RSC navigations", async () => {
const setNavigationContext = vi.fn();
const buildPageElementMock = vi.fn(async () => ({ type: "intercept-element" }));
const renderInterceptResponse = vi.fn(async () => new Response("intercepted"));
const currentRoute = { params: ["id"], pattern: "/photos/[id]" };
const sourceRoute = { params: [], pattern: "/feed" };
const result = await resolveAppPageIntercept({
buildPageElement: buildPageElementMock,
cleanPathname: "/photos/123",
currentRoute,
findIntercept() {
return {
matchedParams: { id: "123" },
page: { default: "modal-page" },
slotKey: "modal@app/feed/@modal",
sourceRouteIndex: 0,
};
},
getRouteParamNames(route) {
return route.params;
},
getSourceRoute() {
return sourceRoute;
},
isRscRequest: true,
renderInterceptResponse,
searchParams: new URLSearchParams("from=feed"),
setNavigationContext,
toInterceptOpts(intercept) {
return {
interceptPage: intercept.page,
interceptParams: intercept.matchedParams,
interceptSlotKey: intercept.slotKey,
};
},
});
expect(result.interceptOpts).toBeUndefined();
expect(result.response).toBeInstanceOf(Response);
expect(setNavigationContext).toHaveBeenCalledWith({
params: { id: "123" },
pathname: "/photos/123",
searchParams: new URLSearchParams("from=feed"),
});
expect(buildPageElementMock).toHaveBeenCalledWith(
sourceRoute,
{},
{
interceptPage: { default: "modal-page" },
interceptParams: { id: "123" },
interceptSlotKey: "modal@app/feed/@modal",
},
new URLSearchParams("from=feed"),
undefined,
);
expect(renderInterceptResponse).toHaveBeenCalledTimes(1);
});
it("returns intercept opts when the source route is the current route", async () => {
const currentRoute = { params: ["id"], pattern: "/photos/[id]" };
const result = await resolveAppPageIntercept({
async buildPageElement() {
throw new Error("should not build a separate intercept element");
},
cleanPathname: "/photos/123",
currentRoute,
findIntercept() {
return {
matchedParams: { id: "123" },
page: { default: "modal-page" },
slotKey: "modal@app/feed/@modal",
sourceRouteIndex: 0,
};
},
getRouteParamNames(route) {
return route.params;
},
getSourceRoute() {
return currentRoute;
},
isRscRequest: true,
async renderInterceptResponse() {
throw new Error("should not render a separate intercept response");
},
searchParams: new URLSearchParams(),
setNavigationContext() {},
toInterceptOpts(intercept) {
return {
interceptPage: intercept.page,
interceptParams: intercept.matchedParams,
interceptSlotKey: intercept.slotKey,
};
},
});
expect(result.response).toBeNull();
expect(result.interceptOpts).toEqual({
interceptPage: { default: "modal-page" },
interceptParams: { id: "123" },
interceptSlotKey: "modal@app/feed/@modal",
});
});
it("returns special-error responses from page builds", async () => {
const result = await buildAppPageElement({
async buildPageElement() {
throw { digest: "NEXT_REDIRECT;replace;%2Ftarget;308" };
},
async renderErrorBoundaryPage() {
throw new Error("should not render boundary for special errors");
},
async renderSpecialError(specialError) {
return new Response(`${specialError.kind}:${specialError.statusCode}`);
},
resolveSpecialError: resolveAppPageSpecialError,
});
expect(result.element).toBeNull();
await expect(result.response?.text()).resolves.toBe("redirect:308");
});
it("falls back to error boundaries for non-special build failures", async () => {
const boundaryResponse = new Response("boundary", { status: 200 });
const result = await buildAppPageElement({
async buildPageElement() {
throw new Error("boom");
},
async renderErrorBoundaryPage(error) {
expect(error).toBeInstanceOf(Error);
return boundaryResponse;
},
async renderSpecialError() {
throw new Error("should not handle as a special error");
},
resolveSpecialError: resolveAppPageSpecialError,
});
expect(result.element).toBeNull();
expect(result.response).toBe(boundaryResponse);
});
});
describe("resolveAppPageInterceptMatch", () => {
const sourceRoute = { params: [], pattern: "/feed" };
const currentRoute = { params: ["id"], pattern: "/photos/[id]" };
const toInterceptOpts = (intercept: {
matchedParams: Record<string, string | string[]>;
page: unknown;
slotKey: string;
}) => ({
interceptPage: intercept.page,
interceptParams: intercept.matchedParams,
interceptSlotKey: intercept.slotKey,
});
it("returns null on non-RSC requests", () => {
const result = resolveAppPageInterceptMatch({
cleanPathname: "/photos/123",
currentRoute,
findIntercept() {
throw new Error("should not look up intercepts on non-RSC requests");
},
getRouteParamNames: (route) => route.params,
getSourceRoute: () => sourceRoute,
isRscRequest: false,
toInterceptOpts,
});
expect(result).toBeNull();
});
it("returns null when findIntercept returns nothing", () => {
const result = resolveAppPageInterceptMatch({
cleanPathname: "/photos/123",
currentRoute,
findIntercept: () => null,
getRouteParamNames: (route) => route.params,
getSourceRoute: () => sourceRoute,
isRscRequest: true,
toInterceptOpts,
});
expect(result).toBeNull();
});
it("returns null when the source route is the current route", () => {
const result = resolveAppPageInterceptMatch({
cleanPathname: "/photos/123",
currentRoute,
findIntercept: () => ({
matchedParams: { id: "123" },
page: { default: "modal-page" },
slotKey: "modal@app/photos/@modal",
sourceRouteIndex: 0,
}),
getRouteParamNames: (route) => route.params,
getSourceRoute: () => currentRoute,
isRscRequest: true,
toInterceptOpts,
});
expect(result).toBeNull();
});
it("returns sourceRoute, sourceParams, matchedParams, and interceptOpts when an intercept applies", () => {
const matchedParams = { id: "123" };
const intercept = {
matchedParams,
page: { default: "modal-page" },
slotKey: "modal@app/feed/@modal",
sourceRouteIndex: 0,
};
const result = resolveAppPageInterceptMatch({
cleanPathname: "/photos/123",
currentRoute,
findIntercept: () => intercept,
getRouteParamNames: (route) => route.params,
getSourceRoute: () => sourceRoute,
isRscRequest: true,
toInterceptOpts,
});
expect(result).not.toBeNull();
expect(result?.sourceRoute).toBe(sourceRoute);
expect(result?.matchedParams).toBe(matchedParams);
// sourceParams keeps only the params declared by the source route.
// /feed has no dynamic params, so the slice is empty.
expect(result?.sourceParams).toEqual({});
expect(result?.interceptOpts).toEqual(toInterceptOpts(intercept));
});
it("slices source params down to the source route's declared params", () => {
const categorySourceRoute = { params: ["category"], pattern: "/feed/[category]" };
const matchedParams = { category: "nature", id: "123" };
const result = resolveAppPageInterceptMatch({
cleanPathname: "/photos/123",
currentRoute,
findIntercept: () => ({
matchedParams,
page: { default: "modal-page" },
slotKey: "modal@app/feed/[category]/@modal",
sourceRouteIndex: 0,
}),
getRouteParamNames: (route) => route.params,
getSourceRoute: () => categorySourceRoute,
isRscRequest: true,
toInterceptOpts,
});
expect(result?.sourceParams).toEqual({ category: "nature" });
expect(result?.matchedParams).toEqual({ category: "nature", id: "123" });
});
});
describe("resolveAppPageActionRerenderTarget", () => {
const sourceRoute = { params: [], pattern: "/feed" };
const currentRoute = { params: ["id"], pattern: "/photos/[id]" };
const toInterceptOpts = (intercept: {
matchedParams: Record<string, string | string[]>;
page: unknown;
slotKey: string;
}) => ({
interceptPage: intercept.page,
interceptParams: intercept.matchedParams,
interceptSlotKey: intercept.slotKey,
});
it("falls through to the current route on non-RSC requests", () => {
const result = resolveAppPageActionRerenderTarget({
cleanPathname: "/photos/123",
currentParams: { id: "123" },
currentRoute,
findIntercept() {
throw new Error("should not look up intercepts on non-RSC requests");
},
getRouteParamNames: (route) => route.params,
getSourceRoute: () => sourceRoute,
isRscRequest: false,
toInterceptOpts,
});
expect(result).toEqual({
interceptOpts: undefined,
navigationParams: { id: "123" },
params: { id: "123" },
route: currentRoute,
});
});
it("falls through to the current route when no intercept matches", () => {
const result = resolveAppPageActionRerenderTarget({
cleanPathname: "/photos/123",
currentParams: { id: "123" },
currentRoute,
findIntercept: () => null,
getRouteParamNames: (route) => route.params,
getSourceRoute: () => sourceRoute,
isRscRequest: true,
toInterceptOpts,
});
expect(result).toEqual({
interceptOpts: undefined,
navigationParams: { id: "123" },
params: { id: "123" },
route: currentRoute,
});
});
it("looks up the intercept once when the source route is the current route", () => {
const findIntercept = vi.fn(() => ({
matchedParams: { id: "123" },
page: { default: "modal-page" },
slotKey: "modal@app/feed/@modal",
sourceRouteIndex: 0,
}));
const result = resolveAppPageActionRerenderTarget({
cleanPathname: "/photos/123",
currentParams: { id: "123" },
currentRoute,
findIntercept,
getRouteParamNames: (route) => route.params,
getSourceRoute: () => currentRoute,
isRscRequest: true,
toInterceptOpts,
});
expect(result).toEqual({
interceptOpts: {
interceptPage: { default: "modal-page" },
interceptParams: { id: "123" },
interceptSlotKey: "modal@app/feed/@modal",
},
navigationParams: { id: "123" },
params: { id: "123" },
route: currentRoute,
});
expect(findIntercept).toHaveBeenCalledTimes(1);
});
it("preserves current-route intercept opts when action rerender stays on the direct route", () => {
const result = resolveAppPageActionRerenderTarget({
cleanPathname: "/photos/123",
currentParams: { id: "123" },
currentRoute,
findIntercept: () => ({
matchedParams: { id: "123" },
page: { default: "modal-page" },
slotKey: "modal@app/feed/@modal",
sourceRouteIndex: 0,
}),
getRouteParamNames: (route) => route.params,
getSourceRoute: () => currentRoute,
isRscRequest: true,
toInterceptOpts,
});
expect(result).toEqual({
interceptOpts: {
interceptPage: { default: "modal-page" },
interceptParams: { id: "123" },
interceptSlotKey: "modal@app/feed/@modal",
},
navigationParams: { id: "123" },
params: { id: "123" },
route: currentRoute,
});
});
it("rerenders the intercepted source route when an intercept match applies", () => {
const result = resolveAppPageActionRerenderTarget({
cleanPathname: "/photos/123",
currentParams: { id: "123" },
currentRoute,
findIntercept: () => ({
matchedParams: { id: "123" },
page: { default: "modal-page" },
slotKey: "modal@app/feed/@modal",
sourceRouteIndex: 0,
}),
getRouteParamNames: (route) => route.params,
getSourceRoute: () => sourceRoute,
isRscRequest: true,
toInterceptOpts,
});
expect(result).toEqual({
interceptOpts: {
interceptPage: { default: "modal-page" },
interceptParams: { id: "123" },
interceptSlotKey: "modal@app/feed/@modal",
},
navigationParams: { id: "123" },
params: {},
route: sourceRoute,
});
});
});