diff --git a/packages/tanstack-query/src/key.test.ts b/packages/tanstack-query/src/key.test.ts index 85a8e5921..541b9876a 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', () => { @@ -12,4 +15,37 @@ 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', () => { + 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.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', 'list']))).toBe(true) + expect(partialMatchKey(skipped, generateOperationKey(['planet', 'list'], { type: 'query' }))).toBe(true) + expect(partialMatchKey(skipped, generateOperationKey(['planet', 'list'], { input: {} }))).toBe(false) + }) }) diff --git a/packages/tanstack-query/src/key.ts b/packages/tanstack-query/src/key.ts index cb1a1e6c9..65cf60609 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 3b7d9a381..d8fa0fa1a 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,53 @@ describe('procedureUtils', () => { }) }) +describe('skipToken queries do not share a query with unskipped ones', () => { + const client = vi.fn(async () => '__output__') + const utils = new ProcedureUtils, { search?: string } | undefined, string, Error>(['planet', 'list'], client) + + it('.queryOptions', async () => { + const unskipped = new QueryObserver(queryClient, utils.queryOptions()) + onTestFinished(unskipped.subscribe(() => {})) + await vi.waitFor(() => expect(unskipped.getCurrentResult().status).toBe('success')) + + const skipped = new QueryObserver(queryClient, utils.queryOptions({ input: skipToken })) + onTestFinished(skipped.subscribe(() => {})) + + expect(queryClient.getQueryCache().getAll()).toHaveLength(2) + + await queryClient.invalidateQueries({ queryKey: utils.key() }) + + expect(unskipped.getCurrentResult().status).toBe('success') + expect(skipped.getCurrentResult().status).toBe('pending') + expect(client).toHaveBeenCalledTimes(2) + }) + + it('.infiniteOptions', async () => { + const unskipped = new InfiniteQueryObserver(queryClient, utils.infiniteOptions({ + input: () => undefined, + initialPageParam: 0, + getNextPageParam: () => undefined, + })) + onTestFinished(unskipped.subscribe(() => {})) + await vi.waitFor(() => expect(unskipped.getCurrentResult().status).toBe('success')) + + const skipped = new InfiniteQueryObserver(queryClient, utils.infiniteOptions({ + input: skipToken, + initialPageParam: 0, + getNextPageParam: () => undefined, + })) + onTestFinished(skipped.subscribe(() => {})) + + expect(queryClient.getQueryCache().getAll()).toHaveLength(2) + + await queryClient.invalidateQueries({ queryKey: utils.key() }) + + expect(unskipped.getCurrentResult().status).toBe('success') + expect(skipped.getCurrentResult().status).toBe('pending') + expect(client).toHaveBeenCalledTimes(2) + }) +}) + 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 082530034..f06806c08 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: {