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..24f4d0ee0 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,31 @@ 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< + const queryOptions: QueryOptionsFunction = (method, path, ...[init, queryOpts]) => ({ + queryKey: buildQueryKey(method, path as string, init) as DataTag< QueryKey, any, any >, - queryFn, - ...options, + 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 +236,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"];