Files
cloudflare__vinext/tests/app-route-handler-dispatch.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

491 lines
17 KiB
TypeScript

import { describe, expect, it, vi } from "vite-plus/test";
import { dispatchAppRouteHandler } from "../packages/vinext/src/server/app-route-handler-dispatch.js";
import type { CachedRouteValue } from "../packages/vinext/src/shims/cache.js";
import type { ISRCacheEntry } from "../packages/vinext/src/server/isr-cache.js";
function buildCachedRouteValue(body: string): CachedRouteValue {
return {
kind: "APP_ROUTE",
body: new TextEncoder().encode(body).buffer,
status: 200,
headers: {
"content-type": "text/plain",
},
};
}
function buildISRCacheEntry(value: CachedRouteValue, isStale = false): ISRCacheEntry {
return {
isStale,
value: {
lastModified: Date.now(),
value,
},
};
}
describe("app route handler dispatch", () => {
it("rejects invalid HTTP methods with 400 before auto-OPTIONS/405 logic", async () => {
// 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#L531-L538
const route = {
pattern: "/api/status",
routeHandler: {
GET() {
throw new Error("GET should not run for invalid methods");
},
},
routeSegments: ["api", "status"],
};
let clearCount = 0;
const invalidMethodResponse = await dispatchAppRouteHandler({
cleanPathname: "/api/status",
clearRequestContext() {
clearCount += 1;
},
draftModeSecret: "test-draft-secret",
i18n: null,
isDevelopment: false,
isProduction: false,
async isrGet() {
throw new Error("invalid method should not read route cache");
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
throw new Error("invalid method should not write route cache");
},
middlewareContext: {
headers: new Headers([["x-middleware", "present"]]),
status: null,
},
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/status", { method: "HEADER" }),
route,
scheduleBackgroundRegeneration() {
throw new Error("invalid method should not schedule regeneration");
},
searchParams: new URLSearchParams(),
});
expect(invalidMethodResponse.status).toBe(400);
expect(invalidMethodResponse.headers.get("x-middleware")).toBe("present");
await expect(invalidMethodResponse.text()).resolves.toBe("");
expect(clearCount).toBe(1);
});
it("handles framework-generated OPTIONS responses and unsupported methods at the dispatch boundary", async () => {
const route = {
pattern: "/api/demo",
routeHandler: {
GET() {
throw new Error("GET should not run for OPTIONS or DELETE");
},
POST() {
throw new Error("POST should not run for OPTIONS or DELETE");
},
},
routeSegments: ["api", "demo"],
};
let clearCount = 0;
const optionsResponse = await dispatchAppRouteHandler({
cleanPathname: "/api/demo",
clearRequestContext() {
clearCount += 1;
},
draftModeSecret: "test-draft-secret",
i18n: null,
isDevelopment: false,
isProduction: false,
async isrGet() {
throw new Error("OPTIONS should not read route cache");
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
throw new Error("OPTIONS should not write route cache");
},
middlewareContext: {
headers: new Headers([["x-middleware", "present"]]),
status: null,
},
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/demo", { method: "OPTIONS" }),
route,
scheduleBackgroundRegeneration() {
throw new Error("OPTIONS should not schedule regeneration");
},
searchParams: new URLSearchParams(),
});
expect(optionsResponse.status).toBe(204);
expect(optionsResponse.headers.get("allow")).toBe("GET, HEAD, OPTIONS, POST");
expect(optionsResponse.headers.get("x-middleware")).toBe("present");
await expect(optionsResponse.text()).resolves.toBe("");
const unsupportedResponse = await dispatchAppRouteHandler({
cleanPathname: "/api/demo",
clearRequestContext() {
clearCount += 1;
},
draftModeSecret: "test-draft-secret",
i18n: null,
isDevelopment: false,
isProduction: false,
async isrGet() {
throw new Error("DELETE should not read route cache");
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
throw new Error("DELETE should not write route cache");
},
middlewareContext: {
headers: new Headers([["x-middleware-delete", "present"]]),
status: null,
},
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/demo", { method: "DELETE" }),
route,
scheduleBackgroundRegeneration() {
throw new Error("DELETE should not schedule regeneration");
},
searchParams: new URLSearchParams(),
});
expect(unsupportedResponse.status).toBe(405);
expect(unsupportedResponse.headers.get("x-middleware-delete")).toBe("present");
await expect(unsupportedResponse.text()).resolves.toBe("");
expect(clearCount).toBe(2);
});
it("reads eligible ISR route handler responses before executing user code", async () => {
const handlerSpy = vi.fn(() => new Response("should not run"));
let didClearRequestContext = false;
let requestedCacheKey: string | null = null;
const response = await dispatchAppRouteHandler({
cleanPathname: "/api/static",
clearRequestContext() {
didClearRequestContext = true;
},
draftModeSecret: "test-draft-secret",
i18n: null,
isDevelopment: false,
isProduction: true,
async isrGet(key) {
requestedCacheKey = key;
return buildISRCacheEntry(buildCachedRouteValue("from-cache"));
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
throw new Error("cache hit should not write route cache");
},
middlewareContext: { headers: null, status: null },
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/static"),
route: {
pattern: "/api/static",
routeHandler: {
GET: handlerSpy,
revalidate: 60,
},
routeSegments: ["api", "static"],
},
scheduleBackgroundRegeneration() {
throw new Error("fresh cache hit should not schedule regeneration");
},
searchParams: new URLSearchParams(),
});
expect(requestedCacheKey).toBe("route:/api/static");
expect(response.status).toBe(200);
expect(response.headers.get("x-vinext-cache")).toBe("HIT");
await expect(response.text()).resolves.toBe("from-cache");
expect(handlerSpy).not.toHaveBeenCalled();
expect(didClearRequestContext).toBe(true);
});
// Matches Next.js behavior: route handlers on non-dynamic routes receive
// `context.params` as null (not `{}`). User code typically does
// `const resolved = params ? await params : null`, and the resolved value
// is observable through tests like `expect(meta.params).toEqual(null)`.
// 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#L424-L431
it("passes params: null to route handlers on non-dynamic routes", async () => {
let receivedParams: unknown = "untouched";
const route = {
pattern: "/api/static",
routeHandler: {
GET(_request: Request, context: { params: unknown }) {
receivedParams = context.params;
return new Response("ok");
},
},
routeSegments: ["api", "static"],
};
await dispatchAppRouteHandler({
cleanPathname: "/api/static",
clearRequestContext() {},
draftModeSecret: "test-draft-secret",
i18n: null,
isDevelopment: false,
isProduction: true,
async isrGet() {
return null;
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {},
middlewareContext: { headers: null, status: null },
middlewareRequestHeaders: null,
params: null,
request: new Request("https://example.com/api/static"),
route,
scheduleBackgroundRegeneration() {},
searchParams: new URLSearchParams(),
});
expect(receivedParams).toBeNull();
});
it("attaches App Router route context when stale route handler cache schedules regeneration", async () => {
const handlerSpy = vi.fn(() => new Response("regenerated"));
let scheduledContext:
| {
routerKind: "App Router";
routePath: string;
routeType: "route";
}
| undefined;
const response = await dispatchAppRouteHandler({
cleanPathname: "/api/stale",
clearRequestContext() {},
draftModeSecret: "test-draft-secret",
i18n: null,
isDevelopment: false,
isProduction: true,
async isrGet() {
return buildISRCacheEntry(buildCachedRouteValue("stale"), true);
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
throw new Error("stale response should not synchronously write route cache");
},
middlewareContext: { headers: null, status: null },
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/stale"),
route: {
pattern: "/api/stale",
routeHandler: {
GET: handlerSpy,
revalidate: 60,
},
routeSegments: ["api", "stale"],
},
scheduleBackgroundRegeneration(_key, _renderFn, errorContext) {
scheduledContext = errorContext;
},
searchParams: new URLSearchParams(),
});
expect(response.status).toBe(200);
expect(response.headers.get("x-vinext-cache")).toBe("STALE");
await expect(response.text()).resolves.toBe("stale");
expect(handlerSpy).not.toHaveBeenCalled();
expect(scheduledContext).toEqual({
routerKind: "App Router",
routePath: "/api/stale",
routeType: "route",
});
});
// Parity with upstream's app-route module: `dynamic = "force-dynamic"` sets
// `workStore.forceDynamic`, which patch-fetch turns into a no-store default
// for fetches without explicit cache config — for route handlers as well as
// pages.
it("applies the force-dynamic fetch default before invoking the route handler", async () => {
const fetchCacheShims = await import("../packages/vinext/src/shims/fetch-cache.js");
const modeSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchCacheMode");
const forceDynamicSpy = vi.spyOn(fetchCacheShims, "setCurrentForceDynamicFetchDefault");
let forceDynamicDefaultAtHandlerTime: boolean | undefined;
let fetchCacheModeAtHandlerTime: unknown = "unset";
const response = await dispatchAppRouteHandler({
cleanPathname: "/api/force-dynamic",
clearRequestContext() {},
draftModeSecret: "test-draft-secret",
i18n: null,
isDevelopment: false,
isProduction: true,
async isrGet() {
throw new Error("force-dynamic handler should not read route cache");
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {
throw new Error("force-dynamic handler should not write route cache");
},
middlewareContext: { headers: null, status: null },
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/force-dynamic"),
route: {
pattern: "/api/force-dynamic",
routeHandler: {
dynamic: "force-dynamic",
GET() {
forceDynamicDefaultAtHandlerTime = forceDynamicSpy.mock.calls.at(-1)?.[0];
fetchCacheModeAtHandlerTime = modeSpy.mock.calls.at(-1)?.[0];
return new Response("dynamic");
},
},
routeSegments: ["api", "force-dynamic"],
},
scheduleBackgroundRegeneration() {
throw new Error("force-dynamic handler should not schedule regeneration");
},
searchParams: new URLSearchParams(),
});
expect(response.status).toBe(200);
expect(forceDynamicDefaultAtHandlerTime).toBe(true);
expect(fetchCacheModeAtHandlerTime).toBeNull();
modeSpy.mockRestore();
forceDynamicSpy.mockRestore();
});
it("applies the handler's explicit fetchCache export without the force-dynamic default", async () => {
const fetchCacheShims = await import("../packages/vinext/src/shims/fetch-cache.js");
const modeSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchCacheMode");
const forceDynamicSpy = vi.spyOn(fetchCacheShims, "setCurrentForceDynamicFetchDefault");
let forceDynamicDefaultAtHandlerTime: boolean | undefined;
let fetchCacheModeAtHandlerTime: unknown = "unset";
const response = await dispatchAppRouteHandler({
cleanPathname: "/api/segment-fetch-cache",
clearRequestContext() {},
draftModeSecret: "test-draft-secret",
i18n: null,
isDevelopment: false,
isProduction: false,
async isrGet() {
return null;
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {},
middlewareContext: { headers: null, status: null },
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/segment-fetch-cache"),
route: {
pattern: "/api/segment-fetch-cache",
routeHandler: {
fetchCache: "force-cache",
GET() {
forceDynamicDefaultAtHandlerTime = forceDynamicSpy.mock.calls.at(-1)?.[0];
fetchCacheModeAtHandlerTime = modeSpy.mock.calls.at(-1)?.[0];
return new Response("cached-fetches");
},
},
routeSegments: ["api", "segment-fetch-cache"],
},
scheduleBackgroundRegeneration() {
throw new Error("uncached handler should not schedule regeneration");
},
searchParams: new URLSearchParams(),
});
expect(response.status).toBe(200);
expect(forceDynamicDefaultAtHandlerTime).toBe(false);
expect(fetchCacheModeAtHandlerTime).toBe("force-cache");
modeSpy.mockRestore();
forceDynamicSpy.mockRestore();
});
it("re-applies the handler's fetch cache mode inside the background regeneration context", async () => {
const fetchCacheShims = await import("../packages/vinext/src/shims/fetch-cache.js");
const modeSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchCacheMode");
const forceDynamicSpy = vi.spyOn(fetchCacheShims, "setCurrentForceDynamicFetchDefault");
let scheduledRender: (() => Promise<void>) | undefined;
let forceDynamicDefaultAtRegenTime: boolean | undefined;
let fetchCacheModeAtRegenTime: unknown = "unset";
const response = await dispatchAppRouteHandler({
cleanPathname: "/api/stale-fetch-cache",
clearRequestContext() {},
draftModeSecret: "test-draft-secret",
i18n: null,
isDevelopment: false,
isProduction: true,
async isrGet() {
return buildISRCacheEntry(buildCachedRouteValue("stale"), true);
},
isrRouteKey(pathname) {
return "route:" + pathname;
},
async isrSet() {},
middlewareContext: { headers: null, status: null },
middlewareRequestHeaders: null,
params: {},
request: new Request("https://example.com/api/stale-fetch-cache"),
route: {
pattern: "/api/stale-fetch-cache",
routeHandler: {
fetchCache: "force-cache",
revalidate: 60,
GET() {
forceDynamicDefaultAtRegenTime = forceDynamicSpy.mock.calls.at(-1)?.[0];
fetchCacheModeAtRegenTime = modeSpy.mock.calls.at(-1)?.[0];
return new Response("regenerated");
},
},
routeSegments: ["api", "stale-fetch-cache"],
},
scheduleBackgroundRegeneration(_key, renderFn) {
scheduledRender = renderFn;
},
searchParams: new URLSearchParams(),
});
expect(response.headers.get("x-vinext-cache")).toBe("STALE");
expect(typeof scheduledRender).toBe("function");
if (typeof scheduledRender !== "function") {
throw new Error("expected stale route handler cache to schedule regeneration");
}
await scheduledRender();
expect(forceDynamicDefaultAtRegenTime).toBe(false);
expect(fetchCacheModeAtRegenTime).toBe("force-cache");
modeSpy.mockRestore();
forceDynamicSpy.mockRestore();
});
});