Files
cloudflare__vinext/tests/app-segment-config.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

313 lines
8.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
isEdgeRuntime,
resolveAppPageFetchCacheMode,
resolveAppPageSegmentConfig,
resolveAppRouteHandlerFetchCacheMode,
} from "../packages/vinext/src/server/app-segment-config.js";
describe("resolveAppPageSegmentConfig", () => {
it("returns defaults when no segment config is present", () => {
expect(resolveAppPageSegmentConfig({})).toEqual({
revalidateSeconds: null,
});
});
it("merges route segment config from layouts and the page", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [
{ revalidate: 120, dynamicParams: false, fetchCache: "default-cache" },
{ dynamic: "error", revalidate: 60 },
],
page: { dynamic: "force-static", revalidate: 300 },
}),
).toEqual({
dynamicConfig: "force-static",
dynamicParamsConfig: false,
fetchCache: "default-cache",
revalidateSeconds: 60,
});
});
it("treats force-dynamic from any effective segment as revalidate zero", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ revalidate: 60 }],
page: { dynamic: "force-dynamic" },
}),
).toEqual({
dynamicConfig: "force-dynamic",
revalidateSeconds: 0,
});
});
it("derives fetchCache from static-only dynamic modes", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "error" }],
page: {},
}),
).toEqual({
dynamicConfig: "error",
dynamicParamsConfig: false,
fetchCache: "only-cache",
revalidateSeconds: null,
});
});
it("lets explicit fetchCache override the dynamic mode default", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "error" }],
page: { fetchCache: "default-cache" },
}),
).toEqual({
dynamicConfig: "error",
dynamicParamsConfig: false,
fetchCache: "default-cache",
revalidateSeconds: null,
});
});
it("resolves fetchCache force modes with route-level precedence", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ fetchCache: "only-cache" }],
page: { fetchCache: "force-cache" },
}).fetchCache,
).toBe("force-cache");
expect(
resolveAppPageSegmentConfig({
layouts: [{ fetchCache: "force-no-store" }],
page: { fetchCache: "only-no-store" },
}).fetchCache,
).toBe("force-no-store");
});
it("rejects incompatible cross-segment fetchCache modes", () => {
expect(() =>
resolveAppPageSegmentConfig({
layouts: [{ fetchCache: "only-cache" }],
page: { fetchCache: "only-no-store" },
}),
).toThrow(/incompatible fetchCache/);
expect(() =>
resolveAppPageSegmentConfig({
layouts: [{ fetchCache: "force-cache" }],
page: { fetchCache: "force-no-store" },
}),
).toThrow(/incompatible fetchCache/);
expect(() =>
resolveAppPageSegmentConfig({
layouts: [{ fetchCache: "default-no-store" }],
page: { fetchCache: "auto" },
}),
).toThrow(/incompatible fetchCache/);
});
it("ignores unknown dynamic values", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "sometimes" }],
page: { dynamic: "force-static" },
}),
).toEqual({
dynamicConfig: "force-static",
dynamicParamsConfig: false,
revalidateSeconds: null,
});
});
it("defaults dynamicParams to false for static-only dynamic modes", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "error" }],
page: {},
}),
).toEqual({
dynamicConfig: "error",
dynamicParamsConfig: false,
fetchCache: "only-cache",
revalidateSeconds: null,
});
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "force-static" }],
page: {},
}),
).toEqual({
dynamicConfig: "force-static",
dynamicParamsConfig: false,
revalidateSeconds: null,
});
});
it("lets explicit dynamicParams override static-only dynamic defaults", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamic: "error" }],
page: { dynamicParams: true },
}),
).toEqual({
dynamicConfig: "error",
dynamicParamsConfig: true,
fetchCache: "only-cache",
revalidateSeconds: null,
});
});
it("uses the child route runtime when segment runtimes differ", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ runtime: "edge" }],
page: { runtime: "nodejs" },
}).runtime,
).toBe("nodejs");
});
it("ignores unknown runtime values", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ runtime: "bun" }],
page: {},
}),
).toEqual({
revalidateSeconds: null,
});
});
it("keeps explicit dynamicParams false sticky across child segments", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ dynamicParams: false }],
page: { dynamicParams: true },
}),
).toEqual({
dynamicParamsConfig: false,
revalidateSeconds: null,
});
});
it("resolves revalidate = false as Infinity (cache indefinitely)", () => {
expect(
resolveAppPageSegmentConfig({
page: { revalidate: false },
}),
).toEqual({
revalidateSeconds: Infinity,
});
});
it("resolves shortest-wins: finite revalidate beats false (Infinity)", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ revalidate: 60 }],
page: { revalidate: false },
}).revalidateSeconds,
).toBe(60);
});
it("resolves shortest-wins: false (Infinity) loses to any finite value", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ revalidate: false }],
page: { revalidate: 60 },
}).revalidateSeconds,
).toBe(60);
});
it("reads unstable_dynamicStaleTime only from page modules", () => {
// Ported from Next.js: test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts
// See also: packages/next/src/server/app-render/app-render.tsx#getDynamicStaleTime
expect(
resolveAppPageSegmentConfig({
layouts: [{ unstable_dynamicStaleTime: 5 }],
page: { unstable_dynamicStaleTime: 60 },
}),
).toEqual({
dynamicStaleTimeSeconds: 60,
revalidateSeconds: null,
});
});
it("uses the shortest unstable_dynamicStaleTime across active page slots", () => {
// Ported from Next.js: test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts
expect(
resolveAppPageSegmentConfig({
page: { unstable_dynamicStaleTime: 60 },
parallelPages: [
{ unstable_dynamicStaleTime: 15 },
{ unstable_dynamicStaleTime: 30 },
{ unstable_dynamicStaleTime: "not-a-number" },
],
}),
).toEqual({
dynamicStaleTimeSeconds: 15,
revalidateSeconds: null,
});
});
it("resolves just the fetchCache mode for route-specific render scopes", () => {
expect(
resolveAppPageFetchCacheMode({
layouts: [{ fetchCache: "only-cache" }],
page: {},
}),
).toBe("only-cache");
expect(
resolveAppPageFetchCacheMode({
layouts: [{ revalidate: 60 }],
page: {},
}),
).toBeNull();
});
it("captures the runtime export and lets child segments override parents", () => {
expect(
resolveAppPageSegmentConfig({
layouts: [{ runtime: "nodejs" }],
page: { runtime: "edge" },
}).runtime,
).toBe("edge");
expect(
resolveAppPageSegmentConfig({
layouts: [{ runtime: "edge" }],
page: {},
}).runtime,
).toBe("edge");
expect(resolveAppPageSegmentConfig({ page: {} }).runtime).toBeUndefined();
});
});
describe("resolveAppRouteHandlerFetchCacheMode", () => {
it("returns the handler module's fetchCache export when valid", () => {
expect(resolveAppRouteHandlerFetchCacheMode({ fetchCache: "force-cache" })).toBe("force-cache");
expect(resolveAppRouteHandlerFetchCacheMode({ fetchCache: "default-no-store" })).toBe(
"default-no-store",
);
});
it("returns null for missing or invalid fetchCache values", () => {
expect(resolveAppRouteHandlerFetchCacheMode({})).toBeNull();
expect(resolveAppRouteHandlerFetchCacheMode({ fetchCache: "bogus" })).toBeNull();
expect(resolveAppRouteHandlerFetchCacheMode({ fetchCache: 42 })).toBeNull();
});
});
describe("isEdgeRuntime", () => {
it("matches Next.js' edge-runtime values", () => {
expect(isEdgeRuntime("edge")).toBe(true);
expect(isEdgeRuntime("experimental-edge")).toBe(true);
expect(isEdgeRuntime("nodejs")).toBe(false);
expect(isEdgeRuntime(undefined)).toBe(false);
});
});