Files
cloudflare__vinext/tests/app-route-handler-execution.test.ts
T
Nathan Nguyen 5ac620c2d0 fix(cache): honor fetch opt-outs and force-dynamic revalidate parity (#1907)
* fix(cache): honor fetch opt-outs in app page output

App Router fetches that explicitly opt out of caching could still leave the rendered page eligible for static output, while dynamic = "force-dynamic" was lowered to fetchCache = "force-no-store" and overrode explicit per-fetch cache/revalidate options.

The cache decision was conflating route-level dynamic rendering with a hard segment fetchCache mode. Next.js treats force-dynamic as a default no-store mode only for fetches without explicit cache or revalidate config, and marks explicit uncached fetches as dynamic for the surrounding render.

Mark explicit uncached fetch decisions through the existing dynamic usage signal, keep force-dynamic as a separate per-request fetch default, and preserve Response.url when reconstructing cached fetch responses.

* fix(cache): correct revalidate:false and Response.url semantics

- Split revalidate: false out of the no-store path; it now caches
  indefinitely (INFINITE_CACHE) matching upstream patch-fetch.ts.
- Store actual response.url (freshResp.url / response.url) in cached
  fetch entries instead of the input request URL.
- Remove markUncachedFetchForPageOutput() from the auth-header fallback
  so it matches upstream's autoNoCache path and does not mark pages
  dynamic.
- Update tests to reflect correct revalidate: false caching behavior
  and add a test for redirect-style response URLs.

* fix(app-router): resolve target route dynamic config during intercepts and revalidation

Prevents the outer route's force-dynamic default from leaking into
interception source routes and ISR revalidation targets.

- app-rsc-entry.ts: expose __resolveRouteDynamicConfig
- app-page-dispatch.ts: pass resolveRouteDynamicConfig to dispatch,
  sync currentForceDynamicFetchDefault when the render target changes
- app-page-dispatch.test.ts: 4 tests covering intercept + revalidation
  paths for both lost and leaked defaults

* fix(app-router): sync fetchCache and force-dynamic default during action redirects/re-renders

* fix(cache): treat revalidate:false as non-explicit for force-dynamic parity

Upstream patch-fetch.ts computes noFetchConfigAndForceDynamic using
!currentFetchRevalidate (truthiness), so revalidate: false is treated
as 'no fetch revalidate config' and force-dynamic wins. vinext's
hasExplicitRevalidateValue() previously returned true for false,
blocking the force-dynamic no-store default and causing the fetch to
cache for 1 year instead.

Update hasExplicitRevalidateValue to exclude false (and 0) from the
explicit set, aligning with upstream's truthiness check. Add a parity
test covering force-dynamic + next.revalidate: false.

* fix(cache): split revalidate predicates for force-dynamic vs segment defaults

The previous fix to hasExplicitRevalidateValue() treated false and 0 as
non-explicit, which matched upstream's force-dynamic truthiness check
but broke segment cache defaults: default-cache would override
revalidate: 0 to force-cache, and default-no-store would override
revalidate: false to no-store.

Split into two helpers:
- hasExplicitRevalidateValue(): any defined value is explicit (for
  segment defaults where 0 and false are explicit opt-outs)
- isFalsyRevalidate(): false and 0 are falsy (for force-dynamic's
  noFetchConfigAndForceDynamic parity check)

Add parity tests for default-cache + revalidate: 0 and
default-no-store + revalidate: false.

* refactor(cache): extract ONE_YEAR_SECONDS and cover only-no-store revalidate:false

* fix(cache): keep dynamic fetch observations for auth bypass and cache-key fallback

Restore recordDynamicFetchObservation in the auth-header safety bypass so
auth-keyed fetches still downgrade the page output to fresh render, and
revert the cache-key-generation fallback to a plain observation so an
internal limitation (oversized/unserializable body) does not mark the
whole page dynamic. Lock in force-dynamic vs explicit segment fetchCache
precedence with a test.

* docs(cache): note deliberate no-cache dynamic-marking divergence from upstream

* fix(route-handler): apply segment fetchCache and force-dynamic fetch default to route handler dispatch

Route handlers never called setCurrentFetchCacheMode /
setCurrentForceDynamicFetchDefault, so a force-dynamic route handler did
not get the per-request no-store fetch default that page dispatch applies,
and an explicit fetchCache export on a route handler module was ignored.

Upstream's app-route module copies userland.fetchCache into the work store
and sets workStore.forceDynamic for dynamic = "force-dynamic", which
patch-fetch turns into a no-store default for fetches without explicit
cache config — for route handlers as well as pages. Mirror that in
dispatchAppRouteHandler and re-apply the state inside the background
regeneration request context.

* fix(fetch-cache): tolerate cached entries missing url and document revalidate:false cacheability

* fix(fetch-cache): fall back to the request URL for cached entries missing url

Reconstructing Response.url from the fetch input preserves the
pre-existing guarantee for legacy/foreign cache entries instead of
degrading to "". Also document why the intercept dispatch deliberately
does not save/restore fetch defaults around buildPageElement.

* docs(fetch-cache): note cached responses now join body-stream cleanup

* docs(fetch-cache): note explicit segment fetchCache beats force-dynamic default

* test(fetch-cache): pin tags-only fetch to no-store under force-dynamic

* docs(fetch-cache): document auth-bypass precedence and test no-store + auth

- Note at the auth-safety bypass that explicit no-store/no-cache/revalidate: 0
  takes the stronger branch first and fully marks the page dynamic; the bypass
  only covers implicitly-cacheable auth-keyed fetches.
- Add a test locking in that explicit no-store with auth headers routes through
  markUncachedFetchForPageOutput rather than the softer auth bypass.
- Clarify in dispatchAppRouteHandler that the fetch-cache setters are new
  wiring for route handlers, not a mirror of a pre-existing pattern.

* test(route-handler): lock no-store fetch bailing ISR for revalidating handlers

A route handler with revalidate = 60 that performs
fetch(url, { cache: "no-store" }) must skip its ISR cache write and be
marked known-dynamic, now that the patched fetch's explicit no-store
branch calls markDynamicUsage() (upstream patch-fetch parity). Exercises
the real fetch-cache shim and the real headers-shim dynamic-usage pair,
matching the app-route-handler-dispatch wiring.

---------

Co-authored-by: James <james@eli.cx>
2026-06-12 11:44:28 +01:00

550 lines
18 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import {
consumeDynamicUsage,
cookies,
headers,
markDynamicUsage,
setHeadersContext,
} from "../packages/vinext/src/shims/headers.js";
import { isKnownDynamicAppRoute } from "../packages/vinext/src/server/app-route-handler-runtime.js";
import {
executeAppRouteHandler,
runAppRouteHandler,
} from "../packages/vinext/src/server/app-route-handler-execution.js";
// The fetch-cache shim captures `originalFetch` from globalThis at import
// time, so stub fetch BEFORE importing it (same pattern as
// tests/fetch-cache.test.ts). None of the static imports above pull
// fetch-cache.js into the runtime module graph — its only reference there is
// the type-only `FetchCacheState` re-export — so the stub is in place before
// the capture happens.
const fetchMock = vi.fn(async (_input: string | URL | Request, _init?: RequestInit) =>
Response.json({ ok: true }),
);
vi.stubGlobal("fetch", fetchMock);
const { withFetchCache } = await import("../packages/vinext/src/shims/fetch-cache.js");
function createDynamicUsageState(): {
consumeDynamicUsage: () => boolean;
markDynamicUsage: () => void;
} {
let didUseDynamic = false;
return {
consumeDynamicUsage() {
const used = didUseDynamic;
didUseDynamic = false;
return used;
},
markDynamicUsage() {
didUseDynamic = true;
},
};
}
describe("app route handler execution helpers", () => {
it("runs route handlers with tracked requests and returns dynamic usage", async () => {
const dynamicUsage = createDynamicUsageState();
let receivedParams: Record<string, string | string[]> | null = null;
const { dynamicUsedInHandler, response } = await runAppRouteHandler({
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
handlerFn(request, context) {
receivedParams = context.params;
return Response.json({
header: request.headers.get("x-test"),
});
},
markDynamicUsage: dynamicUsage.markDynamicUsage,
params: { slug: "demo" },
request: new Request("https://example.com/api/demo", {
headers: { "x-test": "pong" },
}),
});
expect(receivedParams).toEqual({ slug: "demo" });
expect(dynamicUsedInHandler).toBe(true);
await expect(response.json()).resolves.toEqual({ header: "pong" });
});
it("runs force-static route handlers with empty request APIs without marking dynamic usage", async () => {
const dynamicUsage = createDynamicUsageState();
try {
const { dynamicUsedInHandler, response } = await runAppRouteHandler({
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
dynamicConfig: "force-static",
async handlerFn(request) {
const headerStore = await headers();
const cookieStore = await cookies();
return Response.json({
cookie: cookieStore.get("session")?.value ?? null,
geo: request.geo ?? null,
header: headerStore.get("x-test"),
ip: request.ip ?? null,
requestCookie: request.cookies.get("session")?.value ?? null,
requestHeader: request.headers.get("x-test"),
requestUrl: request.url,
search: request.nextUrl.search,
searchParam: request.nextUrl.searchParams.get("token"),
});
},
markDynamicUsage: dynamicUsage.markDynamicUsage,
params: {},
request: new Request("https://tenant.example.com/api/static?token=secret", {
headers: {
"cf-connecting-ip": "203.0.113.10",
"cf-ipcountry": "AU",
cookie: "session=abc",
"x-test": "pong",
},
}),
routePattern: "/api/static",
setHeadersAccessPhase() {
return "render";
},
});
expect(dynamicUsedInHandler).toBe(false);
await expect(response.json()).resolves.toEqual({
cookie: null,
geo: null,
header: null,
ip: null,
requestCookie: null,
requestHeader: null,
requestUrl: "http://localhost:3000/api/static",
search: "",
searchParam: null,
});
} finally {
setHeadersContext(null);
}
});
it("finalizes static route handler responses and schedules cache writes", async () => {
const dynamicUsage = createDynamicUsageState();
const waitUntilPromises: Promise<unknown>[] = [];
const isrSetCalls: Array<{
key: string;
expireSeconds: number | undefined;
revalidateSeconds: number;
tags: string[];
}> = [];
const phaseCalls: string[] = [];
const reportCalls: Error[] = [];
let didClearRequestContext = false;
const response = await executeAppRouteHandler({
buildPageCacheTags(pathname, extraTags) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/static-data",
clearRequestContext() {
didClearRequestContext = true;
},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
executionContext: {
waitUntil(promise) {
waitUntilPromises.push(promise);
},
},
getAndClearPendingCookies() {
return ["session=1; Path=/"];
},
getCollectedFetchTags() {
return ["tag:demo"];
},
getDraftModeCookieHeader() {
return "draft=1; Path=/";
},
handler: { dynamic: "auto" },
handlerFn() {
return new Response("ok", {
status: 201,
headers: {
"content-type": "text/plain",
},
});
},
isAutoHead: false,
isProduction: true,
isrDebug() {},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet(key, value, revalidateSeconds, tags, expireSeconds) {
expect(value.kind).toBe("APP_ROUTE");
isrSetCalls.push({ key, expireSeconds, revalidateSeconds, tags });
},
markDynamicUsage: dynamicUsage.markDynamicUsage,
method: "GET",
middlewareContext: {
headers: new Headers([["x-middleware", "present"]]),
status: 202,
},
params: { slug: "demo" },
reportRequestError(error) {
reportCalls.push(error);
},
request: new Request("https://example.com/api/static-data"),
expireSeconds: 300,
revalidateSeconds: 60,
routePattern: "/api/static-data",
setHeadersAccessPhase(phase) {
phaseCalls.push(phase);
return "render";
},
});
await Promise.all(waitUntilPromises);
expect(response.status).toBe(202);
expect(response.headers.get("cache-control")).toBe("s-maxage=60, stale-while-revalidate=240");
expect(response.headers.get("x-vinext-cache")).toBe("MISS");
expect(response.headers.get("x-middleware")).toBe("present");
expect(response.headers.getSetCookie?.()).toEqual(["session=1; Path=/", "draft=1; Path=/"]);
await expect(response.text()).resolves.toBe("ok");
expect(isrSetCalls).toEqual([
{
key: "route:/api/static-data",
expireSeconds: 300,
revalidateSeconds: 60,
tags: ["/api/static-data", "tag:demo"],
},
]);
expect(phaseCalls).toEqual(["route-handler", "render"]);
expect(didClearRequestContext).toBe(true);
expect(reportCalls).toEqual([]);
});
it("marks dynamic route handlers and skips cache writes when request data is read", async () => {
const dynamicUsage = createDynamicUsageState();
const routePattern = "/api/dynamic-" + Date.now();
let wroteCache = false;
const response = await executeAppRouteHandler({
buildPageCacheTags(pathname, extraTags) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/dynamic",
clearRequestContext() {},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
executionContext: null,
getAndClearPendingCookies() {
return [];
},
getCollectedFetchTags() {
return [];
},
getDraftModeCookieHeader() {
return null;
},
handler: { dynamic: "auto" },
handlerFn(request) {
return Response.json({
ping: request.headers.get("x-test"),
});
},
isAutoHead: false,
isProduction: true,
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
wroteCache = true;
},
markDynamicUsage: dynamicUsage.markDynamicUsage,
method: "GET",
middlewareContext: { headers: null, status: null },
params: {},
reportRequestError() {},
request: new Request("https://example.com/api/dynamic", {
headers: { "x-test": "from-header" },
}),
revalidateSeconds: 60,
routePattern,
setHeadersAccessPhase() {
return "render";
},
});
expect(isKnownDynamicAppRoute(routePattern)).toBe(true);
expect(response.headers.get("cache-control")).toBeNull();
expect(response.headers.get("x-vinext-cache")).toBeNull();
expect(wroteCache).toBe(false);
await expect(response.json()).resolves.toEqual({ ping: "from-header" });
});
it("skips cache writes and marks the route dynamic when a revalidating handler fetches with no-store", async () => {
// Regression test for the patched fetch's explicit no-store branch
// calling markDynamicUsage() (upstream patch-fetch parity, where
// markCurrentScopeAsDynamic bails ISR for the surrounding scope): a route
// handler with `revalidate = 60` that performs
// `fetch(url, { cache: "no-store" })` must not write its ISR entry and
// must be marked known-dynamic. Uses the real headers-shim
// consumeDynamicUsage/markDynamicUsage pair — the same wiring as
// app-route-handler-dispatch — so the mark set by the fetch shim flows
// into `dynamicUsedInHandler`.
const routePattern = "/api/no-store-fetch-" + Date.now();
const waitUntilPromises: Promise<unknown>[] = [];
let wroteCache = false;
const restoreFetchCache = withFetchCache();
try {
// Clear any dynamic usage left over from earlier tests.
consumeDynamicUsage();
const response = await executeAppRouteHandler({
buildPageCacheTags(pathname, extraTags) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/no-store-fetch",
clearRequestContext() {},
consumeDynamicUsage,
executionContext: {
waitUntil(promise) {
waitUntilPromises.push(promise);
},
},
getAndClearPendingCookies() {
return [];
},
getCollectedFetchTags() {
return [];
},
getDraftModeCookieHeader() {
return null;
},
handler: { dynamic: "auto" },
async handlerFn() {
const upstream = await fetch("https://api.example.com/live", {
cache: "no-store",
});
return Response.json(await upstream.json());
},
isAutoHead: false,
isProduction: true,
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
wroteCache = true;
},
markDynamicUsage,
method: "GET",
middlewareContext: { headers: null, status: null },
params: {},
reportRequestError() {},
request: new Request("https://example.com/api/no-store-fetch"),
revalidateSeconds: 60,
routePattern,
setHeadersAccessPhase() {
return "render";
},
});
await Promise.all(waitUntilPromises);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://api.example.com/live");
expect(fetchMock.mock.calls[0]?.[1]?.cache).toBe("no-store");
expect(isKnownDynamicAppRoute(routePattern)).toBe(true);
expect(wroteCache).toBe(false);
expect(response.headers.get("cache-control")).toBeNull();
expect(response.headers.get("x-vinext-cache")).toBeNull();
await expect(response.json()).resolves.toEqual({ ok: true });
} finally {
consumeDynamicUsage();
restoreFetchCache();
}
});
it("maps special route handler errors and reports generic failures", async () => {
const dynamicUsage = createDynamicUsageState();
const reportedErrors: Error[] = [];
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const redirectResponse = await executeAppRouteHandler({
buildPageCacheTags(pathname, extraTags) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/redirect",
clearRequestContext() {},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
executionContext: null,
getAndClearPendingCookies() {
return [];
},
getCollectedFetchTags() {
return [];
},
getDraftModeCookieHeader() {
return null;
},
handler: { dynamic: "auto" },
handlerFn() {
throw { digest: "NEXT_REDIRECT;replace;%2Ftarget;308" };
},
isAutoHead: false,
isProduction: true,
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {},
markDynamicUsage: dynamicUsage.markDynamicUsage,
method: "GET",
middlewareContext: { headers: null, status: null },
params: {},
reportRequestError(error) {
reportedErrors.push(error);
},
request: new Request("https://example.com/api/redirect"),
revalidateSeconds: 60,
routePattern: "/api/redirect",
setHeadersAccessPhase() {
return "render";
},
});
expect(redirectResponse.status).toBe(308);
expect(redirectResponse.headers.get("location")).toBe("https://example.com/target");
expect(reportedErrors).toEqual([]);
const errorResponse = await executeAppRouteHandler({
buildPageCacheTags(pathname, extraTags) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/error",
clearRequestContext() {},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
executionContext: null,
getAndClearPendingCookies() {
return [];
},
getCollectedFetchTags() {
return [];
},
getDraftModeCookieHeader() {
return null;
},
handler: { dynamic: "auto" },
handlerFn() {
throw new Error("boom");
},
isAutoHead: false,
isProduction: true,
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {},
markDynamicUsage: dynamicUsage.markDynamicUsage,
method: "GET",
middlewareContext: { headers: null, status: null },
params: {},
reportRequestError(error) {
reportedErrors.push(error);
},
request: new Request("https://example.com/api/error"),
revalidateSeconds: 60,
routePattern: "/api/error",
setHeadersAccessPhase() {
return "render";
},
});
expect(errorResponse.status).toBe(500);
expect(reportedErrors.map((error) => error.message)).toEqual(["boom"]);
errorSpy.mockRestore();
});
it("rejects middleware control responses returned from route handlers", async () => {
// The NextResponse.next() case is ported from Next.js:
// test/e2e/app-dir/app-routes/app-custom-routes.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-routes/app-custom-routes.test.ts
// The NextResponse.rewrite() case mirrors the adjacent App Route module validation.
const cases = [
{
headerName: "x-middleware-next",
headerValue: "1",
message:
"NextResponse.next() was used in a app route handler, this is not supported. See here for more info: https://nextjs.org/docs/messages/next-response-next-in-app-route-handler",
},
{
headerName: "x-middleware-rewrite",
headerValue: "https://example.com/rewritten",
message:
"NextResponse.rewrite() was used in a app route handler, this is not currently supported. Please remove the invocation to continue.",
},
];
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
for (const testCase of cases) {
const dynamicUsage = createDynamicUsageState();
const reportedErrors: Error[] = [];
let wroteCache = false;
let didClearRequestContext = false;
const response = await executeAppRouteHandler({
buildPageCacheTags(pathname, extraTags) {
return [pathname, ...extraTags];
},
cleanPathname: "/api/middleware-control",
clearRequestContext() {
didClearRequestContext = true;
},
consumeDynamicUsage: dynamicUsage.consumeDynamicUsage,
executionContext: null,
getAndClearPendingCookies() {
return [];
},
getCollectedFetchTags() {
return [];
},
getDraftModeCookieHeader() {
return null;
},
handler: { dynamic: "auto" },
handlerFn() {
return new Response("should not be sent", {
headers: { [testCase.headerName]: testCase.headerValue },
});
},
isAutoHead: false,
isProduction: true,
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
wroteCache = true;
},
markDynamicUsage: dynamicUsage.markDynamicUsage,
method: "GET",
middlewareContext: { headers: null, status: null },
params: {},
reportRequestError(error) {
reportedErrors.push(error);
},
request: new Request("https://example.com/api/middleware-control"),
revalidateSeconds: 60,
routePattern: "/api/middleware-control",
setHeadersAccessPhase() {
return "render";
},
});
expect(response.status).toBe(500);
await expect(response.text()).resolves.toBe("");
expect(reportedErrors.map((error) => error.message)).toEqual([testCase.message]);
expect(wroteCache).toBe(false);
expect(didClearRequestContext).toBe(true);
}
} finally {
errorSpy.mockRestore();
}
});
});