Repository navigation
feat(tanstack-query)!: type-aware structural sharing and serialized input keys - #2305
Conversation
…nput 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.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
More templates
@orpc/ai-sdk
@orpc/arktype
@orpc/bun
@orpc/client
@orpc/cloudflare
@orpc/contract
@orpc/experimental-effect
@orpc/evlog
@orpc/hibernation
@orpc/json-schema
@orpc/experimental-lock
@orpc/mcp
@orpc/msw
@orpc/nest
@orpc/next
@orpc/node
@orpc/openapi
@orpc/opentelemetry
@orpc/pinia-colada
@orpc/pino
@orpc/publisher
@orpc/ratelimit
@orpc/server
@orpc/shared
@orpc/swr
@orpc/tanstack-query
@orpc/trpc
@orpc/valibot
@orpc/zod
commit: |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
orpc | 0fa0916 | Commit Preview URL Branch Preview URL |
Oct 23 2026, 03:52 AM |
ApprovabilityVerdict: Needs Human Approval Macroscope's review found this PR requires human approval — The PR changes default TanStack Query behavior and cache-key semantics across existing query, live, and infinite paths, while also removing You can add or adjust custom eligibility rules. Learn more. |
…ects in structural sharing
A changed null-prototype dictionary was copied into `{}`, so the result
inherited Object.prototype and keys like `toString` resolved to functions.
Merging this PR will not alter performance1 warning
Comparing Footnotes
|
There was a problem hiding this comment.
ℹ️ No blocking issues — one documentation note inline.
Reviewed changes
- Serialized input keys (
key.ts):generateOperationKeynow storesinputin serialized form plusinputMeta(per-path serializer types) so keys hash and partially match by value;createQueryKeyHashFnis removed and a newserializeroption is accepted. - Structural sharing (
structural-sharing.ts, new):createStructuralSharing(serializer)forks TanStack'sreplaceEqualDeepwith aDatefast path and a serializer-backed comparison forMap/Set/custom types. - Options plumbing (
procedure-utils.ts,router-utils.ts,shared-utils.ts,meta.ts): the newserializerflows through procedure/router/scoped options and the contract meta excludes it;.queryOptions/.liveOptions/.infiniteOptionsdefault to the newstructuralSharing,.streamedOptions/.mutationOptionsdo not. - Docs/playground (
tanstack-query.mdx,serializer.mdx, Next.js SSR blog + playground):createQueryKeyHashFnremoved, new "Key Serializer" and "Structural Sharing" sections. - Tests: new
structural-sharing.test.tspluskey/procedure-utils/router-utils/shared-utilscoverage of serializer + sharing.
I traced the replaceEqualDeep fork against TanStack's implementation and the repo's serializeJson: the aSerialized.json === a "no handler" branch only ever returns b (a missed share, never stale data), maps/blobs are redundant because every Blob also appears in json, and the proto/depth guards are conservative. The package tests pass (118), and an additional 20k-case fuzz of the new module found no invariant violations.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…izer 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.
There was a problem hiding this comment.
ℹ️ The follow-up commit is correct — no new issues. The earlier documentation note still applies.
Reviewed changes (since 2b226f71)
structural-sharing.ts: changed objects now copy withObject.create(proto)instead of{}, so a deserialized null-prototype object keeps its prototype when a subtree changes; ordinary objects keep the{}fast path.structural-sharing.test.ts: added coverage asserting the null prototype is preserved,toStringstays absent, and unchanged nested values still share.
The fix is sound: the copy branch is only reached for isPlainObject values, so proto is either Object.prototype (fast path) or a null-prototype/constructor-less prototype, and Object.create(proto) reproduces it faithfully while setOwn continues to guard the __proto__ key. Package tests pass.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
There was a problem hiding this comment.
ℹ️ The follow-up fix is correct — no new issues. The earlier documentation note still applies.
Reviewed changes (since 79c67cdc)
structural-sharing.ts: theDatefast path is now gated on!hasOwnEnumerableKey(a) && !hasOwnEnumerableKey(b), so aDatethat carries own enumerable fields falls through toreplaceEqualSerializedwhere a customdatehandler can compare them; a newhasOwnEnumerableKeyhelper usesfor...inplusObject.hasOwn.structural-sharing.test.ts: added coverage for a customdatehandler that serializes own fields, and for inherited enumerableDate.prototypekeys being ignored.
The change is correct: with the default serializer a Date's own fields are not part of the wire format (only the ISO string is), so sharing by time is unchanged, while a custom handler now sees the own fields. Object.hasOwn inside the for...in loop correctly ignores inherited keys. Tests pass.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…ryClient defaults
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.
…izer 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.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Structural sharing rewritten for speed and any-serializer exactness (
structural-sharing.ts):replaceEqualDeepnow dispatches to dedicated paths —replaceEqualBuiltIn(DatebygetTime,URLbyhref,Set/Mapelement-wise, each gated on no own enumerable keys),replaceEqualArray,replaceEqualObject, andreplaceEqualSerializedfor custom types. - Lazy copy construction (
copyArrayPrefix/copyObjectPrefix,assign): shallow copies are built only once a value actually differs, with__proto__still routed throughsetOwn. - Serializer path tightened (
replaceEqualSerialized): the "no handler" test moved fromjson === ato an emptymeta, andmetais compared withstringifyJSON. - OpenAPI JSON serializer documented and accepted as a serializer (
openapi-json-serializer.tsJSDoc + docs/tests): usable as a key serializer; itsserializeJsoncarries nometa, so with it custom types are never shared and keys carry no type info.
I traced the new branch structure, re-checked isPlainObject equivalence (proto === Object.prototype || proto === null || !proto.constructor), and ran the package tests (43 pass in structural-sharing + key). A 40k-case in-repo fuzz of the new createStructuralSharing() over plain/Date/URL/Set/Map data found no violations of either invariant — result === a ⇒ deeply equal and deeply equal ⇒ result === a. The prior documentation thread is already resolved.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Query results now keep their previous references when a refetch or live event returns equal data, including
Date,URL,Map,Setand custom serializer types, so components stop re-rendering on every update. Query keys now store the input in serialized form, so inputs with these types hash and invalidate by value with TanStack's default hash, andcreateQueryKeyHashFnis no longer needed.Changes
.queryOptions,.liveOptionsand.infiniteOptionsdefault tocreateStructuralSharing(serializer). For queries not built with oRPC utils, usecreateStructuralSharing()inQueryClientdefaults.Date,URL,SetandMapare compared directly, so sharing is exact with any serializer. Custom types are shared when an RPC serializer serializes them the same; with a JSON-only serializer they are always replaced, since equal JSON doesn't prove equal values.serializeroption on router and procedure utils, next toprefix, matching pinia-colada (feat(pinia-colada): configurable key serializer and type-aware input keys #2300). Contract meta can't set it.inputMeta, keyed by path, so aDateand its ISO string get different keys and partial keys still match. AnOpenAPIJsonSerializeralso works as the key serializer when keys don't need types.createQueryKeyHashFn.Fixes
SetandMapinputs no longer share one cache entry under the default hash, andBigIntinputs no longer throw.invalidateQueries({ queryKey: orpc.x.key({ input: { from } }) })only matches queries with an equalfrom, instead of every query with aDate,SetorMapthere.Breaking
createQueryKeyHashFnis removed. DropqueryKeyHashFnfrom theQueryClient; keepcreateHydrationOptionsfor SSR.Date,BigInt,URL,Set,Mapor custom types change shape. Hand-built keys or predicates that readqueryKey[…].inputnow see JSON values. Keys for plain-JSON inputs are unchanged.defaultOptions.queries.structuralSharingon theQueryClient. Override it per call, inscopedoptions or with a plugin.Known limitations
Datethere, since plain values carry no type info..key()input is typed from the procedure input, so this only shows up withDate | stringinputs or casts.Performance
replaceEqualDeepon plain JSON: about 7 ms vs 12 ms for 10k equal rows, and 8 ms vs 11 ms when every row changed.Dates take about 4 ms, a 1k-entryMapabout 0.2 ms.Testing
QueryClient, and OpenAPI serializers for keys and structural sharing. 100% coverage on the structural sharing module.verify-docs-jsdocandblume validate --strictpass.