mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
b663d4bc35
* fix(cache): inherit route revalidate for tagged fetches * fix(cache): inherit the live fetch revalidate interval
3759 lines
131 KiB
TypeScript
3759 lines
131 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,
|
|
setCurrentFetchRevalidate,
|
|
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, setHeadersContext } =
|
|
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);
|
|
});
|
|
|
|
// Next.js stores CachedFetchData.body as base64:
|
|
// https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/patch-fetch.ts
|
|
it("preserves binary response bodies when replaying the fetch cache", async () => {
|
|
const url = "https://api.example.com/compressed";
|
|
const body = new Uint8Array([0x1f, 0x8b, 0x08, 0x00, 0xff, 0x80, 0x00, 0x7f]);
|
|
|
|
fetchMock.mockImplementationOnce(async () => {
|
|
const response = new Response(body, {
|
|
status: 200,
|
|
headers: {
|
|
"content-encoding": "gzip",
|
|
"content-type": "application/octet-stream",
|
|
},
|
|
});
|
|
Object.defineProperty(response, "url", {
|
|
value: url,
|
|
configurable: true,
|
|
enumerable: true,
|
|
writable: false,
|
|
});
|
|
return response;
|
|
});
|
|
|
|
const cold = await fetch(url, { cache: "force-cache" });
|
|
expect(new Uint8Array(await cold.arrayBuffer())).toEqual(body);
|
|
|
|
const cached = await fetch(url, { cache: "force-cache" });
|
|
expect(new Uint8Array(await cached.arrayBuffer())).toEqual(body);
|
|
expect(cached.headers.get("content-encoding")).toBe("gzip");
|
|
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("tags-only fetch inherits the active route revalidate", async () => {
|
|
setCurrentFetchRevalidate(60);
|
|
|
|
await fetch("https://api.example.com/route-revalidate", {
|
|
next: { tags: ["route-revalidate"] },
|
|
});
|
|
|
|
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
|
|
const store = (handler as any).store as Map<string, any>;
|
|
const entries = [...store.values()];
|
|
expect(entries).toHaveLength(1);
|
|
expect(entries[0].value).toMatchObject({
|
|
kind: "FETCH",
|
|
revalidate: 60,
|
|
tags: ["route-revalidate"],
|
|
});
|
|
});
|
|
|
|
it("explicit fetch revalidate overrides the active route revalidate", async () => {
|
|
setCurrentFetchRevalidate(60);
|
|
|
|
await fetch("https://api.example.com/explicit-fetch-revalidate", {
|
|
next: { revalidate: 5, tags: ["explicit-fetch-revalidate"] },
|
|
});
|
|
|
|
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
|
|
const store = (handler as any).store as Map<string, any>;
|
|
expect([...store.values()][0].value.revalidate).toBe(5);
|
|
});
|
|
|
|
it("tags-only fetch inherits an earlier shorter explicit fetch revalidate", async () => {
|
|
setCurrentFetchRevalidate(60);
|
|
|
|
await fetch("https://api.example.com/shorter-explicit-revalidate", {
|
|
next: { revalidate: 5 },
|
|
});
|
|
await fetch("https://api.example.com/inherit-shorter-revalidate", {
|
|
next: { tags: ["inherit-shorter-revalidate"] },
|
|
});
|
|
|
|
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
|
|
const store = (handler as any).store as Map<string, any>;
|
|
const revalidateByUrl = new Map(
|
|
[...store.values()].map((entry) => [entry.value.data.url, entry.value.revalidate]),
|
|
);
|
|
expect(revalidateByUrl).toEqual(
|
|
new Map([
|
|
["https://api.example.com/shorter-explicit-revalidate", 5],
|
|
["https://api.example.com/inherit-shorter-revalidate", 5],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("tags-only fetch is uncached after an earlier zero revalidate fetch", async () => {
|
|
setCurrentFetchRevalidate(60);
|
|
|
|
await fetch("https://api.example.com/zero-explicit-revalidate", {
|
|
next: { revalidate: 0 },
|
|
});
|
|
await fetch("https://api.example.com/inherit-zero-revalidate", {
|
|
next: { tags: ["inherit-zero-revalidate"] },
|
|
});
|
|
|
|
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
|
|
const store = (handler as any).store as Map<string, any>;
|
|
expect(store).toHaveLength(0);
|
|
expect(consumeDynamicUsage()).toBe(true);
|
|
});
|
|
|
|
it("force-static preserves the route revalidate after a zero revalidate fetch", async () => {
|
|
setHeadersContext({
|
|
cookies: new Map(),
|
|
forceStatic: true,
|
|
headers: new Headers(),
|
|
});
|
|
try {
|
|
setCurrentFetchRevalidate(60);
|
|
|
|
await fetch("https://api.example.com/force-static-zero-revalidate", {
|
|
next: { revalidate: 0 },
|
|
});
|
|
await fetch("https://api.example.com/force-static-route-revalidate", {
|
|
next: { tags: ["force-static-route-revalidate"] },
|
|
});
|
|
|
|
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
|
|
const store = (handler as any).store as Map<string, any>;
|
|
expect([...store.values()][0].value.revalidate).toBe(60);
|
|
expect(consumeDynamicUsage()).toBe(false);
|
|
} finally {
|
|
setHeadersContext(null);
|
|
}
|
|
});
|
|
|
|
it("explicit indefinite fetch caching overrides the active route revalidate", async () => {
|
|
setCurrentFetchRevalidate(60);
|
|
|
|
await fetch("https://api.example.com/revalidate-false", {
|
|
next: { revalidate: false, tags: ["revalidate-false"] },
|
|
});
|
|
await fetch("https://api.example.com/force-cache", {
|
|
cache: "force-cache",
|
|
next: { tags: ["force-cache"] },
|
|
});
|
|
|
|
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
|
|
const store = (handler as any).store as Map<string, any>;
|
|
expect([...store.values()].map((entry) => entry.value.revalidate)).toEqual([
|
|
31_536_000, 31_536_000,
|
|
]);
|
|
});
|
|
|
|
it("resets the active route revalidate between fetch-cache scopes", async () => {
|
|
setCurrentFetchRevalidate(60);
|
|
cleanup?.();
|
|
cleanup = withFetchCache();
|
|
|
|
await fetch("https://api.example.com/no-route-revalidate", {
|
|
next: { tags: ["no-route-revalidate"] },
|
|
});
|
|
|
|
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
|
|
const store = (handler as any).store as Map<string, any>;
|
|
expect([...store.values()][0].value.revalidate).toBe(31_536_000);
|
|
});
|
|
|
|
it("tags-only fetch is uncached when the active route revalidate is zero", async () => {
|
|
setCurrentFetchRevalidate(0);
|
|
|
|
await fetch("https://api.example.com/zero-route-revalidate", {
|
|
next: { tags: ["zero-route-revalidate"] },
|
|
});
|
|
|
|
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
|
|
const store = (handler as any).store as Map<string, any>;
|
|
expect(store).toHaveLength(0);
|
|
expect(consumeDynamicUsage()).toBe(true);
|
|
});
|
|
|
|
it("isolates active route revalidate across concurrent fetch-cache scopes", async () => {
|
|
cleanup?.();
|
|
cleanup = null;
|
|
|
|
await Promise.all([
|
|
runWithFetchCache(async () => {
|
|
setCurrentFetchRevalidate(10);
|
|
await Promise.resolve();
|
|
await fetch("https://api.example.com/concurrent-route-a", {
|
|
next: { tags: ["concurrent-route-a"] },
|
|
});
|
|
}),
|
|
runWithFetchCache(async () => {
|
|
setCurrentFetchRevalidate(20);
|
|
await Promise.resolve();
|
|
await fetch("https://api.example.com/concurrent-route-b", {
|
|
next: { tags: ["concurrent-route-b"] },
|
|
});
|
|
}),
|
|
]);
|
|
|
|
const handler = getCacheHandler() as InstanceType<typeof MemoryCacheHandler>;
|
|
const store = (handler as any).store as Map<string, any>;
|
|
const revalidateByUrl = new Map(
|
|
[...store.values()].map((entry) => [entry.value.data.url, entry.value.revalidate]),
|
|
);
|
|
expect(revalidateByUrl).toEqual(
|
|
new Map([
|
|
["https://api.example.com/concurrent-route-a", 10],
|
|
["https://api.example.com/concurrent-route-b", 20],
|
|
]),
|
|
);
|
|
|
|
cleanup = withFetchCache();
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|
|
});
|