From 87d863991244c1697387120d0881fb5cab3b0247 Mon Sep 17 00:00:00 2001 From: Cody Coljee-Gray Date: Fri, 17 Apr 2026 10:46:44 -0700 Subject: [PATCH 1/2] feat(openapi-react-query): add queryKeyFn option to createClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows callers to namespace TanStack Query keys per client instance, preventing unintended deduplication when two clients share endpoint paths but target different base URLs. Also refactors the shared queryFn into a per-call closure so it no longer destructures from the queryKey — a prerequisite for supporting custom key shapes. Co-Authored-By: Claude Sonnet 4.6 --- .changeset/query-key-fn.md | 10 ++ packages/openapi-react-query/README.md | 31 +++++++ packages/openapi-react-query/src/index.ts | 52 +++++------ .../openapi-react-query/test/index.test.tsx | 92 +++++++++++++++++++ 4 files changed, 159 insertions(+), 26 deletions(-) create mode 100644 .changeset/query-key-fn.md diff --git a/.changeset/query-key-fn.md b/.changeset/query-key-fn.md new file mode 100644 index 000000000..4f012e99b --- /dev/null +++ b/.changeset/query-key-fn.md @@ -0,0 +1,10 @@ +--- +"openapi-react-query": minor +--- + +Add `queryKeyFn` option to `createClient` for per-client query key namespacing. + +When two `openapi-fetch` clients target different servers but share endpoint paths, +TanStack Query deduplicates their requests because the default `[method, path, init]` +key is identical. Passing a `queryKeyFn` lets callers namespace keys per client +instance so each client maintains an independent cache. diff --git a/packages/openapi-react-query/README.md b/packages/openapi-react-query/README.md index 50a521c4b..57b848f96 100644 --- a/packages/openapi-react-query/README.md +++ b/packages/openapi-react-query/README.md @@ -60,6 +60,37 @@ const MyComponent = () => { > You can find more information about `createFetchClient` in the [openapi-fetch documentation](../openapi-fetch). +## Options + +### `queryKeyFn` + +TanStack Query deduplicates cache entries by query key. By default, `openapi-react-query` +builds keys as `[method, path, init]`. If you use two separate `openapi-fetch` clients +that target different servers but share endpoint paths, their keys will collide — only +one request will fire and both hooks will receive the same cached data. + +Pass a `queryKeyFn` to `createClient` to namespace keys per client instance: + +```ts +import createFetchClient from "openapi-fetch"; +import createClient from "openapi-react-query"; +import type { paths } from "./my-openapi-3-schema"; + +const appClient = createClient( + createFetchClient({ baseUrl: "https://app-api.example.com" }), +); + +const pdmClient = createClient( + createFetchClient({ baseUrl: "https://pdm-api.example.com" }), + { + queryKeyFn: (method, path, init) => ["pdm", method, path, init], + }, +); +``` + +Each client now maintains its own cache namespace, so identical paths on different +servers are fetched and stored independently. + ## 📓 Docs [View Docs](https://openapi-ts.dev/openapi-react-query/) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index fb9683164..ec932ba40 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -2,7 +2,6 @@ import { type DataTag, type InfiniteData, type QueryClient, - type QueryFunctionContext, type SkipToken, type UseInfiniteQueryOptions, type UseInfiniteQueryResult, @@ -38,6 +37,12 @@ export type QueryKey< Init = MaybeOptionalInit, > = Init extends undefined ? readonly [Method, Path] : readonly [Method, Path, Init]; +export type QueryKeyFn = (method: HttpMethod, path: string, init: unknown) => readonly unknown[]; + +export interface CreateClientOptions { + queryKeyFn?: QueryKeyFn; +} + export type QueryOptionsFunction>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, @@ -192,32 +197,27 @@ export type MethodResponse< // TODO: Add the ability to bring queryClient as argument export default function createClient( client: FetchClient, + clientOptions?: CreateClientOptions, ): OpenapiQueryClient { - const queryFn = async >({ - queryKey: [method, path, init], - signal, - }: QueryFunctionContext>) => { - const mth = method.toUpperCase() as Uppercase; - const fn = client[mth] as ClientMethod; - const { data, error, response } = await fn(path, { signal, ...(init as any) }); // TODO: find a way to avoid as any - if (error) { - throw error; - } - if (response.status === 204 || response.headers.get("Content-Length") === "0") { - return data ?? null; - } + const buildQueryKey: QueryKeyFn = + clientOptions?.queryKeyFn ?? + ((method, path, init) => (init === undefined ? ([method, path] as const) : ([method, path, init] as const))); - return data; - }; - - const queryOptions: QueryOptionsFunction = (method, path, ...[init, options]) => ({ - queryKey: (init === undefined ? ([method, path] as const) : ([method, path, init] as const)) as DataTag< - QueryKey, - any, - any - >, - queryFn, - ...options, + const queryOptions: QueryOptionsFunction = (method, path, ...[init, queryOpts]) => ({ + queryKey: buildQueryKey(method, path as string, init) as DataTag, any, any>, + queryFn: async ({ signal }) => { + const mth = method.toUpperCase() as Uppercase; + const fn = client[mth] as ClientMethod; + const { data, error, response } = await fn(path, { signal, ...(init as any) }); // TODO: find a way to avoid as any + if (error) { + throw error; + } + if (response.status === 204 || response.headers.get("Content-Length") === "0") { + return data ?? null; + } + return data; + }, + ...queryOpts, }); return { @@ -232,7 +232,7 @@ export default function createClient { + queryFn: async ({ pageParam = 0, signal }) => { const mth = method.toUpperCase() as Uppercase; const fn = client[mth] as ClientMethod; const mergedInit = { diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index 78715e643..969e8e3fa 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -286,6 +286,98 @@ describe("client", () => { }); }); + describe("queryKeyFn", () => { + it("default key is [method, path] when no init is provided", () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + const { queryKey } = client.queryOptions("get", "/foo"); + expect(queryKey).toEqual(["get", "/foo"]); + }); + + it("default key is [method, path, init] when init is provided", () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + const { queryKey } = client.queryOptions("get", "/foo", {}); + expect(queryKey).toEqual(["get", "/foo", {}]); + }); + + it("uses custom queryKeyFn when provided", () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient, { + queryKeyFn: (method, path, init) => ["ns", method, path, init], + }); + + const { queryKey } = client.queryOptions("get", "/foo"); + expect(queryKey).toEqual(["ns", "get", "/foo", undefined]); + }); + + it("custom queryKeyFn prevents deduplication across clients with different namespaces", async () => { + const response = ["one", "two", "three"]; + + const fetchClient = createFetchClient({ baseUrl }); + const client1 = createClient(fetchClient, { + queryKeyFn: (method, path, init) => ["ns1", method, path, init], + }); + const client2 = createClient(fetchClient, { + queryKeyFn: (method, path, init) => ["ns2", method, path, init], + }); + + useMockRequestHandler({ + baseUrl, + method: "get", + path: "/string-array", + status: 200, + body: response, + }); + + const { result: result1 } = renderHook(() => client1.useQuery("get", "/string-array"), { wrapper }); + const { result: result2 } = renderHook(() => client2.useQuery("get", "/string-array"), { wrapper }); + + await waitFor(() => { + expect(result1.current.isFetching).toBe(false); + expect(result2.current.isFetching).toBe(false); + }); + + // Both clients should have data resolved independently + expect(result1.current.data).toEqual(response); + expect(result2.current.data).toEqual(response); + + // Two separate cache entries should exist — one per namespace + const cacheKeys = queryClient + .getQueryCache() + .getAll() + .map((q) => q.queryKey); + expect(cacheKeys.some((k) => k[0] === "ns1")).toBe(true); + expect(cacheKeys.some((k) => k[0] === "ns2")).toBe(true); + }); + + it("custom queryKeyFn receives data via useQuery", async () => { + const response = ["one", "two", "three"]; + + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient, { + queryKeyFn: (method, path, init) => ["custom", method, path, init], + }); + + useMockRequestHandler({ + baseUrl, + method: "get", + path: "/string-array", + status: 200, + body: response, + }); + + const { result } = renderHook(() => client.useQuery("get", "/string-array"), { wrapper }); + + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + expect(result.current.data).toEqual(response); + expect(result.current.error).toBeNull(); + }); + }); + describe("useQuery", () => { it("should resolve data properly and have error as null when successful request", async () => { const response = ["one", "two", "three"]; From caad8ea746d6aebc8513837976551e082bd3570c Mon Sep 17 00:00:00 2001 From: Cody Coljee-Gray Date: Fri, 17 Apr 2026 12:55:00 -0700 Subject: [PATCH 2/2] fix(openapi-react-query): fix biome formatting on queryKey line Co-Authored-By: Claude Sonnet 4.6 --- packages/openapi-react-query/src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index ec932ba40..24f4d0ee0 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -204,7 +204,11 @@ export default function createClient (init === undefined ? ([method, path] as const) : ([method, path, init] as const))); const queryOptions: QueryOptionsFunction = (method, path, ...[init, queryOpts]) => ({ - queryKey: buildQueryKey(method, path as string, init) as DataTag, any, any>, + queryKey: buildQueryKey(method, path as string, init) as DataTag< + QueryKey, + any, + any + >, queryFn: async ({ signal }) => { const mth = method.toUpperCase() as Uppercase; const fn = client[mth] as ClientMethod;