Skip to content

Commit 6c2c35e

Browse files
authored
feat(tanstack-query): define base options and interceptors via contract meta (#1705)
## Summary Define base TanStack Query options and interceptors directly on a procedure contract via `tanstackQuery`, and apply them automatically with the new `ContractOptionsUtilsPlugin`. ```ts export const contract = { planet: { find: oc .input(z.object({ id: z.number() })) .meta(tanstackQuery({ queryOptions: { staleTime: 60 * 1000, }, })), }, } const orpc = createTanstackQueryUtils(client, { plugins: [new ContractOptionsUtilsPlugin(contract)], }) ``` - `tanstackQuery` infers input, output, and error types from the contract, supports all procedure utils options (keys, options, interceptors), and merges when applied multiple times - `ContractOptionsUtilsPlugin` applies meta options as the base layer: utils level options override them and utils interceptors run after meta interceptors - Merging is consistent across all layers: interceptors concatenate, plain option objects spread-merge, and any pair involving a function modifier composes - `getTanstackQueryMeta` reads the stored options for custom integrations - Docs cover the plugin, the reference-only nature of contract types, and passing runtime values such as router utils through mutation meta
1 parent 05db814 commit 6c2c35e

9 files changed

Lines changed: 616 additions & 99 deletions

File tree

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

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,103 @@ const orpc = createTanstackQueryUtils(client, {
335335
})
336336
```
337337

338+
### Contract Options Plugin
339+
340+
Use `tanstackQuery` to define base options and interceptors directly on a [procedure contract](/docs/contract/procedure), then pass the contract to `ContractOptionsUtilsPlugin` to apply them automatically. Meta options act as the base layer: [default options](#default-options) and [interceptors](#interceptors) defined on the utils merge on top of them. Passing `undefined` explicitly for a key resets the value from lower layers instead of merging.
341+
342+
```ts
343+
import { ContractOptionsUtilsPlugin, tanstackQuery } from '@orpc/tanstack-query'
344+
345+
export const contract = {
346+
planet: {
347+
find: oc
348+
.input(z.object({ id: z.number() }))
349+
.meta(tanstackQuery({
350+
queryOptions: {
351+
staleTime: 60 * 1000,
352+
},
353+
queryInterceptors: [
354+
async ({ input, next }) => {
355+
// input, output, and errors are typed based on the contract
356+
return await next()
357+
},
358+
],
359+
})),
360+
},
361+
}
362+
363+
const orpc = createTanstackQueryUtils(client, {
364+
plugins: [new ContractOptionsUtilsPlugin(contract)],
365+
})
366+
```
367+
368+
::: warning
369+
Types inferred from the contract are for reference only. The actual types depend on the client the utils are created from. For example, a `JsonifiedClient` created from [OpenAPI Link](/docs/openapi/link#typesafe-clients) returns jsonified outputs that may not match the contract schemas.
370+
:::
371+
372+
::: details Passing runtime values into contract meta?
373+
Contracts are defined separately from your app, so anything inside `tanstackQuery` cannot import runtime values such as your router utils. Instead, [register a global meta type](https://tanstack.com/query/latest/docs/framework/react/typescript#registering-global-meta) and pass the values through the `meta` option, per hook or globally via query client default options. The example below reads router utils from `fnContext.meta` to optimistically update a query:
374+
375+
```ts
376+
import type { RouterContractClient } from '@orpc/contract'
377+
import type { RouterUtils } from '@orpc/tanstack-query'
378+
379+
declare module '@tanstack/react-query' {
380+
interface Register {
381+
mutationMeta: {
382+
utils?: RouterUtils<RouterContractClient<typeof contract>>
383+
}
384+
}
385+
}
386+
387+
export const contract = {
388+
planet: {
389+
find: oc.input(z.object({ id: z.number() })),
390+
update: oc
391+
.input(z.object({ id: z.number(), name: z.string() }))
392+
.meta(tanstackQuery({
393+
mutationInterceptors: [
394+
async ({ input, next, fnContext }) => {
395+
const utils = fnContext.meta?.utils
396+
397+
if (!utils) {
398+
return next()
399+
}
400+
401+
const queryKey = utils.planet.find.queryKey({ input: { id: input.id } })
402+
const previous = fnContext.client.getQueryData(queryKey)
403+
404+
// optimistically update before the request
405+
fnContext.client.setQueryData(queryKey, input)
406+
407+
try {
408+
return await next()
409+
}
410+
catch (error) {
411+
// roll back on error
412+
fnContext.client.setQueryData(queryKey, previous)
413+
throw error
414+
}
415+
finally {
416+
fnContext.client.invalidateQueries({ queryKey })
417+
}
418+
},
419+
],
420+
})),
421+
},
422+
}
423+
424+
const queryClient = new QueryClient({
425+
defaultOptions: {
426+
mutations: {
427+
meta: { utils: orpc },
428+
},
429+
},
430+
})
431+
```
432+
433+
:::
434+
338435
## Client Context
339436

340437
::: warning

packages/tanstack-query/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export * from './contract-utils'
22
export * from './key'
3+
export * from './meta'
4+
export * from './plugin'
35
export * from './procedure-utils'
46
export * from './router-utils'
57
export { createRouterUtils as createTanstackQueryUtils } from './router-utils'
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { ClientContext, ORPCError } from '@orpc/client'
2+
import type { PromiseWithError } from '@orpc/shared'
3+
import type { SkipToken } from '@tanstack/query-core'
4+
import type { OperationContext } from './types'
5+
import { oc, type } from '@orpc/contract'
6+
import { tanstackQuery } from './meta'
7+
8+
describe('tanstackQuery', () => {
9+
it('infers input, output and error types from the contract', () => {
10+
oc
11+
.errors({ BAD_GATEWAY: { data: type<string, RegExp>(vi.fn()) } })
12+
.input(type<string, boolean>(vi.fn()))
13+
.output(type<number, Date>(vi.fn()))
14+
.meta(tanstackQuery({
15+
queryOptions: { staleTime: 1000 },
16+
queryInterceptors: [
17+
async ({ context, input, next }) => {
18+
expectTypeOf(input).toEqualTypeOf<string | SkipToken>()
19+
expectTypeOf(context).toEqualTypeOf<ClientContext & OperationContext>()
20+
21+
const result = next()
22+
23+
expectTypeOf(result).toEqualTypeOf<
24+
PromiseWithError<Date, Error | ORPCError<'BAD_GATEWAY', RegExp>>
25+
>()
26+
27+
return result
28+
},
29+
],
30+
mutationOptions: {
31+
onSuccess: (data, input) => {
32+
expectTypeOf(data).toEqualTypeOf<Date>()
33+
expectTypeOf(input).toEqualTypeOf<string>()
34+
},
35+
},
36+
}))
37+
})
38+
39+
it('rejects invalid base options', () => {
40+
oc.output(type<number, Date>(vi.fn())).meta(tanstackQuery({
41+
queryOptions: {
42+
// @ts-expect-error - staleTime must be a number
43+
staleTime: 'invalid',
44+
},
45+
}))
46+
})
47+
})
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { oc } from '@orpc/contract'
2+
import { ContractOptionsUtilsPlugin, getTanstackQueryMeta, tanstackQuery } from './meta'
3+
import { createRouterUtils } from './router-utils'
4+
5+
describe('tanstackQuery', () => {
6+
it('stores options readable via getTanstackQueryMeta', () => {
7+
const interceptor = vi.fn()
8+
const contract = oc.meta(tanstackQuery({
9+
queryOptions: { staleTime: 1000 },
10+
queryInterceptors: [interceptor],
11+
}))
12+
13+
expect(getTanstackQueryMeta(contract)).toEqual({
14+
queryOptions: { staleTime: 1000 },
15+
queryInterceptors: [interceptor],
16+
})
17+
18+
expect(getTanstackQueryMeta(oc)).toBeUndefined()
19+
})
20+
21+
it('merges options when applied multiple times', () => {
22+
const interceptor1 = vi.fn()
23+
const interceptor2 = vi.fn()
24+
const keyModifier = vi.fn()
25+
26+
const contract = oc
27+
.meta(tanstackQuery({
28+
queryKey: keyModifier,
29+
queryOptions: { staleTime: 1000, retry: 1 },
30+
queryInterceptors: [interceptor1],
31+
}))
32+
.meta(tanstackQuery({
33+
queryOptions: { retry: 2 },
34+
mutationInterceptors: [interceptor2],
35+
}))
36+
37+
expect(getTanstackQueryMeta(contract)).toEqual({
38+
queryKey: keyModifier,
39+
queryOptions: { staleTime: 1000, retry: 2 },
40+
queryInterceptors: [interceptor1],
41+
mutationInterceptors: [interceptor2],
42+
})
43+
})
44+
45+
it('propagates base meta to procedures via .router', () => {
46+
const router = oc
47+
.meta(tanstackQuery({ queryOptions: { staleTime: 1000 } }))
48+
.router({
49+
ping: oc.meta(tanstackQuery({ queryOptions: { retry: 2 } })),
50+
pong: oc,
51+
})
52+
53+
expect(getTanstackQueryMeta(router.ping)).toEqual({
54+
queryOptions: { staleTime: 1000, retry: 2 },
55+
})
56+
57+
expect(getTanstackQueryMeta(router.pong)).toEqual({
58+
queryOptions: { staleTime: 1000 },
59+
})
60+
})
61+
})
62+
63+
describe('contractMetaPlugin', () => {
64+
it('leaves options unchanged when no procedure or no meta at path', () => {
65+
const plugin = new ContractOptionsUtilsPlugin({ planet: { find: oc } })
66+
const options = { prefix: '__prefix__' }
67+
68+
expect(plugin.initProcedureOptions(['unknown'], options)).toBe(options)
69+
expect(plugin.initProcedureOptions(['planet'], options)).toBe(options)
70+
expect(plugin.initProcedureOptions(['planet', 'find'], options)).toBe(options)
71+
})
72+
73+
it('merges meta options as base under current options', () => {
74+
const metaInterceptor = vi.fn()
75+
const existingInterceptor = vi.fn()
76+
77+
const plugin = new ContractOptionsUtilsPlugin({
78+
planet: {
79+
find: oc.meta(tanstackQuery({
80+
queryOptions: { staleTime: 1000, retry: 1 },
81+
queryInterceptors: [metaInterceptor],
82+
})),
83+
},
84+
})
85+
86+
const result = plugin.initProcedureOptions(['planet', 'find'], {
87+
prefix: '__prefix__',
88+
queryOptions: { retry: 2 },
89+
queryInterceptors: [existingInterceptor],
90+
} as any)
91+
92+
expect(result).toEqual({
93+
prefix: '__prefix__',
94+
queryOptions: { staleTime: 1000, retry: 2 },
95+
queryInterceptors: [metaInterceptor, existingInterceptor],
96+
})
97+
})
98+
99+
it('applies contract meta through createRouterUtils', async () => {
100+
const marks: string[] = []
101+
const metaInterceptor = vi.fn(({ next }: any) => {
102+
marks.push('meta')
103+
return next()
104+
})
105+
const utilsInterceptor = vi.fn(({ next }: any) => {
106+
marks.push('utils')
107+
return next()
108+
})
109+
110+
const contract = {
111+
planet: {
112+
find: oc.meta(tanstackQuery({
113+
queryOptions: { staleTime: 1000, retry: 1 },
114+
queryInterceptors: [metaInterceptor],
115+
})),
116+
},
117+
}
118+
119+
const client = {
120+
planet: {
121+
find: vi.fn(async () => '__found__'),
122+
},
123+
}
124+
125+
const utils = createRouterUtils(client as any, {
126+
queryInterceptors: [utilsInterceptor],
127+
scoped: {
128+
planet: {
129+
find: {
130+
queryOptions: { retry: 2 },
131+
},
132+
},
133+
},
134+
plugins: [new ContractOptionsUtilsPlugin(contract)],
135+
}) as any
136+
137+
const options = utils.planet.find.queryOptions({ input: { id: 1 } })
138+
139+
expect(options.staleTime).toBe(1000)
140+
expect(options.retry).toBe(2)
141+
142+
await expect(options.queryFn({ signal: undefined })).resolves.toBe('__found__')
143+
144+
expect(marks).toEqual(['meta', 'utils'])
145+
expect(client.planet.find).toHaveBeenCalledTimes(1)
146+
})
147+
})

0 commit comments

Comments
 (0)