diff --git a/apps/content/blog/nextjs-ssr-orpc-tanstack-query.mdx b/apps/content/blog/nextjs-ssr-orpc-tanstack-query.mdx index 578ca5cea..9c326fdca 100644 --- a/apps/content/blog/nextjs-ssr-orpc-tanstack-query.mdx +++ b/apps/content/blog/nextjs-ssr-orpc-tanstack-query.mdx @@ -190,7 +190,7 @@ That handoff needs a carefully configured `QueryClient`. Create `lib/query-clien ```ts lib/query-client.ts import { RPCJsonSerializer } from '@orpc/client' -import { createHydrationOptions, createQueryKeyHashFn } from '@orpc/tanstack-query' +import { createHydrationOptions } from '@orpc/tanstack-query' import { environmentManager, QueryClient } from '@tanstack/react-query' let browserQueryClient: QueryClient | undefined @@ -214,7 +214,6 @@ function createQueryClient(): QueryClient { defaultOptions: { queries: { staleTime: 60 * 1000, // > 0 to prevent immediate refetching on mount - queryKeyHashFn: createQueryKeyHashFn(serializer), }, ...createHydrationOptions(serializer), }, @@ -225,7 +224,7 @@ function createQueryClient(): QueryClient { Each piece has one job: - **`getQueryClient`** creates a fresh client for every server request, so one user's data can never leak into another user's HTML, while the browser reuses a singleton. -- **`createQueryKeyHashFn` and `createHydrationOptions`** teach query keys and the cache handoff about oRPC's extra types. Dehydration normally goes through plain JSON, which would turn our `Date` objects into strings. `RPCJsonSerializer` round-trips them intact. See [Custom Serializers](/docs/integrations/tanstack-query#custom-serializers) to extend it with your own types. +- **`createHydrationOptions`** teaches the cache handoff about oRPC's extra types. Dehydration normally goes through plain JSON, which would turn our `Date` objects into strings. `RPCJsonSerializer` round-trips them intact. See [Custom Serializers](/docs/integrations/tanstack-query#custom-serializers) to extend it with your own types. - **`staleTime: 60 * 1000`** marks hydrated data as fresh, so the browser doesn't immediately refetch what the server just rendered. ## Wire Up the Providers diff --git a/apps/content/docs/integrations/pinia-colada.mdx b/apps/content/docs/integrations/pinia-colada.mdx index 58549cc1d..cc6779b40 100644 --- a/apps/content/docs/integrations/pinia-colada.mdx +++ b/apps/content/docs/integrations/pinia-colada.mdx @@ -162,22 +162,10 @@ queryCache.setQueryData(orpc.planet.find.queryKey({ input: { id: 123 } }), (old) ### Key Serializer -Because Pinia Colada requires entry keys to be serializable, oRPC serializes inputs with the [RPC JSON Serializer](/docs/rpc/serializer#rpc-json-serializer) when building keys. Native types like `Date`, `URL`, and `BigInt` are supported by default, and keys keep track of each value's type, so a `Date` and its ISO string produce different keys. To support [custom types](/docs/rpc/serializer#custom-serializers), pass the same serializer your [link](/docs/rpc/link#custom-serializer) uses: +Pinia Colada requires serializable keys, so oRPC serializes inputs with the [RPC JSON Serializer](/docs/rpc/serializer#rpc-json-serializer) when building them. Inputs like `Date`, `URL`, and `BigInt` are supported, and a `Date` and its ISO string produce different keys. You can pass a custom [RPC JSON Serializer](/docs/rpc/serializer#rpc-json-serializer), or an [OpenAPI JSON Serializer](/docs/openapi/serializer#openapi-json-serializer) if you don't need type-aware keys: ```ts -import { RPCSerializer } from '@orpc/client' - -const serializer = new RPCSerializer({ - handlers: { - // ...custom handlers - }, -}) - -const link = new RPCLink({ url, serializer }) - -const orpc = createPiniaColadaUtils(client, { - serializer, -}) +const orpc = createPiniaColadaUtils(client, { serializer }) ``` ## Calling Procedure Clients diff --git a/apps/content/docs/integrations/tanstack-query.mdx b/apps/content/docs/integrations/tanstack-query.mdx index e2b962c66..df18d5605 100644 --- a/apps/content/docs/integrations/tanstack-query.mdx +++ b/apps/content/docs/integrations/tanstack-query.mdx @@ -179,6 +179,14 @@ queryClient.setQueryData(orpc.planet.find.queryKey({ input: { id: 123 } }), (old }) ``` +### Key Serializer + +oRPC serializes inputs with the [RPC JSON Serializer](/docs/rpc/serializer#rpc-json-serializer) when building keys, so inputs like `Date`, `BigInt`, `Set`, and `Map` match by value, and a `Date` and its ISO string produce different keys. You can pass a custom [RPC JSON Serializer](/docs/rpc/serializer#rpc-json-serializer), or an [OpenAPI JSON Serializer](/docs/openapi/serializer#openapi-json-serializer) if you don't need type-aware keys: + +```ts +const orpc = createTanstackQueryUtils(client, { serializer }) +``` + ## Calling Clients The `.call` method provides direct access to the underlying procedure client when needed. @@ -258,7 +266,7 @@ const customQuery = useQuery(orpc.planet.find.queryOptions({ ``` :::info -When you configure `queryKey`, it also affects `.queryOptions` because it is used internally to generate query keys. The same applies to live, streamed, infinite, and mutation options when you configure their keys. +When you configure `queryKey`, it also affects `.queryOptions` because it is used internally to generate query keys. The same applies to live, streamed, infinite, and mutation options when you configure their keys. Custom keys skip the [key serializer](#key-serializer), so keep them JSON-compatible. ::: ## Interceptors @@ -504,23 +512,40 @@ const query = useInfiniteQuery( ) ``` +## Structural Sharing + +`.queryOptions`, `.liveOptions`, and `.infiniteOptions` use oRPC's structural sharing by default, and this per-query setting takes precedence over `defaultOptions.queries.structuralSharing` on the `QueryClient`. Unlike TanStack Query's default [structural sharing](https://tanstack.com/query/latest/docs/framework/react/guides/render-optimizations#structural-sharing), it also keeps unchanged `Date`, `URL`, `Map`, and `Set` values, so refetches and live events that return the same data do not cause re-renders. + +```ts +import { createStructuralSharing } from '@orpc/tanstack-query' + +// Opt out per call +orpc.planet.list.queryOptions({ input: { limit: 10 }, structuralSharing: false }) + +// Use it for queries not built with oRPC utils +const queryClient = new QueryClient({ + defaultOptions: { queries: { structuralSharing: createStructuralSharing() } }, +}) +``` + +With an RPC [key serializer](#key-serializer), equal instances of [custom types](/docs/rpc/serializer#custom-serializers) are kept too, compared by what their handler serializes them to. For queries not built with oRPC utils, pass it to `createStructuralSharing(serializer)`. + ## 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. ### Custom Serializers -TanStack Query hashes query keys and dehydrates query data as plain JSON, which cannot represent oRPC types such as `Date`, `BigInt`, `Set`, or `Map`. Pass your [RPC Serializer](/docs/rpc/serializer#custom-serializers) to `createQueryKeyHashFn` and `createHydrationOptions` to keep them intact. +TanStack Query dehydrates query data as plain JSON, which cannot represent oRPC types such as `Date`, `BigInt`, `Set`, or `Map`. Pass your [RPC Serializer](/docs/rpc/serializer#custom-serializers) to `createHydrationOptions` to keep them intact. ```ts -import { createHydrationOptions, createQueryKeyHashFn } from '@orpc/tanstack-query' +import { createHydrationOptions } from '@orpc/tanstack-query' import { QueryClient } from '@tanstack/react-query' // or any framework adapter, e.g. @tanstack/vue-query const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // > 0 to prevent immediate refetching on mount - queryKeyHashFn: createQueryKeyHashFn(serializer), }, ...createHydrationOptions(serializer), // dehydrate.serializeData and hydrate.deserializeData }, diff --git a/apps/content/docs/openapi/serializer.mdx b/apps/content/docs/openapi/serializer.mdx index 6342ac992..c41b735bd 100644 --- a/apps/content/docs/openapi/serializer.mdx +++ b/apps/content/docs/openapi/serializer.mdx @@ -92,6 +92,33 @@ const link = new OpenAPILink(contract, { ::: +## OpenAPI JSON Serializer + +`OpenAPIJsonSerializer` is the JSON layer behind `OpenAPISerializer`. Use it directly wherever only JSON is accepted. + +```ts twoslash +import { OpenAPIJsonSerializer } from '@orpc/openapi' + +const serializer = new OpenAPIJsonSerializer({ + handlers: { + // same handlers as OpenAPISerializer + }, +}) + +const { json } = serializer.serializeJson({ + name: 'John', + createdAt: new Date('2024-01-01'), +}) +// json: { name: 'John', createdAt: '2024-01-01T00:00:00.000Z' } + +const data = serializer.deserializeJson({ json }) +// data: { name: 'John', createdAt: '2024-01-01T00:00:00.000Z' } +``` + +:::info[Reuse an OpenAPI Serializer] +`OpenAPISerializer` extends `OpenAPIJsonSerializer`, so an `OpenAPISerializer` instance also works wherever an `OpenAPIJsonSerializer` is accepted, such as [MCP outputs](/docs/integrations/mcp#output), [TanStack Query keys](/docs/integrations/tanstack-query#key-serializer), or [Pinia Colada keys](/docs/integrations/pinia-colada#key-serializer). Configure your handlers once and share the same instance. +::: + ## Serialization Format In most cases, serialized data is JSON-serializable. diff --git a/apps/content/docs/rpc/serializer.mdx b/apps/content/docs/rpc/serializer.mdx index 4aa3e94ad..a18513f75 100644 --- a/apps/content/docs/rpc/serializer.mdx +++ b/apps/content/docs/rpc/serializer.mdx @@ -137,7 +137,7 @@ const data = serializer.deserializeJson({ json, meta }) ``` :::info[Reuse an RPC Serializer] -`RPCSerializer` extends `RPCJsonSerializer`, so an `RPCSerializer` instance also works wherever an `RPCJsonSerializer` is accepted, such as [Publisher adapters](/docs/helpers/publisher#adapters), [TanStack Query hydration](/docs/integrations/tanstack-query#custom-serializers), or [Pinia Colada keys](/docs/integrations/pinia-colada#key-serializer). Configure your handlers once and share the same instance. +`RPCSerializer` extends `RPCJsonSerializer`, so an `RPCSerializer` instance also works wherever an `RPCJsonSerializer` is accepted, such as [Publisher adapters](/docs/helpers/publisher#adapters), [TanStack Query keys](/docs/integrations/tanstack-query#key-serializer) and [hydration](/docs/integrations/tanstack-query#custom-serializers), or [Pinia Colada keys](/docs/integrations/pinia-colada#key-serializer). Configure your handlers once and share the same instance. ::: ## Serialization Format diff --git a/packages/openapi/src/openapi-json-serializer.ts b/packages/openapi/src/openapi-json-serializer.ts index b544b0f7f..1a6cdc743 100644 --- a/packages/openapi/src/openapi-json-serializer.ts +++ b/packages/openapi/src/openapi-json-serializer.ts @@ -142,6 +142,12 @@ export interface OpenAPIJsonSerializerOptions { omitUndefinedProperties?: boolean | undefined } +/** + * The JSON layer behind `OpenAPISerializer`: one-way serialization of types like `Date`, `BigInt`, + * and `Set` into JSON-friendly values, for places that only accept JSON. + * + * @see {@link https://orpc.dev/docs/openapi/serializer#openapi-json-serializer | OpenAPI Serializer - OpenAPI JSON Serializer} + */ export class OpenAPIJsonSerializer { private readonly inlineBuiltInHandlers: boolean private readonly handlerEntries: OpenAPIJsonSerializerHandler[] | undefined diff --git a/packages/pinia-colada/src/key.test.ts b/packages/pinia-colada/src/key.test.ts index 8f6190a79..82e520578 100644 --- a/packages/pinia-colada/src/key.test.ts +++ b/packages/pinia-colada/src/key.test.ts @@ -1,4 +1,5 @@ import { RPCJsonSerializer } from '@orpc/client' +import { OpenAPIJsonSerializer } from '@orpc/openapi' import { useQueryCache } from '@pinia/colada' import { createPinia } from 'pinia' import { generateOperationKey } from './key' @@ -103,6 +104,12 @@ describe('generateOperationKey serializer', () => { }]) }) + it('accepts an OpenAPI serializer, whose keys carry no type info', () => { + expect(generateOperationKey(['path'], { serializer: new OpenAPIJsonSerializer(), input: { a: new Date(0) } })).toEqual([['path'], { + input: { a: '1970-01-01T00:00:00.000Z' }, + }]) + }) + it('skips the serializer when input is undefined', () => { const serializeJson = vi.fn() diff --git a/packages/pinia-colada/src/router-utils.test-d.ts b/packages/pinia-colada/src/router-utils.test-d.ts index d4de32ac3..8fa6bbd71 100644 --- a/packages/pinia-colada/src/router-utils.test-d.ts +++ b/packages/pinia-colada/src/router-utils.test-d.ts @@ -5,6 +5,7 @@ import type { ProcedureUtils } from './procedure-utils' import type { RouterUtils } from './router-utils' import type { SharedUtils } from './shared-utils' import { RPCSerializer } from '@orpc/client' +import { OpenAPISerializer } from '@orpc/openapi' import { os } from '@orpc/server' import z from 'zod' import { createRouterUtils } from './router-utils' @@ -56,6 +57,10 @@ it('RouterUtils', () => { >() }) +it('createRouterUtils accepts an OpenAPI serializer', () => { + createRouterUtils({} as RouterClient, { serializer: new OpenAPISerializer() }) +}) + it('createRouterUtils', () => { const utils = createRouterUtils({} as RouterClient, { prefix: '__prefix__', diff --git a/packages/tanstack-query/src/index.ts b/packages/tanstack-query/src/index.ts index 5e3d6148e..e077d235a 100644 --- a/packages/tanstack-query/src/index.ts +++ b/packages/tanstack-query/src/index.ts @@ -8,6 +8,7 @@ export { createRouterUtils as createTanstackQueryUtils } from './router-utils' export * from './shared-utils' export * from './ssr' export * from './stream-query' +export * from './structural-sharing' export * from './types' export { OPERATION_CONTEXT_SYMBOL as TANSTACK_QUERY_OPERATION_CONTEXT_SYMBOL, diff --git a/packages/tanstack-query/src/key.test.ts b/packages/tanstack-query/src/key.test.ts index f44cff40d..c10f3c9fa 100644 --- a/packages/tanstack-query/src/key.test.ts +++ b/packages/tanstack-query/src/key.test.ts @@ -1,7 +1,7 @@ -import type { QueryKey } from '@tanstack/query-core' import { RPCJsonSerializer } from '@orpc/client' -import { hashKey, partialMatchKey, skipToken } from '@tanstack/query-core' -import { createQueryKeyHashFn, generateOperationKey } from './key' +import { OpenAPIJsonSerializer } from '@orpc/openapi' +import { hashKey, partialMatchKey, QueryClient, skipToken } from '@tanstack/query-core' +import { generateOperationKey } from './key' it('generateOperationKey', () => { expect(generateOperationKey(['path'])).toEqual([['path'], {}]) @@ -21,15 +21,10 @@ it('generateOperationKey', () => { }) describe('generateOperationKey with skipToken', () => { - const hashers: [name: string, hash: (queryKey: QueryKey) => string][] = [ - ['hashKey', hashKey], - ['createQueryKeyHashFn', createQueryKeyHashFn()], - ] - - 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 }))) + it('does not collide with the no-input key when hashed', () => { + expect(hashKey(generateOperationKey(['planet', 'list'], { type: 'query', input: skipToken as any }))) .not - .toEqual(hash(generateOperationKey(['planet', 'list'], { type: 'query' }))) + .toEqual(hashKey(generateOperationKey(['planet', 'list'], { type: 'query' }))) }) it('still partially matches keys without input', () => { @@ -41,44 +36,128 @@ describe('generateOperationKey with skipToken', () => { }) }) -describe('createQueryKeyHashFn', () => { - const hash = createQueryKeyHashFn() +describe('generateOperationKey input meta', () => { + it('keys meta by JSON-encoded path', () => { + expect(generateOperationKey(['path'], { input: { a: { 'b.c': [1n, new Set([new URL('https://orpc.dev')])] } } })).toEqual([['path'], { + input: { a: { 'b.c': ['1', ['https://orpc.dev/']] } }, + inputMeta: { + '["a","b.c",0]': ['bigint'], + '["a","b.c",1,0]': ['url'], + '["a","b.c",1]': ['set'], + }, + }]) - it('hashes keys hashKey cannot handle', () => { - expect(() => hashKey([{ input: 1n }])).toThrow() - expect(hash([{ input: 1n }])).toEqual(expect.any(String)) + expect(generateOperationKey(['path'], { input: new Date(0) })).toEqual([['path'], { + input: '1970-01-01T00:00:00.000Z', + inputMeta: { '[]': ['date'] }, + }]) }) - it('distinguishes oRPC types from their JSON representation', () => { - const date = new Date('2024-01-01') + it('distinguishes inputs with the same JSON form', () => { + const date = new Date(0) - expect(hashKey([{ input: date }])).toEqual(hashKey([{ input: date.toISOString() }])) - expect(hash([{ input: date }])).not.toEqual(hash([{ input: date.toISOString() }])) - expect(hash([{ input: 1n }])).not.toEqual(hash([{ input: '1' }])) - expect(hash([{ input: new Set([1]) }])).not.toEqual(hash([{ input: [1] }])) - }) + expect(hashKey(generateOperationKey(['path'], { input: { a: date } }))) + .not + .toEqual(hashKey(generateOperationKey(['path'], { input: { a: date.toISOString() } }))) - it('ignores object key order', () => { - expect(hash([{ a: 1n, b: new Date(0) }])).toEqual(hash([{ b: new Date(0), a: 1n }])) - expect(hash([{ a: 1n, b: new Date(0) }])).not.toEqual(hash([{ a: new Date(0), b: 1n }])) + expect(hashKey(generateOperationKey(['path'], { input: 1n }))) + .not + .toEqual(hashKey(generateOperationKey(['path'], { input: '1' }))) }) - it('uses the given serializer', () => { - class Person { - constructor(public name: string) {} - } + it('hashes Set and Map inputs by their content', () => { + expect(hashKey(generateOperationKey(['path'], { input: new Set(['a']) }))) + .not + .toEqual(hashKey(generateOperationKey(['path'], { input: new Set(['b']) }))) + + expect(hashKey(generateOperationKey(['path'], { input: new Map([['a', 1]]) }))) + .not + .toEqual(hashKey(generateOperationKey(['path'], { input: new Map([['a', 2]]) }))) + }) - const hash = createQueryKeyHashFn(new RPCJsonSerializer({ + it('keeps every type of a value serialized by chained handlers', () => { + const serializer = new RPCJsonSerializer({ handlers: { - person: { - condition: value => value instanceof Person, - serialize: (value: Person) => value.name, - deserialize: name => new Person(name as string), + timestamp: { + condition: value => typeof value === 'object' && value !== null && 'timestamp' in value, + serialize: (value: { timestamp: number }) => new Date(value.timestamp), + deserialize: (value: Date) => ({ timestamp: value.getTime() }), }, }, - })) + }) + + expect(generateOperationKey(['path'], { serializer, input: { a: { timestamp: 0 } } })).toEqual([['path'], { + input: { a: '1970-01-01T00:00:00.000Z' }, + inputMeta: { '["a"]': ['date', 'timestamp'] }, + }]) + }) + + it('partially matches queries by value', async () => { + const queryClient = new QueryClient() + const from = new Date(0) + const to = new Date(1) + + queryClient.setQueryData(generateOperationKey(['list'], { type: 'query', input: { from, to } }), 'date') + queryClient.setQueryData(generateOperationKey(['list'], { type: 'query', input: { from: from.toISOString(), to } }), 'string') + queryClient.setQueryData(generateOperationKey(['list'], { type: 'query', input: { from: new Date(2), to } }), 'other date') + + const data = (input: unknown) => queryClient.getQueriesData({ queryKey: generateOperationKey(['list'], { input }) }).map(([, data]) => data) + + expect(queryClient.getQueryCache().getAll()).toHaveLength(3) + expect(data({ to })).toEqual(['date', 'string', 'other date']) + expect(data({ from })).toEqual(['date']) + + await queryClient.invalidateQueries({ queryKey: generateOperationKey(['list'], { input: { from } }) }) + expect(queryClient.getQueryCache().getAll().filter(query => query.state.isInvalidated).map(query => query.state.data)).toEqual(['date']) + }) +}) + +describe('generateOperationKey serializer', () => { + class Point { + constructor(public x: number, public y: number) {} + } + + const serializer = new RPCJsonSerializer({ + handlers: { + point: { + condition: value => value instanceof Point, + serialize: (value: Point) => [value.x, value.y], + deserialize: ([x, y]: [number, number]) => new Point(x, y), + isTerminal: true, + }, + }, + }) + + it('serializes input with the given serializer', () => { + expect(generateOperationKey(['path'], { serializer, input: { p: new Point(1, 2) } })).toEqual([['path'], { + input: { p: [1, 2] }, + inputMeta: { '["p"]': ['point'] }, + }]) + + expect(generateOperationKey(['path'], { input: { p: new Point(1, 2) } })).toEqual([['path'], { + input: { p: new Point(1, 2) }, + }]) + }) + + it('accepts an OpenAPI serializer, whose keys carry no type info', () => { + expect(generateOperationKey(['path'], { serializer: new OpenAPIJsonSerializer(), input: { a: new Date(0) } })).toEqual([['path'], { + input: { a: '1970-01-01T00:00:00.000Z' }, + }]) + }) + + it('skips the serializer when input is undefined or skipToken', () => { + const serializeJson = vi.fn() + const serializer = { serializeJson, deserializeJson: vi.fn() } + + expect(generateOperationKey(['path'], { serializer, type: 'query' })).toEqual([['path'], { type: 'query' }]) + expect(generateOperationKey(['path'], { serializer, input: skipToken as any })).toEqual([['path'], { skipToken: true }]) + expect(serializeJson).not.toHaveBeenCalled() + }) + + it('omits empty meta', () => { + const serializeJson = vi.fn(() => ({ json: { a: 1 }, meta: [] })) - expect(hash([{ input: new Person('Earth') }])).not.toEqual(hash([{ input: new Person('Mars') }])) - expect(hash([{ input: new Person('Earth') }])).not.toEqual(hash([{ input: 'Earth' }])) + expect(generateOperationKey(['path'], { serializer: { serializeJson, deserializeJson: vi.fn() }, input: { a: 1 } })).toEqual([['path'], { input: { a: 1 } }]) + expect(serializeJson).toHaveBeenCalledWith({ a: 1 }) }) }) diff --git a/packages/tanstack-query/src/key.ts b/packages/tanstack-query/src/key.ts index fc9b20f58..3e645016e 100644 --- a/packages/tanstack-query/src/key.ts +++ b/packages/tanstack-query/src/key.ts @@ -1,10 +1,9 @@ import type { PartialDeep, Public } from '@orpc/shared' -import type { QueryKey, QueryKeyHashFunction } from '@tanstack/query-core' import type { SerializableStreamedQueryOptions } from './stream-query' import type { OperationType } from './types' import { RPCJsonSerializer } from '@orpc/client' import { stringifyJSON } from '@orpc/shared' -import { hashKey, skipToken } from '@tanstack/query-core' +import { skipToken } from '@tanstack/query-core' export interface OperationKeyPrefixOptions { /** @@ -14,24 +13,52 @@ export interface OperationKeyPrefixOptions { prefix?: string | undefined } -export type OperationKeyOptions = OperationKeyPrefixOptions & { +export interface OperationKeyBaseOptions extends OperationKeyPrefixOptions { + /** + * Serializes inputs into JSON-compatible values when building keys, + * and compares custom types in structural sharing. + * Pass the serializer your link uses so custom types are supported. + * + * @default new RPCJsonSerializer() + */ + serializer?: Public | undefined +} + +export type OperationKeyOptions = OperationKeyBaseOptions & { type?: TType | undefined input?: (TType extends 'mutation' ? never : PartialDeep) | undefined fnOptions?: (TType extends 'streamed' ? SerializableStreamedQueryOptions : never) | undefined } -type OperationKeyContent = Omit, 'prefix'> & { +type OperationKeyContent = Omit, keyof OperationKeyBaseOptions> & { /** * Set in place of `input` when the operation is disabled with `skipToken`, * because query key hashers drop symbols. */ skipToken?: TType extends 'mutation' ? never : true + + /** + * Serializer types of non-JSON values in `input`, keyed by their JSON-encoded path, + * so inputs with the same JSON form (like a `Date` and its ISO string) produce different keys. + * Keyed by path (not a list) so partial keys still match. + */ + inputMeta?: Record | undefined } export type OperationKey = | [path: string[], options: OperationKeyContent] | [prefix: string, path: string[], options: OperationKeyContent] +const defaultSerializer = new RPCJsonSerializer() + +/** + * Generate a TanStack Query key for a procedure. + * + * The input is serialized to JSON-compatible values, so keys hash and partially match + * by value, including types like `Date`, `Set`, `Map` and custom serializer types. + * + * @see {@link https://orpc.dev/docs/integrations/tanstack-query#key-serializer | TanStack Query Integration - Key Serializer} + */ export function generateOperationKey( path: string[], options: OperationKeyOptions = {}, @@ -42,27 +69,25 @@ export function generateOperationKey( { ...options.input === skipToken ? { skipToken: true } - : options.input !== undefined ? { input: options.input } : {}, + : options.input !== undefined ? serializeInput(options.serializer ?? defaultSerializer, options.input) : {}, ...options.type !== undefined ? { type: options.type } : {}, ...options.fnOptions !== undefined ? { fnOptions: options.fnOptions } : {}, }, ] as OperationKey } -/** - * Creates a `queryKeyHashFn` that supports oRPC types such as `bigint` and `Date`. - * - * @see {@link https://orpc.dev/docs/integrations/tanstack-query#custom-serializers | TanStack Query Integration - Custom Serializers} - */ -export function createQueryKeyHashFn( - serializer: Public = new RPCJsonSerializer(), -): QueryKeyHashFunction { - return (queryKey) => { - const { json, meta } = serializer.serializeJson(queryKey) +function serializeInput(serializer: Public, input: unknown) { + const { json, meta } = serializer.serializeJson(input) - return hashKey([ - json, - meta?.map(entry => stringifyJSON(entry)).sort(), - ]) + if (!meta?.length) { + return { input: json } } + + const inputMeta: Record = {} + + for (const [type, ...segments] of meta) { + (inputMeta[stringifyJSON(segments)] ??= []).push(type) + } + + return { input: json, inputMeta } } diff --git a/packages/tanstack-query/src/meta.test-d.ts b/packages/tanstack-query/src/meta.test-d.ts index cb1d2fab7..2ab7a8dae 100644 --- a/packages/tanstack-query/src/meta.test-d.ts +++ b/packages/tanstack-query/src/meta.test-d.ts @@ -2,6 +2,7 @@ import type { ClientContext, ORPCError } from '@orpc/client' import type { PromiseWithError } from '@orpc/shared' import type { SkipToken } from '@tanstack/query-core' import type { OperationContext } from './types' +import { RPCJsonSerializer } from '@orpc/client' import { oc, type } from '@orpc/contract' import { tanstackQuery } from './meta' @@ -43,5 +44,10 @@ describe('tanstackQuery', () => { staleTime: 'invalid', }, })) + + oc.output(type(vi.fn())).meta(tanstackQuery({ + // @ts-expect-error - serializer belongs to the client, not the contract + serializer: new RPCJsonSerializer(), + })) }) }) diff --git a/packages/tanstack-query/src/meta.ts b/packages/tanstack-query/src/meta.ts index 23b423679..ebe55c287 100644 --- a/packages/tanstack-query/src/meta.ts +++ b/packages/tanstack-query/src/meta.ts @@ -1,13 +1,14 @@ import type { AnyNestedClient, ClientContext, InferClientContext, InferClientError } from '@orpc/client' import type { AnySchema, ErrorMap, InferSchemaInput, InferSchemaOutput, Meta, MetaPlugin, ORPCErrorFromErrorMap, RouterContract } from '@orpc/contract' import type { ThrowableError } from '@orpc/shared' +import type { OperationKeyBaseOptions } from './key' import type { RouterUtilsPlugin } from './plugin' import type { ProcedureUtilsOptions } from './procedure-utils' import { getRouterContract, ProcedureContract } from '@orpc/contract' import { mergeProcedureUtilsOptions } from './procedure-utils' export type TanstackQueryMetaOptions - = Omit, 'prefix'> + = Omit, keyof OperationKeyBaseOptions> export interface TanstackQueryMetaPlugin< TInputSchema extends AnySchema, diff --git a/packages/tanstack-query/src/procedure-utils.test.ts b/packages/tanstack-query/src/procedure-utils.test.ts index 730b0904c..54c905e8d 100644 --- a/packages/tanstack-query/src/procedure-utils.test.ts +++ b/packages/tanstack-query/src/procedure-utils.test.ts @@ -1,3 +1,4 @@ +import { RPCJsonSerializer } from '@orpc/client' import { InfiniteQueryObserver, QueryClient, QueryObserver, skipToken } from '@tanstack/query-core' import * as KeyModule from './key' import * as LiveQuery from './live-query' @@ -36,24 +37,25 @@ describe('procedureUtils', () => { expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) }) - describe('with prefix', () => { - const prefixedUtils = new ProcedureUtils(['ping'], client, { prefix: '__prefix__' }) + describe('with key options', () => { + const serializer = new RPCJsonSerializer() + const keyedUtils = new ProcedureUtils(['ping'], client, { prefix: '__prefix__', serializer }) - it('includes prefix in generated keys', () => { - prefixedUtils.queryKey({ input: 1 } as any) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, ['ping'], { prefix: '__prefix__', type: 'query', input: 1 }) + it('includes key options in generated keys', () => { + keyedUtils.queryKey({ input: 1 } as any) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, ['ping'], { prefix: '__prefix__', serializer, type: 'query', input: 1 }) - prefixedUtils.streamedKey({ input: 1, queryFnOptions: { maxChunks: 1 } } as any) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { prefix: '__prefix__', type: 'streamed', input: 1, fnOptions: { maxChunks: 1 } }) + keyedUtils.streamedKey({ input: 1, queryFnOptions: { maxChunks: 1 } } as any) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['ping'], { prefix: '__prefix__', serializer, type: 'streamed', input: 1, fnOptions: { maxChunks: 1 } }) - prefixedUtils.liveKey({ input: 1 } as any) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(3, ['ping'], { prefix: '__prefix__', type: 'live', input: 1 }) + keyedUtils.liveKey({ input: 1 } as any) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(3, ['ping'], { prefix: '__prefix__', serializer, type: 'live', input: 1 }) - prefixedUtils.infiniteKey({ input: (cursor: number) => ({ cursor }), initialPageParam: 0 } as any) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(4, ['ping'], { prefix: '__prefix__', type: 'infinite', input: { cursor: 0 } }) + keyedUtils.infiniteKey({ input: (cursor: number) => ({ cursor }), initialPageParam: 0 } as any) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(4, ['ping'], { prefix: '__prefix__', serializer, type: 'infinite', input: { cursor: 0 } }) - prefixedUtils.mutationKey() - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(5, ['ping'], { prefix: '__prefix__', type: 'mutation' }) + keyedUtils.mutationKey() + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(5, ['ping'], { prefix: '__prefix__', serializer, type: 'mutation' }) }) }) @@ -488,6 +490,106 @@ describe('skipToken queries do not share a query with unskipped ones', () => { }) }) +describe('structural sharing', () => { + const client = vi.fn() + const utils = new ProcedureUtils, undefined, any, Error>(['planet', 'list'], client) + const planets = () => [{ name: 'Earth', createdAt: new Date('2025-01-01'), tags: new Set(['blue']) }] + + it('.queryOptions keeps the previous data when nothing changed', async () => { + client.mockImplementation(async () => planets()) + const observer = new QueryObserver(queryClient, utils.queryOptions()) + onTestFinished(observer.subscribe(() => {})) + await vi.waitFor(() => expect(observer.getCurrentResult().status).toBe('success')) + + const data = observer.getCurrentResult().data + await observer.refetch() + expect(client).toHaveBeenCalledTimes(2) + expect(observer.getCurrentResult().data).toBe(data) + }) + + it('.queryOptions can opt out with structuralSharing: false', async () => { + client.mockImplementation(async () => planets()) + const observer = new QueryObserver(queryClient, utils.queryOptions({ structuralSharing: false })) + onTestFinished(observer.subscribe(() => {})) + await vi.waitFor(() => expect(observer.getCurrentResult().status).toBe('success')) + + const data = observer.getCurrentResult().data + await observer.refetch() + expect(observer.getCurrentResult().data).not.toBe(data) + expect(observer.getCurrentResult().data).toEqual(data) + }) + + it('.liveOptions keeps the previous data across equal events', async () => { + const results = new Set() + client.mockImplementation(async function* () { + yield planets() + yield planets() + yield [{ ...planets()[0], name: 'Mars' }] + }) + const observer = new QueryObserver(queryClient, utils.liveOptions()) + onTestFinished(observer.subscribe(result => result.data && results.add(result.data))) + await vi.waitFor(() => expect(observer.getCurrentResult().fetchStatus).toBe('idle')) + + const [first, second] = results + expect(results.size).toBe(2) + expect(second[0].name).toBe('Mars') + expect(second[0].createdAt).toBe(first[0].createdAt) + expect(second[0].tags).toBe(first[0].tags) + }) + + it('.infiniteOptions keeps unchanged pages', async () => { + client.mockImplementation(async () => planets()) + const observer = new InfiniteQueryObserver(queryClient, utils.infiniteOptions({ + input: () => undefined, + initialPageParam: 0, + getNextPageParam: lastPage => lastPage.length, + })) + onTestFinished(observer.subscribe(() => {})) + await vi.waitFor(() => expect(observer.getCurrentResult().status).toBe('success')) + + const data = observer.getCurrentResult().data + await observer.fetchNextPage() + expect(observer.getCurrentResult().data!.pages[0]).toBe(data!.pages[0]) + expect(observer.getCurrentResult().data!.pages).toHaveLength(2) + + const twoPages = observer.getCurrentResult().data + await observer.refetch() + expect(observer.getCurrentResult().data).toBe(twoPages) + }) + + it('uses the serializer option to compare custom types', async () => { + class Person { + constructor(public name: string) {} + } + + const serializer = new RPCJsonSerializer({ + handlers: { + person: { + condition: value => value instanceof Person, + serialize: (value: Person) => value.name, + deserialize: name => new Person(name as string), + }, + }, + }) + + client.mockImplementation(async () => [new Person('Earth')]) + const utils = new ProcedureUtils, undefined, any, Error>(['planet', 'list'], client, { serializer }) + const observer = new QueryObserver(queryClient, utils.queryOptions()) + onTestFinished(observer.subscribe(() => {})) + await vi.waitFor(() => expect(observer.getCurrentResult().status).toBe('success')) + + const data = observer.getCurrentResult().data + await observer.refetch() + expect(client).toHaveBeenCalledTimes(2) + expect(observer.getCurrentResult().data).toBe(data) + }) + + it('is not set on .streamedOptions or .mutationOptions', () => { + expect('structuralSharing' in utils.streamedOptions()).toBe(false) + expect('structuralSharing' in utils.mutationOptions()).toBe(false) + }) +}) + describe('createProcedureUtils with options', () => { const client = vi.fn() @@ -1170,6 +1272,7 @@ describe('isProcedureUtilsOptions', () => { it('accepts procedure utils options shapes', () => { expect(isProcedureUtilsOptions({})).toBe(true) expect(isProcedureUtilsOptions({ prefix: 'planet' })).toBe(true) + expect(isProcedureUtilsOptions({ serializer: new RPCJsonSerializer() })).toBe(true) expect(isProcedureUtilsOptions({ queryInterceptors: [], mutationInterceptors: [vi.fn()] })).toBe(true) expect(isProcedureUtilsOptions({ queryOptions: { staleTime: 1000 }, mutationKey: () => ({}) })).toBe(true) expect(isProcedureUtilsOptions({ liveOptions: undefined, streamedKey: undefined })).toBe(true) @@ -1180,6 +1283,7 @@ describe('isProcedureUtilsOptions', () => { expect(isProcedureUtilsOptions(undefined)).toBe(false) expect(isProcedureUtilsOptions('invalid')).toBe(false) expect(isProcedureUtilsOptions({ prefix: 123 })).toBe(false) + expect(isProcedureUtilsOptions({ serializer: { ping: {} } })).toBe(false) expect(isProcedureUtilsOptions({ queryInterceptors: { invalid: true } })).toBe(false) expect(isProcedureUtilsOptions({ queryInterceptors: [{ invalid: true }] })).toBe(false) expect(isProcedureUtilsOptions({ queryOptions: 'invalid' })).toBe(false) diff --git a/packages/tanstack-query/src/procedure-utils.ts b/packages/tanstack-query/src/procedure-utils.ts index 4bb532f13..8b41c86bc 100644 --- a/packages/tanstack-query/src/procedure-utils.ts +++ b/packages/tanstack-query/src/procedure-utils.ts @@ -1,7 +1,7 @@ import type { Client, ClientContext } from '@orpc/client' import type { Interceptor, MaybeOptionalOptions, PromiseWithError } from '@orpc/shared' import type { DataTag, InfiniteData, MutationFunctionContext, QueryFunctionContext, QueryKey, SkipToken } from '@tanstack/query-core' -import type { OperationKeyPrefixOptions } from './key' +import type { OperationKeyBaseOptions } from './key' import type { InferLiveQueryOutput, InferStreamedQueryOutput, @@ -21,10 +21,10 @@ import type { } from './types' import { intercept, isAsyncIteratorObject, isTypescriptObject, resolveMaybeOptionalOptions, toArray } from '@orpc/shared' import { skipToken } from '@tanstack/query-core' -import { generateOperationKey } from './key' import { liveQuery } from './live-query' import { SharedUtils } from './shared-utils' import { serializableStreamedQuery } from './stream-query' +import { createStructuralSharing } from './structural-sharing' import { OPERATION_CONTEXT_SYMBOL } from './types' export interface ProcedureUtilsQueryInterceptorOptions { @@ -65,7 +65,7 @@ export type ProcedureUtilsMutationInterceptor = Partial | ((options: T) => T) -export interface ProcedureUtilsOptions extends OperationKeyPrefixOptions { +export interface ProcedureUtilsOptions extends OperationKeyBaseOptions { /** * Key options modifier for .queryKey and .queryOptions * Can be partial options or a function that receives per-call options and returns override this.options. @@ -208,6 +208,11 @@ export function isProcedureUtilsOptions(value: unknown): value is ProcedureUtils return false } } + else if (key === 'serializer') { + if (!isTypescriptObject(value[key]) || typeof value[key].serializeJson !== 'function') { + return false + } + } else if (PROCEDURE_UTILS_INTERCEPTOR_KEYS.includes(key)) { if (!Array.isArray(value[key]) || value[key].some(i => typeof i !== 'function')) { return false @@ -307,7 +312,7 @@ export class ProcedureUtils } @@ -332,6 +337,7 @@ export class ProcedureUtils { @@ -382,7 +388,7 @@ export class ProcedureUtils, TError> } @@ -470,7 +476,7 @@ export class ProcedureUtils, TError> } @@ -497,6 +503,7 @@ export class ProcedureUtils { @@ -556,8 +563,7 @@ export class ProcedureUtils { @@ -628,7 +635,7 @@ export class ProcedureUtils } diff --git a/packages/tanstack-query/src/router-utils.test-d.ts b/packages/tanstack-query/src/router-utils.test-d.ts index 8513043de..e5eabe673 100644 --- a/packages/tanstack-query/src/router-utils.test-d.ts +++ b/packages/tanstack-query/src/router-utils.test-d.ts @@ -3,6 +3,8 @@ import type { RouterClient } from '@orpc/server' import type { ProcedureUtils, ProcedureUtilsOptions } from './procedure-utils' import type { RouterUtils, RouterUtilsScoped } from './router-utils' import type { SharedUtils } from './shared-utils' +import { RPCSerializer } from '@orpc/client' +import { OpenAPISerializer } from '@orpc/openapi' import { os } from '@orpc/server' import z from 'zod' import { createRouterUtils } from './router-utils' @@ -89,9 +91,14 @@ it('RouterUtilsScoped', () => { >() }) +it('createRouterUtils accepts an OpenAPI serializer', () => { + createRouterUtils({} as RouterClient, { serializer: new OpenAPISerializer() }) +}) + it('createRouterUtils', () => { const utils = createRouterUtils({} as RouterClient, { prefix: '__prefix__', + serializer: new RPCSerializer(), scoped: { nested: { ping: { diff --git a/packages/tanstack-query/src/router-utils.test.ts b/packages/tanstack-query/src/router-utils.test.ts index 815249c60..529f65eae 100644 --- a/packages/tanstack-query/src/router-utils.test.ts +++ b/packages/tanstack-query/src/router-utils.test.ts @@ -1,4 +1,5 @@ import type { RouterUtilsPlugin } from './plugin' +import { RPCJsonSerializer } from '@orpc/client' import * as KeyModule from './key' import { ProcedureUtils } from './procedure-utils' import { createRouterUtils } from './router-utils' @@ -33,30 +34,32 @@ describe('createRouterUtils', () => { client.key.pong = vi.fn() it('create nested procedure & shared utils', () => { + const serializer = new RPCJsonSerializer() const utils = createRouterUtils(client, { prefix: '__prefix__', + serializer, }) as any expect(ProcedureUtils).toHaveBeenCalledTimes(1) - expect(ProcedureUtils).toHaveBeenCalledWith([], client, { prefix: '__prefix__' }) + expect(ProcedureUtils).toHaveBeenCalledWith([], client, { prefix: '__prefix__', serializer }) expect(utils.key({ type: 'infinite' })).toBe(generateOperationKeySpy.mock.results[0]?.value) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, [], { type: 'infinite', prefix: '__prefix__' }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(1, [], { type: 'infinite', prefix: '__prefix__', serializer }) expect(utils.queryOptions()).toBe(vi.mocked(ProcedureUtils).mock.results[0]?.value.queryOptions.mock.results[0]?.value) const keyUtils = utils.key expect(ProcedureUtils).toHaveBeenCalledTimes(2) - expect(ProcedureUtils).toHaveBeenNthCalledWith(2, ['key'], client.key, { prefix: '__prefix__' }) + expect(ProcedureUtils).toHaveBeenNthCalledWith(2, ['key'], client.key, { prefix: '__prefix__', serializer }) expect(keyUtils.key({ type: 'live' })).toBe(generateOperationKeySpy.mock.results[1]?.value) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['key'], { type: 'live', prefix: '__prefix__' }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(2, ['key'], { type: 'live', prefix: '__prefix__', serializer }) expect(keyUtils.queryOptions()).toBe(vi.mocked(ProcedureUtils).mock.results[1]?.value.queryOptions.mock.results[0]?.value) const pongUtils = keyUtils.pong expect(ProcedureUtils).toHaveBeenCalledTimes(3) - expect(ProcedureUtils).toHaveBeenNthCalledWith(3, ['key', 'pong'], client.key.pong, { prefix: '__prefix__' }) + expect(ProcedureUtils).toHaveBeenNthCalledWith(3, ['key', 'pong'], client.key.pong, { prefix: '__prefix__', serializer }) expect(pongUtils.key({ type: 'query' })).toBe(generateOperationKeySpy.mock.results[2]?.value) - expect(generateOperationKeySpy).toHaveBeenNthCalledWith(3, ['key', 'pong'], { type: 'query', prefix: '__prefix__' }) + expect(generateOperationKeySpy).toHaveBeenNthCalledWith(3, ['key', 'pong'], { type: 'query', prefix: '__prefix__', serializer }) expect(pongUtils.queryOptions()).toBe(vi.mocked(ProcedureUtils).mock.results[2]?.value.queryOptions.mock.results[0]?.value) }) diff --git a/packages/tanstack-query/src/router-utils.ts b/packages/tanstack-query/src/router-utils.ts index 9d789e77c..36b162cbb 100644 --- a/packages/tanstack-query/src/router-utils.ts +++ b/packages/tanstack-query/src/router-utils.ts @@ -1,6 +1,6 @@ import type { AnyNestedClient, Client, InferClientContext, InferClientError } from '@orpc/client' import type { Public } from '@orpc/shared' -import type { OperationKeyPrefixOptions } from './key' +import type { OperationKeyBaseOptions } from './key' import type { RouterUtilsPlugin } from './plugin' import type { ProcedureUtilsInfiniteInterceptor, ProcedureUtilsLiveInterceptor, ProcedureUtilsMutationInterceptor, ProcedureUtilsOptions, ProcedureUtilsQueryInterceptor, ProcedureUtilsStreamedInterceptor } from './procedure-utils' import { RECURSIVE_CLIENT_UNWRAP_KEYS } from '@orpc/client' @@ -29,7 +29,7 @@ export type RouterUtilsScoped [K in keyof T]?: (T[K] extends AnyNestedClient ? RouterUtilsScoped : never) | undefined } -export interface RouterUtilsOptions extends OperationKeyPrefixOptions { +export interface RouterUtilsOptions extends OperationKeyBaseOptions { /** * Base path for all query keys. * @@ -108,6 +108,7 @@ function createRouterUtilsInternal( plugin.initProcedureOptions(path, mergeProcedureUtilsOptions( pick(options, [ 'prefix', + 'serializer', 'queryInterceptors', 'streamedInterceptors', 'liveInterceptors', diff --git a/packages/tanstack-query/src/shared-utils.test.ts b/packages/tanstack-query/src/shared-utils.test.ts index f016dd354..218c5e5d6 100644 --- a/packages/tanstack-query/src/shared-utils.test.ts +++ b/packages/tanstack-query/src/shared-utils.test.ts @@ -1,3 +1,4 @@ +import { RPCJsonSerializer } from '@orpc/client' import * as KeyModule from './key' import { SharedUtils } from './shared-utils' @@ -19,14 +20,15 @@ describe('sharedUtils', () => { expect(generateOperationKeySpy).toHaveBeenCalledWith(['path'], { input: { search: '__search__' }, type: 'infinite', prefix: undefined }) }) - it('.key with prefix', () => { - const prefixedUtils = new SharedUtils(['path'], { prefix: '__prefix__' }) + it('.key with key options', () => { + const serializer = new RPCJsonSerializer() + const keyedUtils = new SharedUtils(['path'], { prefix: '__prefix__', serializer }) expect( - prefixedUtils.key({ type: 'query' }), + keyedUtils.key({ type: 'query' }), ).toBe(generateOperationKeySpy.mock.results[0]!.value) expect(generateOperationKeySpy).toHaveBeenCalledTimes(1) - expect(generateOperationKeySpy).toHaveBeenCalledWith(['path'], { type: 'query', prefix: '__prefix__' }) + expect(generateOperationKeySpy).toHaveBeenCalledWith(['path'], { type: 'query', prefix: '__prefix__', serializer }) }) }) diff --git a/packages/tanstack-query/src/shared-utils.ts b/packages/tanstack-query/src/shared-utils.ts index bce0582b1..dd35f6c03 100644 --- a/packages/tanstack-query/src/shared-utils.ts +++ b/packages/tanstack-query/src/shared-utils.ts @@ -1,17 +1,17 @@ -import type { OperationKey, OperationKeyOptions, OperationKeyPrefixOptions } from './key' +import type { OperationKey, OperationKeyBaseOptions, OperationKeyOptions } from './key' import type { OperationType } from './types' import { generateOperationKey } from './key' export class SharedUtils { constructor( protected readonly path: string[], - protected readonly options: OperationKeyPrefixOptions, + protected readonly options: OperationKeyBaseOptions, ) {} /** * Generate a **partial matching** key for actions like revalidating queries, checking mutation status, etc. */ - key(options: Omit, 'prefix'> = {}): OperationKey { - return generateOperationKey(this.path, { ...options, prefix: this.options.prefix }) + key(options: Omit, keyof OperationKeyBaseOptions> = {}): OperationKey { + return generateOperationKey(this.path, { ...options, prefix: this.options.prefix, serializer: this.options.serializer }) } } diff --git a/packages/tanstack-query/src/ssr.test-d.ts b/packages/tanstack-query/src/ssr.test-d.ts index c424f7efd..5941fc236 100644 --- a/packages/tanstack-query/src/ssr.test-d.ts +++ b/packages/tanstack-query/src/ssr.test-d.ts @@ -1,15 +1,11 @@ import type { Client } from '@orpc/client' import { RPCSerializer } from '@orpc/client' -import { createQueryKeyHashFn } from './key' import { createRouterUtils } from './router-utils' import { createHydrationOptions, StreamQuerySSRUtilsPlugin } from './ssr' it('accepts an RPCSerializer', () => { - createQueryKeyHashFn(new RPCSerializer()) createHydrationOptions(new RPCSerializer()) - // @ts-expect-error not a serializer - createQueryKeyHashFn({}) // @ts-expect-error not a serializer createHydrationOptions({}) }) diff --git a/packages/tanstack-query/src/structural-sharing.test.ts b/packages/tanstack-query/src/structural-sharing.test.ts new file mode 100644 index 000000000..8f84f8ab2 --- /dev/null +++ b/packages/tanstack-query/src/structural-sharing.test.ts @@ -0,0 +1,245 @@ +import { RPCJsonSerializer } from '@orpc/client' +import { OpenAPIJsonSerializer } from '@orpc/openapi' +import { NullProtoObj } from '@orpc/shared' +import { replaceEqualDeep } from '@tanstack/query-core' +import { createStructuralSharing } from './structural-sharing' + +const structuralSharing = createStructuralSharing() + +class Planet {} + +class Person { + constructor(public name: string, public born: Date) {} +} + +class Box { + constructor(public value: unknown) {} +} + +describe('structuralSharing', () => { + it('keeps the old reference for equal data', () => { + const create = () => ({ + date: new Date('2025-01-01'), + invalidDate: new Date(Number.NaN), + url: new URL('https://orpc.dev/docs'), + set: new Set([1, 'a', new Date('2025-01-01')]), + map: new Map([['a', { date: new Date('2025-01-01') }], [1, [new Date('2025-01-02')]]]), + nan: Number.NaN, + bigint: 1n, + undefined, + nested: [{ a: 1 }, null, 'b'], + }) + + const old = create() + expect(structuralSharing(old, create())).toBe(old) + // TanStack Query's default replaces everything + expect(replaceEqualDeep(old, create())).not.toBe(old) + }) + + it('keeps unchanged parts and takes changed ones', () => { + const old = { a: { date: new Date('2025-01-01') }, b: { date: new Date('2025-01-01') } } + const next = { a: { date: new Date('2025-01-01') }, b: { date: new Date('2025-01-02') } } + + const result = structuralSharing(old, next) + expect(result).toEqual(next) + expect(result.a).toBe(old.a) + expect(result.b.date).toBe(next.b.date) + }) + + it.each([ + ['date', new Date('2025-01-01'), new Date('2025-01-02')], + ['url', new URL('https://orpc.dev/a'), new URL('https://orpc.dev/b')], + ['negative zero', 0, -0], + ['set size', new Set([1]), new Set([1, 2])], + ['set member', new Set([new Date('2025-01-01')]), new Set([new Date('2025-01-02')])], + ['set order', new Set([1, 2]), new Set([2, 1])], + ['different types', new Date('2025-01-01'), '2025-01-01T00:00:00.000Z'], + ['different prototypes', new Map(), new Set()], + ['subclass and parent', new Date(0), new (class extends Date {})(0)], + ['class instances', new Planet(), new Planet()], + ['sparse arrays', [1, , 3], [1, , 3]], // eslint-disable-line no-sparse-arrays + ])('takes new data when %s differs', (_, old, next) => { + expect(structuralSharing({ value: old }, { value: next }).value).toBe(next) + }) + + it('shares maps all or nothing', () => { + const create = () => new Map([['a', { date: new Date('2025-01-01') }], [{ id: 1 }, 'object key']]) + const old = create() + expect(structuralSharing(old, create())).toBe(old) + + for (const next of [ + new Map([...create()].map(([key, value]) => [key, key === 'a' ? { date: new Date('2025-01-02') } : value])), + new Map([...create()].reverse()), + ]) { + expect(structuralSharing(old, next)).toBe(next) + } + }) + + it('shares array items', () => { + const old = [{ id: 1 }, { id: 2 }] + const next = [{ id: 1 }, { id: 2 }, { id: 3 }] + + const result = structuralSharing(old, next) + expect(result).toEqual(next) + expect(result[0]).toBe(old[0]) + expect(result[1]).toBe(old[1]) + expect(result[2]).toBe(next[2]) + + expect(structuralSharing(next, old)).toEqual(old) + }) + + it('handles key changes on objects', () => { + const old = { a: 1, b: 2 } as Record + expect(structuralSharing(old, { a: 1, c: 2 })).toEqual({ a: 1, c: 2 }) + expect(structuralSharing(old, { a: 1 })).toEqual({ a: 1 }) + expect(structuralSharing({ a: undefined }, { b: undefined })).toEqual({ b: undefined }) + }) + + it('shares null-prototype objects and keeps their prototype when changed', () => { + const shared = Object.assign(new NullProtoObj(), { date: new Date('2025-01-01') }) + expect(structuralSharing(shared, Object.assign(new NullProtoObj(), { date: new Date('2025-01-01') }))).toBe(shared) + + const old = Object.assign(Object.create(null), { a: 1, b: { c: 1 } }) + const result = structuralSharing(old, Object.assign(Object.create(null), { a: 2, b: { c: 1 } })) + + expect(Object.getPrototypeOf(result)).toBe(null) + expect(result.toString).toBeUndefined() + expect(result.b).toBe(old.b) + }) + + it('keeps __proto__ as an own key', () => { + const old = JSON.parse('{"__proto__":{"a":1},"b":1}') + const next = JSON.parse('{"__proto__":{"a":1},"b":2}') + + const result = structuralSharing(old, next) + expect(Object.getOwnPropertyDescriptor(result, '__proto__')?.value).toBe(Object.getOwnPropertyDescriptor(old, '__proto__')!.value) + expect(result.b).toBe(2) + }) + + it('gives up sharing past 500 levels', () => { + const deep = () => { + let value: any = { leaf: true } + for (let i = 0; i < 600; i++) { + value = { value } + } + return value + } + + const old = deep() + const next = deep() + const result = structuralSharing(old, next) + expect(result).not.toBe(old) + expect(result).toEqual(next) + }) + + describe('with a serializer', () => { + const sharing = createStructuralSharing(new RPCJsonSerializer({ + handlers: { + person: { + condition: value => value instanceof Person, + serialize: (value: Person) => [value.name, value.born], + deserialize: ([name, born]: any) => new Person(name, born), + }, + box: { + condition: value => value instanceof Box, + serialize: (value: Box) => value.value, + deserialize: value => new Box(value), + }, + }, + })) + + it('keeps equal instances of types with custom handlers', () => { + const old = { people: [new Person('Earth', new Date('2025-01-01'))] } + + expect(sharing(old, { people: [new Person('Earth', new Date('2025-01-01'))] })).toBe(old) + }) + + it('takes new instances when their serialized form differs', () => { + const next = new Person('Mars', new Date('2025-01-01')) + expect(sharing(new Person('Earth', new Date('2025-01-01')), next)).toBe(next) + }) + + it.each([ + ['type count', new Date(0), '1970-01-01T00:00:00.000Z'], + ['type path', { a: new Date(0), b: '1970-01-01T00:00:00.000Z' }, { a: '1970-01-01T00:00:00.000Z', b: new Date(0) }], + ])('takes new instances when the JSON matches but the %s differs', (_, old, next) => { + const nextBox = new Box(next) + expect(sharing(new Box(old), nextBox)).toBe(nextBox) + }) + + it('compares dates with own fields through a custom date handler', () => { + const zoned = createStructuralSharing(new RPCJsonSerializer({ + handlers: { + date: { + condition: value => value instanceof Date, + serialize: (value: Date & { zone?: string }) => [value.getTime(), value.zone ?? null], + deserialize: ([time, zone]: any) => Object.assign(new Date(time), zone === null ? {} : { zone }), + }, + }, + })) + + const old = Object.assign(new Date(0), { zone: 'UTC' }) + const next = Object.assign(new Date(0), { zone: 'PST' }) + const plain = new Date(0) + + expect(zoned(old, Object.assign(new Date(0), { zone: 'UTC' }))).toBe(old) + expect(zoned(old, next)).toBe(next) + expect(zoned(plain, old)).toBe(old) + }) + + it('ignores inherited enumerable keys of dates', () => { + // eslint-disable-next-line no-extend-native -- simulates a polluted prototype + Object.defineProperty(Date.prototype, 'polluted', { value: 1, enumerable: true, configurable: true }) + + try { + const old = new Date(0) + expect(sharing(old, new Date(0))).toBe(old) + } + finally { + delete (Date.prototype as any).polluted + } + }) + + it('takes new instances of types without a handler', () => { + const next = new Planet() + expect(sharing(new Planet(), next)).toBe(next) + }) + }) + + describe('with an OpenAPI serializer', () => { + const sharing = createStructuralSharing(new OpenAPIJsonSerializer({ + handlers: { + person: { + condition: value => value instanceof Person, + serialize: (value: Person) => ({ name: value.name, born: value.born }), + }, + }, + })) + + it.each([ + ['bigint and string members', new Set([1n]), new Set(['1'])], + ['Date and ISO string keys', new Map([[new Date(0), 1]]), new Map([['1970-01-01T00:00:00.000Z', 1]])], + ['Date and ISO string values', new Map([['a', new Date(0)]]), new Map([['a', '1970-01-01T00:00:00.000Z']])], + ])('takes new built-ins with %s, which share the same JSON', (_, old, next) => { + expect(sharing({ value: old }, { value: next }).value).toBe(next) + }) + + it('always takes new custom values, since equal JSON does not prove equal values', () => { + for (const [old, next] of [ + [new Person('Earth', new Date(0)), new Person('Earth', new Date(0))], + [new Person('Earth', new Date(Number.NaN)), new Person('Earth', null as any)], + ]) { + expect(sharing(old, next)).toBe(next) + } + }) + }) + + it('handles non-object data', () => { + expect(structuralSharing(undefined, 1)).toBe(1) + expect(structuralSharing(1, 1)).toBe(1) + expect(structuralSharing(Number.NaN, Number.NaN)).toBe(Number.NaN) + expect(structuralSharing(null, { a: 1 })).toEqual({ a: 1 }) + const fn = () => {} + expect(structuralSharing(() => {}, fn)).toBe(fn) + }) +}) diff --git a/packages/tanstack-query/src/structural-sharing.ts b/packages/tanstack-query/src/structural-sharing.ts new file mode 100644 index 000000000..85870c144 --- /dev/null +++ b/packages/tanstack-query/src/structural-sharing.ts @@ -0,0 +1,212 @@ +import type { RPCJsonSerializer } from '@orpc/client' +import type { Public } from '@orpc/shared' +import { setOwn, stringifyJSON } from '@orpc/shared' + +export type StructuralSharing = (oldData: unknown, newData: T) => T + +/** + * Creates a `structuralSharing` function that works like TanStack Query's default, but also keeps + * the previous reference for equal `Date`, `URL`, `Map` and `Set` values, which oRPC's serializer + * recreates on every response, and for custom types that serialize the same with a type-aware + * serializer like `RPCJsonSerializer`. + * + * Used by default in `.queryOptions`, `.liveOptions` and `.infiniteOptions`, pass `structuralSharing: false` to opt out. + * + * @see {@link https://orpc.dev/docs/integrations/tanstack-query#structural-sharing | TanStack Query Integration - Structural Sharing} + */ +export function createStructuralSharing(serializer?: Public): StructuralSharing { + return (oldData, newData) => replaceEqualDeep(oldData, newData, 0, serializer) +} + +function replaceEqualDeep(a: any, b: any, depth: number, serializer: Public | undefined): any { + if (Object.is(a, b)) { + return a + } + + if (depth > 500 || typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return b + } + + const proto = Object.getPrototypeOf(a) + + if (proto !== Object.getPrototypeOf(b)) { + return b + } + + if ( + (proto === Date.prototype || proto === URL.prototype || proto === Set.prototype || proto === Map.prototype) + && !hasOwnEnumerableKey(a) + && !hasOwnEnumerableKey(b) + ) { + return replaceEqualBuiltIn(a, b, proto, depth, serializer) + } + + if (Array.isArray(a)) { + return isPlainArray(a) && isPlainArray(b) ? replaceEqualArray(a, b, depth, serializer) : b + } + + // same rule as isPlainObject, but reusing the prototype read above is about 15% faster + if (proto === Object.prototype || proto === null || !proto.constructor) { + return replaceEqualObject(a, b, proto, depth, serializer) + } + + return replaceEqualSerialized(a, b, depth, serializer) +} + +function replaceEqualArray(a: any[], b: any[], depth: number, serializer: Public | undefined): any[] { + let copy: any[] | undefined + + for (let i = 0; i < b.length; i++) { + let value = b[i] + + if (i < a.length) { + const aValue = a[i] + + if (typeof aValue === 'object' && typeof value === 'object') { + value = replaceEqualDeep(aValue, value, depth + 1, serializer) + } + + if (copy === undefined && Object.is(value, aValue)) { + continue + } + } + + copy ??= copyArrayPrefix(a, i) + copy.push(value) + } + + return copy ?? (a.length === b.length ? a : copyArrayPrefix(a, b.length)) +} + +function copyArrayPrefix(a: any[], end: number): any[] { + const copy = [] + + for (let i = 0; i < end; i++) { + copy.push(a[i]) + } + + return copy +} + +function replaceEqualObject(a: any, b: any, proto: object | null, depth: number, serializer: Public | undefined): any { + const bKeys = Object.keys(b) + let copy: any + + for (let i = 0; i < bKeys.length; i++) { + const key = bKeys[i]! + let value = b[key] + + if (Object.hasOwn(a, key)) { + const aValue = a[key] + + if (typeof aValue === 'object' && typeof value === 'object') { + value = replaceEqualDeep(aValue, value, depth + 1, serializer) + } + + if (copy === undefined && Object.is(value, aValue)) { + continue + } + } + + copy ??= copyObjectPrefix(a, bKeys, i, proto) + assign(copy, key, value) + } + + return copy ?? (Object.keys(a).length === bKeys.length ? a : copyObjectPrefix(a, bKeys, bKeys.length, proto)) +} + +function copyObjectPrefix(a: any, keys: string[], end: number, proto: object | null): any { + const copy = proto === Object.prototype ? {} : Object.create(proto) + + for (let i = 0; i < end; i++) { + assign(copy, keys[i]!, a[keys[i]!]) + } + + return copy +} + +function assign(object: any, key: string, value: unknown): void { + // same as always calling setOwn, but a plain assignment keeps the copy loops about 30% faster + if (key === '__proto__') { + setOwn(object, key, value) + } + else { + object[key] = value + } +} + +function replaceEqualBuiltIn(a: any, b: any, proto: object, depth: number, serializer: Public | undefined): object { + if (proto === Date.prototype) { + return Object.is(a.getTime(), b.getTime()) ? a : b + } + + if (proto === URL.prototype) { + return a.href === b.href ? a : b + } + + if (a.size !== b.size) { + return b + } + + if (proto === Set.prototype) { + const aValues = a.values() + + for (const bValue of b.values()) { + const aValue = aValues.next().value + + if (!Object.is(replaceEqualDeep(aValue, bValue, depth + 1, serializer), aValue)) { + return b + } + } + + return a + } + + const aKeys = a.keys() + const aValues = a.values() + const bValues = b.values() + + for (const bKey of b.keys()) { + const aKey = aKeys.next().value + const aValue = aValues.next().value + + if ( + !Object.is(replaceEqualDeep(aKey, bKey, depth + 1, serializer), aKey) + || !Object.is(replaceEqualDeep(aValue, bValues.next().value, depth + 1, serializer), aValue) + ) { + return b + } + } + + return a +} + +function replaceEqualSerialized(a: object, b: object, depth: number, serializer: Public | undefined): object { + const aSerialized = serializer?.serializeJson(a) + + if (!aSerialized?.meta?.length) { + return b + } + + const bSerialized = serializer!.serializeJson(b) + + return stringifyJSON(aSerialized.meta) === stringifyJSON(bSerialized.meta) + && Object.is(replaceEqualDeep(aSerialized.json, bSerialized.json, depth + 1, serializer), aSerialized.json) + ? a + : b +} + +function hasOwnEnumerableKey(value: object): boolean { + // for...in avoids the array allocation of Object.keys, which matters on this hot path + for (const key in value) { + if (Object.hasOwn(value, key)) { + return true + } + } + + return false +} + +function isPlainArray(value: unknown[]): boolean { + return value.length === Object.keys(value).length +} diff --git a/playgrounds/next/src/lib/query-client.ts b/playgrounds/next/src/lib/query-client.ts index b1ccc12af..90b5b9ff3 100644 --- a/playgrounds/next/src/lib/query-client.ts +++ b/playgrounds/next/src/lib/query-client.ts @@ -1,6 +1,6 @@ import { environmentManager, QueryClient } from '@tanstack/react-query' import { RPCJsonSerializer } from '@orpc/client' -import { createHydrationOptions, createQueryKeyHashFn } from '@orpc/tanstack-query' +import { createHydrationOptions } from '@orpc/tanstack-query' let browserQueryClient: QueryClient | undefined @@ -30,7 +30,6 @@ function createQueryClient(): QueryClient { defaultOptions: { queries: { staleTime: 60 * 1000, // > 0 to prevent immediate refetching on mount - queryKeyHashFn: createQueryKeyHashFn(serializer), }, ...createHydrationOptions(serializer), },