Skip to content

Commit

Permalink
fix(openapi-react-query): Fix typing of queryOptions (#1952)
Browse files Browse the repository at this point in the history
- Adds `NoInfer` to function return type because starting from
  TypeScript 5.5 it can affect inference. For example:

      const f = <T,>(x: NoInfer<T>) => x;
      const x: number = f('foo');
                          ^^^^^ expects number

  Previous versions could not infer `T` and complained about something
  like "unknown cannot be assigned to number".

  This error is not reproducible with `paths` so we test against a
  minimal paths type.

- Excludes `SkipToken` (introduced in v5.25) from `queryFn` to be
  compatible with `useSuspenseQuery`.
  • Loading branch information
zsugabubus authored Oct 26, 2024
1 parent 3988612 commit 455b735
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 13 deletions.
5 changes: 5 additions & 0 deletions .changeset/query-options-infer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-react-query": patch
---

Fix return type inference for `queryOptions()` when used inside `useQuery` or `useSuspenseQuery`.
5 changes: 5 additions & 0 deletions .changeset/query-options-queryfn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-react-query": patch
---

Narrow `queryFn` returned by `queryOptions()` to be a function.
2 changes: 1 addition & 1 deletion packages/openapi-react-query/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
"react-error-boundary": "^4.1.2"
},
"peerDependencies": {
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-query": "^5.25.0",
"openapi-fetch": "workspace:^"
}
}
19 changes: 13 additions & 6 deletions packages/openapi-react-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type UseSuspenseQueryResult,
type QueryClient,
type QueryFunctionContext,
type SkipToken,
useMutation,
useQuery,
useSuspenseQuery,
Expand Down Expand Up @@ -37,7 +38,17 @@ export type QueryOptionsFunction<Paths extends Record<string, Record<HttpMethod,
...[init, options]: RequiredKeysOf<Init> extends never
? [InitWithUnknowns<Init>?, Options?]
: [InitWithUnknowns<Init>, Options?]
) => UseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>;
) => NoInfer<
Omit<
UseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>,
"queryFn"
> & {
queryFn: Exclude<
UseQueryOptions<Response["data"], Response["error"], Response["data"], QueryKey<Paths, Method, Path>>["queryFn"],
SkipToken | undefined
>;
}
>;

export type UseQueryMethod<Paths extends Record<string, Record<HttpMethod, {}>>, Media extends MediaType> = <
Method extends HttpMethod,
Expand Down Expand Up @@ -121,11 +132,7 @@ export default function createClient<Paths extends {}, Media extends MediaType =
useQuery: (method, path, ...[init, options, queryClient]) =>
useQuery(queryOptions(method, path, init as InitWithUnknowns<typeof init>, options), queryClient),
useSuspenseQuery: (method, path, ...[init, options, queryClient]) =>
useSuspenseQuery(
// @ts-expect-error TODO: fix minor type mismatch between useQuery and useSuspenseQuery
queryOptions(method, path, init as InitWithUnknowns<typeof init>, options),
queryClient,
),
useSuspenseQuery(queryOptions(method, path, init as InitWithUnknowns<typeof init>, options), queryClient),
useMutation: (method, path, options, queryClient) =>
useMutation(
{
Expand Down
93 changes: 87 additions & 6 deletions packages/openapi-react-query/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,39 @@ import type { paths } from "./fixtures/api.js";
import createClient from "../src/index.js";
import createFetchClient from "openapi-fetch";
import { fireEvent, render, renderHook, screen, waitFor, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider, useQueries } from "@tanstack/react-query";
import {
QueryClient,
QueryClientProvider,
useQueries,
useQuery,
useSuspenseQuery,
skipToken,
} from "@tanstack/react-query";
import { Suspense, type ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary";

type minimalGetPaths = {
// Without parameters.
"/foo": {
get: {
responses: {
200: { content: { "application/json": true } };
500: { content: { "application/json": false } };
};
};
};
// With some parameters (makes init required) and different responses.
"/bar": {
get: {
parameters: { query: {} };
responses: {
200: { content: { "application/json": "bar 200" } };
500: { content: { "application/json": "bar 500" } };
};
};
};
};

const queryClient = new QueryClient({
defaultOptions: {
queries: {
Expand All @@ -27,9 +56,7 @@ const fetchInfinite = async () => {

beforeAll(() => {
server.listen({
onUnhandledRequest: (request) => {
throw new Error(`No request handler found for ${request.method} ${request.url}`);
},
onUnhandledRequest: "error",
});
});

Expand Down Expand Up @@ -96,7 +123,7 @@ describe("client", () => {
expect(data).toEqual(response);
});

it("returns query options that can be passed to useQueries and have correct types inferred", async () => {
it("returns query options that can be passed to useQueries", async () => {
const fetchClient = createFetchClient<paths>({ baseUrl, fetch: fetchInfinite });
const client = createClient(fetchClient);

Expand Down Expand Up @@ -150,6 +177,60 @@ describe("client", () => {
// Generated different queryKey for each query.
expect(queryClient.isFetching()).toBe(4);
});

it("returns query options that can be passed to useQuery", async () => {
const SKIP = { queryKey: [] as any, queryFn: skipToken } as const;

const fetchClient = createFetchClient<minimalGetPaths>({ baseUrl });
const client = createClient(fetchClient);

const { result } = renderHook(
() =>
useQuery(
// biome-ignore lint/correctness/noConstantCondition: it's just here to test types
false
? {
...client.queryOptions("get", "/foo"),
select: (data) => {
expectTypeOf(data).toEqualTypeOf<true>();

return "select(true)" as const;
},
}
: SKIP,
),
{ wrapper },
);

expectTypeOf(result.current.data).toEqualTypeOf<"select(true)" | undefined>();
expectTypeOf(result.current.error).toEqualTypeOf<false | null>();
});

it("returns query options that can be passed to useSuspenseQuery", async () => {
const fetchClient = createFetchClient<minimalGetPaths>({
baseUrl,
fetch: () => Promise.resolve(Response.json(true)),
});
const client = createClient(fetchClient);

const { result } = renderHook(
() =>
useSuspenseQuery({
...client.queryOptions("get", "/foo"),
select: (data) => {
expectTypeOf(data).toEqualTypeOf<true>();

return "select(true)" as const;
},
}),
{ wrapper },
);

await waitFor(() => expect(result.current).not.toBeNull());

expectTypeOf(result.current.data).toEqualTypeOf<"select(true)">();
expectTypeOf(result.current.error).toEqualTypeOf<false | null>();
});
});

describe("useQuery", () => {
Expand Down Expand Up @@ -203,7 +284,7 @@ describe("client", () => {
});

it("should infer correct data and error type", async () => {
const fetchClient = createFetchClient<paths>({ baseUrl });
const fetchClient = createFetchClient<paths>({ baseUrl, fetch: fetchInfinite });
const client = createClient(fetchClient);

const { result } = renderHook(() => client.useQuery("get", "/string-array"), {
Expand Down

0 comments on commit 455b735

Please sign in to comment.