diff --git a/src/fetch.ts b/src/fetch.ts index 10c91583..faa2f72d 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -35,6 +35,7 @@ const nullBodyResponses = new Set([101, 204, 205, 304]); export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { const { fetch = globalThis.fetch } = globalOptions; + const _pendingRequests = new Map>>(); async function onError(context: FetchContext): Promise> { // Is Abort @@ -127,6 +128,36 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { } } + // Request deduplication (after URL is resolved with baseURL/query) + if (context.options.dedupe && !isPayloadMethod(context.options.method)) { + const method = (context.options.method || "GET").toUpperCase(); + const requestUrl = + typeof context.request === "string" + ? context.request + : (context.request as Request).url; + const dedupeKey = `${method}:${requestUrl}`; + const pending = _pendingRequests.get(dedupeKey); + if (pending) { + return pending; + } + const execute = async (): Promise> => { + try { + return await _executeFetch(context); + } finally { + _pendingRequests.delete(dedupeKey); + } + }; + const promise = execute(); + _pendingRequests.set(dedupeKey, promise); + return promise; + } + + return _executeFetch(context); + }; + + async function _executeFetch( + context: FetchContext + ): Promise> { if (context.options.body && isPayloadMethod(context.options.method)) { if (isJSONSerializable(context.options.body)) { const contentType = context.options.headers.get("content-type"); @@ -254,7 +285,7 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { } return context.response; - }; + } const $fetch = async function $fetch(request, options) { const r = await $fetchRaw(request, options); diff --git a/src/types.ts b/src/types.ts index 66a84faf..49750626 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,13 @@ export interface FetchOptions /** Default is [408, 409, 425, 429, 500, 502, 503, 504] */ retryStatusCodes?: number[]; + + /** + * When enabled, identical concurrent requests are coalesced into a single + * network call. The response is shared between all callers. + * Only applies to non-payload methods (GET, HEAD) by default. + */ + dedupe?: boolean; } export interface ResolvedFetchOptions< diff --git a/test/index.test.ts b/test/index.test.ts index 5ac20b07..57e3e461 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -524,4 +524,90 @@ describe("ofetch", () => { timeout: 10_000, }); }); + + describe("request deduplication", () => { + it("coalesces identical concurrent GET requests", async () => { + const url = getURL("ok"); + const [r1, r2, r3] = await Promise.all([ + $fetch(url, { dedupe: true }), + $fetch(url, { dedupe: true }), + $fetch(url, { dedupe: true }), + ]); + expect(r1).toBe("ok"); + expect(r2).toBe("ok"); + expect(r3).toBe("ok"); + // Only one actual fetch call + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("does not dedupe without opt-in", async () => { + const url = getURL("ok"); + await Promise.all([$fetch(url), $fetch(url), $fetch(url)]); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it("does not dedupe POST requests", async () => { + const url = getURL("post"); + await Promise.all([ + $fetch(url, { method: "POST", body: { a: 1 }, dedupe: true }), + $fetch(url, { method: "POST", body: { a: 1 }, dedupe: true }), + ]); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("dedupes different URLs separately", async () => { + const [r1, r2] = await Promise.all([ + $fetch(getURL("ok"), { dedupe: true }), + $fetch(getURL("params?x=1"), { dedupe: true }), + ]); + expect(r1).toBe("ok"); + expect(r2).toEqual({ x: "1" }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("allows new request after previous completes", async () => { + const url = getURL("ok"); + await $fetch(url, { dedupe: true }); + expect(fetch).toHaveBeenCalledOnce(); + fetch.mockClear(); + await $fetch(url, { dedupe: true }); + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("different query params are NOT coalesced", async () => { + const [r1, r2] = await Promise.all([ + $fetch(getURL("params"), { query: { a: "1" }, dedupe: true }), + $fetch(getURL("params"), { query: { a: "2" }, dedupe: true }), + ]); + expect(r1).toEqual({ a: "1" }); + expect(r2).toEqual({ a: "2" }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("propagates errors to all deduped callers", async () => { + const results = await Promise.all([ + $fetch(getURL("403"), { retry: 0, dedupe: true }).catch( + (error_: any) => error_ + ), + $fetch(getURL("403"), { retry: 0, dedupe: true }).catch( + (error_: any) => error_ + ), + ]); + for (const r of results) { + expect(r.status).toBe(403); + } + expect(fetch).toHaveBeenCalledOnce(); + }); + + it("works with $fetch.raw", async () => { + const url = getURL("ok"); + const [r1, r2] = await Promise.all([ + $fetch.raw(url, { dedupe: true }), + $fetch.raw(url, { dedupe: true }), + ]); + expect(r1._data).toBe("ok"); + expect(r2._data).toBe("ok"); + expect(fetch).toHaveBeenCalledOnce(); + }); + }); });