diff --git a/.changeset/khaki-comics-taste.md b/.changeset/khaki-comics-taste.md new file mode 100644 index 0000000..dc9ebb4 --- /dev/null +++ b/.changeset/khaki-comics-taste.md @@ -0,0 +1,6 @@ +--- +"@softnetics/hono-react-query": minor +--- + +Add support for `useSuspenseQuery` and `suspenseQueryOptions` and related types in the React Query Client + diff --git a/src/index.spec.tsx b/src/index.spec.tsx index ad664ec..58b5452 100644 --- a/src/index.spec.tsx +++ b/src/index.spec.tsx @@ -3,6 +3,8 @@ import { type DefinedUseQueryResult, type UseMutationResult, type UseQueryResult, + type UseSuspenseQueryOptions, + type UseSuspenseQueryResult, } from '@tanstack/react-query' import { Hono } from 'hono' import { describe, expect, expectTypeOf, it } from 'vitest' @@ -46,23 +48,92 @@ describe('createReactQueryClient', () => { expect(client.useOptimisticUpdateQuery).toBeDefined() }) - it('should contain Error in Data when throwOnError is false', () => { - const client = createReactQueryClient({ - baseUrl: 'http://localhost:3000', - }) + describe('useQuery', () => { + it('should contain Error in Data when throwOnError is false', () => { + const client = createReactQueryClient({ + baseUrl: 'http://localhost:3000', + }) + + const queryOptions = client.queryOptions('/users/:id', '$get', { + input: { + param: { id: 'none' }, + }, + options: { + throwOnError: false, + }, + }) + + expectTypeOf().toEqualTypeOf< + DefinedInitialDataOptions< + | { + data: { + user: { + id: string + name: string + } + } + status: 200 + format: 'json' + headers: Record + } + | { + data: { + error: string + } + status: 400 + format: 'json' + headers: Record + }, + Error + > + >() + + const queryFn = () => + client.useQuery('/users/:id', '$get', { + input: { param: { id: 'none' } }, + options: { throwOnError: false }, + }) - const queryOptions = client.queryOptions('/users/:id', '$get', { - input: { - param: { id: 'none' }, - }, - options: { - throwOnError: false, - }, + expectTypeOf>().toEqualTypeOf< + DefinedUseQueryResult< + | { + data: { + user: { + id: string + name: string + } + } + status: 200 + format: 'json' + headers: Record + } + | { + data: { + error: string + } + status: 400 + format: 'json' + headers: Record + }, + Error + > + >() }) - expectTypeOf().toEqualTypeOf< - DefinedInitialDataOptions< - | { + it('should not contain Error in Data when throwOnError is true', () => { + const client = createReactQueryClient({ + baseUrl: 'http://localhost:3000', + }) + + const queryOptions = client.queryOptions('/users/:id', '$get', { + input: { + param: { id: 'none' }, + }, + }) + + expectTypeOf().toEqualTypeOf< + DefinedInitialDataOptions< + { data: { user: { id: string @@ -72,28 +143,129 @@ describe('createReactQueryClient', () => { status: 200 format: 'json' headers: Record - } - | { + }, + Error | HonoResponseError<{ error: string }, 400, 'json'> + > + >() + + const queryFn = () => + client.useQuery('/users/:id', '$get', { + input: { param: { id: 'none' } }, + options: { throwOnError: true }, + }) + + expectTypeOf>().toEqualTypeOf< + DefinedUseQueryResult< + { data: { - error: string + user: { + id: string + name: string + } } - status: 400 + status: 200 format: 'json' headers: Record }, - Error - > - >() - - const queryFn = () => - client.useQuery('/users/:id', '$get', { - input: { param: { id: 'none' } }, - options: { throwOnError: false }, + | Error + | HonoResponseError< + { + error: string + }, + 400, + 'json' + > + > + >() + }) + }) + + describe('useSuspenseQuery', () => { + it('should contain Error in Data when throwOnError is false', () => { + const client = createReactQueryClient({ + baseUrl: 'http://localhost:3000', + }) + + const queryOptions = client.suspenseQueryOptions('/users/:id', '$get', { + input: { + param: { id: 'none' }, + }, + options: { + throwOnError: false, + }, + }) + + expectTypeOf().toEqualTypeOf< + UseSuspenseQueryOptions< + | { + data: { + user: { + id: string + name: string + } + } + status: 200 + format: 'json' + headers: Record + } + | { + data: { + error: string + } + status: 400 + format: 'json' + headers: Record + }, + Error + > + >() + + const queryFn = () => + client.useSuspenseQuery('/users/:id', '$get', { + input: { param: { id: 'none' } }, + options: { throwOnError: false }, + }) + + expectTypeOf>().toEqualTypeOf< + UseSuspenseQueryResult< + | { + data: { + user: { + id: string + name: string + } + } + status: 200 + format: 'json' + headers: Record + } + | { + data: { + error: string + } + status: 400 + format: 'json' + headers: Record + }, + Error + > + >() + }) + + it('should not contain Error in Data when throwOnError is true', () => { + const client = createReactQueryClient({ + baseUrl: 'http://localhost:3000', + }) + + const queryOptions = client.suspenseQueryOptions('/users/:id', '$get', { + input: { + param: { id: 'none' }, + }, }) - expectTypeOf>().toEqualTypeOf< - DefinedUseQueryResult< - | { + expectTypeOf().toEqualTypeOf< + UseSuspenseQueryOptions< + { data: { user: { id: string @@ -103,137 +275,103 @@ describe('createReactQueryClient', () => { status: 200 format: 'json' headers: Record - } - | { + }, + Error | HonoResponseError<{ error: string }, 400, 'json'> + > + >() + + const queryFn = () => + client.useSuspenseQuery('/users/:id', '$get', { + input: { param: { id: 'none' } }, + options: { throwOnError: true }, + }) + + expectTypeOf>().toEqualTypeOf< + UseSuspenseQueryResult< + { data: { - error: string + user: { + id: string + name: string + } } - status: 400 + status: 200 format: 'json' headers: Record }, - Error - > - >() + | Error + | HonoResponseError< + { + error: string + }, + 400, + 'json' + > + > + >() + }) }) - it('should not contain Error in Data when throwOnError is true', () => { - const client = createReactQueryClient({ - baseUrl: 'http://localhost:3000', - }) + describe('misc', () => { + it('should create the query client with the correct types', () => { + const client = createReactQueryClient({ + baseUrl: 'http://localhost:3000', + }) - const queryOptions = client.queryOptions('/users/:id', '$get', { - input: { - param: { id: 'none' }, - }, - }) + // /users routes + expectTypeOf>>().toMatchTypeOf< + UseQueryResult< + { data: { users: { id: string; name: string }[] }; status: 200; format: 'json' }, + Error | HonoResponseError<{ error: string }, 400, 'json'> + > + >() - expectTypeOf().toEqualTypeOf< - DefinedInitialDataOptions< - { - data: { - user: { - id: string - name: string - } - } - status: 200 + expectTypeOf>>().toMatchTypeOf< + UseMutationResult< + { data: { user: { id: string; name: string } }; status: 201; format: 'json' }, + Error | HonoResponseError<{ error: string }, 400, 'json'>, + {} | undefined + > + >() + + expectTypeOf>>().toMatchTypeOf< + () => { data: { users: { id: string; name: string }[] }; status: 200; format: 'json' } + >() + + expectTypeOf>>().toMatchTypeOf< + (data: { + data: { user: { id: string; name: string } } + status: 201 format: 'json' headers: Record - }, - Error | HonoResponseError<{ error: string }, 400, 'json'> - > - >() - - const queryFn = () => - client.useQuery('/users/:id', '$get', { - input: { param: { id: 'none' } }, - options: { throwOnError: true }, - }) - - expectTypeOf>().toEqualTypeOf< - DefinedUseQueryResult< - { - data: { - user: { - id: string - name: string - } - } - status: 200 + }) => { + data: { user: { id: string; name: string } } + status: 201 format: 'json' headers: Record - }, - | Error - | HonoResponseError< - { - error: string - }, - 400, - 'json' - > - > - >() - }) + } + >() - it('should create the query client with the correct types', () => { - const client = createReactQueryClient({ - baseUrl: 'http://localhost:3000', - }) + expectTypeOf< + ReturnType> + >().toMatchTypeOf<() => void>() + + // /users/:id routes + + expectTypeOf>>().toMatchTypeOf< + UseQueryResult< + { data: { user: { id: string; name: string } }; status: 200; format: 'json' }, + Error | HonoResponseError<{ error: string }, 400, 'json'> + > + >() - // /users routes - expectTypeOf>>().toMatchTypeOf< - UseQueryResult< - { data: { users: { id: string; name: string }[] }; status: 200; format: 'json' }, - Error | HonoResponseError<{ error: string }, 400, 'json'> - > - >() - - expectTypeOf>>().toMatchTypeOf< - UseMutationResult< - { data: { user: { id: string; name: string } }; status: 201; format: 'json' }, - Error | HonoResponseError<{ error: string }, 400, 'json'>, - {} | undefined - > - >() - - expectTypeOf>>().toMatchTypeOf< - () => { data: { users: { id: string; name: string }[] }; status: 200; format: 'json' } - >() - - expectTypeOf>>().toMatchTypeOf< - (data: { - data: { user: { id: string; name: string } } - status: 201 - format: 'json' - headers: Record - }) => { - data: { user: { id: string; name: string } } - status: 201 - format: 'json' - headers: Record - } - >() - - expectTypeOf>>().toMatchTypeOf< - () => void - >() - - // /users/:id routes - - expectTypeOf>>().toMatchTypeOf< - UseQueryResult< - { data: { user: { id: string; name: string } }; status: 200; format: 'json' }, - Error | HonoResponseError<{ error: string }, 400, 'json'> - > - >() - - expectTypeOf>>().toMatchTypeOf< - () => { data: { user: { id: string; name: string } }; status: 200; format: 'json' } - >() - - expectTypeOf< - ReturnType> - >().toMatchTypeOf<() => void>() + expectTypeOf>>().toMatchTypeOf< + () => { data: { user: { id: string; name: string } }; status: 200; format: 'json' } + >() + + expectTypeOf< + ReturnType> + >().toMatchTypeOf<() => void>() + }) }) }) diff --git a/src/index.ts b/src/index.ts index 8ddbc47..a712f0d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,10 @@ -import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + queryOptions, + useMutation, + useQuery, + useQueryClient, + useSuspenseQuery, +} from '@tanstack/react-query' import type { ClientRequestOptions, Hono } from 'hono' import { type ClientRequest, hc } from 'hono/client' import { useCallback } from 'hono/jsx' @@ -10,6 +16,7 @@ import { type Client, type HonoMutationOptions, type HonoQueryOptions, + type HonoSuspenseQueryOptions, type InferUseHonoQuery, type ReactQueryClient, type UseHonoGetQueryData, @@ -18,6 +25,7 @@ import { type UseHonoOptimisticUpdateQuery, type UseHonoQuery, type UseHonoSetQueryData, + type UseHonoSuspenseQuery, } from './types' interface CreateReactQueryClientOptions extends ClientRequestOptions { @@ -35,8 +43,10 @@ function createReactQueryClient( return { useQuery: useQueryFactory(client), + useSuspenseQuery: useSuspenseQueryFactory(client), useMutation: useMutationFactory(client), queryOptions: queryOptionsFactory(client), + suspenseQueryOptions: suspenseQueryOptionsFactory(client), mutationOptions: mutationOptionsFactory(client), useGetQueryData: useGetQueryDataFactory(), useSetQueryData: useSetQueryDataFactory(), @@ -87,16 +97,21 @@ async function responseParser(response: Response, throwOnError?: boolean): Promi function useQueryFactory>( client: Record> ): UseHonoQuery { - return ((path, method, honoPayload, hookOptions) => { + return (path, method, honoPayload, hookOptions) => { return useQuery( - queryOptionsFactory(client)( - path.toString(), - method, - honoPayload as any, - hookOptions as any - ) as any + queryOptionsFactory(client)(path.toString(), method, honoPayload as any, hookOptions as any) ) - }) as UseHonoQuery + } +} + +function useSuspenseQueryFactory>( + client: Record> +): UseHonoSuspenseQuery { + return (path, method, honoPayload, hookOptions) => { + return useSuspenseQuery( + suspenseQueryOptionsFactory(client)(path.toString(), method, honoPayload, hookOptions as any) + ) + } } function queryOptionsFactory>( @@ -124,6 +139,31 @@ function queryOptionsFactory>( } } +function suspenseQueryOptionsFactory>( + client: Record> +): HonoSuspenseQueryOptions { + return (path, method, honoPayload, hookOptions) => { + const paths = [...path.toString().split('/').filter(Boolean), method.toString()] + const handler = getter(client, paths) + const payload = (honoPayload as any)?.input ?? {} + + const isThrowOnError = honoPayload.options?.throwOnError ?? true + + return { + queryKey: createQueryKey( + method.toString(), + path.toString(), + Object.keys(payload).length > 0 ? payload : undefined + ), + queryFn: async () => { + const response = await handler(payload, honoPayload.options) + return responseParser(response, isThrowOnError) + }, + ...hookOptions, + } as any + } +} + function useMutationFactory>( client: Record> ): UseHonoMutation { diff --git a/src/types.ts b/src/types.ts index dbf029b..2b91596 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,8 @@ import type { UseMutationResult, UseQueryOptions, UseQueryResult, + UseSuspenseQueryOptions, + UseSuspenseQueryResult, } from '@tanstack/react-query' import type { Schema } from 'hono' import type { ClientRequest, ClientRequestOptions, ClientResponse } from 'hono/client' @@ -99,6 +101,38 @@ export type UseHonoQuery> = < ErrorResponse> | Error > +export type UseHonoSuspenseQuery> = < + TPath extends keyof TApp, + TMethod extends keyof TApp[TPath], + TQueryOptions extends Omit< + UseSuspenseQueryOptions< + SuccessResponse>, + ErrorResponse> | Error + >, + 'queryKey' | 'queryFn' + > = Omit< + UseSuspenseQueryOptions< + SuccessResponse>, + ErrorResponse> | Error + >, + 'queryKey' | 'queryFn' + >, + TOptions extends HonoPayloadOptions | undefined = HonoPayloadOptions | undefined, +>( + path: TPath, + method: TMethod, + honoPayload: HonoPayload, TOptions>, + queryOptions?: TQueryOptions +) => TOptions extends { throwOnError: false } + ? UseSuspenseQueryResult< + ClientResponseParser>, + DefaultError + > + : UseSuspenseQueryResult< + SuccessResponse>, + ErrorResponse> | Error + > + export type UseHonoMutation> = < TPath extends keyof TApp, TMethod extends keyof TApp[TPath], @@ -165,6 +199,38 @@ export type HonoQueryOptions> = < ErrorResponse> | Error > +export type HonoSuspenseQueryOptions> = < + TPath extends keyof TApp, + TMethod extends keyof TApp[TPath], + TQueryOptions extends Omit< + UseSuspenseQueryOptions< + SuccessResponse>, + ErrorResponse> | Error + >, + 'queryKey' | 'queryFn' + > = Omit< + UseSuspenseQueryOptions< + SuccessResponse>, + ErrorResponse> | Error + >, + 'queryKey' | 'queryFn' + >, + TOptions extends HonoPayloadOptions | undefined = HonoPayloadOptions | undefined, +>( + path: TPath, + method: TMethod, + honoPayload: HonoPayload, TOptions>, + queryOptions?: TQueryOptions +) => TOptions extends { throwOnError: false } + ? UseSuspenseQueryOptions< + ClientResponseParser>, + DefaultError + > + : UseSuspenseQueryOptions< + SuccessResponse>, + ErrorResponse> | Error + > + export type HonoMutationOptions> = < TPath extends keyof TApp, TMethod extends keyof TApp[TPath], @@ -240,8 +306,10 @@ export type UseHonoOptimisticUpdateQuery> = < export type ReactQueryClient> = { useQuery: UseHonoQuery + useSuspenseQuery: UseHonoSuspenseQuery useMutation: UseHonoMutation queryOptions: HonoQueryOptions + suspenseQueryOptions: HonoSuspenseQueryOptions mutationOptions: HonoMutationOptions useGetQueryData: UseHonoGetQueryData useSetQueryData: UseHonoSetQueryData