Files
cloudflare__vinext/tests/fetch-cache.test.ts
T
Nathan Nguyen 4108dab89c fix(fetch-cache): honor RequestInit in request dedupe (#2762)
* fix(fetch-cache): honor RequestInit in request dedupe

Request inputs with per-call overrides were deduped using the base Request even though fetch executes the overridden headers and options. Distinct authenticated requests could therefore share one response and persist it under the wrong cache key.

Normalize the effective GET or HEAD request before deriving the dedupe key so request-scoped reuse and persistent cache storage stay partitioned by the options the network sees.

* fix(fetch-cache): preserve effective request semantics

* fix(fetch-cache): key inherited request options

* fix(fetch-cache): serialize bodies with effective headers

* fix(fetch-cache): normalize default request options

* fix(fetch-cache): key normalized request body metadata

* fix(fetch-cache): distinguish normalized body variants

* fix(fetch-cache): hash body bytes without loss

* fix(fetch-cache): bound body key fallback work

* test(fetch-cache): lock effective auth bypass

---------

Co-authored-by: James <james@eli.cx>
2026-08-03 15:07:04 +01:00

3550 lines
124 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,
setRefreshStaleFetchesInForeground,
runWithFetchDedupe,
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 Promise.resolve(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 Promise.resolve(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
});
it("bypasses a stored tagged fetch while its request-local invalidation is pending", async () => {
const previousHandler = getCacheHandler();
let markInvalidationStarted!: () => void;
const invalidationStarted = new Promise<void>((resolve) => {
markInvalidationStarted = resolve;
});
let releaseInvalidation!: () => void;
const invalidationGate = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
let getCalls = 0;
setCacheHandler({
async get() {
getCalls++;
if (getCalls === 1) return null;
return {
lastModified: Date.now(),
value: {
kind: "FETCH",
data: {
headers: { "content-type": "application/json" },
body: JSON.stringify({ count: 1 }),
url: "https://api.example.com/pending-tag",
},
tags: ["posts"],
revalidate: 60,
},
};
},
async set() {},
async revalidateTag() {
markInvalidationStarted();
await invalidationGate;
},
});
try {
await runWithRequestContext(createRequestContext(), () =>
runWithFetchDedupe(async () => {
const initialResponse = await fetch("https://api.example.com/pending-tag", {
next: { revalidate: 60, tags: ["posts"] },
});
expect((await initialResponse.json()).count).toBe(1);
expect(revalidateTag("posts", { expire: 0 })).toBeUndefined();
await invalidationStarted;
// The current call deliberately omits `next.tags`: the stored entry's
// tags must reject it and bypass the pre-invalidation request dedupe.
const response = await fetch("https://api.example.com/pending-tag", {
next: { revalidate: 60 },
});
expect((await response.json()).count).toBe(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
}),
);
} finally {
releaseInvalidation();
setCacheHandler(previousHandler);
}
});
// ── 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("refreshes stale fetch entries when foreground fetch refresh is enabled", async () => {
const res1 = await fetch("https://api.example.com/foreground-stale", {
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();
await runWithRequestContext(createRequestContext(), async () => {
setRefreshStaleFetchesInForeground(true);
const res2 = await fetch("https://api.example.com/foreground-stale", {
next: { revalidate: 1 },
});
expect((await res2.json()).count).toBe(2);
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("foreground fetch refresh treats a shorter current revalidate as stale", async () => {
const res1 = await fetch("https://api.example.com/foreground-shorter-revalidate", {
next: { revalidate: 60 },
});
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.lastModified = Date.now() - 2_000;
entry.revalidateAt = Date.now() + 58_000;
}
startNewFetchCacheScope();
await runWithRequestContext(createRequestContext(), async () => {
setRefreshStaleFetchesInForeground(true);
const res2 = await fetch("https://api.example.com/foreground-shorter-revalidate", {
next: { revalidate: 1 },
});
expect((await res2.json()).count).toBe(2);
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("refreshes in the background when a shorter current revalidate makes a fetch stale", async () => {
const res1 = await fetch("https://api.example.com/background-shorter-revalidate", {
next: { revalidate: 60 },
});
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.lastModified = Date.now() - 2_000;
entry.revalidateAt = Date.now() + 58_000;
}
startNewFetchCacheScope();
const res2 = await fetch("https://api.example.com/background-shorter-revalidate", {
next: { revalidate: 1 },
});
expect((await res2.json()).count).toBe(1);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not clone the upstream response body for stale background revalidation", async () => {
const res1 = await fetch("https://api.example.com/background-no-returned-clone", {
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 cloneSpy = vi.spyOn(Response.prototype, "clone");
try {
const res2 = await fetch("https://api.example.com/background-no-returned-clone", {
next: { revalidate: 1 },
});
expect((await res2.json()).count).toBe(1);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(cloneSpy).not.toHaveBeenCalled();
} finally {
cloneSpy.mockRestore();
}
expect(fetchMock).toHaveBeenCalledTimes(2);
});
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 Promise.resolve(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("shares persistent cache entries between equivalent URL and Request inputs", async () => {
const url = "https://api.example.com";
const urlResponse = await fetch(url, { next: { revalidate: 60 } });
expect((await urlResponse.json()).count).toBe(1);
startNewFetchCacheScope();
const requestResponse = await fetch(new Request(url), {
next: { revalidate: 60 },
});
expect((await requestResponse.json()).count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("includes synthesized Blob content types in the persistent cache key", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
contentType: request.headers.get("content-type"),
count: requestCount,
}),
);
});
const url = "https://api.example.com/blob-content-type";
const jsonResponse = await fetch(url, {
method: "POST",
body: new Blob(["same-body"], { type: "application/json" }),
next: { revalidate: 60 },
});
expect(await jsonResponse.json()).toEqual({
contentType: "application/json",
count: 1,
});
startNewFetchCacheScope();
const textResponse = await fetch(url, {
method: "POST",
body: new Blob(["same-body"], { type: "text/plain" }),
next: { revalidate: 60 },
});
expect(await textResponse.json()).toEqual({
contentType: "text/plain",
count: 2,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("serializes ArrayBuffer and ArrayBufferView byte ranges in persistent cache keys", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
bytes: Array.from(new Uint8Array(await request.arrayBuffer())),
count: requestCount,
}),
);
});
const url = "https://api.example.com/buffer-source-body";
const arrayBufferResponse = await fetch(url, {
method: "POST",
body: Uint8Array.of(0x80).buffer,
next: { revalidate: 60 },
});
expect(await arrayBufferResponse.json()).toEqual({ bytes: [0x80], count: 1 });
startNewFetchCacheScope();
const viewBytes = Uint8Array.of(0, 0x81, 0);
const dataViewResponse = await fetch(url, {
method: "POST",
body: new DataView(viewBytes.buffer, 1, 1),
next: { revalidate: 60 },
});
expect(await dataViewResponse.json()).toEqual({ bytes: [0x81], count: 2 });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("hashes invalid UTF-8 body bytes without replacement-character collisions", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
bytes: Array.from(new Uint8Array(await request.arrayBuffer())),
count: requestCount,
}),
);
});
const url = "https://api.example.com/binary-body";
const firstResponse = await fetch(url, {
method: "POST",
body: Uint8Array.of(0x80),
next: { revalidate: 60 },
});
expect(await firstResponse.json()).toEqual({ bytes: [0x80], count: 1 });
startNewFetchCacheScope();
const secondResponse = await fetch(url, {
method: "POST",
body: Uint8Array.of(0x81),
next: { revalidate: 60 },
});
expect(await secondResponse.json()).toEqual({ bytes: [0x81], count: 2 });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("hashes binary Blob bodies without lossy text decoding", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
bytes: Array.from(new Uint8Array(await request.arrayBuffer())),
count: requestCount,
}),
);
});
const url = "https://api.example.com/binary-blob-body";
const firstResponse = await fetch(url, {
method: "POST",
body: new Blob([Uint8Array.of(0x80)]),
next: { revalidate: 60 },
});
expect(await firstResponse.json()).toEqual({ bytes: [0x80], count: 1 });
startNewFetchCacheScope();
const secondResponse = await fetch(url, {
method: "POST",
body: new Blob([Uint8Array.of(0x81)]),
next: { revalidate: 60 },
});
expect(await secondResponse.json()).toEqual({ bytes: [0x81], count: 2 });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("distinguishes an empty string body from an absent body in the persistent cache key", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
contentType: request.headers.get("content-type"),
count: requestCount,
}),
);
});
const url = "https://api.example.com/empty-string-body";
const absentResponse = await fetch(url, {
method: "POST",
next: { revalidate: 60 },
});
expect(await absentResponse.json()).toEqual({
contentType: null,
count: 1,
});
startNewFetchCacheScope();
const emptyResponse = await fetch(url, {
method: "POST",
body: "",
next: { revalidate: 60 },
});
expect(await emptyResponse.json()).toEqual({
contentType: "text/plain;charset=UTF-8",
count: 2,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("distinguishes an empty binary body from an absent body in the persistent cache key", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
hasBody: request.body !== null,
count: requestCount,
}),
);
});
const url = "https://api.example.com/empty-binary-body";
const absentResponse = await fetch(url, {
method: "POST",
next: { revalidate: 60 },
});
expect(await absentResponse.json()).toEqual({ hasBody: false, count: 1 });
startNewFetchCacheScope();
const emptyResponse = await fetch(url, {
method: "POST",
body: new Uint8Array(),
next: { revalidate: 60 },
});
expect(await emptyResponse.json()).toEqual({ hasBody: true, count: 2 });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
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("uses replacement content-type headers when serializing Request bodies", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
contentType: request.headers.get("content-type"),
count: requestCount,
}),
);
});
const makeMultipartRequest = () =>
new Request("https://api.example.com/req-form-replaced-content-type", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=base" },
body: [
"--base",
'Content-Disposition: form-data; name="name"',
"",
"same-value",
"--base--",
"",
].join("\r\n"),
});
const jsonResponse = await fetch(makeMultipartRequest(), {
headers: { "content-type": "application/json" },
next: { revalidate: 60 },
});
expect(await jsonResponse.json()).toEqual({
contentType: "application/json",
count: 1,
});
const textResponse = await fetch(makeMultipartRequest(), {
headers: { "content-type": "text/plain" },
next: { revalidate: 60 },
});
expect(await textResponse.json()).toEqual({
contentType: "text/plain",
count: 2,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("separates generated FormData content types from explicit bare multipart headers", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
contentType: request.headers.get("content-type"),
count: requestCount,
}),
);
});
const makeForm = () => {
const form = new FormData();
form.append("name", "same-value");
return form;
};
const url = "https://api.example.com/form-data-content-type-source";
const generatedResponse = await fetch(url, {
method: "POST",
body: makeForm(),
next: { revalidate: 60 },
});
expect((await generatedResponse.json()).count).toBe(1);
startNewFetchCacheScope();
const bareResponse = await fetch(url, {
method: "POST",
headers: { "content-type": "multipart/form-data" },
body: makeForm(),
next: { revalidate: 60 },
});
expect(await bareResponse.json()).toEqual({
contentType: "multipart/form-data",
count: 2,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
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("bases the auth cache bypass on effective replacement headers", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
authorization: request.headers.get("authorization"),
count: requestCount,
}),
);
});
const makeBaseAuthRequest = () =>
new Request("https://api.example.com/replaced-auth-bypass", {
headers: { Authorization: "Bearer removed" },
});
const anonymousResponse = await fetch(makeBaseAuthRequest(), {
headers: {},
next: { tags: ["public-data"] },
});
expect(await anonymousResponse.json()).toEqual({ authorization: null, count: 1 });
startNewFetchCacheScope();
const cachedAnonymousResponse = await fetch(makeBaseAuthRequest(), {
headers: {},
next: { tags: ["public-data"] },
});
expect(await cachedAnonymousResponse.json()).toEqual({ authorization: null, count: 1 });
startNewFetchCacheScope();
const authenticatedResponse = await fetch("https://api.example.com/effective-auth-bypass", {
headers: { Authorization: "Bearer effective" },
next: { tags: ["user-data"] },
});
expect(await authenticatedResponse.json()).toEqual({
authorization: "Bearer effective",
count: 2,
});
startNewFetchCacheScope();
const freshAuthenticatedResponse = await fetch(
"https://api.example.com/effective-auth-bypass",
{
headers: { Authorization: "Bearer effective" },
next: { tags: ["user-data"] },
},
);
expect(await freshAuthenticatedResponse.json()).toEqual({
authorization: "Bearer effective",
count: 3,
});
expect(fetchMock).toHaveBeenCalledTimes(3);
});
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);
});
it("applies RequestInit overrides when deduping Request inputs", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
authorization: request.headers.get("authorization"),
credentials: request.credentials,
}),
);
});
const request = new Request("https://api.example.com/req-auth-override", {
headers: { Authorization: "Bearer base" },
credentials: "same-origin",
});
const [aliceResponse, bobResponse] = await Promise.all([
fetch(request, {
cache: "no-store",
headers: { Authorization: "Bearer alice" },
credentials: "include",
}),
fetch(request, {
cache: "no-store",
headers: { Authorization: "Bearer bob" },
credentials: "omit",
}),
]);
expect(await aliceResponse.json()).toEqual({
authorization: "Bearer alice",
credentials: "include",
});
expect(await bobResponse.json()).toEqual({
authorization: "Bearer bob",
credentials: "omit",
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not persist a deduped response under a different RequestInit cache key", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
authorization: request.headers.get("authorization"),
count: requestCount,
}),
);
});
const request = new Request("https://api.example.com/req-auth-cache-override");
const aliceResponse = await fetch(request, {
headers: { Authorization: "Bearer alice" },
next: { revalidate: 60 },
});
expect(await aliceResponse.json()).toEqual({
authorization: "Bearer alice",
count: 1,
});
const bobResponse = await fetch(request, {
headers: { Authorization: "Bearer bob" },
next: { revalidate: 60 },
});
expect(await bobResponse.json()).toEqual({
authorization: "Bearer bob",
count: 2,
});
startNewFetchCacheScope();
const cachedBobResponse = await fetch(request, {
headers: { Authorization: "Bearer bob" },
next: { revalidate: 60 },
});
expect(await cachedBobResponse.json()).toEqual({
authorization: "Bearer bob",
count: 2,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("replaces Request headers when deriving the persistent cache key", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
authorization: request.headers.get("authorization"),
count: requestCount,
}),
);
});
const authenticatedRequest = new Request("https://api.example.com/replaced-headers", {
headers: { Authorization: "Bearer alice" },
});
const anonymousResponse = await fetch(authenticatedRequest, {
headers: {},
next: { revalidate: 60 },
});
expect(await anonymousResponse.json()).toEqual({
authorization: null,
count: 1,
});
const authenticatedResponse = await fetch(authenticatedRequest, {
next: { revalidate: 60 },
});
expect(await authenticatedResponse.json()).toEqual({
authorization: "Bearer alice",
count: 2,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("includes inherited Request options in the persistent cache key", async () => {
fetchMock.mockImplementation(async (input, init) => {
requestCount++;
const request = new Request(input, init);
return new Response(
JSON.stringify({
redirect: request.redirect,
count: requestCount,
}),
);
});
const manualRequest = new Request("https://api.example.com/request-options", {
redirect: "manual",
});
const followRequest = new Request("https://api.example.com/request-options", {
redirect: "follow",
});
const manualResponse = await fetch(manualRequest, {
next: { revalidate: 60 },
});
expect(await manualResponse.json()).toEqual({
redirect: "manual",
count: 1,
});
const followResponse = await fetch(followRequest, {
next: { revalidate: 60 },
});
expect(await followResponse.json()).toEqual({
redirect: "follow",
count: 2,
});
expect(fetchMock).toHaveBeenCalledTimes(2);
});
// Adapted from Next.js: 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("does not consume an ineligible Request body while checking dedupe eligibility", async () => {
fetchMock.mockImplementationOnce(async (input) => {
expect(input).toBe(request);
expect(request.bodyUsed).toBe(false);
return new Response(await request.text());
});
const request = new Request("https://api.example.com/request-body", {
method: "POST",
body: new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("stream data"));
controller.close();
},
}),
duplex: "half",
} as RequestInit & { duplex: "half" });
const response = await fetch(request, { cache: "no-store" });
expect(await response.text()).toBe("stream data");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
// ── 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("hashes FormData file bytes without lossy text decoding", async () => {
const makeForm = (byte: number) => {
const form = new FormData();
form.append(
"file",
new File([Uint8Array.of(byte)], "binary.bin", {
type: "application/octet-stream",
}),
);
return form;
};
const res1 = await fetch("https://api.example.com/body-form-file-binary", {
method: "POST",
body: makeForm(0x80),
next: { revalidate: 60 },
});
expect((await res1.json()).count).toBe(1);
startNewFetchCacheScope();
const res2 = await fetch("https://api.example.com/body-form-file-binary", {
method: "POST",
body: makeForm(0x81),
next: { revalidate: 60 },
});
expect((await res2.json()).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);
});
it("hashes ReadableStream bytes without lossy text decoding", async () => {
const makeStream = (byte: number) =>
new ReadableStream({
start(controller) {
controller.enqueue(Uint8Array.of(byte));
controller.close();
},
});
const res1 = await fetch("https://api.example.com/body-stream-binary", {
method: "POST",
body: makeStream(0x80),
next: { revalidate: 60 },
});
expect((await res1.json()).count).toBe(1);
startNewFetchCacheScope();
const res2 = await fetch("https://api.example.com/body-stream-binary", {
method: "POST",
body: makeStream(0x81),
next: { revalidate: 60 },
});
expect((await res2.json()).count).toBe(2);
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("does not wait for one tee branch to cancel before oversized stream fallback", async () => {
let chunk = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (chunk >= 10) {
controller.close();
return;
}
chunk++;
controller.enqueue(new Uint8Array(600 * 1024));
},
});
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const response = await Promise.race([
fetch("https://api.example.com/large-pull-stream", {
method: "POST",
body: stream,
next: { revalidate: 60 },
}),
new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error("oversized stream fallback timed out")),
1_000,
);
}),
]);
expect((await response.json()).count).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
} finally {
clearTimeout(timeout);
}
});
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);
});
it("FormData with oversized File metadata bypasses cache key generation", async () => {
const form = new FormData();
form.append("file", new File([], `${"x".repeat(1024 * 1024)}.txt`));
const res1 = await fetch("https://api.example.com/large-formdata-metadata", {
method: "POST",
body: form,
next: { revalidate: 60 },
});
expect((await res1.json()).count).toBe(1);
const res2 = await fetch("https://api.example.com/large-formdata-metadata", {
method: "POST",
body: form,
next: { revalidate: 60 },
});
expect((await res2.json()).count).toBe(2);
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");
});
});
});