From 2b226f71cc8e02355c2f586c696f8ed76575c7d0 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 22 Oct 2026 20:21:46 +0700 Subject: [PATCH 1/7] feat(tanstack-query)!: type-aware structural sharing and serialized input keys Query results now keep their previous references when a refetch or live event returns equal data, including Date, Map, Set and custom serializer types, so components no longer re-render on every update. `.queryOptions`, `.liveOptions` and `.infiniteOptions` default to `createStructuralSharing`. Query keys now store the serialized input plus `inputMeta` (ported from pinia-colada #2300), driven by a new `serializer` option, so inputs with these types hash and partially match by value with TanStack's default hash. BREAKING CHANGE: `createQueryKeyHashFn` is removed. Keys for inputs that contain Date, BigInt, URL, Set, Map or custom types change shape. --- .../blog/nextjs-ssr-orpc-tanstack-query.mdx | 5 +- .../docs/integrations/tanstack-query.mdx | 45 ++++- apps/content/docs/rpc/serializer.mdx | 2 +- packages/tanstack-query/src/index.ts | 1 + packages/tanstack-query/src/key.test.ts | 148 ++++++++++---- packages/tanstack-query/src/key.ts | 63 ++++-- packages/tanstack-query/src/meta.test-d.ts | 6 + packages/tanstack-query/src/meta.ts | 3 +- .../src/procedure-utils.test.ts | 130 +++++++++++-- .../tanstack-query/src/procedure-utils.ts | 25 ++- .../tanstack-query/src/router-utils.test-d.ts | 2 + .../tanstack-query/src/router-utils.test.ts | 15 +- packages/tanstack-query/src/router-utils.ts | 5 +- .../tanstack-query/src/shared-utils.test.ts | 10 +- packages/tanstack-query/src/shared-utils.ts | 8 +- packages/tanstack-query/src/ssr.test-d.ts | 4 - .../src/structural-sharing.test.ts | 184 ++++++++++++++++++ .../tanstack-query/src/structural-sharing.ts | 113 +++++++++++ playgrounds/next/src/lib/query-client.ts | 3 +- 19 files changed, 662 insertions(+), 110 deletions(-) create mode 100644 packages/tanstack-query/src/structural-sharing.test.ts create mode 100644 packages/tanstack-query/src/structural-sharing.ts diff --git a/apps/content/blog/nextjs-ssr-orpc-tanstack-query.mdx b/apps/content/blog/nextjs-ssr-orpc-tanstack-query.mdx index 578ca5ceaa..9c326fdca2 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/tanstack-query.mdx b/apps/content/docs/integrations/tanstack-query.mdx index e2b962c667..ef303aca69 100644 --- a/apps/content/docs/integrations/tanstack-query.mdx +++ b/apps/content/docs/integrations/tanstack-query.mdx @@ -179,6 +179,26 @@ 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 keys are plain JSON and match by value. Native types like `Date`, `BigInt`, `Set`, and `Map` 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: + +```ts +import { RPCSerializer } from '@orpc/client' + +const serializer = new RPCSerializer({ + handlers: { + // ...custom handlers + }, +}) + +const link = new RPCLink({ url, serializer }) + +const orpc = createTanstackQueryUtils(client, { + serializer, +}) +``` + ## Calling Clients The `.call` method provides direct access to the underlying procedure client when needed. @@ -258,7 +278,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 +524,40 @@ const query = useInfiniteQuery( ) ``` +## Structural Sharing + +`.queryOptions`, `.liveOptions`, and `.infiniteOptions` use oRPC's structural sharing by default. 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 a [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/rpc/serializer.mdx b/apps/content/docs/rpc/serializer.mdx index 4aa3e94ad9..a18513f75d 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/tanstack-query/src/index.ts b/packages/tanstack-query/src/index.ts index 5e3d6148e1..e077d235a5 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 f44cff40da..36eff28c27 100644 --- a/packages/tanstack-query/src/key.test.ts +++ b/packages/tanstack-query/src/key.test.ts @@ -1,7 +1,6 @@ -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 { hashKey, partialMatchKey, QueryClient, skipToken } from '@tanstack/query-core' +import { generateOperationKey } from './key' it('generateOperationKey', () => { expect(generateOperationKey(['path'])).toEqual([['path'], {}]) @@ -21,15 +20,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 +35,122 @@ 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']) }))) - const hash = createQueryKeyHashFn(new RPCJsonSerializer({ + expect(hashKey(generateOperationKey(['path'], { input: new Map([['a', 1]]) }))) + .not + .toEqual(hashKey(generateOperationKey(['path'], { input: new Map([['a', 2]]) }))) + }) + + 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('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 fc9b20f584..3e645016e3 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 cb1d2fab7b..2ab7a8dae8 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 23b423679a..ebe55c2877 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 730b0904c9..54c905e8d9 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 4bb532f13f..8b41c86bc0 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 8513043de8..4f08d99556 100644 --- a/packages/tanstack-query/src/router-utils.test-d.ts +++ b/packages/tanstack-query/src/router-utils.test-d.ts @@ -3,6 +3,7 @@ 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 { os } from '@orpc/server' import z from 'zod' import { createRouterUtils } from './router-utils' @@ -92,6 +93,7 @@ it('RouterUtilsScoped', () => { 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 815249c602..529f65eaed 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 9d789e77c7..36b162cbb5 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 f016dd3548..218c5e5d68 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 bce0582b1f..dd35f6c037 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 c424f7efda..5941fc2360 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 0000000000..9b631d0fe6 --- /dev/null +++ b/packages/tanstack-query/src/structural-sharing.test.ts @@ -0,0 +1,184 @@ +import { RPCJsonSerializer } from '@orpc/client' +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()], + ['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', () => { + const old = Object.assign(new NullProtoObj(), { date: new Date('2025-01-01') }) + const next = Object.assign(new NullProtoObj(), { date: new Date('2025-01-01') }) + + expect(structuralSharing(old, next)).toBe(old) + }) + + 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, + // not terminal, so the Date inside is serialized by the built-in handler + 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) + expect(structuralSharing(old, { people: [new Person('Earth', new Date('2025-01-01'))] })).not.toBe(old) + }) + + it('takes new instances when their serialized form differs', () => { + const old = new Person('Earth', new Date('2025-01-01')) + + for (const next of [new Person('Mars', new Date('2025-01-01')), new Person('Earth', new Date('2025-01-02'))]) { + expect(sharing(old, next)).toBe(next) + } + }) + + it.each([ + ['type', new Date(0), 0n], + ['type count', new Date(0), '1970-01-01T00:00:00.000Z'], + ['type path', { a: new Date(0) }, { b: new Date(0) }], + ['type depth', { a: new Date(0) }, { a: { b: new Date(0) } }], + ])('takes new instances when a nested %s differs', (_, old, next) => { + const nextBox = new Box(next) + expect(sharing(new Box(old), nextBox)).toBe(nextBox) + }) + + it('takes new instances of types without a handler', () => { + const next = new Planet() + expect(sharing(new Planet(), 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 0000000000..2f72bb6ff0 --- /dev/null +++ b/packages/tanstack-query/src/structural-sharing.ts @@ -0,0 +1,113 @@ +import type { RPCJsonSerializationMeta } from '@orpc/client' +import type { Public } from '@orpc/shared' +import { RPCJsonSerializer } from '@orpc/client' +import { isPlainObject, setOwn } from '@orpc/shared' + +export type StructuralSharing = (oldData: unknown, newData: T) => T + +const defaultSerializer = new RPCJsonSerializer() + +/** + * Creates a `structuralSharing` function that works like TanStack Query's default, but also keeps + * the previous reference for values that serialize the same, such as `Date`, `URL`, `Map`, `Set` + * and custom types, which oRPC's serializer recreates on every response. + * + * 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 = defaultSerializer): StructuralSharing { + return (oldData, newData) => replaceEqualDeep(oldData, newData, 0, serializer) +} + +function replaceEqualDeep(a: any, b: any, depth: number, serializer: Public): 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) + + // the serializer compares content, not class, so a subclass would otherwise pass for its parent + if (proto !== Object.getPrototypeOf(b)) { + return b + } + + // the most common non-JSON value, compared directly since serializing it is much slower + if (proto === Date.prototype) { + return Object.is(a.getTime(), b.getTime()) ? a : b + } + + const isArray = Array.isArray(a) + + if (isArray ? !isPlainArray(a) || !isPlainArray(b) : !isPlainObject(a)) { + return isArray ? b : replaceEqualSerialized(a, b, depth, serializer) + } + + const aKeys = isArray ? a : Object.keys(a) + const bKeys = isArray ? b : Object.keys(b) + const copy: any = isArray ? [] : {} + let equalItems = 0 + + for (let i = 0; i < bKeys.length; i++) { + const key = isArray ? i : bKeys[i] + let value = b[key] + + if (isArray ? i < aKeys.length : Object.hasOwn(a, key)) { + const aValue = a[key] + + // only recurse into objects, a primitive is shared exactly when it is the same value + if (typeof aValue === 'object' && typeof value === 'object') { + value = replaceEqualDeep(aValue, value, depth + 1, serializer) + } + + if (Object.is(value, aValue)) { + equalItems++ + } + } + + if (key === '__proto__') { + setOwn(copy, key, value) + } + else { + // same as always calling setOwn, but a plain assignment keeps this hot loop about 30% faster + copy[key] = value + } + } + + return aKeys.length === bKeys.length && equalItems === aKeys.length ? a : copy +} + +/** + * Shares a non-plain object, like a `Map` or a custom type, when it serializes the same as the previous one. + */ +function replaceEqualSerialized(a: object, b: object, depth: number, serializer: Public): object { + const aSerialized = serializer.serializeJson(a) + + // no handler knows this type + if (aSerialized.json === a) { + return b + } + + const bSerialized = serializer.serializeJson(b) + + // compare only the payload and the flat meta, walking the whole serialization would copy its wrapper + return isSameMeta(aSerialized.meta, bSerialized.meta) + && Object.is(replaceEqualDeep(aSerialized.json, bSerialized.json, depth + 1, serializer), aSerialized.json) + ? a + : b +} + +function isSameMeta(a: RPCJsonSerializationMeta[] = [], b: RPCJsonSerializationMeta[] = []): boolean { + return a.length === b.length && a.every((entry, i) => { + const other = b[i]! + return entry.length === other.length && entry.every((segment, j) => segment === other[j]) + }) +} + +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 b1ccc12af4..90b5b9ff35 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), }, From 79c67cdcbd5fd170fada3ca3665ef3527e9141f5 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 22 Oct 2026 20:28:44 +0700 Subject: [PATCH 2/7] fix(tanstack-query): keep the prototype of changed null-prototype objects in structural sharing A changed null-prototype dictionary was copied into `{}`, so the result inherited Object.prototype and keys like `toString` resolved to functions. --- packages/tanstack-query/src/structural-sharing.test.ts | 9 +++++++++ packages/tanstack-query/src/structural-sharing.ts | 5 +++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/tanstack-query/src/structural-sharing.test.ts b/packages/tanstack-query/src/structural-sharing.test.ts index 9b631d0fe6..f2f06227d2 100644 --- a/packages/tanstack-query/src/structural-sharing.test.ts +++ b/packages/tanstack-query/src/structural-sharing.test.ts @@ -100,6 +100,15 @@ describe('structuralSharing', () => { expect(structuralSharing(old, next)).toBe(old) }) + it('keeps the prototype of changed null-prototype objects', () => { + 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}') diff --git a/packages/tanstack-query/src/structural-sharing.ts b/packages/tanstack-query/src/structural-sharing.ts index 2f72bb6ff0..ea17aa7654 100644 --- a/packages/tanstack-query/src/structural-sharing.ts +++ b/packages/tanstack-query/src/structural-sharing.ts @@ -49,7 +49,8 @@ function replaceEqualDeep(a: any, b: any, depth: number, serializer: Public Date: Thu, 22 Oct 2026 20:35:42 +0700 Subject: [PATCH 3/7] fix(tanstack-query): compare dates with own fields through the serializer The Date fast path compared timestamps only, so a custom date handler that also serializes own fields (like a zone) kept a stale Date when only that field changed. Dates without own enumerable keys keep the fast path. --- .../src/structural-sharing.test.ts | 31 +++++++++++++++++++ .../tanstack-query/src/structural-sharing.ts | 16 ++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/tanstack-query/src/structural-sharing.test.ts b/packages/tanstack-query/src/structural-sharing.test.ts index f2f06227d2..fb83746823 100644 --- a/packages/tanstack-query/src/structural-sharing.test.ts +++ b/packages/tanstack-query/src/structural-sharing.test.ts @@ -176,6 +176,37 @@ describe('structuralSharing', () => { 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' }) + + expect(zoned(old, Object.assign(new Date(0), { zone: 'UTC' }))).toBe(old) + expect(zoned(old, next)).toBe(next) + }) + + 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) diff --git a/packages/tanstack-query/src/structural-sharing.ts b/packages/tanstack-query/src/structural-sharing.ts index ea17aa7654..7722a715f9 100644 --- a/packages/tanstack-query/src/structural-sharing.ts +++ b/packages/tanstack-query/src/structural-sharing.ts @@ -36,8 +36,9 @@ function replaceEqualDeep(a: any, b: any, depth: number, serializer: Public Date: Thu, 22 Oct 2026 20:39:40 +0700 Subject: [PATCH 4/7] docs(tanstack-query): note that oRPC structural sharing overrides QueryClient defaults --- apps/content/docs/integrations/tanstack-query.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/content/docs/integrations/tanstack-query.mdx b/apps/content/docs/integrations/tanstack-query.mdx index ef303aca69..bcd70a4109 100644 --- a/apps/content/docs/integrations/tanstack-query.mdx +++ b/apps/content/docs/integrations/tanstack-query.mdx @@ -526,7 +526,7 @@ const query = useInfiniteQuery( ## Structural Sharing -`.queryOptions`, `.liveOptions`, and `.infiniteOptions` use oRPC's structural sharing by default. 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. +`.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' From 592ba7fcf89e67c2874a32c7c342b8c0c3c08a1b Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 23 Oct 2026 10:37:31 +0700 Subject: [PATCH 5/7] docs: document OpenAPI JSON Serializer and accept it as a key serializer Add an OpenAPI JSON Serializer section like the RPC one, and shorten the TanStack Query and Pinia Colada Key Serializer sections: pass a custom RPC JSON Serializer, or an OpenAPI JSON Serializer when keys don't need types. Tests cover OpenAPI serializers as key serializers in both packages. --- .../docs/integrations/pinia-colada.mdx | 16 ++--------- .../docs/integrations/tanstack-query.mdx | 18 +++---------- apps/content/docs/openapi/serializer.mdx | 27 +++++++++++++++++++ .../openapi/src/openapi-json-serializer.ts | 6 +++++ packages/pinia-colada/src/key.test.ts | 7 +++++ .../pinia-colada/src/router-utils.test-d.ts | 5 ++++ packages/tanstack-query/src/key.test.ts | 7 +++++ .../tanstack-query/src/router-utils.test-d.ts | 5 ++++ 8 files changed, 62 insertions(+), 29 deletions(-) diff --git a/apps/content/docs/integrations/pinia-colada.mdx b/apps/content/docs/integrations/pinia-colada.mdx index 58549cc1dc..cc6779b40f 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 bcd70a4109..df18d56056 100644 --- a/apps/content/docs/integrations/tanstack-query.mdx +++ b/apps/content/docs/integrations/tanstack-query.mdx @@ -181,22 +181,10 @@ 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 keys are plain JSON and match by value. Native types like `Date`, `BigInt`, `Set`, and `Map` 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: +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 -import { RPCSerializer } from '@orpc/client' - -const serializer = new RPCSerializer({ - handlers: { - // ...custom handlers - }, -}) - -const link = new RPCLink({ url, serializer }) - -const orpc = createTanstackQueryUtils(client, { - serializer, -}) +const orpc = createTanstackQueryUtils(client, { serializer }) ``` ## Calling Clients @@ -540,7 +528,7 @@ const queryClient = new QueryClient({ }) ``` -With a [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)`. +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) diff --git a/apps/content/docs/openapi/serializer.mdx b/apps/content/docs/openapi/serializer.mdx index 6342ac992d..c41b735bdf 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/packages/openapi/src/openapi-json-serializer.ts b/packages/openapi/src/openapi-json-serializer.ts index b544b0f7fd..1a6cdc7437 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 8f6190a797..82e5205783 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 d4de32ac37..8fa6bbd711 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/key.test.ts b/packages/tanstack-query/src/key.test.ts index 36eff28c27..c10f3c9fa9 100644 --- a/packages/tanstack-query/src/key.test.ts +++ b/packages/tanstack-query/src/key.test.ts @@ -1,4 +1,5 @@ import { RPCJsonSerializer } from '@orpc/client' +import { OpenAPIJsonSerializer } from '@orpc/openapi' import { hashKey, partialMatchKey, QueryClient, skipToken } from '@tanstack/query-core' import { generateOperationKey } from './key' @@ -138,6 +139,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 or skipToken', () => { const serializeJson = vi.fn() const serializer = { serializeJson, deserializeJson: vi.fn() } diff --git a/packages/tanstack-query/src/router-utils.test-d.ts b/packages/tanstack-query/src/router-utils.test-d.ts index 4f08d99556..e5eabe6735 100644 --- a/packages/tanstack-query/src/router-utils.test-d.ts +++ b/packages/tanstack-query/src/router-utils.test-d.ts @@ -4,6 +4,7 @@ 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' @@ -90,6 +91,10 @@ it('RouterUtilsScoped', () => { >() }) +it('createRouterUtils accepts an OpenAPI serializer', () => { + createRouterUtils({} as RouterClient, { serializer: new OpenAPISerializer() }) +}) + it('createRouterUtils', () => { const utils = createRouterUtils({} as RouterClient, { prefix: '__prefix__', From e1216e8041261f482c609a297b763c99e2866cc4 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 23 Oct 2026 10:37:31 +0700 Subject: [PATCH 6/7] perf(tanstack-query): faster structural sharing, exact for any serializer Date, URL, Set and Map are compared directly, so results are exact even with a JSON-only serializer like OpenAPIJsonSerializer, which serializes Set([1n]) and Set(['1']) the same. Custom types are shared only when the serializer reports type info, and the serializer is no longer defaulted. Copies are built lazily, arrays and objects get their own loops, and maps are walked without entry arrays: 37-47% faster on 10k-row payloads, 76% on maps. --- .../src/structural-sharing.test.ts | 57 ++++-- .../tanstack-query/src/structural-sharing.ts | 186 +++++++++++++----- 2 files changed, 176 insertions(+), 67 deletions(-) diff --git a/packages/tanstack-query/src/structural-sharing.test.ts b/packages/tanstack-query/src/structural-sharing.test.ts index fb83746823..8f84f8ab29 100644 --- a/packages/tanstack-query/src/structural-sharing.test.ts +++ b/packages/tanstack-query/src/structural-sharing.test.ts @@ -1,4 +1,5 @@ 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' @@ -54,6 +55,7 @@ describe('structuralSharing', () => { ['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) => { @@ -93,14 +95,10 @@ describe('structuralSharing', () => { expect(structuralSharing({ a: undefined }, { b: undefined })).toEqual({ b: undefined }) }) - it('shares null-prototype objects', () => { - const old = Object.assign(new NullProtoObj(), { date: new Date('2025-01-01') }) - const next = Object.assign(new NullProtoObj(), { date: new Date('2025-01-01') }) + 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) - expect(structuralSharing(old, next)).toBe(old) - }) - - it('keeps the prototype of changed null-prototype objects', () => { 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 } })) @@ -139,7 +137,6 @@ describe('structuralSharing', () => { handlers: { person: { condition: value => value instanceof Person, - // not terminal, so the Date inside is serialized by the built-in handler serialize: (value: Person) => [value.name, value.born], deserialize: ([name, born]: any) => new Person(name, born), }, @@ -155,23 +152,17 @@ describe('structuralSharing', () => { 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) - expect(structuralSharing(old, { people: [new Person('Earth', new Date('2025-01-01'))] })).not.toBe(old) }) it('takes new instances when their serialized form differs', () => { - const old = new Person('Earth', new Date('2025-01-01')) - - for (const next of [new Person('Mars', new Date('2025-01-01')), new Person('Earth', new Date('2025-01-02'))]) { - expect(sharing(old, next)).toBe(next) - } + 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', new Date(0), 0n], ['type count', new Date(0), '1970-01-01T00:00:00.000Z'], - ['type path', { a: new Date(0) }, { b: new Date(0) }], - ['type depth', { a: new Date(0) }, { a: { b: new Date(0) } }], - ])('takes new instances when a nested %s differs', (_, old, next) => { + ['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) }) @@ -189,9 +180,11 @@ describe('structuralSharing', () => { 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', () => { @@ -213,6 +206,34 @@ describe('structuralSharing', () => { }) }) + 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) diff --git a/packages/tanstack-query/src/structural-sharing.ts b/packages/tanstack-query/src/structural-sharing.ts index 7722a715f9..09d14efd12 100644 --- a/packages/tanstack-query/src/structural-sharing.ts +++ b/packages/tanstack-query/src/structural-sharing.ts @@ -1,26 +1,26 @@ -import type { RPCJsonSerializationMeta } from '@orpc/client' +import type { RPCJsonSerializer } from '@orpc/client' import type { Public } from '@orpc/shared' -import { RPCJsonSerializer } from '@orpc/client' -import { isPlainObject, setOwn } from '@orpc/shared' +import { setOwn, stringifyJSON } from '@orpc/shared' export type StructuralSharing = (oldData: unknown, newData: T) => T -const defaultSerializer = new RPCJsonSerializer() +type Serializer = Public | undefined /** * Creates a `structuralSharing` function that works like TanStack Query's default, but also keeps - * the previous reference for values that serialize the same, such as `Date`, `URL`, `Map`, `Set` - * and custom types, which oRPC's serializer recreates on every response. + * 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 = defaultSerializer): StructuralSharing { +export function createStructuralSharing(serializer?: Public): StructuralSharing { return (oldData, newData) => replaceEqualDeep(oldData, newData, 0, serializer) } -function replaceEqualDeep(a: any, b: any, depth: number, serializer: Public): any { +function replaceEqualDeep(a: any, b: any, depth: number, serializer: Serializer): any { if (Object.is(a, b)) { return a } @@ -31,85 +31,173 @@ function replaceEqualDeep(a: any, b: any, depth: number, serializer: Public): object { - const aSerialized = serializer.serializeJson(a) +function replaceEqualSerialized(a: object, b: object, depth: number, serializer: Serializer): object { + const aSerialized = serializer?.serializeJson(a) - // no handler knows this type - if (aSerialized.json === a) { + if (!aSerialized?.meta?.length) { return b } - const bSerialized = serializer.serializeJson(b) + const bSerialized = serializer!.serializeJson(b) - // compare only the payload and the flat meta, walking the whole serialization would copy its wrapper - return isSameMeta(aSerialized.meta, bSerialized.meta) + return stringifyJSON(aSerialized.meta) === stringifyJSON(bSerialized.meta) && Object.is(replaceEqualDeep(aSerialized.json, bSerialized.json, depth + 1, serializer), aSerialized.json) ? a : b } -function isSameMeta(a: RPCJsonSerializationMeta[] = [], b: RPCJsonSerializationMeta[] = []): boolean { - return a.length === b.length && a.every((entry, i) => { - const other = b[i]! - return entry.length === other.length && entry.every((segment, j) => segment === other[j]) - }) -} - 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) { From 0fa0916a3c2b4e89c509f70fe2bed3ef0596d648 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 23 Oct 2026 10:49:21 +0700 Subject: [PATCH 7/7] refactor(tanstack-query): inline the serializer type in structural sharing --- packages/tanstack-query/src/structural-sharing.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/tanstack-query/src/structural-sharing.ts b/packages/tanstack-query/src/structural-sharing.ts index 09d14efd12..85870c144e 100644 --- a/packages/tanstack-query/src/structural-sharing.ts +++ b/packages/tanstack-query/src/structural-sharing.ts @@ -4,8 +4,6 @@ import { setOwn, stringifyJSON } from '@orpc/shared' export type StructuralSharing = (oldData: unknown, newData: T) => T -type Serializer = Public | undefined - /** * 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 @@ -20,7 +18,7 @@ export function createStructuralSharing(serializer?: Public): return (oldData, newData) => replaceEqualDeep(oldData, newData, 0, serializer) } -function replaceEqualDeep(a: any, b: any, depth: number, serializer: Serializer): any { +function replaceEqualDeep(a: any, b: any, depth: number, serializer: Public | undefined): any { if (Object.is(a, b)) { return a } @@ -55,7 +53,7 @@ function replaceEqualDeep(a: any, b: any, depth: number, serializer: Serializer) return replaceEqualSerialized(a, b, depth, serializer) } -function replaceEqualArray(a: any[], b: any[], depth: number, serializer: Serializer): any[] { +function replaceEqualArray(a: any[], b: any[], depth: number, serializer: Public | undefined): any[] { let copy: any[] | undefined for (let i = 0; i < b.length; i++) { @@ -90,7 +88,7 @@ function copyArrayPrefix(a: any[], end: number): any[] { return copy } -function replaceEqualObject(a: any, b: any, proto: object | null, depth: number, serializer: Serializer): any { +function replaceEqualObject(a: any, b: any, proto: object | null, depth: number, serializer: Public | undefined): any { const bKeys = Object.keys(b) let copy: any @@ -137,7 +135,7 @@ function assign(object: any, key: string, value: unknown): void { } } -function replaceEqualBuiltIn(a: any, b: any, proto: object, depth: number, serializer: Serializer): object { +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 } @@ -183,7 +181,7 @@ function replaceEqualBuiltIn(a: any, b: any, proto: object, depth: number, seria return a } -function replaceEqualSerialized(a: object, b: object, depth: number, serializer: Serializer): object { +function replaceEqualSerialized(a: object, b: object, depth: number, serializer: Public | undefined): object { const aSerialized = serializer?.serializeJson(a) if (!aSerialized?.meta?.length) {