Skip to content

fix(tanstack-query): give skipToken queries a distinct query key - #2210

Merged
dinwwwh merged 3 commits into
mainfrom
claude/nice-galileo-2hzg4n
Oct 13, 2026
Merged

dinwwwh merged 3 commits into
mainfrom
claude/nice-galileo-2hzg4n

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 13, 2026

Copy link
Copy Markdown
Member

Problem

skipToken is a Symbol, and both TanStack's default hashKey (JSON.stringify) and the RPCJsonSerializer-based queryKeyHashFn from the docs drop symbol values. So these two hashed to the same key and shared one Query:

orpc.planet.list.queryOptions()                      // [["planet","list"],{"type":"query"}]
orpc.planet.list.queryOptions({ input: skipToken })  // [["planet","list"],{"type":"query"}]  ← same hash

TanStack lets the most recent observer's options win (QueryObserver.setOptions → query.setOptions), so the shared query picks up the skipped observer's queryFn, which always throws queryFn should not be called when skipToken used for skipping. enabled: false only applies to the skipped observer. The enabled observer keeps the shared query active, so the next invalidateQueries/refetchQueries runs the throwing queryFn and the enabled query ends up in error.

This hits the pattern the docs recommend (input: search ? { search } : skipToken) whenever another component uses the same procedure without input. It affects queryOptions, streamedOptions, liveOptions and infiniteOptions (for infinite queries, when the input function returns undefined for the initial page).

Fix

generateOperationKey now writes skipToken: true in place of input when the input is skipToken, and the OperationKey type gains an optional skipToken field.

// before
[['planet', 'list'], { type: 'query', input: skipToken }]  // hash: {"type":"query"}
// after
[['planet', 'list'], { type: 'query', skipToken: true }]   // hash: {"skipToken":true,"type":"query"}
  • Skipped and unskipped queries now hash differently with both hashers.
  • Partial matching is unchanged: orpc.planet.key() and .key({ type: 'query' }) still match skipped queries, and .key({ input: {...} }) does not.
  • generateOperationKey is still called with { type, input: skipToken }, and options.queryKey is still its return value, so the existing spy assertions hold.
  • enabled: false and the throwing guard in the queryFn are unchanged.

Pinia Colada and SWR have no skipToken support, so they are not affected.

Tests

The new tests were written first and fail without the fix:

  • key.test.ts: the skipToken: true marker; skipped vs. no-input hashes differ under both hashKey and the docs' RPCJsonSerializer hasher; partial matching still works.
  • procedure-utils.test.ts: QueryObserver (queryOptions) and InfiniteQueryObserver (infiniteOptions) repros: the cache holds two queries, and the enabled one stays success after invalidateQueries (it previously ended in error).
  • tests/e2e.test.tsx: two useQuery hooks with streamedOptions() and streamedOptions({ input: skipToken }) in one renderHook.

Validation

  • pnpm vitest run packages/tanstack-query: 127 passed
  • pnpm type:check: clean
  • pnpm lint: clean

🤖 Generated with Claude Code

https://claude.ai/code/session_01DGZkM7MFXNDHJBg9SmARgj


Generated by Claude Code

`skipToken` is a symbol, and both TanStack's default `hashKey` and the
documented `RPCJsonSerializer`-based `queryKeyHashFn` drop symbol values.
A query skipped with `input: skipToken` therefore hashed to the same key
as the same procedure called without input, and the two observers shared
one `Query`. The skipped observer's throwing `queryFn` could win, so the
next invalidation put the enabled query into an error state.

`generateOperationKey` now writes `skipToken: true` in place of `input`
when the input is `skipToken`. Partial-match keys such as `.key()` and
`.key({ type })` still match skipped queries, while `.key({ input })`
does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGZkM7MFXNDHJBg9SmARgj
Reuse the module-level QueryClient in the observer tests (retry already
defaults to 0 outside the browser) and register unsubscribes with
onTestFinished so they also run when an assertion fails. Narrow the key
tests to what the fix changes: the skipToken marker, the skipped vs.
no-input hash under both hashers, and partial matching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGZkM7MFXNDHJBg9SmARgj
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DGZkM7MFXNDHJBg9SmARgj
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
orpc 3a775a8 Commit Preview URL

Branch Preview URL
Oct 12 2026, 08:43 AM

@pkg-pr-new

pkg-pr-new Bot commented Oct 13, 2026

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2210

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2210

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2210

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2210

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2210

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2210

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@2210

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2210

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2210

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2210

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2210

@orpc/experimental-msw

npm i https://pkg.pr.new/@orpc/experimental-msw@2210

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2210

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2210

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2210

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2210

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2210

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2210

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2210

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2210

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2210

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2210

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2210

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2210

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2210

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2210

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2210

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2210

commit: 3a775a8

@codecov

codecov Bot commented Oct 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — the input: skipToken collision is fixed correctly. One sibling disabling path (queryFn: skipToken) is not covered.

Reviewed changes

This PR gives input: skipToken queries a distinct query key so they no longer share a Query with the same procedure's no-input query.

  • generateOperationKey writes a skipToken: true marker — key.ts now emits { skipToken: true } in place of input when the input is the skipToken symbol; OperationKey gains the optional field.
  • Key unit tests — key.test.ts asserts the marker, that TanStack's hashKey and the docs' RPCJsonSerializer hasher both distinguish skipped from no-input keys, and that partial matching is unchanged.
  • Observer-level regression tests — procedure-utils.test.ts verifies queryOptions and infiniteOptions keep two cache entries and the enabled query stays success after invalidateQueries.
  • E2E test — tests/e2e.test.tsx renders streamedOptions() and streamedOptions({ input: skipToken }) together and asserts the same.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment thread packages/tanstack-query/src/key.ts
@codspeed

codspeed Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

⚠️ 6 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 28 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/nice-galileo-2hzg4n (3a775a8) with main (5f441da)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@dinwwwh
dinwwwh merged commit 937af92 into main Oct 13, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants