Files
cloudflare__vinext/tests/fetch-cache.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

2753 lines
98 KiB
TypeScript

/**
* Unit tests for fetch cache shim.
*
* Tests the patched fetch() with Next.js caching semantics:
* - next.revalidate for TTL-based caching
* - next.tags for tag-based invalidation
* - cache: 'no-store' and cache: 'force-cache'
* - Stale-while-revalidate behavior
* - next property stripping
* - Independent cache entries per URL
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test";
// We need to mock fetch at the module level BEFORE fetch-cache.ts captures
// `originalFetch`. Use vi.stubGlobal to intercept at import time.
let requestCount = 0;
const defaultFetchMockImplementation = async (
input: string | URL | Request,
_init?: RequestInit,
) => {
requestCount++;
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const response = new Response(JSON.stringify({ url, count: requestCount }), {
status: 200,
headers: { "content-type": "application/json" },
});
Object.defineProperty(response, "url", {
value: url,
configurable: true,
enumerable: true,
writable: false,
});
return response;
};
const fetchMock = vi.fn(defaultFetchMockImplementation);
// Stub globalThis.fetch BEFORE importing modules that capture it
vi.stubGlobal("fetch", fetchMock);
// Now import — these will capture fetchMock as "originalFetch"
const {
withFetchCache,
runWithFetchCache,
getCollectedFetchTags,
setCurrentFetchCacheMode,
setCurrentForceDynamicFetchDefault,
setCurrentFetchSoftTags,
getOriginalFetch,
_resetPendingRefetches,
consumeDynamicFetchObservations,
peekDynamicFetchObservations,
} = await import("../packages/vinext/src/shims/fetch-cache.js");
const { getCacheHandler, revalidatePath, revalidateTag, MemoryCacheHandler, setCacheHandler } =
await import("../packages/vinext/src/shims/cache.js");
const { consumeDynamicUsage } = await import("../packages/vinext/src/shims/headers.js");
const { runWithExecutionContext } = await import("../packages/vinext/src/shims/request-context.js");
const { createRequestContext, runWithRequestContext } =
await import("../packages/vinext/src/shims/unified-request-context.js");
describe("fetch cache shim", () => {
let cleanup: (() => void) | null = null;
function startNewFetchCacheScope(): void {
cleanup?.();
cleanup = withFetchCache();
}
beforeEach(() => {
// Reset state
requestCount = 0;
fetchMock.mockReset();
fetchMock.mockImplementation(defaultFetchMockImplementation);
// Reset the cache handler to a fresh instance for each test
setCacheHandler(new MemoryCacheHandler());
// Clear in-flight refetch dedup state
_resetPendingRefetches();
consumeDynamicUsage();
// Install the patched fetch
cleanup = withFetchCache();
});
afterEach(() => {
consumeDynamicUsage();
cleanup?.();
cleanup = null;
});
// ── Basic caching with next.revalidate ──────────────────────────────
it("caches fetch with next.revalidate and returns cached on second call", async () => {
const res1 = await fetch("https://api.example.com/data", {
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Second call should return cached data (no new network request)
const res2 = await fetch("https://api.example.com/data", {
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same count = cached
expect(fetchMock).toHaveBeenCalledTimes(1); // Only one real fetch
});
it("cache: 'force-cache' caches indefinitely", async () => {
const res1 = await fetch("https://api.example.com/force", {
cache: "force-cache",
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/force", {
cache: "force-cache",
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Cached
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("preserves Response.url on cached fetch responses", async () => {
const url = "https://api.example.com/force-url";
await fetch(url, {
cache: "force-cache",
});
const cached = await fetch(url, {
cache: "force-cache",
});
expect(cached.url).toBe(url);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("preserves actual response URL when it differs from request URL", async () => {
const requestUrl = "https://api.example.com/redirect-request";
const responseUrl = "https://api.example.com/redirect-actual";
fetchMock.mockImplementationOnce(async () => {
requestCount++;
const response = new Response(JSON.stringify({ url: responseUrl, count: requestCount }), {
status: 200,
headers: { "content-type": "application/json" },
});
Object.defineProperty(response, "url", {
value: responseUrl,
configurable: true,
enumerable: true,
writable: false,
});
return response;
});
const res1 = await fetch(requestUrl, {
cache: "force-cache",
});
expect(res1.url).toBe(responseUrl);
const cached = await fetch(requestUrl, {
cache: "force-cache",
});
expect(cached.url).toBe(responseUrl);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("falls back to the request URL when a cached entry lacks url", async () => {
const url = "https://api.example.com/legacy-no-url";
await fetch(url, {
cache: "force-cache",
});
// Simulate a legacy/third-party cache writer (e.g. an external KV backend)
// that never populated `data.url` on the serialized entry.
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
delete entry.value.data.url;
}
startNewFetchCacheScope();
const cached = await fetch(url, {
cache: "force-cache",
});
expect(cached.url).toBe(url);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache default-cache caches fetches without per-fetch options", async () => {
setCurrentFetchCacheMode("default-cache");
const res1 = await fetch("https://api.example.com/segment-default-cache");
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/segment-default-cache");
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache default-cache caches Request inputs without per-fetch options", async () => {
setCurrentFetchCacheMode("default-cache");
const res1 = await fetch(new Request("https://api.example.com/segment-request-default-cache"));
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch(new Request("https://api.example.com/segment-request-default-cache"));
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache default-cache caches fetches with metadata-only next options", async () => {
setCurrentFetchCacheMode("default-cache");
const res1 = await fetch("https://api.example.com/segment-default-cache-tags", {
next: { tags: ["segment-default-cache-tags"] },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/segment-default-cache-tags", {
next: { tags: ["segment-default-cache-tags"] },
});
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache default-cache does not override explicit no-store", async () => {
setCurrentFetchCacheMode("default-cache");
const res1 = await fetch("https://api.example.com/segment-explicit-no-store", {
cache: "no-store",
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/segment-explicit-no-store", {
cache: "no-store",
});
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
// revalidate: 0 is an explicit opt-out — default-cache must not override it to force-cache
it("segment fetchCache default-cache does not override next.revalidate: 0", async () => {
setCurrentFetchCacheMode("default-cache");
await fetch("https://api.example.com/segment-default-cache-revalidate-zero", {
next: { revalidate: 0 },
});
// revalidate: 0 bypasses cache entirely (no persistent cache entry)
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(consumeDynamicUsage()).toBe(true);
});
// revalidate: false is an explicit opt-out — default-no-store must not override it
it("segment fetchCache default-no-store does not override next.revalidate: false", async () => {
setCurrentFetchCacheMode("default-no-store");
const res1 = await fetch("https://api.example.com/segment-default-no-store-revalidate-false", {
next: { revalidate: false },
});
const data1 = await res1.json();
// revalidate: false → cache indefinitely (1 year), so second fetch hits cache
const res2 = await fetch("https://api.example.com/segment-default-no-store-revalidate-false", {
next: { revalidate: false },
});
const data2 = await res2.json();
expect(data1.count).toBe(1);
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache default-no-store bypasses cache with metadata-only next options", async () => {
setCurrentFetchCacheMode("default-no-store");
const res1 = await fetch("https://api.example.com/segment-default-no-store-tags", {
next: { tags: ["segment-default-no-store-tags"] },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/segment-default-no-store-tags", {
next: { tags: ["segment-default-no-store-tags"] },
});
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache force-no-store overrides explicit force-cache", async () => {
setCurrentFetchCacheMode("force-no-store");
const res1 = await fetch("https://api.example.com/segment-force-no-store", {
cache: "force-cache",
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/segment-force-no-store", {
cache: "force-cache",
});
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("segment fetchCache force-no-store forwards no-store to the real fetch", async () => {
setCurrentFetchCacheMode("force-no-store");
await fetch("https://api.example.com/segment-force-no-store-init", {
cache: "force-cache",
});
expect(fetchMock).toHaveBeenCalledWith("https://api.example.com/segment-force-no-store-init", {
cache: "no-store",
});
});
// Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app-static/app-static.test.ts
// Upstream verifies that explicit uncached fetches in /default-cache and
// fetchCache = "force-no-store" make the page output non-reusable, while
// auto/default fetches can still participate in static prerender output.
it("marks page output dynamic for explicit uncached fetch decisions", async () => {
setCurrentFetchCacheMode("default-cache");
await fetch("https://api.example.com/segment-explicit-no-cache", {
cache: "no-cache",
});
expect(consumeDynamicUsage()).toBe(true);
setCurrentFetchCacheMode("force-no-store");
await fetch("https://api.example.com/segment-force-no-store-dynamic", {
cache: "force-cache",
});
expect(consumeDynamicUsage()).toBe(true);
});
it("does not mark page output dynamic for auto/default pass-through fetches", async () => {
await fetch("https://api.example.com/auto-pass-through");
await fetch("https://api.example.com/default-pass-through", {
cache: "default",
});
expect(consumeDynamicUsage()).toBe(false);
expect(peekDynamicFetchObservations()).toEqual([
"https://api.example.com/auto-pass-through",
"https://api.example.com/default-pass-through",
]);
});
it("does not mark page output dynamic for fetchCache default-cache implicit cache hits", async () => {
setCurrentFetchCacheMode("default-cache");
const res1 = await fetch("https://api.example.com/segment-default-cache-static");
const data1 = await res1.json();
const res2 = await fetch("https://api.example.com/segment-default-cache-static");
const data2 = await res2.json();
expect(data1.count).toBe(1);
expect(data2.count).toBe(1);
expect(consumeDynamicUsage()).toBe(false);
});
it("uses force-dynamic as a default no-store fetch mode without overriding explicit revalidate", async () => {
setCurrentForceDynamicFetchDefault(true);
await fetch("https://api.example.com/force-dynamic-default");
expect(fetchMock).toHaveBeenLastCalledWith("https://api.example.com/force-dynamic-default", {
cache: "no-store",
});
expect(consumeDynamicUsage()).toBe(true);
await fetch("https://api.example.com/force-dynamic-cache-default", {
cache: "default",
});
expect(fetchMock).toHaveBeenLastCalledWith(
"https://api.example.com/force-dynamic-cache-default",
{
cache: "no-store",
},
);
expect(consumeDynamicUsage()).toBe(true);
const res1 = await fetch("https://api.example.com/force-dynamic-explicit-revalidate", {
next: { revalidate: 3 },
});
const data1 = await res1.json();
const res2 = await fetch("https://api.example.com/force-dynamic-explicit-revalidate", {
next: { revalidate: 3 },
});
const data2 = await res2.json();
expect(data1.count).toBe(3);
expect(data2.count).toBe(3);
expect(consumeDynamicUsage()).toBe(false);
});
// Ported from Next.js: test/e2e/app-dir/force-dynamic-fetch-revalidate/force-dynamic-fetch-revalidate.test.ts
// Upstream noFetchConfigAndForceDynamic uses !currentFetchRevalidate (truthiness),
// so revalidate: false is treated as "no fetch revalidate config" and force-dynamic wins.
it("force-dynamic overrides next.revalidate: false to no-store (upstream parity)", async () => {
setCurrentForceDynamicFetchDefault(true);
await fetch("https://api.example.com/force-dynamic-revalidate-false", {
next: { revalidate: false },
});
expect(fetchMock).toHaveBeenLastCalledWith(
"https://api.example.com/force-dynamic-revalidate-false",
{
cache: "no-store",
},
);
expect(consumeDynamicUsage()).toBe(true);
});
// The force-dynamic fetch default only applies when the segment has no
// explicit fetchCache mode — an explicit `fetchCache = "default-cache"` /
// `"default-no-store"` takes precedence over the force-dynamic default.
it("explicit segment fetchCache takes precedence over force-dynamic fetch default", async () => {
setCurrentForceDynamicFetchDefault(true);
setCurrentFetchCacheMode("default-cache");
const res1 = await fetch("https://api.example.com/force-dynamic-segment-default-cache");
const data1 = await res1.json();
const res2 = await fetch("https://api.example.com/force-dynamic-segment-default-cache");
const data2 = await res2.json();
// default-cache promotes the fetch to force-cache despite force-dynamic
expect(data1.count).toBe(1);
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(consumeDynamicUsage()).toBe(false);
setCurrentFetchCacheMode("default-no-store");
await fetch("https://api.example.com/force-dynamic-segment-default-no-store");
expect(fetchMock).toHaveBeenLastCalledWith(
"https://api.example.com/force-dynamic-segment-default-no-store",
{
cache: "no-store",
},
);
});
// Upstream noFetchConfigAndForceDynamic: tags alone are not cache config, so
// a tags-only fetch under force-dynamic still defaults to no-store. The tags
// are stripped with the rest of `next` and never registered for
// revalidation, so they must not re-enable caching.
it("force-dynamic defaults tags-only fetches to no-store without registering tags", async () => {
setCurrentForceDynamicFetchDefault(true);
const res1 = await fetch("https://api.example.com/force-dynamic-tags-only", {
next: { tags: ["force-dynamic-tags-only"] },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
expect(fetchMock).toHaveBeenLastCalledWith("https://api.example.com/force-dynamic-tags-only", {
cache: "no-store",
});
expect(getCollectedFetchTags()).toEqual([]);
expect(consumeDynamicUsage()).toBe(true);
// No persistent cache entry: a fresh render scope re-fetches.
startNewFetchCacheScope();
setCurrentForceDynamicFetchDefault(true);
const res2 = await fetch("https://api.example.com/force-dynamic-tags-only", {
next: { tags: ["force-dynamic-tags-only"] },
});
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("segment fetchCache only-cache rejects no-store fetches", async () => {
setCurrentFetchCacheMode("only-cache");
await expect(
fetch("https://api.example.com/segment-only-cache", {
cache: "no-store",
}),
).rejects.toThrow(/only-cache/);
});
it("segment fetchCache only-cache rejects no-store Request inputs", async () => {
setCurrentFetchCacheMode("only-cache");
await expect(
fetch(
new Request("https://api.example.com/segment-only-cache-request", {
cache: "no-store",
}),
),
).rejects.toThrow(/only-cache/);
});
it("segment fetchCache only-no-store rejects cacheable fetches", async () => {
setCurrentFetchCacheMode("only-no-store");
await expect(
fetch("https://api.example.com/segment-only-no-store", {
cache: "force-cache",
}),
).rejects.toThrow(/only-no-store/);
});
it("segment fetchCache only-no-store rejects next.revalidate: false fetches", async () => {
setCurrentFetchCacheMode("only-no-store");
await expect(
fetch("https://api.example.com/segment-only-no-store-revalidate-false", {
next: { revalidate: false },
}),
).rejects.toThrow(/only-no-store/);
});
it("segment fetchCache only-no-store rejects cacheable Request inputs", async () => {
setCurrentFetchCacheMode("only-no-store");
await expect(
fetch(
new Request("https://api.example.com/segment-only-no-store-request", {
cache: "force-cache",
}),
),
).rejects.toThrow(/only-no-store/);
});
// ── No caching (no-store, revalidate: 0) ─────────────────────────────
// Ported from Next.js: test coverage for packages/next/src/server/lib/dedupe-fetch.test.ts
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/dedupe-fetch.test.ts
it("cache: 'no-store' bypasses persistent cache but dedupes identical render fetches", async () => {
const res1 = await fetch("https://api.example.com/nostore", {
cache: "no-store",
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/nostore", {
cache: "no-store",
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("next.revalidate: 0 bypasses persistent cache but dedupes identical render fetches", async () => {
const res1 = await fetch("https://api.example.com/rev0", {
next: { revalidate: 0 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/rev0", {
next: { revalidate: 0 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("dedupes dynamic fetch observations within a fetch cache scope", async () => {
await fetch("https://api.example.com/dynamic?token=secret", {
cache: "no-store",
});
await fetch("https://api.example.com/dynamic?token=secret", {
cache: "no-store",
});
await fetch(new URL("https://api.example.com/other-dynamic"), {
cache: "no-store",
});
const expected = [
"https://api.example.com/dynamic?token=secret",
"https://api.example.com/other-dynamic",
];
expect(peekDynamicFetchObservations()).toEqual(expected);
expect(consumeDynamicFetchObservations()).toEqual(expected);
expect(peekDynamicFetchObservations()).toEqual([]);
});
it("next.revalidate: false caches indefinitely", async () => {
const res1 = await fetch("https://api.example.com/revfalse", {
next: { revalidate: false },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/revfalse", {
next: { revalidate: false },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Cached indefinitely
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("next.revalidate: false does not mark page output dynamic", async () => {
await fetch("https://api.example.com/revfalse-dynamic", {
next: { revalidate: false },
});
expect(consumeDynamicUsage()).toBe(false);
});
it("no cache or next options bypasses persistent cache but dedupes identical render fetches", async () => {
const res1 = await fetch("https://api.example.com/passthrough");
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/passthrough");
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("no cache or next options do not dedupe across request scopes", async () => {
cleanup?.();
cleanup = null;
const data1 = await runWithFetchCache(async () => {
const res = await fetch("https://api.example.com/request-scoped-dedupe");
return await res.json();
});
const data2 = await runWithFetchCache(async () => {
const res = await fetch("https://api.example.com/request-scoped-dedupe");
return await res.json();
});
expect(data1.count).toBe(1);
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
cleanup = withFetchCache();
});
it("does not dedupe ordinary fetches outside a fetch-cache scope", async () => {
cleanup?.();
cleanup = null;
await fetch("https://api.example.com/outside-scope");
await fetch("https://api.example.com/outside-scope");
expect(fetchMock).toHaveBeenCalledTimes(2);
cleanup = withFetchCache();
});
it("dedupes identical uncached responses with independent response bodies", async () => {
const [res1, res2] = await Promise.all([
fetch("https://api.example.com/body-dedupe"),
fetch("https://api.example.com/body-dedupe"),
]);
expect(await res1.json()).toEqual({
url: "https://api.example.com/body-dedupe",
count: 1,
});
expect(await res2.json()).toEqual({
url: "https://api.example.com/body-dedupe",
count: 1,
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("does not dedupe uncached fetches with abort signals", async () => {
const controller = new AbortController();
await fetch("https://api.example.com/signal-dedupe", { signal: controller.signal });
await fetch("https://api.example.com/signal-dedupe", { signal: controller.signal });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not dedupe uncached fetches with side-effecting methods", async () => {
await fetch("https://api.example.com/post-dedupe", { method: "POST", body: "one" });
await fetch("https://api.example.com/post-dedupe", { method: "POST", body: "one" });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("dedupes uncached fetches across trace header differences only", async () => {
const [res1, res2] = await Promise.all([
fetch("https://api.example.com/trace-dedupe", {
headers: {
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
tracestate: "vendor=a",
},
}),
fetch("https://api.example.com/trace-dedupe", {
headers: {
traceparent: "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-cccccccccccccccc-01",
tracestate: "vendor=b",
},
}),
]);
expect((await res1.json()).count).toBe(1);
expect((await res2.json()).count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
await fetch("https://api.example.com/trace-dedupe", {
headers: { "x-custom": "different" },
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("dedupes identical Request object inputs as the dedupe key source", async () => {
const [res1, res2] = await Promise.all([
fetch(new Request("https://api.example.com/req-input-dedupe")),
fetch(new Request("https://api.example.com/req-input-dedupe")),
]);
expect((await res1.json()).count).toBe(1);
expect((await res2.json()).count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("removes failed dedupe entries so a later fetch in the same scope can retry", async () => {
fetchMock.mockReset();
fetchMock
.mockImplementationOnce(async () => {
throw new Error("network down");
})
.mockImplementation(defaultFetchMockImplementation);
await expect(fetch("https://api.example.com/retry-after-failure")).rejects.toThrow(
"network down",
);
const res = await fetch("https://api.example.com/retry-after-failure");
expect((await res.json()).url).toBe("https://api.example.com/retry-after-failure");
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not dedupe Request inputs that differ in non-trace headers", async () => {
const [res1, res2] = await Promise.all([
fetch(
new Request("https://api.example.com/req-input-headers", {
headers: { "x-variant": "a" },
}),
),
fetch(
new Request("https://api.example.com/req-input-headers", {
headers: { "x-variant": "b" },
}),
),
]);
expect((await res1.json()).count).toBe(1);
expect((await res2.json()).count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── Tag-based invalidation ──────────────────────────────────────────
it("next.tags caches and revalidateTag invalidates", async () => {
const res1 = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Cached
const res2 = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
// Invalidate via tag
await revalidateTag("posts");
startNewFetchCacheScope();
// Should re-fetch after tag invalidation
const res3 = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
const data3 = await res3.json();
expect(data3.count).toBe(2); // Fresh fetch
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("revalidateTag only invalidates matching tags", async () => {
// Cache two different tagged fetches
await fetch("https://api.example.com/posts-tag", {
next: { tags: ["posts"] },
});
await fetch("https://api.example.com/users-tag", {
next: { tags: ["users"] },
});
expect(fetchMock).toHaveBeenCalledTimes(2);
// Invalidate only "posts"
await revalidateTag("posts");
startNewFetchCacheScope();
// Posts should re-fetch
const postRes = await fetch("https://api.example.com/posts-tag", {
next: { tags: ["posts"] },
});
const postData = await postRes.json();
expect(postData.count).toBe(3); // Fresh fetch (count continues from 2)
// Users should still be cached
const userRes = await fetch("https://api.example.com/users-tag", {
next: { tags: ["users"] },
});
const userData = await userRes.json();
expect(userData.count).toBe(2); // Still the cached version
expect(fetchMock).toHaveBeenCalledTimes(3); // Only posts re-fetched
});
// ── TTL expiry (stale-while-revalidate) ─────────────────────────────
it("returns stale data after TTL expires and triggers background refetch", async () => {
const res1 = await fetch("https://api.example.com/stale-test", {
next: { revalidate: 1 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Manually expire the cache entry (key is a SHA-256 hash, find it dynamically)
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000; // Expired 1 second ago
}
startNewFetchCacheScope();
// Should return stale data immediately
const res2 = await fetch("https://api.example.com/stale-test", {
next: { revalidate: 1 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Stale data (same as first fetch)
// Wait for background refetch
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(2); // Original + background refetch
});
it("preserves Request bodies for stale background revalidation", async () => {
const seenBodies: string[] = [];
fetchMock.mockImplementation(async (input: string | URL | Request, _init?: RequestInit) => {
requestCount++;
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const body = input instanceof Request ? await input.clone().text() : "";
seenBodies.push(body);
return new Response(JSON.stringify({ url, count: requestCount, body }), {
status: 200,
headers: { "content-type": "application/json" },
});
});
const makeRequest = () =>
new Request("https://api.example.com/stale-request-body", {
method: "POST",
body: "request-body-content",
headers: { "content-type": "text/plain" },
});
const res1 = await fetch(makeRequest(), { next: { revalidate: 1 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
expect(data1.body).toBe("request-body-content");
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
const res2 = await fetch(makeRequest(), { next: { revalidate: 1 } });
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(data2.body).toBe("request-body-content");
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(seenBodies).toEqual(["request-body-content", "request-body-content"]);
});
it("registers stale background refetch with waitUntil when ExecutionContext is available", async () => {
const waitUntilSpy = vi.fn<(p: Promise<unknown>) => void>();
const mockCtx = { waitUntil: waitUntilSpy };
await runWithExecutionContext(mockCtx, async () => {
// Populate cache
const res1 = await fetch("https://api.example.com/waituntil-test", {
next: { revalidate: 1 },
});
expect((await res1.json()).count).toBe(1);
// Manually expire the entry
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// Trigger stale hit — should fire background refetch via waitUntil
const res2 = await fetch("https://api.example.com/waituntil-test", {
next: { revalidate: 1 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Stale data returned
expect(waitUntilSpy).toHaveBeenCalledTimes(1);
expect(waitUntilSpy.mock.calls[0]![0]).toBeInstanceOf(Promise);
// Wait for the refetch to complete
await waitUntilSpy.mock.calls[0]![0];
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
it("registers stale background refetch with waitUntil inside a unified request scope", async () => {
const waitUntilSpy = vi.fn<(p: Promise<unknown>) => void>();
const mockCtx = { waitUntil: waitUntilSpy };
await runWithExecutionContext(mockCtx, async () => {
await runWithRequestContext(createRequestContext(), async () => {
const res1 = await fetch("https://api.example.com/unified-waituntil-test", {
next: { revalidate: 1 },
});
expect((await res1.json()).count).toBe(1);
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
const res2 = await fetch("https://api.example.com/unified-waituntil-test", {
next: { revalidate: 1 },
});
expect((await res2.json()).count).toBe(1);
expect(waitUntilSpy).toHaveBeenCalledTimes(1);
expect(waitUntilSpy.mock.calls[0]![0]).toBeInstanceOf(Promise);
await waitUntilSpy.mock.calls[0]![0];
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
});
it("deduplicates concurrent stale background refetches for the same cache key", async () => {
// Use a deferred promise to control when the background refetch resolves,
// ensuring all concurrent stale hits see stale data before the refetch completes.
let resolveRefetch!: () => void;
const refetchGate = new Promise<void>((r) => {
resolveRefetch = r;
});
// Populate cache (first call resolves normally)
const res1 = await fetch("https://api.example.com/dedup-stale", {
next: { revalidate: 1 },
});
expect((await res1.json()).count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
// Subsequent calls wait on the gate before resolving
fetchMock.mockImplementation(async (input: string | URL | Request) => {
await refetchGate;
requestCount++;
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return new Response(JSON.stringify({ url, count: requestCount }), {
status: 200,
headers: { "content-type": "application/json" },
});
});
// Expire the entry
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// Fire 5 concurrent stale hits — should all return stale data
// but only trigger ONE background refetch
const results = await Promise.all(
Array.from({ length: 5 }, () =>
fetch("https://api.example.com/dedup-stale", {
next: { revalidate: 1 },
}),
),
);
// All 5 should return the stale data
for (const res of results) {
const data = await res.json();
expect(data.count).toBe(1);
}
// Let the background refetch complete
resolveRefetch();
await new Promise((resolve) => setTimeout(resolve, 50));
// Original fetch (1) + exactly one background refetch (1) = 2 total
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("allows a new background refetch after the previous one completes", async () => {
// Populate cache
await fetch("https://api.example.com/dedup-cycle", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// Expire the entry
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// First stale hit — triggers background refetch
await fetch("https://api.example.com/dedup-cycle", {
next: { revalidate: 1 },
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(2);
// Expire again
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// Second stale hit — should trigger a NEW background refetch
// (the previous one completed and cleaned up)
await fetch("https://api.example.com/dedup-cycle", {
next: { revalidate: 1 },
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("cleans up dedup entry when background refetch fails, allowing retry", async () => {
// Populate cache
await fetch("https://api.example.com/dedup-error", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// Make subsequent fetches reject
fetchMock.mockImplementation(async () => {
throw new Error("network down");
});
// Expire the entry
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// Stale hit — background refetch will fail
const res = await fetch("https://api.example.com/dedup-error", {
next: { revalidate: 1 },
});
expect((await res.json()).count).toBe(1); // Still returns stale data
await new Promise((resolve) => setTimeout(resolve, 50));
// The failed refetch should have been called and cleaned up
expect(fetchMock).toHaveBeenCalledTimes(2);
// Restore working fetch and expire again
fetchMock.mockImplementation(defaultFetchMockImplementation);
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// A new stale hit should trigger a fresh refetch (dedup entry was cleaned up)
await fetch("https://api.example.com/dedup-error", {
next: { revalidate: 1 },
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("background revalidation does not cache error responses", async () => {
// Populate cache with a good response
const res1 = await fetch("https://api.example.com/revalidate-error-test", {
next: { revalidate: 1 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
expect(res1.status).toBe(200);
// Manually expire the cache entry
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
// Make the upstream return a 500 error for the background refetch
fetchMock.mockImplementationOnce(
async () =>
new Response("Internal Server Error", {
status: 500,
headers: { "content-type": "text/plain" },
}),
);
// Should return stale data immediately (stale-while-revalidate)
const res2 = await fetch("https://api.example.com/revalidate-error-test", {
next: { revalidate: 1 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Stale data returned
expect(res2.status).toBe(200);
// Wait for background refetch to complete
await new Promise((resolve) => setTimeout(resolve, 50));
// The background refetch got a 500, so the cache should still hold the
// original good response — not the error.
// Expire the entry again to force another stale read from cache.
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
// Restore good fetch for next background refetch
fetchMock.mockImplementation(defaultFetchMockImplementation);
const res3 = await fetch("https://api.example.com/revalidate-error-test", {
next: { revalidate: 1 },
});
// If the bug exists, this will be 500 (the error was cached).
// If fixed, this will be 200 (the original good data was preserved).
expect(res3.status).toBe(200);
});
it("force-cleans dedup entry after timeout when upstream fetch hangs", async () => {
vi.useFakeTimers();
try {
// Populate cache
await fetch("https://api.example.com/dedup-hang", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// Make subsequent fetches hang forever
fetchMock.mockImplementation(() => new Promise(() => {}));
// Expire the entry
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// Stale hit — background refetch hangs
await fetch("https://api.example.com/dedup-hang", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(2); // Hung fetch was called
// Another stale hit before timeout — dedup suppresses it
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
await fetch("https://api.example.com/dedup-hang", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(2); // Still suppressed
// Advance past the 60s timeout — dedup entry should be force-cleaned
vi.advanceTimersByTime(60_000);
// Restore working fetch and expire again
fetchMock.mockImplementation(defaultFetchMockImplementation);
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// New stale hit should trigger a fresh refetch
await fetch("https://api.example.com/dedup-hang", {
next: { revalidate: 1 },
});
// Flush the microtask for the background refetch
await vi.advanceTimersByTimeAsync(50);
expect(fetchMock).toHaveBeenCalledTimes(3); // New refetch succeeded
} finally {
vi.useRealTimers();
}
});
it("hung fetch settling after timeout does not evict replacement refetch", async () => {
vi.useFakeTimers();
try {
let resolveHungFetch!: (resp: Response) => void;
// Populate cache
await fetch("https://api.example.com/dedup-race", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// Make next fetch hang until we resolve it manually
fetchMock.mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolveHungFetch = resolve;
}),
);
// Expire and trigger a stale hit — background refetch #1 hangs
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
await fetch("https://api.example.com/dedup-race", {
next: { revalidate: 1 },
});
expect(fetchMock).toHaveBeenCalledTimes(2); // Hung fetch was called
// Advance past the 60s timeout — dedup entry is force-cleaned
vi.advanceTimersByTime(60_000);
// Restore working fetch for the replacement refetch
fetchMock.mockImplementation(defaultFetchMockImplementation);
// Expire and trigger a new stale hit — background refetch #2 starts
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
await fetch("https://api.example.com/dedup-race", {
next: { revalidate: 1 },
});
// Let refetch #2 complete
await vi.advanceTimersByTimeAsync(50);
expect(fetchMock).toHaveBeenCalledTimes(3); // Replacement refetch ran
// Now the hung refetch #1 finally settles — it must NOT evict #2's slot
resolveHungFetch(
new Response(JSON.stringify({ url: "stale", count: 999 }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
await vi.advanceTimersByTimeAsync(50);
// Expire again — a new stale hit should NOT start another refetch
// because #2's slot should still be gone (it completed normally).
// The key behavior: #1 settling did not delete #2's entry while #2 was live.
// Since #2 already completed and cleaned up its own slot, a new refetch
// should start normally (proving #1 didn't corrupt state).
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
await fetch("https://api.example.com/dedup-race", {
next: { revalidate: 1 },
});
await vi.advanceTimersByTimeAsync(50);
expect(fetchMock).toHaveBeenCalledTimes(4); // Clean new refetch
} finally {
vi.useRealTimers();
}
});
// ── Independent cache entries per URL ───────────────────────────────
it("different URLs get independent cache entries", async () => {
const res1 = await fetch("https://api.example.com/url-a", {
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.url).toBe("https://api.example.com/url-a");
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/url-b", {
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.url).toBe("https://api.example.com/url-b");
expect(data2.count).toBe(2); // Different URL = different cache
// Re-fetch url-a should be cached
const res3 = await fetch("https://api.example.com/url-a", {
next: { revalidate: 60 },
});
const data3 = await res3.json();
expect(data3.count).toBe(1); // Cached
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("same URL with different methods get separate cache entries", async () => {
const getRes = await fetch("https://api.example.com/method-test", {
method: "GET",
next: { revalidate: 60 },
});
const getData = await getRes.json();
expect(getData.count).toBe(1);
const postRes = await fetch("https://api.example.com/method-test", {
method: "POST",
body: "test",
next: { revalidate: 60 },
});
const postData = await postRes.json();
expect(postData.count).toBe(2); // Different method = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── next property stripping ─────────────────────────────────────────
it("strips next property before passing to real fetch", async () => {
await fetch("https://api.example.com/strip-test", {
next: { revalidate: 60, tags: ["test"] },
headers: { "X-Custom": "value" },
});
// Verify the mock was called with init that does NOT have `next`
const call = fetchMock.mock.calls[0];
const init = call[1] as RequestInit;
expect(init).toBeDefined();
expect((init as any).next).toBeUndefined();
expect((init as any).headers).toEqual({ "X-Custom": "value" });
});
it("strips next property for no-store fetches too", async () => {
await fetch("https://api.example.com/strip-nostore", {
cache: "no-store",
next: { tags: ["test"] },
});
const call = fetchMock.mock.calls[0];
const init = call[1] as RequestInit;
expect((init as any).next).toBeUndefined();
});
// ── Tag collection during rendering ─────────────────────────────────
it("collects tags during render pass via getCollectedFetchTags", async () => {
await fetch("https://api.example.com/tag-collect-a", {
next: { tags: ["posts", "list"] },
});
await fetch("https://api.example.com/tag-collect-b", {
next: { tags: ["users"] },
});
const tags = getCollectedFetchTags();
expect(tags).toContain("posts");
expect(tags).toContain("list");
expect(tags).toContain("users");
expect(tags).toHaveLength(3);
});
it("does not collect duplicate tags", async () => {
await fetch("https://api.example.com/dup-tag-a", {
next: { tags: ["data"] },
});
await fetch("https://api.example.com/dup-tag-b", {
next: { tags: ["data"] },
});
const tags = getCollectedFetchTags();
expect(tags.filter((t) => t === "data")).toHaveLength(1);
});
it("revalidatePath invalidates fetch cache through current render soft tags", async () => {
setCurrentFetchSoftTags(["_N_T_/posts/hello"]);
const res1 = await fetch("https://api.example.com/path-soft-tag", {
next: { revalidate: 3600 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
await revalidatePath("/posts/hello");
startNewFetchCacheScope();
setCurrentFetchSoftTags(["_N_T_/posts/hello"]);
const res2 = await fetch("https://api.example.com/path-soft-tag", {
next: { revalidate: 3600 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── Only caches successful responses ────────────────────────────────
it("does not cache non-2xx responses", async () => {
// Override mock to return 404 once
fetchMock.mockImplementationOnce(async () => {
requestCount++;
return new Response("Not found", { status: 404 });
});
const res1 = await fetch("https://api.example.com/missing-page", {
next: { revalidate: 60 },
});
expect(res1.status).toBe(404);
// Should re-fetch since 404 wasn't cached
startNewFetchCacheScope();
const res2 = await fetch("https://api.example.com/missing-page", {
next: { revalidate: 60 },
});
expect(res2.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── URL and Request object inputs ───────────────────────────────────
it("handles URL objects as input", async () => {
const url = new URL("https://api.example.com/url-obj");
const res = await fetch(url, { next: { revalidate: 60 } });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(1);
// Cached on second call
const res2 = await fetch(url, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("handles Request objects as input", async () => {
const req = new Request("https://api.example.com/req-obj");
const res = await fetch(req, { next: { revalidate: 60 } });
expect(res.status).toBe(200);
const data = await res.json();
expect(data.count).toBe(1);
// Cached on second call with same URL
const req2 = new Request("https://api.example.com/req-obj");
const res2 = await fetch(req2, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("includes Request object bodies in the cache key", async () => {
const req1 = new Request("https://api.example.com/req-body", {
method: "POST",
body: "alpha",
headers: { "content-type": "text/plain" },
});
const res1 = await fetch(req1, { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const req2 = new Request("https://api.example.com/req-body", {
method: "POST",
body: "bravo",
headers: { "content-type": "text/plain" },
});
const res2 = await fetch(req2, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different Request body = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("same Request object bodies hit the same cache entry", async () => {
const req1 = new Request("https://api.example.com/req-body-same", {
method: "POST",
body: "same-body",
headers: { "content-type": "text/plain" },
});
const res1 = await fetch(req1, { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const req2 = new Request("https://api.example.com/req-body-same", {
method: "POST",
body: "same-body",
headers: { "content-type": "text/plain" },
});
const res2 = await fetch(req2, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same Request body = cached
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("Request FormData values with commas do not collide in the cache key", async () => {
const formA = new FormData();
formA.append("name", "a,b");
formA.append("name", "c");
const req1 = new Request("https://api.example.com/req-form-body", {
method: "POST",
body: formA,
});
const res1 = await fetch(req1, { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const formB = new FormData();
formB.append("name", "a");
formB.append("name", "b,c");
const req2 = new Request("https://api.example.com/req-form-body", {
method: "POST",
body: formB,
});
const res2 = await fetch(req2, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different Request FormData body = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("same Request FormData bodies hit the same cache entry despite generated multipart boundaries", async () => {
const makeForm = () => {
const form = new FormData();
form.append("name", "same-value");
return form;
};
const req1 = new Request("https://api.example.com/req-form-same", {
method: "POST",
body: makeForm(),
});
const res1 = await fetch(req1, { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const req2 = new Request("https://api.example.com/req-form-same", {
method: "POST",
body: makeForm(),
});
const res2 = await fetch(req2, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("same multipart Request bodies hit the same cache entry even with different boundaries", async () => {
const makeMultipartRequest = (boundary: string) =>
new Request("https://api.example.com/req-form-boundary", {
method: "POST",
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
body: [
`--${boundary}`,
'Content-Disposition: form-data; name="name"',
"",
"same-value",
`--${boundary}--`,
"",
].join("\r\n"),
});
const res1 = await fetch(makeMultipartRequest("boundary-a"), { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch(makeMultipartRequest("boundary-b"), { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("malformed multipart Request bodies bypass cache instead of hashing raw bytes", async () => {
const makeMalformedMultipartRequest = () =>
new Request("https://api.example.com/req-form-malformed", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=expected" },
body: [
"--actual",
'Content-Disposition: form-data; name="name"',
"",
"value",
"--actual--",
"",
].join("\r\n"),
});
const res1 = await fetch(makeMalformedMultipartRequest(), { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch(makeMalformedMultipartRequest(), { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("urlencoded Request bodies with different charset headers get separate cache entries", async () => {
const makeRequest = (charset: string) =>
new Request("https://api.example.com/req-form-charset", {
method: "POST",
headers: { "content-type": `application/x-www-form-urlencoded; charset=${charset}` },
body: "name=value",
});
const res1 = await fetch(makeRequest("utf-8"), { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch(makeRequest("shift_jis"), { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── force-cache with next.revalidate ────────────────────────────────
it("cache: 'force-cache' with next.revalidate uses the specified TTL", async () => {
const res1 = await fetch("https://api.example.com/force-ttl", {
cache: "force-cache",
next: { revalidate: 1 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Verify it's cached
const res2 = await fetch("https://api.example.com/force-ttl", {
cache: "force-cache",
next: { revalidate: 1 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1);
// Expire the cache manually (key is a SHA-256 hash, find it dynamically)
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
const store = (handler as any).store as Map<string, any>;
for (const [, entry] of store) {
entry.revalidateAt = Date.now() - 1000;
}
startNewFetchCacheScope();
// Should return stale
const res3 = await fetch("https://api.example.com/force-ttl", {
cache: "force-cache",
next: { revalidate: 1 },
});
const data3 = await res3.json();
expect(data3.count).toBe(1); // Stale data returned
// Background refetch
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── Cleanup clears per-request state ─────────────────────────────────
it("cleanup function clears collected tags", async () => {
// Collect some tags
await fetch("https://api.example.com/cleanup-test", {
next: { tags: ["cleanup-tag"] },
});
expect(getCollectedFetchTags()).toContain("cleanup-tag");
// Cleanup should reset tag state
cleanup!();
cleanup = null;
expect(getCollectedFetchTags()).toHaveLength(0);
// Re-install for afterEach cleanup
cleanup = withFetchCache();
});
// ── getOriginalFetch ────────────────────────────────────────────────
it("getOriginalFetch returns the module-level original fetch", () => {
const orig = getOriginalFetch();
expect(typeof orig).toBe("function");
// It should be fetchMock since that was the global fetch when the module loaded
expect(orig).toBe(fetchMock);
});
// ── next: {} empty passes through ───────────────────────────────────
it("next: {} with no revalidate or tags bypasses persistent cache but dedupes render fetches", async () => {
const res1 = await fetch("https://api.example.com/empty-next", { next: {} });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/empty-next", { next: {} });
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
// ── Concurrent request isolation via ALS ─────────────────────────────
it("concurrent runWithFetchCache calls have isolated tags", async () => {
// Clean up the withFetchCache() from beforeEach — runWithFetchCache
// manages its own ALS scope.
cleanup?.();
cleanup = null;
const [tags1, tags2] = await Promise.all([
runWithFetchCache(async () => {
await fetch("https://api.example.com/concurrent-a", {
next: { tags: ["request-1"] },
});
return getCollectedFetchTags();
}),
runWithFetchCache(async () => {
await fetch("https://api.example.com/concurrent-b", {
next: { tags: ["request-2"] },
});
return getCollectedFetchTags();
}),
]);
expect(tags1).toEqual(["request-1"]);
expect(tags2).toEqual(["request-2"]);
// Re-install for afterEach
cleanup = withFetchCache();
});
// ── Auth header isolation in cache keys ─────────────────────────────
describe("auth header cache isolation", () => {
it("different Authorization headers produce separate cache entries", async () => {
// Alice fetches with her token — explicitly opt into caching
const res1 = await fetch("https://api.example.com/me", {
headers: { Authorization: "Bearer alice-token" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Bob fetches with his token — should NOT get Alice's cached response
const res2 = await fetch("https://api.example.com/me", {
headers: { Authorization: "Bearer bob-token" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different cache entry
expect(fetchMock).toHaveBeenCalledTimes(2);
// Alice fetches again — should get her cached response
const res3 = await fetch("https://api.example.com/me", {
headers: { Authorization: "Bearer alice-token" },
next: { revalidate: 60 },
});
const data3 = await res3.json();
expect(data3.count).toBe(1); // Cached from first request
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("different Cookie headers produce separate cache entries", async () => {
const res1 = await fetch("https://api.example.com/profile", {
headers: { Cookie: "session=alice" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Bob's cookie should get a separate cache entry
const res2 = await fetch("https://api.example.com/profile", {
headers: { Cookie: "session=bob" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Fresh fetch, not Alice's data
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("requests without auth headers share cache (public data)", async () => {
const res1 = await fetch("https://api.example.com/public", {
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// No auth headers → same cache entry
const res2 = await fetch("https://api.example.com/public", {
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Cached
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("auth headers with force-cache still produce per-user cache entries", async () => {
const res1 = await fetch("https://api.example.com/forced", {
headers: { Authorization: "Bearer alice" },
cache: "force-cache",
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/forced", {
headers: { Authorization: "Bearer bob" },
cache: "force-cache",
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Separate cache entry
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("auth headers with tags-only (no explicit revalidate) bypass cache", async () => {
// When only tags are specified but no explicit revalidate or force-cache,
// auth headers should cause a cache bypass
const res1 = await fetch("https://api.example.com/tagged-auth", {
headers: { Authorization: "Bearer alice" },
next: { tags: ["user-data"] },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Same user, same tags — should still bypass (no explicit cache opt-in)
const res2 = await fetch("https://api.example.com/tagged-auth", {
headers: { Authorization: "Bearer alice" },
next: { tags: ["user-data"] },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("auth-keyed safety bypass records a dynamic fetch observation without marking the page dynamic", async () => {
await fetch("https://api.example.com/auth-bypass-page-output", {
headers: { Authorization: "Bearer alice" },
next: { tags: ["user-data"] },
});
// The fetch itself is bypassed (not cached), but the per-user response
// must still downgrade the page output to fresh render so auth-keyed
// data is never statically cached and served across users.
expect(peekDynamicFetchObservations()).toEqual([
"https://api.example.com/auth-bypass-page-output",
]);
// The safety bypass is automatic, not an explicit uncached-fetch
// decision, so the page is not marked dynamic.
expect(consumeDynamicUsage()).toBe(false);
});
it("explicit no-store with auth headers marks the page dynamic instead of taking the auth bypass", async () => {
await fetch("https://api.example.com/nostore-auth-dynamic", {
cache: "no-store",
headers: { Authorization: "Bearer alice" },
});
// An explicit `no-store` is an explicit uncached-fetch decision, so it
// hits the no-store branch (full markDynamicUsage) before the softer
// auth-safety bypass: the page is fully marked dynamic, not merely
// downgraded via a dynamic fetch observation.
expect(peekDynamicFetchObservations()).toEqual([
"https://api.example.com/nostore-auth-dynamic",
]);
expect(consumeDynamicUsage()).toBe(true);
});
it("X-API-Key header is included in cache key", async () => {
const res1 = await fetch("https://api.example.com/api-key", {
headers: { "X-API-Key": "key-alice" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/api-key", {
headers: { "X-API-Key": "key-bob" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different key = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("auth headers from Request object are included in cache key", async () => {
const req1 = new Request("https://api.example.com/req-auth", {
headers: { Authorization: "Bearer alice" },
});
const res1 = await fetch(req1, { next: { revalidate: 60 } });
const data1 = await res1.json();
expect(data1.count).toBe(1);
const req2 = new Request("https://api.example.com/req-auth", {
headers: { Authorization: "Bearer bob" },
});
const res2 = await fetch(req2, { next: { revalidate: 60 } });
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different auth = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
// ── cache: 'no-cache' bypass ────────────────────────────────────────
it("cache: 'no-cache' bypasses cache entirely", async () => {
const res1 = await fetch("https://api.example.com/nocache", {
cache: "no-cache" as RequestCache,
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/nocache", {
cache: "no-cache" as RequestCache,
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("cache: 'no-store' with auth headers bypasses cache", async () => {
const res1 = await fetch("https://api.example.com/nostore-auth", {
cache: "no-store",
headers: { Authorization: "Bearer token" },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/nostore-auth", {
cache: "no-store",
headers: { Authorization: "Bearer token" },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same render fetch is deduped
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("cache: 'no-cache' with auth headers bypasses cache", async () => {
const res1 = await fetch("https://api.example.com/nocache-auth", {
cache: "no-cache" as RequestCache,
headers: { Cookie: "session=alice" },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/nocache-auth", {
cache: "no-cache" as RequestCache,
headers: { Cookie: "session=bob" },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Always fresh
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// ── Cache key: body type handling ─────────────────────────────────
describe("cache key body type handling", () => {
it("different string bodies produce separate cache entries", async () => {
const res1 = await fetch("https://api.example.com/body-str", {
method: "POST",
body: '{"type":"a"}',
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-str", {
method: "POST",
body: '{"type":"b"}',
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different body = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("same string bodies hit the same cache entry", async () => {
const res1 = await fetch("https://api.example.com/body-same", {
method: "POST",
body: '{"query":"test"}',
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-same", {
method: "POST",
body: '{"query":"test"}',
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same body = same cache
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("Uint8Array bodies are included in cache key", async () => {
const bodyA = new TextEncoder().encode("payload-a");
const bodyB = new TextEncoder().encode("payload-b");
const res1 = await fetch("https://api.example.com/body-uint8", {
method: "POST",
body: bodyA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-uint8", {
method: "POST",
body: bodyB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different binary body = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("same Uint8Array bodies hit the same cache entry", async () => {
const body1 = new TextEncoder().encode("same-payload");
const body2 = new TextEncoder().encode("same-payload");
const res1 = await fetch("https://api.example.com/body-uint8-same", {
method: "POST",
body: body1,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-uint8-same", {
method: "POST",
body: body2,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same payload = same cache
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("Blob bodies are included in cache key", async () => {
const blobA = new Blob(["blob-content-a"], { type: "text/plain" });
const blobB = new Blob(["blob-content-b"], { type: "text/plain" });
const res1 = await fetch("https://api.example.com/body-blob", {
method: "POST",
body: blobA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-blob", {
method: "POST",
body: blobB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different blob = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("FormData bodies are included in cache key", async () => {
const formA = new FormData();
formA.append("name", "alice");
const formB = new FormData();
formB.append("name", "bob");
const res1 = await fetch("https://api.example.com/body-form", {
method: "POST",
body: formA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-form", {
method: "POST",
body: formB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different form data = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("FormData values with commas do not collide in the cache key", async () => {
const formA = new FormData();
formA.append("name", "a,b");
formA.append("name", "c");
const formB = new FormData();
formB.append("name", "a");
formB.append("name", "b,c");
const res1 = await fetch("https://api.example.com/body-form-comma", {
method: "POST",
body: formA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-form-comma", {
method: "POST",
body: formB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different multi-value form data = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("FormData entry order is preserved in the cache key", async () => {
const formA = new FormData();
formA.append("a", "1");
formA.append("b", "2");
formA.append("a", "3");
const formB = new FormData();
formB.append("a", "1");
formB.append("a", "3");
formB.append("b", "2");
const res1 = await fetch("https://api.example.com/body-form-order", {
method: "POST",
body: formA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-form-order", {
method: "POST",
body: formB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("FormData file metadata is included in the cache key", async () => {
const formA = new FormData();
formA.append("file", new File(["same-bytes"], "a.txt", { type: "text/plain" }));
const formB = new FormData();
formB.append("file", new File(["same-bytes"], "b.bin", { type: "application/octet-stream" }));
const res1 = await fetch("https://api.example.com/body-form-file-metadata", {
method: "POST",
body: formA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-form-file-metadata", {
method: "POST",
body: formB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("ReadableStream bodies are included in cache key", async () => {
const streamA = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("stream-a"));
controller.close();
},
});
const streamB = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("stream-b"));
controller.close();
},
});
const res1 = await fetch("https://api.example.com/body-stream", {
method: "POST",
body: streamA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-stream", {
method: "POST",
body: streamB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different stream = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
// ── Cache key: header inclusion (all headers minus blocklist) ──────
describe("cache key header inclusion", () => {
it("different Accept headers produce separate cache entries", async () => {
const res1 = await fetch("https://api.example.com/accept-test", {
headers: { Accept: "application/json" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/accept-test", {
headers: { Accept: "text/html" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different Accept = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("different Accept-Language headers produce separate cache entries", async () => {
const res1 = await fetch("https://api.example.com/lang-test", {
headers: { "Accept-Language": "en-US" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/lang-test", {
headers: { "Accept-Language": "fr-FR" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different language = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("custom headers are included in cache key", async () => {
const res1 = await fetch("https://api.example.com/custom-hdr", {
headers: { "X-Feature-Flag": "variant-a" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/custom-hdr", {
headers: { "X-Feature-Flag": "variant-b" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different custom header = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("traceparent and tracestate headers are excluded from cache key", async () => {
const res1 = await fetch("https://api.example.com/trace-test", {
headers: {
traceparent: "00-trace-id-1-01",
tracestate: "vendor=value1",
"X-Custom": "same",
},
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Same request but different trace headers — should hit cache
const res2 = await fetch("https://api.example.com/trace-test", {
headers: {
traceparent: "00-trace-id-2-01",
tracestate: "vendor=value2",
"X-Custom": "same",
},
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Cached — trace headers excluded from key
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("same headers produce same cache entry regardless of order", async () => {
const res1 = await fetch("https://api.example.com/hdr-order", {
headers: new Headers([
["X-First", "1"],
["X-Second", "2"],
]),
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
// Headers in different construction order — Headers object normalizes
const res2 = await fetch("https://api.example.com/hdr-order", {
headers: new Headers([
["X-Second", "2"],
["X-First", "1"],
]),
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same cache entry
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("requests with no headers vs with headers get separate cache entries", async () => {
const res1 = await fetch("https://api.example.com/hdr-vs-none", {
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/hdr-vs-none", {
headers: { "X-Extra": "present" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different cache entry
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
// ── Body restoration after cache key generation ───────────────────
describe("body restoration (_ogBody)", () => {
it("ReadableStream body is correctly passed to real fetch after cache key generation", async () => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("stream-body-content"));
controller.close();
},
});
await fetch("https://api.example.com/stream-restore", {
method: "POST",
body: stream,
next: { revalidate: 60 },
});
// Verify the mock was called and the body was preserved as a stream
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
const init = call[1] as RequestInit;
expect(init.body).toBeInstanceOf(ReadableStream);
const reader = (init.body as ReadableStream<Uint8Array>).getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const length = chunks.reduce((acc, chunk) => acc + chunk.byteLength, 0);
const full = new Uint8Array(length);
let offset = 0;
for (const chunk of chunks) {
full.set(chunk, offset);
offset += chunk.byteLength;
}
const decoded = new TextDecoder().decode(full);
expect(decoded).toBe("stream-body-content");
});
it("Blob body is correctly passed to real fetch after cache key generation", async () => {
const blob = new Blob(["blob-body-content"], { type: "text/plain" });
await fetch("https://api.example.com/blob-restore", {
method: "POST",
body: blob,
next: { revalidate: 60 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
const init = call[1] as RequestInit;
// The body should be a Blob (reconstructed)
expect(init.body).toBeInstanceOf(Blob);
const text = await (init.body as Blob).text();
expect(text).toBe("blob-body-content");
});
it("Uint8Array body is correctly passed to real fetch after cache key generation", async () => {
const body = new TextEncoder().encode("uint8-body-content");
await fetch("https://api.example.com/uint8-restore", {
method: "POST",
body: body,
next: { revalidate: 60 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
const init = call[1] as RequestInit;
expect(init.body).toBeInstanceOf(Uint8Array);
const decoded = new TextDecoder().decode(init.body as Uint8Array);
expect(decoded).toBe("uint8-body-content");
});
it("string body is correctly passed to real fetch after cache key generation", async () => {
await fetch("https://api.example.com/string-restore", {
method: "POST",
body: '{"key":"value"}',
next: { revalidate: 60 },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
const init = call[1] as RequestInit;
expect(init.body).toBe('{"key":"value"}');
});
it("Request object body is still passed through after cache key generation", async () => {
const request = new Request("https://api.example.com/request-restore", {
method: "POST",
body: "request-body-content",
headers: { "content-type": "text/plain" },
});
await fetch(request, { next: { revalidate: 60 } });
expect(fetchMock).toHaveBeenCalledTimes(1);
const call = fetchMock.mock.calls[0];
const forwardedRequest = call[0] as Request;
expect(forwardedRequest).toBeInstanceOf(Request);
expect(await forwardedRequest.text()).toBe("request-body-content");
});
it("already-consumed Request bodies bypass cache key generation and defer to the underlying fetch", async () => {
fetchMock.mockImplementation(async (input: string | URL | Request, _init?: RequestInit) => {
if (input instanceof Request && input.bodyUsed) {
throw new TypeError("body already used");
}
return defaultFetchMockImplementation(input, _init);
});
const request = new Request("https://api.example.com/request-used", {
method: "POST",
body: "request-body-content",
headers: { "content-type": "text/plain" },
});
await request.text();
await expect(fetch(request, { next: { revalidate: 60 } })).rejects.toThrow(
"body already used",
);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("cache key oversized body safeguards", () => {
it("oversized Blob body bypasses cache and still fetches", async () => {
const largeBlob = new Blob(["x".repeat(1024 * 1024 + 1)]);
const res1 = await fetch("https://api.example.com/large-blob", {
method: "POST",
body: largeBlob,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/large-blob", {
method: "POST",
body: largeBlob,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // bypassed cache because body is oversized
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("oversized ReadableStream body bypasses cache and preserves stream body", async () => {
const makeLargeStream = () =>
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(1024 * 1024 + 1));
controller.close();
},
});
await fetch("https://api.example.com/large-stream", {
method: "POST",
body: makeLargeStream(),
next: { revalidate: 60 },
});
await fetch("https://api.example.com/large-stream", {
method: "POST",
body: makeLargeStream(),
next: { revalidate: 60 },
});
expect(fetchMock).toHaveBeenCalledTimes(2);
const init = fetchMock.mock.calls[0][1] as RequestInit;
expect(init.body).toBeInstanceOf(ReadableStream);
});
it("oversized Uint8Array body bypasses cache and still fetches", async () => {
const largeBuffer = new Uint8Array(1024 * 1024 + 1);
const res1 = await fetch("https://api.example.com/large-uint8", {
method: "POST",
body: largeBuffer,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/large-uint8", {
method: "POST",
body: largeBuffer,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // bypassed cache because body is oversized
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("oversized string body bypasses cache and still fetches", async () => {
const largeString = "x".repeat(1024 * 1024 + 1);
const res1 = await fetch("https://api.example.com/large-string", {
method: "POST",
body: largeString,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/large-string", {
method: "POST",
body: largeString,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // bypassed cache because body is oversized
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("oversized body with explicit cache opt-in does not mark page output dynamic", async () => {
await fetch("https://api.example.com/large-body-page-output", {
method: "POST",
body: "x".repeat(1024 * 1024 + 1),
cache: "force-cache",
});
// The developer opted into caching; failing to build a cache key is an
// internal vinext limitation, not an explicit uncached-fetch decision.
// The observation downgrades the page output to fresh render, but the
// page is not marked dynamic.
expect(consumeDynamicUsage()).toBe(false);
expect(peekDynamicFetchObservations()).toContain(
"https://api.example.com/large-body-page-output",
);
});
it("oversized Request body bypasses cache without cloning the body when content-length exceeds the limit", async () => {
const cloneSpy = vi.spyOn(Request.prototype, "clone");
const request = new Request("https://api.example.com/large-request-stream", {
method: "POST",
headers: {
"content-type": "application/octet-stream",
"content-length": String(1024 * 1024 + 1),
},
body: new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(new Uint8Array([1]));
controller.close();
},
}),
duplex: "half",
} as RequestInit & { duplex: "half" });
try {
const res = await fetch(request, { next: { revalidate: 60 } });
const data = await res.json();
expect(data.count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(cloneSpy).not.toHaveBeenCalled();
expect(request.bodyUsed).toBe(false);
} finally {
cloneSpy.mockRestore();
}
});
it("ReadableStream with many small chunks accumulating past limit bypasses cache", async () => {
const chunkSize = 64 * 1024; // 64 KiB per chunk
const numChunks = 17; // 17 * 64 KiB = 1088 KiB > 1 MiB
const makeLargeMultiChunkStream = () =>
new ReadableStream({
start(controller) {
for (let i = 0; i < numChunks; i++) {
controller.enqueue(new Uint8Array(chunkSize));
}
controller.close();
},
});
const res1 = await fetch("https://api.example.com/large-multi-chunk", {
method: "POST",
body: makeLargeMultiChunkStream(),
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/large-multi-chunk", {
method: "POST",
body: makeLargeMultiChunkStream(),
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // bypassed cache because cumulative size exceeds limit
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("FormData with large File entry bypasses cache and still fetches", async () => {
const largeContent = "x".repeat(1024 * 1024 + 1);
const largeFile = new File([largeContent], "big.txt", { type: "text/plain" });
const form = new FormData();
form.append("file", largeFile);
const res1 = await fetch("https://api.example.com/large-formdata", {
method: "POST",
body: form,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/large-formdata", {
method: "POST",
body: form,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // bypassed cache because file is oversized
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
// ── URLSearchParams body ──────────────────────────────────────────
describe("URLSearchParams body", () => {
it("different URLSearchParams bodies produce separate cache entries", async () => {
const paramsA = new URLSearchParams({ q: "alpha" });
const paramsB = new URLSearchParams({ q: "beta" });
const res1 = await fetch("https://api.example.com/body-usp", {
method: "POST",
body: paramsA,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-usp", {
method: "POST",
body: paramsB,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2); // Different params = different cache
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("same URLSearchParams bodies hit the same cache entry", async () => {
const params1 = new URLSearchParams({ q: "same" });
const params2 = new URLSearchParams({ q: "same" });
const res1 = await fetch("https://api.example.com/body-usp-same", {
method: "POST",
body: params1,
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-usp-same", {
method: "POST",
body: params2,
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(1); // Same params = cached
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("explicit URLSearchParams charset headers remain part of the cache key", async () => {
const res1 = await fetch("https://api.example.com/body-usp-charset", {
method: "POST",
body: new URLSearchParams({ q: "same" }),
headers: { "content-type": "application/x-www-form-urlencoded; charset=utf-8" },
next: { revalidate: 60 },
});
const data1 = await res1.json();
expect(data1.count).toBe(1);
const res2 = await fetch("https://api.example.com/body-usp-charset", {
method: "POST",
body: new URLSearchParams({ q: "same" }),
headers: { "content-type": "application/x-www-form-urlencoded; charset=shift_jis" },
next: { revalidate: 60 },
});
const data2 = await res2.json();
expect(data2.count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
// ── Set-Cookie stripping from cached responses ──────────────────────────
describe("Set-Cookie header stripping", () => {
it("does not include Set-Cookie in cached response headers", async () => {
fetchMock.mockImplementationOnce(
async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"content-type": "application/json",
"set-cookie": "session=abc123; Path=/; HttpOnly",
"x-custom": "keep-me",
},
}),
);
// First request — response has Set-Cookie
const res1 = await fetch("https://api.example.com/set-cookie-test", {
next: { revalidate: 300 },
});
expect(res1.headers.get("set-cookie")).toBe("session=abc123; Path=/; HttpOnly");
expect(res1.headers.get("x-custom")).toBe("keep-me");
// Second request — served from cache, Set-Cookie must be absent
const res2 = await fetch("https://api.example.com/set-cookie-test", {
next: { revalidate: 300 },
});
expect(res2.headers.get("set-cookie")).toBeNull();
expect(res2.headers.get("x-custom")).toBe("keep-me");
});
});
});