Skip to content

Commit d2a7109

Browse files
authored
feat(query): support skipToken as input (#505)
Closes: https://github.com/unnoq/orpc/issues/495 Alternative: https://github.com/unnoq/orpc/pull/496 - [x] react - [x] vue - [x] solid - [x] svelte - [x] docs Co-authored-by: Louis Haftmann <30736553+LouisHaftmann@users.noreply.github.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Summary by CodeRabbit - **New Features** - Added support for using `skipToken` to conditionally disable queries in React, Solid, Svelte, and Vue Query integrations. - Documentation updated to explain how to use `skipToken` for disabling queries. - **Bug Fixes** - Queries now properly prevent execution and fetching when `skipToken` is used as input. - **Tests** - Expanded and updated test suites across all supported frameworks to verify correct behavior and typing when using `skipToken`. - Added new tests ensuring queries remain pending and do not fetch when `skipToken` is used. - **Documentation** - Improved guides and examples for disabling queries using `skipToken`. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent eae6003 commit d2a7109

27 files changed

Lines changed: 851 additions & 245 deletions

apps/content/docs/tanstack-query/basic.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,25 @@ if (mutation.error && isDefinedError(mutation.error)) {
120120
```
121121

122122
For more details, see our [type-safe error handling guide](/docs/error-handling#type‐safe-error-handling).
123+
124+
## `skipToken` for Disabling Queries
125+
126+
The `skipToken` symbol offers a type-safe alternative to the `disabled` option when you need to conditionally disable a query by omitting its `input`.
127+
128+
```ts
129+
const query = useQuery(
130+
orpc.planet.list.queryOptions({
131+
input: search ? { search } : skipToken, // [!code highlight]
132+
})
133+
)
134+
135+
const query = useInfiniteQuery(
136+
orpc.planet.list.infiniteOptions({
137+
input: search // [!code highlight]
138+
? (offset: number | undefined) => ({ limit: 10, offset, search }) // [!code highlight]
139+
: skipToken, // [!code highlight]
140+
initialPageParam: undefined,
141+
getNextPageParam: lastPage => lastPage.nextPageParam,
142+
})
143+
)
144+
```

apps/content/docs/tanstack-query/react.md

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -119,26 +119,3 @@ Integrate oRPC React Query utils into your React app with Context:
119119

120120
const query = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 } }))
121121
```
122-
123-
## `skipToken` for Disabling Queries
124-
125-
You can still use [skipToken](https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries/#typesafe-disabling-of-queries-using-skiptoken) by conditionally overriding the `queryFn` property:
126-
127-
```ts twoslash
128-
import type { router } from './shared/planet'
129-
import type { RouterClient } from '@orpc/server'
130-
import type { RouterUtils } from '@orpc/react-query'
131-
declare const orpc: RouterUtils<RouterClient<typeof router>>
132-
declare const condition: boolean
133-
// ---cut---
134-
import { skipToken, useQuery } from '@tanstack/react-query'
135-
136-
const options = orpc.planet.find.queryOptions({
137-
input: { id: 123 },
138-
})
139-
140-
const query = useQuery({
141-
...options,
142-
queryFn: condition ? skipToken : options.queryFn,
143-
})
144-
```

packages/react-query/src/procedure-utils.test-d.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import type { ErrorFromErrorMap } from '@orpc/contract'
33
import type { GetNextPageParamFunction, InfiniteData } from '@tanstack/react-query'
44
import type { baseErrorMap } from '../../contract/tests/shared'
55
import type { ProcedureUtils } from './procedure-utils'
6-
import { useInfiniteQuery, useMutation, useQueries, useQuery, useSuspenseInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
6+
import { skipToken, useInfiniteQuery, useMutation, useQueries, useQuery, useSuspenseInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'
77
import { queryClient } from '../tests/shared'
88

99
describe('ProcedureUtils', () => {
1010
type UtilsInput = { search?: string, cursor?: number } | undefined
1111
type UtilsOutput = { title: string }[]
1212

13+
const condition = {} as boolean
14+
1315
const utils = {} as ProcedureUtils<
1416
{ batch?: boolean },
1517
UtilsInput,
@@ -30,30 +32,40 @@ describe('ProcedureUtils', () => {
3032

3133
describe('.queryOptions', () => {
3234
it('can optional options', () => {
33-
const requiredUtils = {} as ProcedureUtils<{ batch?: boolean }, 'input', UtilsOutput, Error>
35+
const requiredUtils = {} as ProcedureUtils<{ batch: boolean }, 'input', UtilsOutput, Error>
3436

3537
utils.queryOptions()
3638
utils.queryOptions({ context: { batch: true } })
3739
utils.queryOptions({ input: { search: 'search' } })
40+
utils.queryOptions({ input: condition ? skipToken : { search: 'search' } })
3841

3942
requiredUtils.queryOptions({
4043
context: { batch: true },
4144
input: 'input',
4245
})
46+
requiredUtils.queryOptions({
47+
context: { batch: true },
48+
input: condition ? skipToken : 'input',
49+
})
4350
// @ts-expect-error input and context is required
4451
requiredUtils.queryOptions()
4552
// @ts-expect-error input and context is required
4653
requiredUtils.queryOptions({})
4754
// @ts-expect-error input is required
4855
requiredUtils.queryOptions({ context: { batch: true } })
4956
// @ts-expect-error context is required
50-
requiredUtils.queryOptions({ input: { search: 'search' } })
57+
requiredUtils.queryOptions({ input: 'input' })
58+
// @ts-expect-error context is required
59+
requiredUtils.queryOptions({ input: condition ? skipToken : 'input' })
5160
})
5261

5362
it('infer correct input type', () => {
5463
utils.queryOptions({ input: { cursor: 1 }, context: { batch: true } })
64+
utils.queryOptions({ input: condition ? { cursor: 2 } : skipToken, context: { batch: true } })
5565
// @ts-expect-error invalid input
5666
utils.queryOptions({ input: { cursor: 'invalid' }, context: { batch: true } })
67+
// @ts-expect-error invalid input
68+
utils.queryOptions({ input: condition ? { cursor: 'invalid' } : skipToken, context: { batch: true } })
5769
})
5870

5971
it('infer correct context type', () => {
@@ -173,13 +185,23 @@ describe('ProcedureUtils', () => {
173185
})
174186

175187
utils.infiniteOptions({
176-
input: (pageParam: number | undefined) => {
177-
return { cursor: pageParam }
188+
input: (cursor: number | undefined) => {
189+
return { cursor }
178190
},
179191
getNextPageParam: lastPage => 1,
180192
initialPageParam: undefined,
181193
})
182194

195+
utils.infiniteOptions({
196+
input: condition
197+
? skipToken
198+
: (cursor: number | undefined) => {
199+
return { cursor }
200+
},
201+
getNextPageParam,
202+
initialPageParam,
203+
})
204+
183205
utils.infiniteOptions({
184206
// @ts-expect-error invalid input
185207
input: (pageParam) => {
@@ -191,11 +213,24 @@ describe('ProcedureUtils', () => {
191213
initialPageParam,
192214
})
193215

216+
utils.infiniteOptions({
217+
// @ts-expect-error invalid input
218+
input: condition
219+
? skipToken
220+
: (cursor) => {
221+
return 'invalid'
222+
},
223+
getNextPageParam,
224+
initialPageParam,
225+
})
226+
194227
utils.infiniteOptions({
195228
// @ts-expect-error conflict types
196-
input: (pageParam: number) => {
197-
return 'input'
198-
},
229+
input: condition
230+
? skipToken
231+
: (cursor: number) => {
232+
return { cursor }
233+
},
199234
// @ts-expect-error conflict types
200235
getNextPageParam,
201236
// @ts-expect-error conflict types

packages/react-query/src/procedure-utils.test.ts

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { skipToken } from '@tanstack/react-query'
12
import * as Key from './key'
23
import { createProcedureUtils } from './procedure-utils'
34

@@ -17,38 +18,82 @@ describe('createProcedureUtils', () => {
1718
expect(utils.call).toBe(client)
1819
})
1920

20-
it('.queryOptions', async () => {
21-
const options = utils.queryOptions({ input: { search: '__search__' }, context: { batch: '__batch__' } })
21+
describe('.queryOptions', () => {
22+
it('without skipToken', async () => {
23+
const options = utils.queryOptions({ input: { search: '__search__' }, context: { batch: '__batch__' } })
2224

23-
expect(options.queryKey).toBe(buildKeySpy.mock.results[0]!.value)
24-
expect(buildKeySpy).toHaveBeenCalledTimes(1)
25-
expect(buildKeySpy).toHaveBeenCalledWith(['ping'], { type: 'query', input: { search: '__search__' } })
25+
expect(options.enabled).toBe(true)
2626

27-
await expect(options.queryFn!({ signal } as any)).resolves.toEqual('__output__')
28-
expect(client).toHaveBeenCalledTimes(1)
29-
expect(client).toBeCalledWith({ search: '__search__' }, { signal, context: { batch: '__batch__' } })
27+
expect(options.queryKey).toBe(buildKeySpy.mock.results[0]!.value)
28+
expect(buildKeySpy).toHaveBeenCalledTimes(1)
29+
expect(buildKeySpy).toHaveBeenCalledWith(['ping'], { type: 'query', input: { search: '__search__' } })
30+
31+
await expect(options.queryFn!({ signal } as any)).resolves.toEqual('__output__')
32+
expect(client).toHaveBeenCalledTimes(1)
33+
expect(client).toBeCalledWith({ search: '__search__' }, { signal, context: { batch: '__batch__' } })
34+
})
35+
36+
it('with skipToken', async () => {
37+
const options = utils.queryOptions({ input: skipToken, context: { batch: '__batch__' } })
38+
39+
expect(options.enabled).toBe(false)
40+
41+
expect(options.queryKey).toBe(buildKeySpy.mock.results[0]!.value)
42+
expect(buildKeySpy).toHaveBeenCalledTimes(1)
43+
expect(buildKeySpy).toHaveBeenCalledWith(['ping'], { type: 'query', input: skipToken })
44+
45+
expect(() => options.queryFn!({ signal } as any)).toThrow('queryFn should not be called with skipToken used as input')
46+
expect(client).toHaveBeenCalledTimes(0)
47+
})
3048
})
3149

32-
it('.infiniteOptions', async () => {
33-
const getNextPageParam = vi.fn()
50+
describe('.infiniteOptions', () => {
51+
it('without skipToken', async () => {
52+
const getNextPageParam = vi.fn()
3453

35-
const options = utils.infiniteOptions({
36-
input: pageParam => ({ search: '__search__', pageParam }),
37-
context: { batch: '__batch__' },
38-
getNextPageParam,
39-
initialPageParam: '__initialPageParam__',
54+
const options = utils.infiniteOptions({
55+
input: pageParam => ({ search: '__search__', pageParam }),
56+
context: { batch: '__batch__' },
57+
getNextPageParam,
58+
initialPageParam: '__initialPageParam__',
59+
})
60+
61+
expect(options.enabled).toBe(true)
62+
63+
expect(options.queryKey).toBe(buildKeySpy.mock.results[0]!.value)
64+
expect(buildKeySpy).toHaveBeenCalledTimes(1)
65+
expect(buildKeySpy).toHaveBeenCalledWith(['ping'], { type: 'infinite', input: { search: '__search__', pageParam: '__initialPageParam__' } })
66+
67+
expect(options.initialPageParam).toEqual('__initialPageParam__')
68+
expect(options.getNextPageParam).toBe(getNextPageParam)
69+
70+
await expect(options.queryFn!({ signal, pageParam: '__pageParam__' } as any)).resolves.toEqual('__output__')
71+
expect(client).toHaveBeenCalledTimes(1)
72+
expect(client).toBeCalledWith({ search: '__search__', pageParam: '__pageParam__' }, { signal, context: { batch: '__batch__' } })
4073
})
4174

42-
expect(options.queryKey).toBe(buildKeySpy.mock.results[0]!.value)
43-
expect(buildKeySpy).toHaveBeenCalledTimes(1)
44-
expect(buildKeySpy).toHaveBeenCalledWith(['ping'], { type: 'infinite', input: { search: '__search__', pageParam: '__initialPageParam__' } })
75+
it('with skipToken', async () => {
76+
const getNextPageParam = vi.fn()
4577

46-
expect(options.initialPageParam).toEqual('__initialPageParam__')
47-
expect(options.getNextPageParam).toBe(getNextPageParam)
78+
const options = utils.infiniteOptions({
79+
input: skipToken,
80+
context: { batch: '__batch__' },
81+
getNextPageParam,
82+
initialPageParam: '__initialPageParam__',
83+
})
4884

49-
await expect(options.queryFn!({ signal, pageParam: '__pageParam__' } as any)).resolves.toEqual('__output__')
50-
expect(client).toHaveBeenCalledTimes(1)
51-
expect(client).toBeCalledWith({ search: '__search__', pageParam: '__pageParam__' }, { signal, context: { batch: '__batch__' } })
85+
expect(options.enabled).toBe(false)
86+
87+
expect(options.queryKey).toBe(buildKeySpy.mock.results[0]!.value)
88+
expect(buildKeySpy).toHaveBeenCalledTimes(1)
89+
expect(buildKeySpy).toHaveBeenCalledWith(['ping'], { type: 'infinite', input: skipToken })
90+
91+
expect(options.initialPageParam).toEqual('__initialPageParam__')
92+
expect(options.getNextPageParam).toBe(getNextPageParam)
93+
94+
expect(() => options.queryFn!({ signal, pageParam: '__pageParam__' } as any)).toThrow('queryFn should not be called with skipToken used as input')
95+
expect(client).toHaveBeenCalledTimes(0)
96+
})
5297
})
5398

5499
it('.mutationOptions', async () => {

packages/react-query/src/procedure-utils.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Client, ClientContext } from '@orpc/client'
22
import type { MaybeOptionalOptions } from '@orpc/shared'
33
import type { InfiniteData } from '@tanstack/react-query'
44
import type { InfiniteOptionsBase, InfiniteOptionsIn, MutationOptions, MutationOptionsIn, QueryOptionsBase, QueryOptionsIn } from './types'
5+
import { skipToken } from '@tanstack/react-query'
56
import { buildKey } from './key'
67

78
export interface ProcedureUtils<TClientContext extends ClientContext, TInput, TOutput, TError> {
@@ -56,17 +57,32 @@ export function createProcedureUtils<TClientContext extends ClientContext, TInpu
5657
queryOptions(...[optionsIn = {} as any]) {
5758
return {
5859
queryKey: buildKey(options.path, { type: 'query', input: optionsIn.input }),
59-
queryFn: ({ signal }) => client(optionsIn.input, { signal, context: optionsIn.context }),
60+
queryFn: ({ signal }) => {
61+
if (optionsIn.input === skipToken) {
62+
throw new Error('queryFn should not be called with skipToken used as input')
63+
}
64+
65+
return client(optionsIn.input, { signal, context: optionsIn.context })
66+
},
67+
enabled: optionsIn.input !== skipToken,
6068
...optionsIn,
6169
}
6270
},
6371

6472
infiniteOptions(optionsIn) {
6573
return {
66-
queryKey: buildKey(options.path, { type: 'infinite', input: optionsIn.input(optionsIn.initialPageParam) as any }),
74+
queryKey: buildKey(options.path, {
75+
type: 'infinite',
76+
input: optionsIn.input === skipToken ? skipToken : optionsIn.input(optionsIn.initialPageParam) as any,
77+
}),
6778
queryFn: ({ pageParam, signal }) => {
79+
if (optionsIn.input === skipToken) {
80+
throw new Error('queryFn should not be called with skipToken used as input')
81+
}
82+
6883
return client(optionsIn.input(pageParam as any), { signal, context: optionsIn.context as any })
6984
},
85+
enabled: optionsIn.input !== skipToken,
7086
...(optionsIn as any),
7187
}
7288
},

packages/react-query/src/types.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,29 @@
11
import type { ClientContext } from '@orpc/client'
22
import type { SetOptional } from '@orpc/shared'
3-
import type { QueryFunctionContext, QueryKey, UseInfiniteQueryOptions, UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'
3+
import type { QueryFunctionContext, QueryKey, SkipToken, UseInfiniteQueryOptions, UseMutationOptions, UseQueryOptions } from '@tanstack/react-query'
44

55
export type QueryOptionsIn<TClientContext extends ClientContext, TInput, TOutput, TError, TSelectData> =
6-
& (undefined extends TInput ? { input?: TInput } : { input: TInput })
6+
& (undefined extends TInput ? { input?: TInput | SkipToken } : { input: TInput | SkipToken })
77
& (Record<never, never> extends TClientContext ? { context?: TClientContext } : { context: TClientContext })
88
& SetOptional<UseQueryOptions<TOutput, TError, TSelectData>, 'queryKey'>
99

1010
export interface QueryOptionsBase<TOutput, TError> {
1111
queryKey: QueryKey
1212
queryFn(ctx: QueryFunctionContext): Promise<TOutput>
1313
retry?(failureCount: number, error: TError): boolean // this make tanstack can infer the TError type
14+
enabled: boolean
1415
}
1516

1617
export type InfiniteOptionsIn<TClientContext extends ClientContext, TInput, TOutput, TError, TSelectData, TPageParam> =
17-
& { input: (pageParam: TPageParam) => TInput }
18+
& { input: ((pageParam: TPageParam) => TInput) | SkipToken }
1819
& (Record<never, never> extends TClientContext ? { context?: TClientContext } : { context: TClientContext })
1920
& SetOptional<UseInfiniteQueryOptions<TOutput, TError, TSelectData, TOutput, QueryKey, TPageParam>, 'queryKey'>
2021

2122
export interface InfiniteOptionsBase<TOutput, TError, TPageParam> {
2223
queryKey: QueryKey
2324
queryFn(ctx: QueryFunctionContext<QueryKey, TPageParam>): Promise<TOutput>
2425
retry?(failureCount: number, error: TError): boolean // this make tanstack can infer the TError type
26+
enabled: boolean
2527
}
2628

2729
export type MutationOptionsIn<TClientContext extends ClientContext, TInput, TOutput, TError, TMutationContext> =

packages/react-query/tests/e2e.test-d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ describe('.queryOptions', () => {
4141
}
4242

4343
useQuery(orpc.ping.queryOptions({
44+
// @ts-expect-error --- input is invalid
4445
input: {
45-
// @ts-expect-error --- input is invalid
4646
input: '123',
4747
},
4848
}))
@@ -77,8 +77,8 @@ describe('.queryOptions', () => {
7777
}
7878

7979
useSuspenseQuery(orpc.ping.queryOptions({
80+
// @ts-expect-error --- input is invalid
8081
input: {
81-
// @ts-expect-error --- input is invalid
8282
input: '123',
8383
},
8484
}))

0 commit comments

Comments
 (0)