Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Promise<FetchResponse<any>>>();

async function onError(context: FetchContext): Promise<FetchResponse<any>> {
// Is Abort
Expand Down Expand Up @@ -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<FetchResponse<any>> => {
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<FetchResponse<any>> {
if (context.options.body && isPayloadMethod(context.options.method)) {
if (isJSONSerializable(context.options.body)) {
const contentType = context.options.headers.get("content-type");
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ export interface FetchOptions<R extends ResponseType = ResponseType, T = any>

/** 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<
Expand Down
86 changes: 86 additions & 0 deletions test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});