diff --git a/docs/.vitepress/en.ts b/docs/.vitepress/en.ts index b70514df7..90533ad77 100644 --- a/docs/.vitepress/en.ts +++ b/docs/.vitepress/en.ts @@ -76,6 +76,7 @@ export default defineConfig({ { text: "useSuspenseQuery", link: "/use-suspense-query" }, { text: "useInfiniteQuery", link: "/use-infinite-query" }, { text: "queryOptions", link: "/query-options" }, + { text: "infiniteQueryOptions", link: '/infinite-query-options'} ], }, { diff --git a/docs/openapi-react-query/infinite-query-options.md b/docs/openapi-react-query/infinite-query-options.md new file mode 100644 index 000000000..25194a01a --- /dev/null +++ b/docs/openapi-react-query/infinite-query-options.md @@ -0,0 +1,161 @@ +--- +title: infiniteQueryOptions +--- + +# {{ $frontmatter.title }} + +The `infiniteQueryOptions` method allows you to construct type-safe [Infinite Query Options](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries). + +`infiniteQueryOptions` can be used together with `@tanstack/react-query` APIs that take infinite query options, such as +[useInfiniteQuery](https://tanstack.com/query/latest/docs/framework/react/reference/useInfiniteQuery), +[usePrefetchInfiniteQuery](https://tanstack.com/query/latest/docs/framework/react/reference/usePrefetchInfiniteQuery), +[QueryClient.fetchInfiniteQuery](https://tanstack.com/query/latest/docs/reference/QueryClient#queryclientfetchinfinitequery) +among many others. + +If you would like to use an infinite query API that is not explicitly supported by `openapi-react-query`, this is the way to go. + +## Examples + +[useInfiniteQuery example](use-infinite-query#example) rewritten using `infiniteQueryOptions`. + +::: code-group + +```tsx [src/app.tsx] +import { useInfiniteQuery } from '@tanstack/react-query'; +import { $api } from './api'; + +const PostList = () => { + const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery( + $api.infiniteQueryOptions( + 'get', + '/posts', + { + params: { + query: { + limit: 10, + }, + }, + }, + { + getNextPageParam: lastPage => lastPage.nextPage, + initialPageParam: 0, + } + ) + ); + + return ( +
+ {data?.pages.map((page, i) => ( +
+ {page.items.map(post => ( +
{post.title}
+ ))} +
+ ))} + {hasNextPage && ( + + )} +
+ ); +}; +``` + +```ts [src/api.ts] +import createFetchClient from 'openapi-fetch'; +import createClient from 'openapi-react-query'; +import type { paths } from './my-openapi-3-schema'; // generated by openapi-typescript + +const fetchClient = createFetchClient({ + baseUrl: 'https://myapi.dev/v1/', +}); +export const $api = createClient(fetchClient); +``` + +::: + +::: info Good to Know + +[useInfiniteQuery](use-infinite-query) uses `infiniteQueryOptions` under the hood. + +::: + +Usage with `usePrefetchInfiniteQuery`. + +::: code-group + +```tsx [src/post-list.tsx] +import { usePrefetchInfiniteQuery } from '@tanstack/react-query'; +import { $api } from './api'; + +export const PostList = () => { + // Prefetch infinite query + usePrefetchInfiniteQuery( + $api.infiniteQueryOptions( + 'get', + '/posts', + { + params: { + query: { + limit: 10, + }, + }, + }, + { + getNextPageParam: lastPage => lastPage.nextPage, + initialPageParam: 0, + pages: 3, // Prefetch first 3 pages + } + ) + ); + + // ... rest of the component +}; +``` + +```ts [src/api.ts] +import createFetchClient from 'openapi-fetch'; +import createClient from 'openapi-react-query'; +import type { paths } from './my-openapi-3-schema'; // generated by openapi-typescript + +const fetchClient = createFetchClient({ + baseUrl: 'https://myapi.dev/v1/', +}); +export const $api = createClient(fetchClient); +``` + +::: + +## Api + +```tsx +const infiniteQueryOptions = $api.infiniteQueryOptions(method, path, options, infiniteQueryOptions); +``` + +**Arguments** + +- `method` **(required)** + - The HTTP method to use for the request. + - The method is used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information. +- `path` **(required)** + - The pathname to use for the request. + - Must be an available path for the given method in your schema. + - The pathname is used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information. +- `options` + - The fetch options to use for the request. + - Only required if the OpenApi schema requires parameters. + - The options `params` are used as key. See [Query Keys](https://tanstack.com/query/latest/docs/framework/react/guides/query-keys) for more information. +- `infiniteQueryOptions` **(required)** + - `pageParamName` The query param name used for pagination, `"cursor"` by default. + - `initialPageParam` **(required)**: The initial page param for the first page. + - `getNextPageParam`: Function to calculate the next page param. + - `getPreviousPageParam`: Function to calculate the previous page param. + - Additional infinite query options can be passed through. + +**Returns** + +- [Infinite Query Options](https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries) + - Fully typed thus `data` and `error` will be correctly deduced. + - `queryKey` is `[method, path, params]`. + - `queryFn` is set to a fetcher function that handles pagination. diff --git a/packages/openapi-react-query/biome.json b/packages/openapi-react-query/biome.json index fab77483f..b70618d3d 100644 --- a/packages/openapi-react-query/biome.json +++ b/packages/openapi-react-query/biome.json @@ -1,9 +1,9 @@ { "root": false, - "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.2.5/schema.json", "extends": "//", "files": { - "includes": ["**", "!dist/**", "!test/fixtures/**"] + "includes": ["**", "!dist", "!test/fixtures"] }, "linter": { "rules": { diff --git a/packages/openapi-react-query/src/index.ts b/packages/openapi-react-query/src/index.ts index 337919ac3..d2ed452bc 100644 --- a/packages/openapi-react-query/src/index.ts +++ b/packages/openapi-react-query/src/index.ts @@ -79,6 +79,59 @@ export type QueryOptionsFunction; +// Helper type to infer TPageParam type +type InferPageParamType = T extends { initialPageParam: infer P } ? P : unknown; + +export type InfiniteQueryOptionsFunction< + Paths extends Record>, + Media extends MediaType, +> = < + Method extends HttpMethod, + Path extends PathsWithMethod, + Init extends MaybeOptionalInit, + Response extends Required>, + Options extends Omit< + UseInfiniteQueryOptions< + Response["data"], + Response["error"], + InferSelectReturnType, Options["select"]>, + QueryKey, + InferPageParamType + >, + "queryKey" | "queryFn" + > & { + pageParamName?: string; + initialPageParam: InferPageParamType; + }, +>( + method: Method, + path: Path, + init: InitWithUnknowns, + options: Options, +) => NoInfer< + Omit< + UseInfiniteQueryOptions< + Response["data"], + Response["error"], + InferSelectReturnType, Options["select"]>, + QueryKey, + InferPageParamType + >, + "queryFn" + > & { + queryFn: Exclude< + UseInfiniteQueryOptions< + Response["data"], + Response["error"], + InferSelectReturnType, Options["select"]>, + QueryKey, + InferPageParamType + >["queryFn"], + SkipToken | undefined + >; + } +>; + export type UseQueryMethod>, Media extends MediaType> = < Method extends HttpMethod, Path extends PathsWithMethod, @@ -112,11 +165,12 @@ export type UseInfiniteQueryMethod, Options["select"]>, QueryKey, - unknown + InferPageParamType >, "queryKey" | "queryFn" > & { pageParamName?: string; + initialPageParam: InferPageParamType; }, >( method: Method, @@ -166,6 +220,7 @@ export type UseMutationMethod { queryOptions: QueryOptionsFunction; + infiniteQueryOptions: InfiniteQueryOptionsFunction; useQuery: UseQueryMethod; useSuspenseQuery: UseSuspenseQueryMethod; useInfiniteQuery: UseInfiniteQueryMethod; @@ -214,44 +269,47 @@ export default function createClient = (method, path, init, options) => { + const { pageParamName = "cursor", initialPageParam, ...restOptions } = options; + const { queryKey } = queryOptions(method, path, init); + + return { + queryKey, + initialPageParam, + queryFn: async ({ queryKey: [method, path, init], pageParam, signal }) => { + const mth = method.toUpperCase() as Uppercase; + const fn = client[mth] as ClientMethod; + const mergedInit = { + ...init, + signal, + params: { + ...(init?.params || {}), + query: { + ...(init?.params as { query?: DefaultParamsOption })?.query, + [pageParamName]: pageParam, + }, + }, + }; + + const { data, error } = await fn(path, mergedInit as any); + if (error) { + throw error; + } + return data; + }, + ...restOptions, + }; + }; + return { queryOptions, + infiniteQueryOptions, useQuery: (method, path, ...[init, options, queryClient]) => useQuery(queryOptions(method, path, init as InitWithUnknowns, options), queryClient), useSuspenseQuery: (method, path, ...[init, options, queryClient]) => useSuspenseQuery(queryOptions(method, path, init as InitWithUnknowns, options), queryClient), - useInfiniteQuery: (method, path, init, options, queryClient) => { - const { pageParamName = "cursor", ...restOptions } = options; - const { queryKey } = queryOptions(method, path, init); - return useInfiniteQuery( - { - queryKey, - queryFn: async ({ queryKey: [method, path, init], pageParam = 0, signal }) => { - const mth = method.toUpperCase() as Uppercase; - const fn = client[mth] as ClientMethod; - const mergedInit = { - ...init, - signal, - params: { - ...(init?.params || {}), - query: { - ...(init?.params as { query?: DefaultParamsOption })?.query, - [pageParamName]: pageParam, - }, - }, - }; - - const { data, error } = await fn(path, mergedInit as any); - if (error) { - throw error; - } - return data; - }, - ...restOptions, - }, - queryClient, - ); - }, + useInfiniteQuery: (method, path, init, options, queryClient) => + useInfiniteQuery(infiniteQueryOptions(method, path, init, options), queryClient), useMutation: (method, path, options, queryClient) => useMutation( { diff --git a/packages/openapi-react-query/test/index.test.tsx b/packages/openapi-react-query/test/index.test.tsx index 4dcca2eee..3db6dc1ce 100644 --- a/packages/openapi-react-query/test/index.test.tsx +++ b/packages/openapi-react-query/test/index.test.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider, skipToken, + useInfiniteQuery, useQueries, useQuery, useSuspenseQuery, @@ -75,9 +76,87 @@ describe("client", () => { expect(client).toHaveProperty("useQuery"); expect(client).toHaveProperty("useSuspenseQuery"); expect(client).toHaveProperty("useMutation"); + if ("infiniteQueryOptions" in client) { + expect(client).toHaveProperty("infiniteQueryOptions"); + } }); describe("queryOptions", () => { + describe("infiniteQueryOptions", () => { + it("returns infinite query options that can be passed to useInfiniteQuery", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + const options = client.infiniteQueryOptions( + "get", + "/paginated-data", + { + params: { + query: { + limit: 3, + }, + }, + }, + { + getNextPageParam: (lastPage) => lastPage.nextPage, + initialPageParam: 0, + }, + ); + + expect(options).toHaveProperty("queryKey"); + expect(options).toHaveProperty("queryFn"); + expect(Array.isArray(options.queryKey)).toBe(true); + expectTypeOf(options.queryFn).toBeFunction(); + + useMockRequestHandler({ + baseUrl, + method: "get", + path: "/paginated-data", + status: 200, + body: { items: [1, 2, 3], nextPage: 1 }, + }); + + const { result } = renderHook(() => useInfiniteQuery(options), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.pages[0].items).toEqual([1, 2, 3]); + }); + + it("returns infinite query options with custom pageParamName", async () => { + const fetchClient = createFetchClient({ baseUrl }); + const client = createClient(fetchClient); + + const options = client.infiniteQueryOptions( + "get", + "/paginated-data", + { + params: { + query: { + limit: 3, + }, + }, + }, + { + getNextPageParam: (lastPage) => lastPage.nextPage, + initialPageParam: 0, + pageParamName: "follow_cursor", + }, + ); + + useMockRequestHandler({ + baseUrl, + method: "get", + path: "/paginated-data", + status: 200, + body: { items: [1, 2, 3], nextPage: 1 }, + }); + + const { result } = renderHook(() => useInfiniteQuery(options), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.pages[0].items).toEqual([1, 2, 3]); + }); + }); it("has correct parameter types", async () => { const fetchClient = createFetchClient({ baseUrl }); const client = createClient(fetchClient); @@ -158,7 +237,10 @@ describe("client", () => { ); expectTypeOf(result.current[0].data).toEqualTypeOf(); - expectTypeOf(result.current[0].error).toEqualTypeOf<{ code: number; message: string } | null>(); + expectTypeOf(result.current[0].error).toEqualTypeOf<{ + code: number; + message: string; + } | null>(); expectTypeOf(result.current[1]).toEqualTypeOf<(typeof result.current)[0]>(); @@ -170,7 +252,10 @@ describe("client", () => { } | undefined >(); - expectTypeOf(result.current[2].error).toEqualTypeOf<{ code: number; message: string } | null>(); + expectTypeOf(result.current[2].error).toEqualTypeOf<{ + code: number; + message: string; + } | null>(); expectTypeOf(result.current[3]).toEqualTypeOf<(typeof result.current)[2]>(); @@ -811,7 +896,9 @@ describe("client", () => { wrapper, }); - const data = await result.current.mutateAsync({ body: { message: "Hello", replied_at: 0 } }); + const data = await result.current.mutateAsync({ + body: { message: "Hello", replied_at: 0 }, + }); expect(data.message).toBe("Hello"); }); @@ -1015,7 +1102,7 @@ describe("client", () => { expect(firstRequestUrl?.searchParams.get("cursor")).toBe("0"); // Set up mock for second page before triggering next page fetch - const secondRequestHandler = useMockRequestHandler({ + useMockRequestHandler({ baseUrl, method: "get", path: "/paginated-data", @@ -1142,7 +1229,7 @@ describe("client", () => { const client = createClient(fetchClient); // First page request handler - const firstRequestHandler = useMockRequestHandler({ + useMockRequestHandler({ baseUrl, method: "get", path: "/paginated-data", @@ -1178,7 +1265,7 @@ describe("client", () => { expect(result.current.data).toEqual([1, 2, 3]); // Set up mock for second page before triggering next page fetch - const secondRequestHandler = useMockRequestHandler({ + useMockRequestHandler({ baseUrl, method: "get", path: "/paginated-data",