From 81cff566e4661b492e1c9640cf5a6b71cb71c84a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 06:03:02 +0000 Subject: [PATCH 1/3] fix(tanstack-query): give skipToken queries a distinct query key `skipToken` is a symbol, and both TanStack's default `hashKey` and the documented `RPCJsonSerializer`-based `queryKeyHashFn` drop symbol values. A query skipped with `input: skipToken` therefore hashed to the same key as the same procedure called without input, and the two observers shared one `Query`. The skipped observer's throwing `queryFn` could win, so the next invalidation put the enabled query into an error state. `generateOperationKey` now writes `skipToken: true` in place of `input` when the input is `skipToken`. Partial-match keys such as `.key()` and `.key({ type })` still match skipped queries, while `.key({ input })` does not. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DGZkM7MFXNDHJBg9SmARgj --- .../docs/integrations/tanstack-query.mdx | 2 + packages/tanstack-query/src/key.test.ts | 48 +++++++++++++++ packages/tanstack-query/src/key.ts | 17 +++++- .../src/procedure-utils.test.ts | 60 ++++++++++++++++++- packages/tanstack-query/tests/e2e.test.tsx | 20 +++++++ 5 files changed, 143 insertions(+), 4 deletions(-) diff --git a/apps/content/docs/integrations/tanstack-query.mdx b/apps/content/docs/integrations/tanstack-query.mdx index 7b3440b8d7..e62068ac93 100644 --- a/apps/content/docs/integrations/tanstack-query.mdx +++ b/apps/content/docs/integrations/tanstack-query.mdx @@ -504,6 +504,8 @@ const query = useInfiniteQuery( ) ``` +Skipped queries store `skipToken: true` in their query key instead of `input`, so they never share a cache entry with the same procedure called without input. Partial-match keys such as `orpc.planet.list.key()` still match them. + ## Server-Side Rendering (SSR) When [server-side rendering](/docs/recipes/optimizing-ssr) with [TanStack Query hydration](https://tanstack.com/query/latest/docs/framework/react/guides/ssr), two things need special care: oRPC types that JSON cannot represent, and streamed or live queries that never complete. diff --git a/packages/tanstack-query/src/key.test.ts b/packages/tanstack-query/src/key.test.ts index 85a8e59216..ef71baab80 100644 --- a/packages/tanstack-query/src/key.test.ts +++ b/packages/tanstack-query/src/key.test.ts @@ -1,3 +1,6 @@ +import type { QueryKey } from '@tanstack/query-core' +import { RPCJsonSerializer } from '@orpc/client' +import { hashKey, partialMatchKey, skipToken } from '@tanstack/query-core' import { generateOperationKey } from './key' it('generateOperationKey', () => { @@ -13,3 +16,48 @@ it('generateOperationKey', () => { expect(generateOperationKey(['path'], { prefix: undefined })).toEqual([['path'], {}]) expect(generateOperationKey(['path'], { prefix: '__prefix__', type: 'query', input: { a: 1 } })).toEqual(['__prefix__', ['path'], { type: 'query', input: { a: 1 } }]) }) + +describe('generateOperationKey with skipToken', () => { + const serializer = new RPCJsonSerializer() + + const hashers: [name: string, hash: (queryKey: QueryKey) => string][] = [ + ['hashKey', hashKey], + ['RPCJsonSerializer', (queryKey) => { + const { json, meta } = serializer.serialize(queryKey) + + return hashKey([ + json, + meta?.map(entry => JSON.stringify(entry)).sort(), + ]) + }], + ] + + it('replaces the symbol with a serializable marker', () => { + expect(generateOperationKey(['planet', 'list'], { type: 'query', input: skipToken as any })) + .toEqual([['planet', 'list'], { type: 'query', skipToken: true }]) + expect(generateOperationKey(['planet', 'list'], { prefix: '__prefix__', type: 'streamed', input: skipToken as any, fnOptions: { refetchMode: 'append' } })) + .toEqual(['__prefix__', ['planet', 'list'], { type: 'streamed', skipToken: true, fnOptions: { refetchMode: 'append' } }]) + }) + + it.each(hashers)('does not collide with unskipped keys when hashed with %s', (_, hash) => { + for (const type of ['query', 'streamed', 'live', 'infinite'] as const) { + const skipped = hash(generateOperationKey(['planet', 'list'], { type, input: skipToken as any })) + + expect(skipped).not.toEqual(hash(generateOperationKey(['planet', 'list'], { type }))) + expect(skipped).not.toEqual(hash(generateOperationKey(['planet', 'list'], { type, input: {} }))) + expect(skipped).not.toEqual(hash(generateOperationKey(['planet', 'list'], { type, input: { search: 'earth' } }))) + } + }) + + it('still partially matches keys without input', () => { + const skipped = generateOperationKey(['planet', 'list'], { type: 'query', input: skipToken as any }) + + expect(partialMatchKey(skipped, generateOperationKey(['planet']))).toBe(true) + expect(partialMatchKey(skipped, generateOperationKey(['planet', 'list']))).toBe(true) + expect(partialMatchKey(skipped, generateOperationKey(['planet', 'list'], { type: 'query' }))).toBe(true) + + expect(partialMatchKey(skipped, generateOperationKey(['planet', 'list'], { type: 'infinite' }))).toBe(false) + expect(partialMatchKey(skipped, generateOperationKey(['planet', 'list'], { input: {} }))).toBe(false) + expect(partialMatchKey(skipped, generateOperationKey(['planet', 'list'], { input: { search: 'earth' } }))).toBe(false) + }) +}) diff --git a/packages/tanstack-query/src/key.ts b/packages/tanstack-query/src/key.ts index cb1a1e6c9f..65cf606096 100644 --- a/packages/tanstack-query/src/key.ts +++ b/packages/tanstack-query/src/key.ts @@ -1,6 +1,7 @@ import type { PartialDeep } from '@orpc/shared' import type { SerializableStreamedQueryOptions } from './stream-query' import type { OperationType } from './types' +import { skipToken } from '@tanstack/query-core' export interface OperationKeyPrefixOptions { /** @@ -16,9 +17,17 @@ export type OperationKeyOptions = Operation fnOptions?: TType extends 'streamed' ? SerializableStreamedQueryOptions : never } +type OperationKeyContent = Omit, 'prefix'> & { + /** + * Set in place of `input` when the operation is disabled with `skipToken`, + * because query key hashers drop symbols. + */ + skipToken?: TType extends 'mutation' ? never : true +} + export type OperationKey - = | [path: string[], options: Omit, 'prefix'>] - | [prefix: string, path: string[], options: Omit, 'prefix'>] + = | [path: string[], options: OperationKeyContent] + | [prefix: string, path: string[], options: OperationKeyContent] export function generateOperationKey( path: string[], @@ -28,7 +37,9 @@ export function generateOperationKey( ...options.prefix !== undefined ? [options.prefix] : [], path, { - ...options.input !== undefined ? { input: options.input } : {}, + ...options.input === skipToken + ? { skipToken: true } + : options.input !== undefined ? { input: options.input } : {}, ...options.type !== undefined ? { type: options.type } : {}, ...options.fnOptions !== undefined ? { fnOptions: options.fnOptions } : {}, }, diff --git a/packages/tanstack-query/src/procedure-utils.test.ts b/packages/tanstack-query/src/procedure-utils.test.ts index 3b7d9a3816..fb238b4e83 100644 --- a/packages/tanstack-query/src/procedure-utils.test.ts +++ b/packages/tanstack-query/src/procedure-utils.test.ts @@ -1,4 +1,4 @@ -import { QueryClient, skipToken } from '@tanstack/query-core' +import { InfiniteQueryObserver, QueryClient, QueryObserver, skipToken } from '@tanstack/query-core' import * as KeyModule from './key' import * as LiveQuery from './live-query' import { isProcedureUtilsOptions, mergeProcedureUtilsOptions, ProcedureUtils } from './procedure-utils' @@ -441,6 +441,64 @@ describe('procedureUtils', () => { }) }) +describe('skipToken queries do not share a query with unskipped ones', () => { + const retrylessQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const client = vi.fn(async () => '__output__') + const utils = new ProcedureUtils, { search?: string } | undefined, string, Error>(['planet', 'list'], client) + + beforeEach(() => { + retrylessQueryClient.clear() + }) + + it('.queryOptions', async () => { + const unskipped = new QueryObserver(retrylessQueryClient, utils.queryOptions()) + const unsubscribeUnskipped = unskipped.subscribe(() => {}) + await vi.waitFor(() => expect(unskipped.getCurrentResult().status).toBe('success')) + + const skipped = new QueryObserver(retrylessQueryClient, utils.queryOptions({ input: skipToken })) + const unsubscribeSkipped = skipped.subscribe(() => {}) + + expect(retrylessQueryClient.getQueryCache().getAll()).toHaveLength(2) + + await retrylessQueryClient.invalidateQueries({ queryKey: utils.key() }) + + expect(unskipped.getCurrentResult().status).toBe('success') + expect(skipped.getCurrentResult().status).toBe('pending') + expect(client).toHaveBeenCalledTimes(2) + + unsubscribeUnskipped() + unsubscribeSkipped() + }) + + it('.infiniteOptions', async () => { + const unskipped = new InfiniteQueryObserver(retrylessQueryClient, utils.infiniteOptions({ + input: () => undefined, + initialPageParam: 0, + getNextPageParam: () => undefined, + })) + const unsubscribeUnskipped = unskipped.subscribe(() => {}) + await vi.waitFor(() => expect(unskipped.getCurrentResult().status).toBe('success')) + + const skipped = new InfiniteQueryObserver(retrylessQueryClient, utils.infiniteOptions({ + input: skipToken, + initialPageParam: 0, + getNextPageParam: () => undefined, + })) + const unsubscribeSkipped = skipped.subscribe(() => {}) + + expect(retrylessQueryClient.getQueryCache().getAll()).toHaveLength(2) + + await retrylessQueryClient.invalidateQueries({ queryKey: utils.key() }) + + expect(unskipped.getCurrentResult().status).toBe('success') + expect(skipped.getCurrentResult().status).toBe('pending') + expect(client).toHaveBeenCalledTimes(2) + + unsubscribeUnskipped() + unsubscribeSkipped() + }) +}) + describe('createProcedureUtils with options', () => { const client = vi.fn() diff --git a/packages/tanstack-query/tests/e2e.test.tsx b/packages/tanstack-query/tests/e2e.test.tsx index 082530034f..f06806c083 100644 --- a/packages/tanstack-query/tests/e2e.test.tsx +++ b/packages/tanstack-query/tests/e2e.test.tsx @@ -55,6 +55,26 @@ it('case: with useQuery and skipToken', async () => { expect(result.current.status).toEqual('pending') }) +it('case: useQuery with and without skipToken do not share a query', async () => { + const { result } = renderHook(() => ({ + unskipped: useQuery(orpc.stream.streamedOptions(), queryClient), + skipped: useQuery(orpc.stream.streamedOptions({ input: skipToken }), queryClient), + })) + + await act(async () => { + await vi.waitFor(() => expect(result.current.unskipped.status).toEqual('success')) + }) + + expect(queryClient.getQueryCache().findAll({ queryKey: orpc.stream.key() })).toHaveLength(2) + + await act(async () => { + await queryClient.invalidateQueries({ queryKey: orpc.stream.key() }) + }) + + expect(result.current.unskipped.status).toEqual('success') + expect(result.current.skipped.status).toEqual('pending') +}) + it('case: with streamed/useQuery', async () => { const { result } = renderHook(() => useQuery(orpc.stream.streamedOptions({ queryFnOptions: { From 2d4357d68f7dcfc26880163f2f2a02d39feb46ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 08:36:02 +0000 Subject: [PATCH 2/3] test(tanstack-query): simplify skipToken key tests Reuse the module-level QueryClient in the observer tests (retry already defaults to 0 outside the browser) and register unsubscribes with onTestFinished so they also run when an assertion fails. Narrow the key tests to what the fix changes: the skipToken marker, the skipped vs. no-input hash under both hashers, and partial matching. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DGZkM7MFXNDHJBg9SmARgj --- packages/tanstack-query/src/key.test.ts | 26 ++++---------- .../src/procedure-utils.test.ts | 35 +++++++------------ 2 files changed, 19 insertions(+), 42 deletions(-) diff --git a/packages/tanstack-query/src/key.test.ts b/packages/tanstack-query/src/key.test.ts index ef71baab80..541b9876ab 100644 --- a/packages/tanstack-query/src/key.test.ts +++ b/packages/tanstack-query/src/key.test.ts @@ -15,6 +15,9 @@ it('generateOperationKey', () => { expect(generateOperationKey(['path'], { prefix: '__prefix__' })).toEqual(['__prefix__', ['path'], {}]) expect(generateOperationKey(['path'], { prefix: undefined })).toEqual([['path'], {}]) expect(generateOperationKey(['path'], { prefix: '__prefix__', type: 'query', input: { a: 1 } })).toEqual(['__prefix__', ['path'], { type: 'query', input: { a: 1 } }]) + + expect(generateOperationKey(['planet', 'list'], { type: 'query', input: skipToken as any })) + .toEqual([['planet', 'list'], { type: 'query', skipToken: true }]) }) describe('generateOperationKey with skipToken', () => { @@ -32,32 +35,17 @@ describe('generateOperationKey with skipToken', () => { }], ] - it('replaces the symbol with a serializable marker', () => { - expect(generateOperationKey(['planet', 'list'], { type: 'query', input: skipToken as any })) - .toEqual([['planet', 'list'], { type: 'query', skipToken: true }]) - expect(generateOperationKey(['planet', 'list'], { prefix: '__prefix__', type: 'streamed', input: skipToken as any, fnOptions: { refetchMode: 'append' } })) - .toEqual(['__prefix__', ['planet', 'list'], { type: 'streamed', skipToken: true, fnOptions: { refetchMode: 'append' } }]) - }) - - it.each(hashers)('does not collide with unskipped keys when hashed with %s', (_, hash) => { - for (const type of ['query', 'streamed', 'live', 'infinite'] as const) { - const skipped = hash(generateOperationKey(['planet', 'list'], { type, input: skipToken as any })) - - expect(skipped).not.toEqual(hash(generateOperationKey(['planet', 'list'], { type }))) - expect(skipped).not.toEqual(hash(generateOperationKey(['planet', 'list'], { type, input: {} }))) - expect(skipped).not.toEqual(hash(generateOperationKey(['planet', 'list'], { type, input: { search: 'earth' } }))) - } + it.each(hashers)('does not collide with the no-input key when hashed with %s', (_, hash) => { + expect(hash(generateOperationKey(['planet', 'list'], { type: 'query', input: skipToken as any }))) + .not + .toEqual(hash(generateOperationKey(['planet', 'list'], { type: 'query' }))) }) it('still partially matches keys without input', () => { const skipped = generateOperationKey(['planet', 'list'], { type: 'query', input: skipToken as any }) - expect(partialMatchKey(skipped, generateOperationKey(['planet']))).toBe(true) expect(partialMatchKey(skipped, generateOperationKey(['planet', 'list']))).toBe(true) expect(partialMatchKey(skipped, generateOperationKey(['planet', 'list'], { type: 'query' }))).toBe(true) - - expect(partialMatchKey(skipped, generateOperationKey(['planet', 'list'], { type: 'infinite' }))).toBe(false) expect(partialMatchKey(skipped, generateOperationKey(['planet', 'list'], { input: {} }))).toBe(false) - expect(partialMatchKey(skipped, generateOperationKey(['planet', 'list'], { input: { search: 'earth' } }))).toBe(false) }) }) diff --git a/packages/tanstack-query/src/procedure-utils.test.ts b/packages/tanstack-query/src/procedure-utils.test.ts index fb238b4e83..d8fa0fa1a6 100644 --- a/packages/tanstack-query/src/procedure-utils.test.ts +++ b/packages/tanstack-query/src/procedure-utils.test.ts @@ -442,60 +442,49 @@ describe('procedureUtils', () => { }) describe('skipToken queries do not share a query with unskipped ones', () => { - const retrylessQueryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) const client = vi.fn(async () => '__output__') const utils = new ProcedureUtils, { search?: string } | undefined, string, Error>(['planet', 'list'], client) - beforeEach(() => { - retrylessQueryClient.clear() - }) - it('.queryOptions', async () => { - const unskipped = new QueryObserver(retrylessQueryClient, utils.queryOptions()) - const unsubscribeUnskipped = unskipped.subscribe(() => {}) + const unskipped = new QueryObserver(queryClient, utils.queryOptions()) + onTestFinished(unskipped.subscribe(() => {})) await vi.waitFor(() => expect(unskipped.getCurrentResult().status).toBe('success')) - const skipped = new QueryObserver(retrylessQueryClient, utils.queryOptions({ input: skipToken })) - const unsubscribeSkipped = skipped.subscribe(() => {}) + const skipped = new QueryObserver(queryClient, utils.queryOptions({ input: skipToken })) + onTestFinished(skipped.subscribe(() => {})) - expect(retrylessQueryClient.getQueryCache().getAll()).toHaveLength(2) + expect(queryClient.getQueryCache().getAll()).toHaveLength(2) - await retrylessQueryClient.invalidateQueries({ queryKey: utils.key() }) + await queryClient.invalidateQueries({ queryKey: utils.key() }) expect(unskipped.getCurrentResult().status).toBe('success') expect(skipped.getCurrentResult().status).toBe('pending') expect(client).toHaveBeenCalledTimes(2) - - unsubscribeUnskipped() - unsubscribeSkipped() }) it('.infiniteOptions', async () => { - const unskipped = new InfiniteQueryObserver(retrylessQueryClient, utils.infiniteOptions({ + const unskipped = new InfiniteQueryObserver(queryClient, utils.infiniteOptions({ input: () => undefined, initialPageParam: 0, getNextPageParam: () => undefined, })) - const unsubscribeUnskipped = unskipped.subscribe(() => {}) + onTestFinished(unskipped.subscribe(() => {})) await vi.waitFor(() => expect(unskipped.getCurrentResult().status).toBe('success')) - const skipped = new InfiniteQueryObserver(retrylessQueryClient, utils.infiniteOptions({ + const skipped = new InfiniteQueryObserver(queryClient, utils.infiniteOptions({ input: skipToken, initialPageParam: 0, getNextPageParam: () => undefined, })) - const unsubscribeSkipped = skipped.subscribe(() => {}) + onTestFinished(skipped.subscribe(() => {})) - expect(retrylessQueryClient.getQueryCache().getAll()).toHaveLength(2) + expect(queryClient.getQueryCache().getAll()).toHaveLength(2) - await retrylessQueryClient.invalidateQueries({ queryKey: utils.key() }) + await queryClient.invalidateQueries({ queryKey: utils.key() }) expect(unskipped.getCurrentResult().status).toBe('success') expect(skipped.getCurrentResult().status).toBe('pending') expect(client).toHaveBeenCalledTimes(2) - - unsubscribeUnskipped() - unsubscribeSkipped() }) }) From 3a775a89ca132c2b143f934e3f8e79ff33752ff6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 08:37:38 +0000 Subject: [PATCH 3/3] docs(tanstack-query): drop skipToken query key note Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DGZkM7MFXNDHJBg9SmARgj --- apps/content/docs/integrations/tanstack-query.mdx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/content/docs/integrations/tanstack-query.mdx b/apps/content/docs/integrations/tanstack-query.mdx index e62068ac93..7b3440b8d7 100644 --- a/apps/content/docs/integrations/tanstack-query.mdx +++ b/apps/content/docs/integrations/tanstack-query.mdx @@ -504,8 +504,6 @@ const query = useInfiniteQuery( ) ``` -Skipped queries store `skipToken: true` in their query key instead of `input`, so they never share a cache entry with the same procedure called without input. Partial-match keys such as `orpc.planet.list.key()` still match them. - ## Server-Side Rendering (SSR) When [server-side rendering](/docs/recipes/optimizing-ssr) with [TanStack Query hydration](https://tanstack.com/query/latest/docs/framework/react/guides/ssr), two things need special care: oRPC types that JSON cannot represent, and streamed or live queries that never complete.