feat(tanstack-query): add SSR helpers for hashing, hydration and streamed queries by dinwwwh · Pull Request #2289 · middleapi/orpc · GitHub
Skip to content

feat(tanstack-query): add SSR helpers for hashing, hydration and streamed queries - #2289

Open
dinwwwh wants to merge 1 commit into
mainfrom
claude/tanstack-query-ssr-helpers-8f1dbn
Open

dinwwwh wants to merge 1 commit into
mainfrom
claude/tanstack-query-ssr-helpers-8f1dbn

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Summary

TanStack Query SSR setup was copied by hand. Users wrote queryKeyHashFn, dehydrate.serializeData, hydrate.deserializeData, a stream-cancel subscriber and a refetchOnMount plugin themselves, and the same code lived in the docs, the Next.js blog post, the Next.js playground and a test. This PR replaces all of it with exports from @orpc/tanstack-query.

import { RPCSerializer } from '@orpc/client'
import { createHydrationOptions, createQueryKeyHashFn, StreamQuerySSRUtilsPlugin } from '@orpc/tanstack-query'

const serializer = new RPCSerializer({ handlers: { /* ... */ } }) // the same instance you pass to RPCLink

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,
      queryKeyHashFn: createQueryKeyHashFn(serializer),
    },
    ...createHydrationOptions(serializer),
  },
})

const orpc = createTanstackQueryUtils(client, {
  plugins: [new StreamQuerySSRUtilsPlugin()],
})

Changes

  • createQueryKeyHashFn(serializer?) (key.ts): a queryKeyHashFn that hashes keys through an RPC JSON serializer. Unlike hashKey, it doesn't throw on bigint, and it keeps Date, Set, Map, ... distinct from their JSON form (a Date and its ISO string hash differently). Meta entries are sorted, so object key order doesn't change the hash. It lives in key.ts because it is useful without SSR too.
  • createHydrationOptions(serializer?) (ssr.ts): returns { dehydrate: { serializeData }, hydrate: { deserializeData } } to spread into defaultOptions. It drops maps/blobs, which don't survive JSON.
  • StreamQuerySSRUtilsPlugin (ssr.ts): SSR support for streamed and live queries, in one router utils plugin.
    • On the server, streamed queries settle as soon as they connect and live queries on their first event, then the stream is closed. Prefetching and suspense settle, and the snapshot dehydrates as a normal success / idle query.
    • In the browser, streamed and live queries get refetchOnMount: 'always', so a new stream opens after hydration. Streamed queries also start from initialData: [].
    • It treats an environment without a global window as the server; new StreamQuerySSRUtilsPlugin({ isServer }) overrides that.
    • Its defaults are applied as a base layer with mergeProcedureUtilsOptions: scoped and per-call options override them, and function-form options are kept. The hand-written plugin's spread dropped those.
  • snapshotStreams (procedure-utils.ts, @internal): the procedure utils option the plugin sets on the server. When it's set, the default streamed query function reads no chunks and the live query function reads one, then both close the iterator. Queries with a custom queryFn are left as is.
  • Both serializer helpers take Public<RPCJsonSerializer>, so an RPCSerializer works too, and default to new RPCJsonSerializer().
  • Docs and examples: the TanStack Query SSR section, the Next.js SSR blog post and the Next.js playground use the new exports instead of inline copies. The query client no longer needs any setup for streams. key.test.ts uses createQueryKeyHashFn instead of its own hasher.

Why not a query cache subscriber

The docs previously cancelled streams on the server with a query cache subscriber that silently cancelled any query in success + fetching state. Doing it in the plugin is better:

  • It only touches oRPC's own streamed and live queries. The subscriber also cancelled regular background refetches, which is why it needed a server-only guard at every call site.
  • The query settles as a normal success. A silent cancel leaves fetchStatus at fetching on the server client.
  • It doesn't depend on query-core's notification timing. The subscriber had a bug there: query-core notifies listeners before the fetch's retryer exists, so for a query that already had data (for example initialData: [] from the documented plugin), the cancel did nothing and SSR hung on the endless stream.

Fixes

Testing

  • ssr.test.ts:
    • Hydration round trip of oRPC types, including a custom handler.
    • Plugin in server mode:
      • live queries settle on their first event, and the stream's cleanup runs
      • streamed queries settle with [] once connected, and the iterator is closed
      • both end as success / idle
      • a live stream that ends before any event fails with liveQuery's usual error instead of hanging
    • Plugin in browser mode: queries follow the stream.
    • Server detection via window, the refetchOnMount / initialData defaults, and overrides.
    • Both main server-mode tests time out with the snapshot logic removed.
  • key.test.ts: hash edge cases (bigint, Date vs ISO string, Set vs array, key order, custom serializer).
  • ssr.test-d.ts: the helpers accept an RPCSerializer, and the plugin infers the client type.
  • Locally, all of these pass:
    • eslint on the changed packages
    • tsc for @orpc/tanstack-query, the root project and the Next playground
    • the tanstack-query vitest suite (143 tests, 100% line coverage of the changed files)
    • pnpm docs:validate (JSDoc backlinks and Blume links)
  • Also checked by hand, not committed: server rendering with React 19 (prerenderToNodeStream + useSuspenseQuery) and Vue (renderToString + onServerPrefetch), against a real oRPC router over RPCLink and an in-process router client. The HTML contains the first live event, the server stream closes, and after hydration the browser reopens the stream.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CTKKtCpRNTor3JCoqar7BR

Note

Add SSR helpers createQueryKeyHashFn, createHydrationOptions, and StreamQuerySSRUtilsPlugin to tanstack-query

  • createQueryKeyHashFn hashes query keys with an oRPC JSON serializer, so types like bigint, Date, Set, and Map hash deterministically instead of relying on JSON-unsafe native hashing. Both the factory and createHydrationOptions accept a custom serializer.
  • createHydrationOptions returns dehydration/hydration defaults that preserve oRPC values across server-to-browser transport via serialized JSON and metadata.
  • StreamQuerySSRUtilsPlugin (a router-utils plugin) detects the server via isServer (default: typeof window === 'undefined') and sets snapshotStreams. On the server, streamed queries take a finite snapshot and live queries take their first event; in the browser, streamed/live queries refetch on mount and streamed queries default to empty initial data. Utility and per-call options can override plugin defaults.
  • Updated docs, blog examples, and the Next playground to use the new helpers, replacing manual hashing, hydration callbacks, and stream-cancellation code.
  • Behavioral Change: procedures wrapped with snapshotStreams (via takeChunks in procedure-utils.ts) now close their iterators early instead of consuming the full stream on the server.

Macroscope summarized 05ab337.

Comment thread packages/tanstack-query/src/ssr.ts Outdated
@pkg-pr-new

pkg-pr-new Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@macroscopeapp

macroscopeapp Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Approvability

Verdict: Approved at 05ab337

Macroscope's review found this PR approvable — Adds localized, backwards-compatible SSR helpers for hashing, hydration, and streamed-query snapshots. The stream behavior is opt-in through a plugin, with existing paths unchanged and focused tests covering server and browser modes.

You can add or adjust custom eligibility rules. Learn more.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 21, 2026 •

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 3774105 Commit Preview URL

Branch Preview URL
Oct 21 2026, 02:20 PM

@codspeed

codspeed Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

1 warning

⚠️ 5 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 28 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/tanstack-query-ssr-helpers-8f1dbn (05ab337) with main (a762db7)2

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. ↩

  2. No successful run was found on main (8d84d77) during the generation of this report, so a762db7 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@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 new issues found.

Reviewed changes — a refactor that promotes TanStack Query SSR boilerplate into three public exports plus a plugin in packages/tanstack-query, with tests, docs, and playground switched over.

  • createQueryKeyHashFn (src/key.ts) — oRPC-type-aware query key hashing (hashKey([json, meta?.map(stringifyJSON).sort()])), defaulting to new RPCJsonSerializer(). stringifyJSON resolves to JSON.stringify, so this matches the inline form it replaces.
  • createHydrationOptions (src/ssr.ts) — returns dehydrate/hydrate defaults for QueryClient defaultOptions; serializeData intentionally drops maps/blobs.
  • cancelStreamsOnSuccess (src/ssr.ts) — same success+fetching guard, now returns the cache-subscription unsubscribe fn and uses a WeakSet<Query> instead of Set<string>.
  • StreamQuerySSRUtilsPlugin (src/ssr.ts) — applies refetchOnMount: 'always' to streamed/live options and initialData: [] to streamed, via mergeProcedureUtilsOptions; this correctly composes with function modifiers, which the old object-spread plugin could not.
  • Tests — ssr.test.ts / ssr.test-d.ts cover serialization round-tripping, custom serializers, cancellation, unsubscribe, and plugin override precedence.
  • Docs / playground — tanstack-query.mdx, the SSR blog post, and playgrounds/next/src/lib/{orpc,query-client}.ts now use the public API.

I ran the new unit tests (17 passed), the root tsc -p tsconfig.json --noEmit (clean, covers *.test-d.ts), and scripts/verify-docs-jsdoc.ts (0 errors — all new @see anchors resolve to real docs sections).

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh dinwwwh changed the title refactor(tanstack-query): extract SSR utilities into public API feat(tanstack-query): add SSR helpers for hashing, hydration and streamed queries Oct 21, 2026
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Oct 21, 2026
@macroscopeapp
macroscopeapp Bot dismissed their stale review October 21, 2026 13:37

Dismissing prior approval to re-evaluate bd321f8

@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 new issues found.

Reviewed changes — this run re-reviewed the single commit pushed since pullfrog's prior review (476239f), a targeted fix to cancelStreamsOnSuccess.

  • cancelStreamsOnSuccess now waits for an active retryer — the cache subscriber only cancels when query.promise !== undefined, and the dedup WeakSet<Query> was removed. Query#fetch dispatches the fetch action (which notifies listeners) before it installs #retryer, so for a query seeded with initialData: [] the old code called cancel() too early — a no-op — and then the WeakSet blocked any retry, hanging SSR on an endless stream. Since query.cancel() is a no-op on a settled retryer and #retryer is cleared in fetch's finally, the dedup is genuinely unnecessary.
  • Regression test added — cancels streams of queries that already had data before the fetch started, which times out on the previous implementation.

The fix matches the resolved macroscope thread at src/ssr.ts:53, and the query.promise getter it relies on is present in @tanstack/query-core as far back as 5.89.0, so the >=5.89.0 peer range is safe. I ran pnpm vitest run packages/tanstack-query/src/ssr.test.ts — 10/10 passing.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@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 new issues found.

Reviewed changes — this run re-reviewed the one commit pushed since pullfrog's prior review (bd321f8), a refactor that replaces the server-side stream-cancel subscriber with settling inside the plugin, plus the docs and playground that follow it.

  • Removed cancelStreamsOnSuccess — the query-cache subscriber is gone (never released), so the package no longer exports a helper that silently cancels regular background refetches or leaves fetchStatus at fetching on the server client.
  • Added snapshotStreams to ProcedureUtilsOptions (@internal) — the plugin now sets snapshotStreams: this.isServer, and the built-in streamed/live queryFns wrap the client iterator with takeChunks(output, 0 | 1) so the server settles as a normal success / idle query instead of relying on query-core notification timing.
  • Added the takeChunks generator (procedure-utils.ts:693) — reads count chunks then closes the iterator in a finally, so the underlying request is aborted even when zero chunks are read. AsyncIteratorClass.return() runs cleanup (reader.cancel()) even when next() was never called, so this does not leak the server-side stream.
  • StreamQuerySSRUtilsPlugin gained { isServer } — defaults to !('window' in globalThis), replacing the call-site environmentManager.isServer() guards in the playground and blog snippets.
  • Tests rewritten — ssr.test.ts now drives the plugin end-to-end through createRouterUtils (server settle + cleanup for streamed/live, browser following, window detection, override precedence); key.ts / key.test.ts are unchanged since the prior review.

I ran pnpm vitest run packages/tanstack-query/src/ssr.test.ts packages/tanstack-query/src/key.test.ts (17 passed), pnpm --filter @orpc/tanstack-query type:check, and eslint over the changed files — all clean. No lingering references to the removed symbol, and the @see anchors resolve to existing docs sections.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@dinwwwh
dinwwwh force-pushed the claude/tanstack-query-ssr-helpers-8f1dbn branch 2 times, most recently from 084979f to 3774105 Compare October 21, 2026 14:13

@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 new issues found.

Reviewed changes — this run re-reviewed the single commit pushed since pullfrog's prior review (ae5804b), a test-only addition to packages/tanstack-query/src/ssr.test.ts.

  • Added a server-mode regression test — settles live queries whose stream ends before the first event on the server (src/ssr.test.ts:104) drives an empty stream through createRouterUtils with StreamQuerySSRUtilsPlugin({ isServer: true }) and asserts it rejects with liveQuery's did not yield any data error rather than hanging. This complements the existing "settles on the first event" coverage.

I ran pnpm vitest run packages/tanstack-query/src/ssr.test.ts packages/tanstack-query/src/key.test.ts (18 passed: key 8 + ssr 10), pnpm --filter @orpc/tanstack-query type:check, and eslint over the changed files — all clean.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

…amed queries

TanStack Query SSR setup was copied by hand into the docs, the Next.js
blog post, the Next.js playground and a test. Export it instead:

- `createQueryKeyHashFn(serializer?)` (`key.ts`): a `queryKeyHashFn` that
  hashes keys through an RPC JSON serializer, so `bigint` doesn't throw
  and `Date`, `Set`, `Map`, ... stay distinct from their JSON form.
- `createHydrationOptions(serializer?)` (`ssr.ts`): `dehydrate.serializeData`
  and `hydrate.deserializeData` defaults that preserve oRPC types.
- `StreamQuerySSRUtilsPlugin` (`ssr.ts`): SSR support for streamed and live
  queries. On the server (no global `window`, or `isServer: true`), it sets
  the internal `snapshotStreams` procedure option: streamed queries settle
  once they connect and live queries on their first event, then the stream
  is closed. In the browser, streamed and live queries refetch on mount so
  streams reopen after hydration, and streamed queries start from
  `initialData: []`. Utils and per-call options override its defaults.

Both serializer helpers take `Public<RPCJsonSerializer>` (an `RPCSerializer`
works too) and default to `new RPCJsonSerializer()`.

The docs, blog post and playground now use these exports, and the Custom
Serializers section now says its `serializer` is your RPC serializer; since
81698ab the snippet used it without explanation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CTKKtCpRNTor3JCoqar7BR
@dinwwwh
dinwwwh force-pushed the claude/tanstack-query-ssr-helpers-8f1dbn branch 2 times, most recently from b9e3e17 to 05ab337 Compare October 21, 2026 14:37

dinwwwh commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

@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 — one documentation snippet nit inline.

Reviewed changes — this run re-reviewed the single commit pushed since pullfrog's prior review (3774105a), rebased and squashed as 05ab337. Only the TanStack Query integration docs changed.

  • Reworded the Custom Serializers prose (tanstack-query.mdx) — now says to pass "your RPC Serializer" and links to /docs/rpc/serializer#custom-serializers.
  • Removed the snippet's serializer setup — the import { RPCJsonSerializer } from '@orpc/client' and const serializer = new RPCJsonSerializer({ handlers: {...} }) lines are gone, while the snippet still calls createQueryKeyHashFn(serializer) and createHydrationOptions(serializer).
  • Condensed the Streamed and Live Queries section — prose-only; the plugin example and the isServer override note remain.

Every other file is byte-identical to the previously reviewed revision (verified by git ls-tree blob hashes), so the code, tests, blog, and playground are unchanged.

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

Comment thread apps/content/docs/integrations/tanstack-query.mdx

This branch has not been deployed

No deployments
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