Skip to content

Better typesafety for useInfiniteQuery #2126

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
1 change: 1 addition & 0 deletions docs/openapi-react-query/use-infinite-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const PostList = () => {
},
{
getNextPageParam: (lastPage) => lastPage.nextPage,
pageParamName: "cursor",
initialPageParam: 0,
}
);
Expand Down
19 changes: 11 additions & 8 deletions packages/openapi-react-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ import type {
MaybeOptionalInit,
Client as FetchClient,
DefaultParamsOption,
ParamsOption,
} from "openapi-fetch";
import type { HttpMethod, MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers";
import type { FilterKeys, HttpMethod, MediaType, PathsWithMethod, RequiredKeysOf } from "openapi-typescript-helpers";

// Helper type to dynamically infer the type from the `select` property
type InferSelectReturnType<TData, TSelect> = TSelect extends (data: TData) => infer R ? R : TData;
Expand Down Expand Up @@ -106,24 +107,26 @@ export type UseInfiniteQueryMethod<Paths extends Record<string, Record<HttpMetho
Path extends PathsWithMethod<Paths, Method>,
Init extends MaybeOptionalInit<Paths[Path], Method>,
Response extends Required<FetchResponse<Paths[Path][Method], Init, Media>>,
Query extends ParamsOption<FilterKeys<Paths[Path], Method>>["params"] extends { query: infer Query } ? Query : never,
PageParamName extends keyof Query,
Options extends Omit<
UseInfiniteQueryOptions<
Response["data"],
Response["error"],
InfiniteData<Response["data"]>,
Response["data"],
QueryKey<Paths, Method, Path>,
unknown
NonNullable<Query[PageParamName]>
>,
"queryKey" | "queryFn"
> & {
pageParamName?: string;
},
>,
>(
method: Method,
url: Path,
init: InitWithUnknowns<Init>,
options: Options,
options: Options & {
pageParamName: PageParamName;
},
queryClient?: QueryClient,
) => UseInfiniteQueryResult<InfiniteData<Response["data"]>, Response["error"]>;

Expand Down Expand Up @@ -216,12 +219,12 @@ export default function createClient<Paths extends {}, Media extends MediaType =
useSuspenseQuery: (method, path, ...[init, options, queryClient]) =>
useSuspenseQuery(queryOptions(method, path, init as InitWithUnknowns<typeof init>, options), queryClient),
useInfiniteQuery: (method, path, init, options, queryClient) => {
const { pageParamName = "cursor", ...restOptions } = options;
const { pageParamName, ...restOptions } = options;
const { queryKey } = queryOptions(method, path, init);
return useInfiniteQuery(
{
queryKey,
queryFn: async ({ queryKey: [method, path, init], pageParam = 0, signal }) => {
queryFn: async ({ queryKey: [method, path, init], pageParam, signal }) => {
const mth = method.toUpperCase() as Uppercase<typeof method>;
const fn = client[mth] as ClientMethod<Paths, typeof method, Media>;
const mergedInit = {
Expand Down
88 changes: 2 additions & 86 deletions packages/openapi-react-query/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,7 @@ describe("client", () => {
{
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 0,
pageParamName: "cursor",
},
),
{ wrapper },
Expand Down Expand Up @@ -947,6 +948,7 @@ describe("client", () => {
{
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 0,
pageParamName: "cursor",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe here we could use a different param name other than the default one?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, not sure what you mean by "the default one". The schema only provides cursor and limit as parameters, so any other param name here will trigger a type error (due to the changes in this PR). Or are you suggesting a separate schema for the second test?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, np

select: (data) => ({
pages: [...data.pages].reverse(),
pageParams: [...data.pageParams].reverse(),
Expand Down Expand Up @@ -1001,91 +1003,5 @@ describe("client", () => {
const allItems = result.current.data?.pages.flatMap((page) => page.items);
expect(allItems).toEqual([4, 5, 6, 1, 2, 3]);
});
it("should use custom cursor params", async () => {
const fetchClient = createFetchClient<paths>({ baseUrl });
const client = createClient(fetchClient);

// First page request handler
const firstRequestHandler = useMockRequestHandler({
baseUrl,
method: "get",
path: "/paginated-data",
status: 200,
body: { items: [1, 2, 3], nextPage: 1 },
});

const { result, rerender } = renderHook(
() =>
client.useInfiniteQuery(
"get",
"/paginated-data",
{
params: {
query: {
limit: 3,
},
},
},
{
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 0,
pageParamName: "follow_cursor",
},
),
{ wrapper },
);

// Wait for initial query to complete
await waitFor(() => expect(result.current.isSuccess).toBe(true));

// Verify first request
const firstRequestUrl = firstRequestHandler.getRequestUrl();
expect(firstRequestUrl?.searchParams.get("limit")).toBe("3");
expect(firstRequestUrl?.searchParams.get("follow_cursor")).toBe("0");

// Set up mock for second page before triggering next page fetch
const secondRequestHandler = useMockRequestHandler({
baseUrl,
method: "get",
path: "/paginated-data",
status: 200,
body: { items: [4, 5, 6], nextPage: 2 },
});

// Fetch next page
await act(async () => {
await result.current.fetchNextPage();
// Force a rerender to ensure state is updated
rerender();
});

// Wait for second page to be fetched and verify loading states
await waitFor(() => {
expect(result.current.isFetching).toBe(false);
expect(result.current.hasNextPage).toBe(true);
expect(result.current.data?.pages).toHaveLength(2);
});

// Verify second request
const secondRequestUrl = secondRequestHandler.getRequestUrl();
expect(secondRequestUrl?.searchParams.get("limit")).toBe("3");
expect(secondRequestUrl?.searchParams.get("follow_cursor")).toBe("1");

expect(result.current.data).toBeDefined();
expect(result.current.data?.pages[0].nextPage).toBe(1);

expect(result.current.data).toBeDefined();
expect(result.current.data?.pages[1].nextPage).toBe(2);

// Verify the complete data structure
expect(result.current.data?.pages).toEqual([
{ items: [1, 2, 3], nextPage: 1 },
{ items: [4, 5, 6], nextPage: 2 },
]);

// Verify we can access all items through pages
const allItems = result.current.data?.pages.flatMap((page) => page.items);
expect(allItems).toEqual([1, 2, 3, 4, 5, 6]);
});
});
});