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