diff --git a/apps/content/content/docs/client/react-query.mdx b/apps/content/content/docs/client/react-query.mdx new file mode 100644 index 00000000..7aad76bf --- /dev/null +++ b/apps/content/content/docs/client/react-query.mdx @@ -0,0 +1,172 @@ +--- +title: React Query +description: Simplify React Query usage with minimal integration using ORPC and TanStack Query +--- + +## Installation + +```package-install +@orpc/client @orpc/react-query @tanstack/react-query +``` + +## Setup + +### Using a Global Client + +```ts twoslash +import { createORPCReactQueryUtils } from '@orpc/react-query'; +import { createORPCClient } from '@orpc/client'; +import type { router } from 'examples/server'; + +// Create an ORPC client +export const client = createORPCClient({ + baseURL: 'http://localhost:3000/api', +}); + +// Create React Query utilities for ORPC +export const orpc = createORPCReactQueryUtils(client); + +// @noErrors +orpc.getting. +// ^| +``` + +### Using Context with a Client + +```tsx twoslash +import { createORPCReactQueryUtils, RouterUtils } from '@orpc/react-query'; +import { createORPCClient } from '@orpc/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { router } from 'examples/server'; +import * as React from 'react'; + +const ORPCContext = React.createContext | undefined>(undefined); + +export function useORPC() { + const orpc = React.useContext(ORPCContext); + + if (!orpc) { + throw new Error('ORPCContext is not available.'); + } + + return orpc; +} + +export function ORPCProvider({ children }: { children: React.ReactNode }) { + const [client] = React.useState(() => + createORPCClient({ + baseURL: 'http://localhost:3000/api', + }) + ); + const [queryClient] = React.useState(() => new QueryClient()); + + const orpc = React.useMemo(() => createORPCReactQueryUtils(client), [client]); + + return ( + + + {children} + + + ); +} + +// Example usage +const orpc = useORPC(); +// @noErrors +orpc.getting. +// ^| +``` + +## Multiple ORPC Instances + +To prevent conflicts when using multiple ORPC instances, you can provide a unique base path to `createORPCReactQueryUtils`. + +```tsx twoslash +import { createORPCReactQueryUtils } from '@orpc/react-query' + +// Create separate ORPC instances with unique base paths +const userORPC = createORPCReactQueryUtils('fake-client' as any, ['__user-api__']) +const postORPC = createORPCReactQueryUtils('fake-client' as any, ['__post-api__']) +``` + +This ensures that each instance manages its own set of query keys and avoids any conflicts. + +## Usage + +### Standard Queries and Mutations + +```ts twoslash +import { useMutation, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query' +import { orpc } from 'examples/react-query' + +// Fetch data +const { data: gettingData } = useQuery(orpc.getting.queryOptions({ input: { name: 'unnoq' } })) + +// Use suspense query +const { data: postData } = useSuspenseQuery( + orpc.post.find.queryOptions({ input: { id: 'example' } }), +) + +// Perform mutations +const { mutate: postMutate } = useMutation(orpc.post.create.mutationOptions()) + +// Invalidate queries +const queryClient = useQueryClient() +queryClient.invalidateQueries({ queryKey: orpc.key() }) // Invalidate all queries +queryClient.invalidateQueries({ queryKey: orpc.post.find.key({ input: { id: 'example' } }) }) // Specific queries +``` + +> **Note**: This library enhances [TanStack Query](https://tanstack.com/query/latest) by managing query keys and functions for you, providing a seamless developer experience. + + +## Infinite Queries + +Infinite queries require a `cursor` in the input field for pagination. + +```tsx twoslash +import { os } from '@orpc/server'; +import { z } from 'zod'; +import { createORPCReactQueryUtils } from '@orpc/react-query'; +import { useInfiniteQuery, useSuspenseInfiniteQuery } from '@tanstack/react-query'; +import * as React from 'react'; + +const router = { + user: { + list: os + .input(z.object({ cursor: z.number(), limit: z.number() })) + .func((input) => ({ + nextCursor: input.cursor + input.limit, + users: [], // Fetch your actual data here + })), + }, +}; + +const orpc = createORPCReactQueryUtils('fake-client' as any); + +export function MyComponent() { + const query = useInfiniteQuery( + orpc.user.list.infiniteOptions({ + input: { limit: 10 }, + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialPageParam: 0, + }) + ); + + const query2 = useSuspenseInfiniteQuery( + orpc.user.list.infiniteOptions({ + input: { limit: 10 }, + getNextPageParam: (lastPage) => lastPage.nextCursor, + initialPageParam: 0, + }) + ); + + return ( +
+ {query.isLoading && 'Loading...'} + {query.isSuccess &&
Data: {JSON.stringify(query.data)}
} + {query.isError &&
Error: {query.error.message}
} +
+ ); +} +``` diff --git a/apps/content/content/home/landing.mdx b/apps/content/content/home/landing.mdx index f69115b4..64edb66d 100644 --- a/apps/content/content/home/landing.mdx +++ b/apps/content/content/home/landing.mdx @@ -57,24 +57,35 @@ Our [Vanilla Client](/docs/client/vanilla) is fully typed and doesn't rely on ge ## Seamless Integration with TanStack Query ```tsx -const utils = orpc.useUtils() -const { mutate, isPending } = orpc.getting.useMutation({ - onSuccess(){ - utils.invalidate() // invalidate all queries - } -}) - -mutate({ - id: 1992n, - user: { - name: 'unnoq', - avatar: document.getElementById('avatar').files[0], - } -}) +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; + +// Fetch data with oRPC +const { data, status } = useQuery( + orpc.post.find.queryOptions({ input: { id: 'example' } }) +); + +// Perform a mutation and invalidate related queries on success +const { mutate, isPending } = useMutation( + orpc.getting.mutationOptions({ + onSuccess() { + queryClient.invalidateQueries({ + queryKey: orpc.post.find.key({ input: { id: 'example' } }), + }); + }, + }) +); + +// Execute mutation with structured input +mutate({ + id: 1992n, + user: { + name: 'unnoq', + avatar: document.getElementById('avatar').files[0], // Handle file uploads + }, +}); ``` -oRPC's [TanStack Query integration](/docs/client/react) includes over 10 hooks and 29+ utilities, -such as `useQuery`, `useSuspenseQuery`, `useMutation`, `invalidate`, and more. Absolutely everything is typesafe! +Learn more about [React Query Integration](/docs/client/react-query). ## Access via OpenAPI Standard diff --git a/apps/content/examples/react-query.ts b/apps/content/examples/react-query.ts new file mode 100644 index 00000000..d3b20b7c --- /dev/null +++ b/apps/content/examples/react-query.ts @@ -0,0 +1,4 @@ +import type { router } from 'examples/server' +import { createORPCReactQueryUtils } from '@orpc/react-query' + +export const orpc = createORPCReactQueryUtils('fake-client' as any) diff --git a/apps/content/package.json b/apps/content/package.json index 15d3eb1e..69e77b57 100644 --- a/apps/content/package.json +++ b/apps/content/package.json @@ -26,6 +26,7 @@ "@orpc/next": "workspace:*", "@orpc/openapi": "workspace:*", "@orpc/react": "workspace:*", + "@orpc/react-query": "workspace:*", "@orpc/server": "workspace:*", "@orpc/zod": "workspace:*", "@types/express": "^5.0.0", diff --git a/apps/content/tsconfig.json b/apps/content/tsconfig.json index cdb1a047..fb4c05b4 100644 --- a/apps/content/tsconfig.json +++ b/apps/content/tsconfig.json @@ -30,6 +30,7 @@ { "path": "../../packages/server" }, { "path": "../../packages/client" }, { "path": "../../packages/react" }, + { "path": "../../packages/react-query" }, { "path": "../../packages/zod" }, { "path": "../../packages/next" } ], diff --git a/packages/react-query/.gitignore b/packages/react-query/.gitignore new file mode 100644 index 00000000..f3620b55 --- /dev/null +++ b/packages/react-query/.gitignore @@ -0,0 +1,26 @@ +# Hidden folders and files +.* +!.gitignore +!.*.example + +# Common generated folders +logs/ +node_modules/ +out/ +dist/ +dist-ssr/ +build/ +coverage/ +temp/ + +# Common generated files +*.log +*.log.* +*.tsbuildinfo +*.vitest-temp.json +vite.config.ts.timestamp-* +vitest.config.ts.timestamp-* + +# Common manual ignore files +*.local +*.pem \ No newline at end of file diff --git a/packages/react-query/package.json b/packages/react-query/package.json new file mode 100644 index 00000000..e3e066ac --- /dev/null +++ b/packages/react-query/package.json @@ -0,0 +1,60 @@ +{ + "name": "@orpc/react-query", + "type": "module", + "version": "0.0.0", + "license": "MIT", + "homepage": "https://orpc.unnoq.com", + "repository": { + "type": "git", + "url": "git+https://github.com/unnoq/orpc.git", + "directory": "packages/react-query" + }, + "keywords": [ + "unnoq", + "orpc", + "react-query", + "tanstack query", + "react" + ], + "publishConfig": { + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./🔒/*": { + "types": "./dist/src/*.d.ts" + } + } + }, + "exports": { + ".": "./src/index.ts", + "./🔒/*": { + "types": "./src/*.ts" + } + }, + "files": [ + "!**/*.map", + "!**/*.tsbuildinfo", + "dist" + ], + "scripts": { + "build": "tsup --clean --sourcemap --entry.index=src/index.ts --format=esm --onSuccess='tsc -b --noCheck'", + "build:watch": "pnpm run build --watch", + "type:check": "tsc -b" + }, + "peerDependencies": { + "@orpc/client": "workspace:*", + "@orpc/contract": "workspace:*", + "@orpc/server": "workspace:*", + "@tanstack/react-query": ">=5.59.0", + "react": ">=18.3.0" + }, + "dependencies": { + "@orpc/shared": "workspace:*" + }, + "devDependencies": { + "zod": "^3.21.4" + } +} diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts new file mode 100644 index 00000000..e8ab418b --- /dev/null +++ b/packages/react-query/src/index.ts @@ -0,0 +1,9 @@ +import { createRouterUtils } from './utils-router' + +export * from './key' +export * from './types' +export * from './utils-general' +export * from './utils-procedure' +export * from './utils-router' + +export const createORPCReactQueryUtils = createRouterUtils diff --git a/packages/react-query/src/key.test.ts b/packages/react-query/src/key.test.ts new file mode 100644 index 00000000..de7f32e7 --- /dev/null +++ b/packages/react-query/src/key.test.ts @@ -0,0 +1,17 @@ +import { buildKey } from './key' + +describe('buildKey', () => { + it('works', () => { + expect(buildKey(['path'])).toEqual(['__ORPC__', ['path'], {}]) + expect(buildKey(['path'], { type: 'query' })).toEqual(['__ORPC__', ['path'], { type: 'query' }]) + + expect(buildKey(['path'], { type: 'query', input: { a: 1 } })) + .toEqual(['__ORPC__', ['path'], { type: 'query', input: { a: 1 } }]) + + expect(buildKey(['path'], { type: 'query', input: undefined })) + .toEqual(['__ORPC__', ['path'], { type: 'query' }]) + + expect(buildKey(['path'], { type: undefined, input: undefined })) + .toEqual(['__ORPC__', ['path'], { }]) + }) +}) diff --git a/packages/react-query/src/key.ts b/packages/react-query/src/key.ts new file mode 100644 index 00000000..f4c61c84 --- /dev/null +++ b/packages/react-query/src/key.ts @@ -0,0 +1,27 @@ +import type { PartialDeep } from '@orpc/shared' +import type { QueryKey } from '@tanstack/react-query' + +export type KeyType = 'query' | 'infinite' | 'mutation' | undefined + +export interface BuildKeyOptions { + type?: TType + input?: TType extends 'mutation' ? never : PartialDeep +} + +export function buildKey( + path: string[], + options?: BuildKeyOptions, +): QueryKey { + const withInput + = options?.input !== undefined ? { input: options?.input } : {} + const withType = options?.type !== undefined ? { type: options?.type } : {} + + return [ + '__ORPC__', + path, + { + ...withInput, + ...withType, + }, + ] +} diff --git a/packages/react-query/src/types.ts b/packages/react-query/src/types.ts new file mode 100644 index 00000000..d103ff6c --- /dev/null +++ b/packages/react-query/src/types.ts @@ -0,0 +1,23 @@ +import type { SetOptional } from '@orpc/shared' +import type { + DefaultError, + QueryKey, + UndefinedInitialDataInfiniteOptions, + UndefinedInitialDataOptions, + UseMutationOptions, +} from '@tanstack/react-query' + +export type InferCursor = T extends { cursor?: any } ? T['cursor'] : never + +export type QueryOptions = (undefined extends TInput ? { input?: TInput } : { input: TInput }) & ( + SetOptional, 'queryKey'> +) + +export type InfiniteOptions = (undefined extends TInput ? { input?: Omit } : { input: Omit }) & ( + SetOptional< + UndefinedInitialDataInfiniteOptions>, + 'queryKey' | (undefined extends InferCursor ? 'initialPageParam' : never) + > +) + +export type MutationOptions = UseMutationOptions diff --git a/packages/react-query/src/utils-general.test-d.ts b/packages/react-query/src/utils-general.test-d.ts new file mode 100644 index 00000000..1d0210c9 --- /dev/null +++ b/packages/react-query/src/utils-general.test-d.ts @@ -0,0 +1,30 @@ +import { createGeneralUtils } from './utils-general' + +describe('key', () => { + const utils = createGeneralUtils<{ a: { b: { c: number } } }>([]) + + it('infer correct input type & partial input', () => { + utils.key() + utils.key({}) + utils.key({ type: 'infinite' }) + utils.key({ input: {}, type: 'query' }) + utils.key({ input: {} }) + utils.key({ input: { a: {} } }) + utils.key({ input: { a: { b: {} } } }) + utils.key({ input: { a: { b: { c: 1 } } } }) + + // @ts-expect-error invalid input + utils.key({ input: 123 }) + // @ts-expect-error invalid input + utils.key({ input: { a: { b: { c: '1' } } } }) + + // @ts-expect-error invalid input + utils.key({ type: 'ddd' }) + }) + + it('it prevent pass input when type is mutation', () => { + utils.key({ type: 'mutation' }) + // @ts-expect-error input is not allowed when type is mutation + utils.key({ input: {}, type: 'mutation' }) + }) +}) diff --git a/packages/react-query/src/utils-general.test.ts b/packages/react-query/src/utils-general.test.ts new file mode 100644 index 00000000..0d43971e --- /dev/null +++ b/packages/react-query/src/utils-general.test.ts @@ -0,0 +1,17 @@ +import * as keyModule from './key' +import { createGeneralUtils } from './utils-general' + +const buildKeySpy = vi.spyOn(keyModule, 'buildKey') + +beforeEach(() => { + buildKeySpy.mockClear() +}) + +describe('key', () => { + it('works', () => { + const utils = createGeneralUtils(['path']) + expect(utils.key({ input: 'input', type: 'infinite' })).toEqual(['__ORPC__', ['path'], { input: 'input', type: 'infinite' }]) + expect(buildKeySpy).toHaveBeenCalledTimes(1) + expect(buildKeySpy).toHaveBeenCalledWith(['path'], { input: 'input', type: 'infinite' }) + }) +}) diff --git a/packages/react-query/src/utils-general.ts b/packages/react-query/src/utils-general.ts new file mode 100644 index 00000000..934347de --- /dev/null +++ b/packages/react-query/src/utils-general.ts @@ -0,0 +1,20 @@ +import type { QueryKey } from '@tanstack/react-query' +import type { BuildKeyOptions, KeyType } from './key' +import { buildKey } from './key' + +/** + * Utils at any level (procedure or router) + */ +export interface GeneralUtils { + key: (options?: BuildKeyOptions) => QueryKey +} + +export function createGeneralUtils( + path: string[], +): GeneralUtils { + return { + key(options) { + return buildKey(path, options) + }, + } +} diff --git a/packages/react-query/src/utils-procedure.test-d.ts b/packages/react-query/src/utils-procedure.test-d.ts new file mode 100644 index 00000000..c42d3ce0 --- /dev/null +++ b/packages/react-query/src/utils-procedure.test-d.ts @@ -0,0 +1,240 @@ +import type { Caller } from '@orpc/server' +import type { InfiniteData, QueryKey } from '@tanstack/react-query' +import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { createProcedureUtils } from './utils-procedure' + +describe('queryOptions', () => { + const client = vi.fn>( + (...[input]) => Promise.resolve(input?.toString()), + ) + const utils = createProcedureUtils(client, []) + + const client2 = vi.fn((input: number) => Promise.resolve(input.toString())) + const utils2 = createProcedureUtils(client2, []) + + it('infer correct input type', () => { + utils.queryOptions({ input: 1 }) + utils.queryOptions({ input: undefined }) + + // @ts-expect-error invalid input + utils.queryOptions({ input: '1' }) + }) + + it('can be called without argument', () => { + const option = utils.queryOptions() + + expectTypeOf(option.queryKey).toEqualTypeOf() + expectTypeOf(option.queryFn).toEqualTypeOf<() => Promise>() + // @ts-expect-error invalid is required + utils2.queryOptions() + }) + + it('infer correct output type', () => { + const query = useQuery(utils2.queryOptions({ input: 1 })) + + if (query.status === 'success') { + expectTypeOf(query.data).toEqualTypeOf() + } + }) + + it('work with select options', () => { + const query = useQuery(utils2.queryOptions({ + input: 1, + select(data) { + expectTypeOf(data).toEqualTypeOf() + + return 12344 as const + }, + })) + + if (query.status === 'success') { + expectTypeOf(query.data).toEqualTypeOf<12344>() + } + }) +}) + +describe('infiniteOptions', () => { + const getNextPageParam = vi.fn() + + it('cannot use on procedure without input object-able', () => { + const utils = createProcedureUtils({} as (input: number) => Promise, []) + + // @ts-expect-error missing initialPageParam + utils.infiniteOptions({ + input: 123, + getNextPageParam, + }) + + utils.infiniteOptions({ + input: 123, + getNextPageParam, + // @ts-expect-error initialPageParam must be never + initialPageParam: 123, + }) + + utils.infiniteOptions({ + input: 123, + getNextPageParam, + initialPageParam: 123 as never, + }) + }) + + it('infer correct input type', () => { + const utils = createProcedureUtils({} as (input: { limit?: number, cursor: number }) => Promise, []) + + utils.infiniteOptions({ + input: { + limit: 1, + }, + getNextPageParam, + initialPageParam: 1, + }) + utils.infiniteOptions({ + input: { + limit: undefined, + }, + getNextPageParam, + initialPageParam: 1, + + }) + + utils.infiniteOptions({ + input: { + // @ts-expect-error invalid input + limit: 'string', + // cursor will be ignored + cursor: {}, + }, + getNextPageParam, + initialPageParam: 1, + }) + }) + + it('infer correct initialPageParam type', () => { + const utils = createProcedureUtils({} as (input: { limit?: number, cursor: number }) => Promise, []) + + utils.infiniteOptions({ + input: {}, + getNextPageParam, + initialPageParam: 1, + }) + + utils.infiniteOptions({ + input: {}, + getNextPageParam, + // @ts-expect-error initialPageParam must be number + initialPageParam: '234', + }) + + // @ts-expect-error initialPageParam is required + utils.infiniteOptions({ + input: {}, + getNextPageParam, + }) + }) + + it('initialPageParam can be optional', () => { + const utils = createProcedureUtils({} as (input: { limit?: number, cursor?: number }) => Promise, []) + + utils.infiniteOptions({ + input: {}, + getNextPageParam, + }) + + const utils2 = createProcedureUtils({} as (input: { limit?: number, cursor: number }) => Promise, []) + + // @ts-expect-error initialPageParam is required + utils2.infiniteOptions({ + input: {}, + getNextPageParam, + }) + }) + + it('input can be optional', () => { + const utils = createProcedureUtils({} as Caller<{ limit?: number, cursor?: number } | undefined, string>, []) + + utils.infiniteOptions({ + getNextPageParam, + }) + + const utils2 = createProcedureUtils({} as (input: { limit?: number, cursor?: number }) => Promise, []) + + // @ts-expect-error input is required + utils2.infiniteOptions({ + getNextPageParam, + }) + }) + + it('infer correct output type', () => { + const utils = createProcedureUtils({} as (input: { limit?: number, cursor: number }) => Promise, []) + const query = useInfiniteQuery(utils.infiniteOptions({ + input: { + limit: 1, + }, + getNextPageParam: () => 1, + initialPageParam: 1, + })) + + if (query.status === 'success') { + expectTypeOf(query.data).toEqualTypeOf>() + } + }) + + it('work with select options', () => { + const utils = createProcedureUtils({} as (input: { limit?: number, cursor: number }) => Promise, []) + const query = useInfiniteQuery(utils.infiniteOptions({ + input: { + limit: 1, + }, + getNextPageParam, + initialPageParam: 1, + select(data) { + expectTypeOf(data).toEqualTypeOf>() + + return { value: 'string' } + }, + })) + + if (query.status === 'success') { + expectTypeOf(query.data).toEqualTypeOf<{ value: string }>() + } + }) +}) + +describe('mutationOptions', () => { + const client = vi.fn((input: number) => Promise.resolve(input.toString())) + const utils = createProcedureUtils(client, []) + + it('infer correct input type', () => { + const option = utils.mutationOptions({ + onSuccess: (data, input) => { + expectTypeOf(input).toEqualTypeOf() + }, + }) + + option.mutationFn!(1) + + // @ts-expect-error invalid input + option.mutationFn!('1') + // @ts-expect-error invalid input + option.mutationFn!() + }) + + it('infer correct output type', () => { + const option = utils.mutationOptions({ + onSuccess: (data, input) => { + expectTypeOf(input).toEqualTypeOf() + expectTypeOf(data).toEqualTypeOf() + }, + }) + + expectTypeOf(option.mutationFn!(1)).toEqualTypeOf >() + }) + + it('can be called without argument', () => { + const option = utils.mutationOptions() + + expectTypeOf(option.mutationKey).toEqualTypeOf() + expectTypeOf(option.mutationFn).toEqualTypeOf<(input: number) => Promise>() + }) +}) diff --git a/packages/react-query/src/utils-procedure.test.ts b/packages/react-query/src/utils-procedure.test.ts new file mode 100644 index 00000000..d39e6170 --- /dev/null +++ b/packages/react-query/src/utils-procedure.test.ts @@ -0,0 +1,71 @@ +import type { Caller } from '@orpc/server' +import * as keyModule from './key' +import { createProcedureUtils } from './utils-procedure' + +const buildKeySpy = vi.spyOn(keyModule, 'buildKey') + +beforeEach(() => { + buildKeySpy.mockClear() +}) + +describe('queryOptions', () => { + const client = vi.fn>((...[input]) => Promise.resolve(input?.toString())) + const utils = createProcedureUtils(client, ['ping']) + + it('works', async () => { + const options = utils.queryOptions({ input: 1 }) + + expect(options.queryKey).toEqual(['__ORPC__', ['ping'], { type: 'query', input: 1 }]) + expect(buildKeySpy).toHaveBeenCalledTimes(1) + expect(buildKeySpy).toHaveBeenCalledWith(['ping'], { type: 'query', input: 1 }) + + client.mockResolvedValueOnce('__mocked__') + await expect((options as any).queryFn()).resolves.toEqual('__mocked__') + expect(client).toHaveBeenCalledTimes(1) + expect(client).toBeCalledWith(1) + }) +}) + +describe('infiniteOptions', () => { + const getNextPageParam = vi.fn() + + it('works ', async () => { + const client = vi.fn<(input: { limit?: number, cursor: number | undefined }) => Promise>() + const utils = createProcedureUtils(client, []) + + const options = utils.infiniteOptions({ + input: { limit: 5 }, + getNextPageParam, + initialPageParam: 1, + }) + + expect(options.initialPageParam).toEqual(1) + expect(options.queryKey).toEqual(['__ORPC__', [], { type: 'infinite', input: { limit: 5 } }]) + expect(buildKeySpy).toHaveBeenCalledTimes(1) + expect(buildKeySpy).toHaveBeenCalledWith([], { type: 'infinite', input: { limit: 5 } }) + + client.mockResolvedValueOnce('__mocked__') + await expect((options as any).queryFn({ pageParam: 1 })).resolves.toEqual('__mocked__') + expect(client).toHaveBeenCalledTimes(1) + expect(client).toBeCalledWith({ limit: 5, cursor: 1 }) + }) + + it('works without initialPageParam', async () => { + const client = vi.fn<(input: { limit?: number, cursor: number | undefined }) => Promise>() + const utils = createProcedureUtils(client, []) + + const options = utils.infiniteOptions({ + input: { limit: 5 }, + getNextPageParam, + }) + + expect(options.queryKey).toEqual(['__ORPC__', [], { type: 'infinite', input: { limit: 5 } }]) + expect(buildKeySpy).toHaveBeenCalledTimes(1) + expect(buildKeySpy).toHaveBeenCalledWith([], { type: 'infinite', input: { limit: 5 } }) + + client.mockResolvedValueOnce('__mocked__') + await expect((options as any).queryFn({ pageParam: undefined })).resolves.toEqual('__mocked__') + expect(client).toHaveBeenCalledTimes(1) + expect(client).toBeCalledWith({ limit: 5, cursor: undefined }) + }) +}) diff --git a/packages/react-query/src/utils-procedure.ts b/packages/react-query/src/utils-procedure.ts new file mode 100644 index 00000000..3a64169a --- /dev/null +++ b/packages/react-query/src/utils-procedure.ts @@ -0,0 +1,61 @@ +import type { Caller } from '@orpc/server' +import type { IsEqual } from '@orpc/shared' +import type { QueryKey } from '@tanstack/react-query' +import type { InfiniteOptions, MutationOptions, QueryOptions } from './types' +import { buildKey } from './key' + +/** + * Utils at procedure level + */ +export interface ProcedureUtils { + queryOptions: >( + ...opts: [options: U] | (undefined extends TInput ? [] : never) + ) => IsEqual> extends true + ? { queryKey: QueryKey, queryFn: () => Promise } + : Omit<{ queryKey: QueryKey, queryFn: () => Promise }, keyof U> & U + + infiniteOptions: >( + options: U + ) => Omit<{ queryKey: QueryKey, queryFn: () => Promise, initialPageParam: undefined }, keyof U> & U + + mutationOptions: >( + options?: U + ) => IsEqual> extends true + ? { mutationKey: QueryKey, mutationFn: (input: TInput) => Promise } + : Omit<{ mutationKey: QueryKey, mutationFn: (input: TInput) => Promise }, keyof U> & U +} + +export function createProcedureUtils( + client: Caller, + path: string[], +): ProcedureUtils { + return { + queryOptions(...[options]) { + const input = options?.input as any + + return { + queryKey: buildKey(path, { type: 'query', input }), + queryFn: () => client(input), + ...(options as any), + } + }, + + infiniteOptions(options) { + const input = options.input as any + + return { + queryKey: buildKey(path, { type: 'infinite', input }), + queryFn: ({ pageParam }: { pageParam: unknown }) => client({ ...input, cursor: pageParam }), + ...(options as any), + } + }, + + mutationOptions(options) { + return { + mutationKey: buildKey(path, { type: 'mutation' }), + mutationFn: input => client(input), + ...(options as any), + } + }, + } +} diff --git a/packages/react-query/src/utils-router.test-d.ts b/packages/react-query/src/utils-router.test-d.ts new file mode 100644 index 00000000..41d89c73 --- /dev/null +++ b/packages/react-query/src/utils-router.test-d.ts @@ -0,0 +1,57 @@ +import { oc } from '@orpc/contract' +import { os } from '@orpc/server' +import { z } from 'zod' +import { createGeneralUtils } from './utils-general' +import { createProcedureUtils } from './utils-procedure' +import { createRouterUtils } from './utils-router' + +const pingContract = oc.input(z.object({ name: z.string() })).output(z.string()) +const pongContract = oc.input(z.number()).output(z.string()) +const contractRouter = oc.router({ + ping: pingContract, + pong: pongContract, +}) + +const ping = os.contract(pingContract).func(({ name }) => `ping ${name}`) +const pong = os.contract(pongContract).func(num => `pong ${num}`) + +const router = os.contract(contractRouter).router({ + ping, + pong: os.lazy(() => Promise.resolve({ default: pong })), +}) + +describe('with contract router', () => { + it('build correct types', () => { + const utils = createRouterUtils({} as any) + + const generalUtils = createGeneralUtils([]) + const pingUtils = createProcedureUtils(ping, []) + const pingGeneralUtils = createGeneralUtils<{ name: string }>(['ping']) + const pongUtils = createProcedureUtils(pong, []) + const pongGeneralUtils = createGeneralUtils(['ping']) + + expectTypeOf(utils).toMatchTypeOf() + expectTypeOf(utils.ping).toMatchTypeOf() + expectTypeOf(utils.ping).toMatchTypeOf() + expectTypeOf(utils.pong).toMatchTypeOf() + expectTypeOf(utils.pong).toMatchTypeOf() + }) +}) + +describe('with router', () => { + it('build correct types', () => { + const utils = createRouterUtils({} as any) + + const generalUtils = createGeneralUtils([]) + const pingUtils = createProcedureUtils(ping, []) + const pingGeneralUtils = createGeneralUtils<{ name: string }>(['ping']) + const pongUtils = createProcedureUtils(pong, []) + const pongGeneralUtils = createGeneralUtils(['ping']) + + expectTypeOf(utils).toMatchTypeOf() + expectTypeOf(utils.ping).toMatchTypeOf() + expectTypeOf(utils.ping).toMatchTypeOf() + expectTypeOf(utils.pong).toMatchTypeOf() + expectTypeOf(utils.pong).toMatchTypeOf() + }) +}) diff --git a/packages/react-query/src/utils-router.test.ts b/packages/react-query/src/utils-router.test.ts new file mode 100644 index 00000000..ecbb2bcb --- /dev/null +++ b/packages/react-query/src/utils-router.test.ts @@ -0,0 +1,67 @@ +import * as generalUtilsModule from './utils-general' +import * as procedureUtilsModule from './utils-procedure' +import { createRouterUtils } from './utils-router' + +const procedureUtilsSpy = vi.spyOn(procedureUtilsModule, 'createProcedureUtils') +const generalUtilsSpy = vi.spyOn(generalUtilsModule, 'createGeneralUtils') + +beforeEach(() => { + procedureUtilsSpy.mockClear() + generalUtilsSpy.mockClear() +}) + +describe('router utils', () => { + it('works', () => { + const client = vi.fn() as any + client.ping = vi.fn() + client.ping.peng = vi.fn() + + const utils = createRouterUtils(client) as any + + expect(generalUtilsSpy).toHaveBeenCalledTimes(1) + expect(generalUtilsSpy).toHaveBeenCalledWith([]) + expect(procedureUtilsSpy).toHaveBeenCalledTimes(1) + expect(procedureUtilsSpy).toHaveBeenCalledWith(client, []) + + expect(typeof utils.key).toEqual('function') + expect(typeof utils.queryOptions).toEqual('function') + + generalUtilsSpy.mockClear() + procedureUtilsSpy.mockClear() + void utils.ping + + expect(generalUtilsSpy).toHaveBeenCalledTimes(1) + expect(generalUtilsSpy).toHaveBeenCalledWith(['ping']) + expect(procedureUtilsSpy).toHaveBeenCalledTimes(1) + expect(procedureUtilsSpy).toHaveBeenCalledWith(client.ping, ['ping']) + + expect(typeof utils.ping.key).toEqual('function') + expect(typeof utils.ping.queryOptions).toEqual('function') + + generalUtilsSpy.mockClear() + procedureUtilsSpy.mockClear() + void utils.ping.peng + + expect(generalUtilsSpy).toHaveBeenCalledTimes(2) + expect(generalUtilsSpy).toHaveBeenNthCalledWith(1, ['ping']) + expect(generalUtilsSpy).toHaveBeenNthCalledWith(2, ['ping', 'peng']) + + expect(procedureUtilsSpy).toHaveBeenCalledTimes(2) + expect(procedureUtilsSpy).toHaveBeenNthCalledWith(1, client.ping, ['ping']) + expect(procedureUtilsSpy).toHaveBeenNthCalledWith(2, client.ping.peng, ['ping', 'peng']) + + expect(typeof utils.ping.peng.key).toEqual('function') + expect(typeof utils.ping.peng.queryOptions).toEqual('function') + }) + + it('can custom base path', () => { + const client = vi.fn() as any + + const utils = createRouterUtils(client, ['base']) as any + + expect(generalUtilsSpy).toHaveBeenCalledTimes(1) + expect(generalUtilsSpy).toHaveBeenCalledWith(['base']) + expect(procedureUtilsSpy).toHaveBeenCalledTimes(1) + expect(procedureUtilsSpy).toHaveBeenCalledWith(client, ['base']) + }) +}) diff --git a/packages/react-query/src/utils-router.ts b/packages/react-query/src/utils-router.ts new file mode 100644 index 00000000..1a34a815 --- /dev/null +++ b/packages/react-query/src/utils-router.ts @@ -0,0 +1,57 @@ +import type { RouterClient } from '@orpc/client' +import type { ContractProcedure, ContractRouter, SchemaInput, SchemaOutput } from '@orpc/contract' +import type { Lazy, Procedure, Router } from '@orpc/server' +import { createGeneralUtils, type GeneralUtils } from './utils-general' +import { createProcedureUtils, type ProcedureUtils } from './utils-procedure' + +export type RouterUtils | ContractRouter> = { + [K in keyof T]: T[K] extends + | ContractProcedure + | Procedure + | Lazy> + ? + & ProcedureUtils, SchemaOutput> + & GeneralUtils> + : T[K] extends Router | ContractRouter + ? RouterUtils + : never +} & GeneralUtils + +/** + * @param client - The client create form `@orpc/client` + * @param path - The base path for query key + */ +export function createRouterUtils | ContractRouter>( + client: RouterClient, + path: string[] = [], +): RouterUtils { + const generalUtils = createGeneralUtils(path) + const procedureUtils = createProcedureUtils(client as any, path) + + const recursive = new Proxy({ + ...generalUtils, + ...procedureUtils, + }, { + get(target, prop) { + const value = Reflect.get(target, prop) + + if (typeof prop !== 'string') { + return value + } + + const nextUtils = createRouterUtils((client as any)[prop], [...path, prop]) + + if (typeof value !== 'function') { + return nextUtils + } + + return new Proxy(value, { + get(_, prop) { + return Reflect.get(nextUtils, prop) + }, + }) + }, + }) + + return recursive as any +} diff --git a/packages/react-query/tests/e2e.test.tsx b/packages/react-query/tests/e2e.test.tsx new file mode 100644 index 00000000..53c0d3e5 --- /dev/null +++ b/packages/react-query/tests/e2e.test.tsx @@ -0,0 +1,120 @@ +import { useInfiniteQuery, useMutation, useQueries, useQuery, useSuspenseInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query' +import { renderHook } from '@testing-library/react' +import { orpc, queryClient } from './helpers' + +beforeEach(() => { + queryClient.clear() +}) + +describe('useQuery', () => { + it('works - onSuccess', async () => { + const { result } = renderHook(() => useQuery(orpc.ping.queryOptions(), queryClient)) + + expect(queryClient.isFetching({ queryKey: orpc.key() })).toEqual(1) + expect(queryClient.isFetching({ queryKey: orpc.ping.key() })).toEqual(1) + expect(queryClient.isFetching({ queryKey: orpc.ping.key({ type: 'mutation' }) })).toEqual(0) + + await vi.waitFor(() => expect(result.current.data).toEqual('pong')) + + expect(queryClient.getQueryData(orpc.ping.key({ type: 'query' }))).toEqual('pong') + }) + + it('works - onError', async () => { + // @ts-expect-error -- invalid input + const { result } = renderHook(() => useQuery(orpc.user.create.queryOptions({ input: {} }), queryClient)) + + await vi.waitFor(() => expect(result.current.error).toEqual(new Error('Validation input failed'))) + + expect(queryClient.getQueryData(orpc.ping.key({ type: 'query' }))).toEqual(undefined) + }) +}) + +describe('useInfiniteQuery', () => { + it('works - onSuccess', async () => { + const { result } = renderHook(() => useInfiniteQuery(orpc.user.list.infiniteOptions({ + input: {}, + getNextPageParam: lastPage => lastPage.nextCursor, + }), queryClient)) + + await vi.waitFor(() => expect(result.current.data?.pages.length).toEqual(1)) + + result.current.fetchNextPage() + + await vi.waitFor(() => expect(result.current.data?.pages.length).toEqual(2)) + }) +}) + +describe('useMutation', () => { + it('works - onSuccess', async () => { + const { result } = renderHook(() => useMutation(orpc.ping.mutationOptions(), queryClient)) + + expect(queryClient.isFetching({ queryKey: orpc.ping.key({ type: 'mutation' }) })).toEqual(0) + + result.current.mutate({}) + + expect(queryClient.isMutating({ mutationKey: orpc.ping.key() })).toEqual(1) + expect(queryClient.isMutating({ mutationKey: orpc.ping.key({ type: 'mutation' }) })).toEqual(1) + + await vi.waitFor(() => expect(result.current.data).toEqual('pong')) + }) +}) + +describe('useSuspenseQuery', () => { + it('works - onSuccess', async () => { + const { result } = renderHook(() => useSuspenseQuery(orpc.ping.queryOptions(), queryClient)) + + expect(queryClient.isFetching({ queryKey: orpc.key() })).toEqual(1) + expect(queryClient.isFetching({ queryKey: orpc.ping.key() })).toEqual(1) + expect(queryClient.isFetching({ queryKey: orpc.ping.key({ type: 'mutation' }) })).toEqual(0) + + await vi.waitFor(() => expect(result.current.data).toEqual('pong')) + + expect(queryClient.getQueryData(orpc.ping.key({ type: 'query' }))).toEqual('pong') + }) +}) + +describe('useSuspenseInfiniteQuery', () => { + it('works - onSuccess', async () => { + const { result } = renderHook(() => useSuspenseInfiniteQuery(orpc.user.list.infiniteOptions({ + input: {}, + getNextPageParam: lastPage => lastPage.nextCursor, + }), queryClient)) + + await vi.waitFor(() => expect(result.current.data?.pages.length).toEqual(1)) + + result.current.fetchNextPage() + + await vi.waitFor(() => expect(result.current.data?.pages.length).toEqual(2)) + }) +}) + +describe('useQueries', () => { + it('works - onSuccess', async () => { + const { result } = renderHook(() => useQueries({ + queries: [ + orpc.user.find.queryOptions({ + queryKey: [''], + input: { id: '0' }, + }), + orpc.user.list.queryOptions({ + input: {}, + }), + ], + combine([user, users]) { + return [user, users] as const + }, + }, queryClient)) + + await vi.waitFor(() => expect(result.current[0].status).toEqual('success')) + await vi.waitFor(() => expect(result.current[1].status).toEqual('success')) + + expect(result.current[0].data).toEqual({ id: '0', name: 'name-0' }) + expect(result.current[1].data).toEqual({ + users: [ + { id: 'id-0', name: 'number-0' }, + { id: 'id-1', name: 'number-1' }, + ], + nextCursor: 2, + }) + }) +}) diff --git a/packages/react-query/tests/helpers.tsx b/packages/react-query/tests/helpers.tsx new file mode 100644 index 00000000..e310b8c0 --- /dev/null +++ b/packages/react-query/tests/helpers.tsx @@ -0,0 +1,117 @@ +import { createORPCClient } from '@orpc/client' +import { os } from '@orpc/server' +import { createORPCHandler, handleFetchRequest } from '@orpc/server/fetch' +import { QueryClient } from '@tanstack/react-query' +import { z } from 'zod' +import { createORPCReactQueryUtils } from '../src' + +export const orpcServer = os + +export const ping = orpcServer.func(() => 'pong') + +export const UserSchema = z + .object({ data: z.object({ id: z.string(), name: z.string() }) }) + .transform(data => data.data) +export const UserFindInputSchema = z + .object({ id: z.string() }) + .transform(data => ({ data })) + +export const userFind = orpcServer + .input(UserFindInputSchema) + .output(UserSchema) + .func((input) => { + return { + data: { + id: input.data.id, + name: `name-${input.data.id}`, + }, + } + }) + +export const UserListInputSchema = z + .object({ + keyword: z.string().optional(), + cursor: z.number().default(0), + }) + .transform(data => ({ data })) +export const UserListOutputSchema = z + .object({ + data: z.object({ + nextCursor: z.number(), + users: z.array(UserSchema), + }), + }) + .transform(data => data.data) +export const userList = orpcServer + .input(UserListInputSchema) + .output(UserListOutputSchema) + .func((input) => { + return { + data: { + nextCursor: input.data.cursor + 2, + users: [ + { + data: { + id: `id-${input.data.cursor}`, + name: `number-${input.data.cursor}`, + }, + }, + { + data: { + id: `id-${input.data.cursor + 1}`, + name: `number-${input.data.cursor + 1}`, + }, + }, + ], + }, + } + }) + +export const UserCreateInputSchema = z + .object({ name: z.string() }) + .transform(data => ({ data })) +export const userCreate = orpcServer + .input(UserCreateInputSchema) + .output(UserSchema) + .func((input) => { + return { + data: { + id: '28aa6286-48e9-4f23-adea-3486c86acd55', + name: input.data.name, + }, + } + }) + +export const appRouter = orpcServer.router({ + ping, + user: { + find: userFind, + list: userList, + create: userCreate, + }, +}) + +export const orpcClient = createORPCClient({ + baseURL: 'http://localhost:3000', + + async fetch(...args) { + await new Promise(resolve => setTimeout(resolve, 100)) + const request = new Request(...args) + + return handleFetchRequest({ + router: appRouter, + request, + handlers: [createORPCHandler()], + }) + }, +}) + +export const orpc = createORPCReactQueryUtils(orpcClient) + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) diff --git a/packages/react-query/tsconfig.json b/packages/react-query/tsconfig.json new file mode 100644 index 00000000..49afa752 --- /dev/null +++ b/packages/react-query/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.lib.json", + "compilerOptions": { + "types": [] + }, + "references": [ + { "path": "../client" }, + { "path": "../contract" }, + { "path": "../shared" }, + { "path": "../server" } + ], + "include": ["src"], + "exclude": [ + "**/*.test.*", + "**/*.test-d.ts", + "**/__tests__/**", + "**/__mocks__/**", + "**/__snapshots__/**" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71303044..a325b06e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,9 @@ importers: '@orpc/react': specifier: workspace:* version: link:../../packages/react + '@orpc/react-query': + specifier: workspace:* + version: link:../../packages/react-query '@orpc/server': specifier: workspace:* version: link:../../packages/server @@ -260,6 +263,31 @@ importers: specifier: ^3.23.8 version: 3.23.8 + packages/react-query: + dependencies: + '@orpc/client': + specifier: workspace:* + version: link:../client + '@orpc/contract': + specifier: workspace:* + version: link:../contract + '@orpc/server': + specifier: workspace:* + version: link:../server + '@orpc/shared': + specifier: workspace:* + version: link:../shared + '@tanstack/react-query': + specifier: '>=5.59.0' + version: 5.59.15(react@18.3.1) + react: + specifier: '>=18.3.0' + version: 18.3.1 + devDependencies: + zod: + specifier: ^3.21.4 + version: 3.23.8 + packages/server: dependencies: '@orpc/contract': diff --git a/vitest.workspace.ts b/vitest.workspace.ts index f26b0e01..52e144b7 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -19,7 +19,7 @@ export default defineWorkspace([ globals: true, environment: 'jsdom', setupFiles: ['./vitest.jsdom-react.ts'], - include: ['./packages/{next,react}/**/*.test.tsx'], + include: ['./packages/next/**/*.test.tsx', './packages/react-query/**/*.test.tsx'], }, }, ])