From c2d47f066b68205a52bca342f17c543ccebc8469 Mon Sep 17 00:00:00 2001 From: productdevbook Date: Mon, 30 Mar 2026 07:45:27 +0300 Subject: [PATCH] feat: merge hooks from defaults and per-request options Hooks from `$fetch.create()` defaults and per-request options are now concatenated instead of overwritten. Default hooks run first, followed by per-request hooks. This applies to all hook types: onRequest, onRequestError, onResponse, onResponseError. Resolves #319 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils.ts | 37 +++++++++++++++++++++ test/index.test.ts | 83 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/src/utils.ts b/src/utils.ts index a773431b..eefe5f10 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ import type { FetchContext, FetchHook, + FetchHooks, FetchOptions, FetchRequest, ResolvedFetchOptions, @@ -109,15 +110,51 @@ export function resolveFetchOptions< }; } + // Merge hooks: concatenate defaults and input hooks (defaults run first) + const mergedHooks = mergeHooks( + defaults as FetchOptions | undefined, + input as FetchOptions | undefined + ); + return { ...defaults, ...input, + ...mergedHooks, query, params: query, headers, }; } +const hookNames: Array = [ + "onRequest", + "onRequestError", + "onResponse", + "onResponseError", +]; + +function toArray(value: T | T[] | undefined): T[] { + if (!value) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +function mergeHooks( + defaults: FetchOptions | undefined, + input: FetchOptions | undefined +): Partial { + const merged: Partial = {}; + for (const name of hookNames) { + const defaultHooks = toArray((defaults as any)?.[name]); + const inputHooks = toArray((input as any)?.[name]); + if (defaultHooks.length > 0 || inputHooks.length > 0) { + (merged as any)[name] = [...defaultHooks, ...inputHooks]; + } + } + return merged; +} + function mergeHeaders( input: HeadersInit | undefined, defaults: HeadersInit | undefined, diff --git a/test/index.test.ts b/test/index.test.ts index 5ac20b07..74ebbcfe 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -505,6 +505,89 @@ describe("ofetch", () => { expect(onResponseError).toHaveBeenCalledTimes(2); }); + describe("hook merging", () => { + it("merges default and per-request hooks (defaults run first)", async () => { + const order: string[] = []; + const customFetch = $fetch.create({ + onRequest() { + order.push("default"); + }, + }); + await customFetch(getURL("ok"), { + onRequest() { + order.push("per-request"); + }, + }); + expect(order).toEqual(["default", "per-request"]); + }); + + it("merges array hooks from defaults and per-request", async () => { + const order: string[] = []; + const customFetch = $fetch.create({ + onRequest: [ + () => { + order.push("default-1"); + }, + () => { + order.push("default-2"); + }, + ], + }); + await customFetch(getURL("ok"), { + onRequest: [ + () => { + order.push("per-request-1"); + }, + () => { + order.push("per-request-2"); + }, + ], + }); + expect(order).toEqual([ + "default-1", + "default-2", + "per-request-1", + "per-request-2", + ]); + }); + + it("works when only defaults have hooks", async () => { + const onRequest = vi.fn(); + const customFetch = $fetch.create({ onRequest }); + await customFetch(getURL("ok")); + expect(onRequest).toHaveBeenCalledOnce(); + }); + + it("works when only per-request has hooks", async () => { + const onRequest = vi.fn(); + const customFetch = $fetch.create({}); + await customFetch(getURL("ok"), { onRequest }); + expect(onRequest).toHaveBeenCalledOnce(); + }); + + it("merges all hook types", async () => { + const defaultOnResponse = vi.fn(); + const defaultOnResponseError = vi.fn(); + const perRequestOnResponse = vi.fn(); + const perRequestOnResponseError = vi.fn(); + + const customFetch = $fetch.create({ + onResponse: defaultOnResponse, + onResponseError: defaultOnResponseError, + }); + + await customFetch(getURL("403"), { + onResponse: perRequestOnResponse, + onResponseError: perRequestOnResponseError, + }).catch(() => {}); + + expect(defaultOnResponse).toHaveBeenCalledOnce(); + expect(perRequestOnResponse).toHaveBeenCalledOnce(); + expect(defaultOnResponseError).toHaveBeenCalledOnce(); + expect(perRequestOnResponseError).toHaveBeenCalledOnce(); + }); + }); + it("default fetch options", async () => { await $fetch("https://jsonplaceholder.typicode.com/todos/1", {}); expect(fetch).toHaveBeenCalledOnce();