Skip to content

Conversation

luixo
Copy link

@luixo luixo commented Sep 22, 2025

I tried to add Tanstack Query integration to Tanstack Start and it failed with Seroval's error (also crashed the whole app!)

Error reading routerStream: g [Error]: The value [object Object] of type "object" cannot be parsed/serialized.

Before that I had my own implementation of Tanstack Query integration and it didn't fail because I (de)serialized data, but with integration that's not an option.
This PR adds such an option.

Summary by CodeRabbit

  • New Features
    • Added optional SSR hydration and dehydration configuration (server and client) for finer control over how query state is serialized and restored.
    • Streaming updates now include per-item hydration configuration to ensure consistent behavior during incremental rendering.
    • Client hydration respects these options for both initial load and streamed updates.
    • Defaults unchanged so existing setups continue to work.

Copy link
Contributor

coderabbitai bot commented Sep 22, 2025

Walkthrough

Adds hydrateOptions and dehydrateOptions to RouterSsrQueryOptions and threads them through server and client hydration/dehydration flows in packages/router-ssr-query-core/src/index.ts, updating calls to queryDehydrate and queryHydrate, including streaming paths.

Changes

Cohort / File(s) Summary
SSR query integration (core)
packages/router-ssr-query-core/src/index.ts
- Added RouterSsrQueryOptions fields: hydrateOptions?: Omit<HydrateOptions, 'shouldDehydrateQuery'>, dehydrateOptions?: DehydrateOptions.
- setupCoreRouterSsrQueryIntegration now accepts and forwards these options.
- Server: queryDehydrate(queryClient, dehydrateOptions) for full dehydration; per-stream: queryDehydrate(queryClient, { ...dehydrateOptions, shouldDehydrateQuery: ... }).
- Emitted stream payloads include dehydrated query values (and hydrateOptions where propagated).
- Client: initial and streamed hydration call queryHydrate(queryClient, data, hydrateOptions).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Server
  participant Router as SSR Router Core
  participant QC as QueryClient (Server)
  participant Stream as Stream/Transport
  participant Client
  participant QCc as QueryClient (Client)

  Server->>Router: setupCoreRouterSsrQueryIntegration({ dehydrateOptions, hydrateOptions })
  Router->>QC: prepare queries
  Router->>QC: queryDehydrate(QC, dehydrateOptions)
  QC-->>Router: DehydratedState
  Router-->>Server: send Initial payload (DehydratedState)

  rect rgb(245,250,240)
    note over Router,QC: Streaming individual queries (per-item dehydrateOptions applied)
    Router->>QC: queryDehydrate(QC, { ...dehydrateOptions, shouldDehydrateQuery: fn })
    QC-->>Router: DehydratedQuery
    Router->>Stream: emit { value: DehydratedQuery, hydrateOptions }
  end

  Server->>Client: initial + streamed payloads
  Client->>QCc: queryHydrate(QCc, InitialDehydratedState, hydrateOptions)
  Client->>QCc: On stream: queryHydrate(QCc, value, hydrateOptions)
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

A rabbit nudges options bright,
Dehydrate by day, hydrate by night.
Streamed carrots tumble through,
Client sips the morning brew.
Hops and bytes in tidy rows—🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title concisely and accurately summarizes the primary change—adding hydration/dehydration options to the TanStack Query integration—and aligns with the PR description and code diffs, making the intent clear to reviewers.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
packages/router-ssr-query-core/src/index.ts (3)

104-115: Bug: Spreading hydrateOptions into queryDehydrate (should be dehydrateOptions) and ignoring user predicate

queryDehydrate expects DehydrateOptions. Spreading hydrateOptions is a type/semantic mismatch. Also, the custom shouldDehydrateQuery should respect user‑provided dehydrateOptions.shouldDehydrateQuery, not only ogClientOptions.

Apply:

-      queryStream.enqueue(
-        queryDehydrate(queryClient, {
-          ...hydrateOptions,
-          shouldDehydrateQuery: (query) => {
-            if (query.queryHash === event.query.queryHash) {
-              return (
-                ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ?? true
-              )
-            }
-            return false
-          },
-        }),
-      )
+      queryStream.enqueue(
+        queryDehydrate(queryClient, {
+          ...dehydrateOptions,
+          shouldDehydrateQuery: (query) => {
+            if (query.queryHash === event.query.queryHash) {
+              const userShould =
+                dehydrateOptions?.shouldDehydrateQuery ??
+                ogClientOptions.dehydrate?.shouldDehydrateQuery
+              return userShould?.(query) ?? true
+            }
+            return false
+          },
+        }),
+      )

133-141: Avoid hydrating when the stream signals done

When done is true, value is undefined; calling queryHydrate with it risks runtime errors. Hydrate only when !done.

Apply:

-        .then(async function handle({ done, value }) {
-          queryHydrate(queryClient, value, hydrateOptions)
+        .then(async function handle({ done, value }) {
+          if (!done) {
+            queryHydrate(queryClient, value, hydrateOptions)
+          }
           if (done) {
             return
           }
           const result = await reader.read()
           return handle(result)
         })

1-209: Fix queryDehydrate call to spread dehydrateOptions (not hydrateOptions)

In packages/router-ssr-query-core/src/index.ts — server-side queryCache subscription where you call queryStream.enqueue(queryDehydrate(...)), replace the spread of ...hydrateOptions with ...dehydrateOptions so the dehydrate options (not hydrate options) are passed to queryDehydrate.

🧹 Nitpick comments (3)
packages/router-ssr-query-core/src/index.ts (3)

25-27: Simplify hydrateOptions type (Omit likely redundant/confusing)

Omitting 'shouldDehydrateQuery' from HydrateOptions is unusual; that predicate belongs to DehydrateOptions. Recommend using HydrateOptions directly for clarity.

Apply:

-  hydrateOptions?: Omit<HydrateOptions, 'shouldDehydrateQuery'>
+  hydrateOptions?: HydrateOptions

48-52: Unsubscribe query cache on render finish to prevent leaks

The subscription persists past render without an unsubscribe. Tie it to onRenderFinished.

Apply:

   if (router.isServer) {
     const sentQueries = new Set<string>()
     const queryStream = createPushableStream()
+    let unsubscribe: () => void = () => {}

     router.options.dehydrate =
       async (): Promise<DehydratedRouterQueryState> => {
-        router.serverSsr!.onRenderFinished(() => queryStream.close())
+        router.serverSsr!.onRenderFinished(() => {
+          try { unsubscribe() } finally { queryStream.close() }
+        })
         const ogDehydrated = await ogDehydrate?.()
@@
-    queryClient.getQueryCache().subscribe((event) => {
+    unsubscribe = queryClient.getQueryCache().subscribe((event) => {

Also applies to: 82-116


181-208: Optional: Strongly type the pushable stream payload

Typing the stream ensures enqueue/value are QueryDehydratedState and improves DX.

Apply:

-type PushableStream = {
-  stream: ReadableStream
-  enqueue: (chunk: unknown) => void
+type PushableStream<T> = {
+  stream: ReadableStream<T>
+  enqueue: (chunk: T) => void
   close: () => void
   isClosed: () => boolean
   error: (err: unknown) => void
 }
 
-function createPushableStream(): PushableStream {
-  let controllerRef: ReadableStreamDefaultController
-  const stream = new ReadableStream({
+function createPushableStream<T = QueryDehydratedState>(): PushableStream<T> {
+  let controllerRef: ReadableStreamDefaultController<T>
+  const stream = new ReadableStream<T>({
     start(controller) {
       controllerRef = controller
     },
   })
   let _isClosed = false
 
   return {
     stream,
-    enqueue: (chunk) => controllerRef.enqueue(chunk),
+    enqueue: (chunk: T) => controllerRef.enqueue(chunk),

And update the instantiation:

-const queryStream = createPushableStream()
+const queryStream = createPushableStream<QueryDehydratedState>()
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4a6dcd0 and 3924890.

📒 Files selected for processing (1)
  • packages/router-ssr-query-core/src/index.ts (6 hunks)
🔇 Additional comments (4)
packages/router-ssr-query-core/src/index.ts (4)

8-12: Type-only imports look good

Importing DehydrateOptions and HydrateOptions as types is correct and treeshake‑friendly.


38-40: Plumbing new options into setup: LGTM

Accepting dehydrateOptions and hydrateOptions in the setup signature is consistent with the API expansion.


59-63: Server dehydrate with dehydrateOptions: LGTM

Passing dehydrateOptions into the initial queryDehydrate is correct.


123-128: Client hydration with hydrateOptions: LGTM

Hydrating the initial dehydrated state with hydrateOptions is correct.

@luixo luixo force-pushed the fix/query-client-integration-options branch from 3924890 to ea411ce Compare September 23, 2025 18:03
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/router-ssr-query-core/src/index.ts (1)

104-105: Consider preserving user-defined shouldDehydrateQuery in streaming.

When streaming queries, the current implementation spreads dehydrateOptions first, which means any user-defined shouldDehydrateQuery would be overridden by the local implementation. This could lead to unexpected behavior if users expect their dehydration logic to be respected during streaming.

Consider preserving the user's shouldDehydrateQuery logic by combining it with the query hash check:

         queryStream.enqueue(
           queryDehydrate(queryClient, {
-            ...dehydrateOptions,
-            shouldDehydrateQuery: (query) => {
-              if (query.queryHash === event.query.queryHash) {
-                return (
-                  ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ?? true
-                )
-              }
-              return false
-            },
+            ...dehydrateOptions,
+            shouldDehydrateQuery: (query) => {
+              // First check if this is the query we want to stream
+              if (query.queryHash !== event.query.queryHash) {
+                return false
+              }
+              // Then apply user-defined dehydration logic if provided
+              const userShouldDehydrate = dehydrateOptions?.shouldDehydrateQuery?.(query)
+              if (userShouldDehydrate !== undefined) {
+                return userShouldDehydrate
+              }
+              // Fall back to default options
+              return ogClientOptions.dehydrate?.shouldDehydrateQuery?.(query) ?? true
+            },
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3924890 and ea411ce.

📒 Files selected for processing (1)
  • packages/router-ssr-query-core/src/index.ts (6 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

Use TypeScript in strict mode with extensive type safety across the codebase

Files:

  • packages/router-ssr-query-core/src/index.ts
🔇 Additional comments (1)
packages/router-ssr-query-core/src/index.ts (1)

34-40: Well-structured implementation of (de)hydration options.

The addition of dehydrateOptions and hydrateOptions parameters provides the necessary flexibility for handling custom serialization scenarios. The implementation correctly threads these options through all relevant hydration and dehydration paths.

* @link [Guide](https://tanstack.com/router/latest/docs/framework/react/api/router/redirectFunction)
*/
handleRedirects?: boolean
hydrateOptions?: Omit<HydrateOptions, 'shouldDehydrateQuery'>
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix the hydrateOptions type definition to avoid potential issues.

The shouldDehydrateQuery property should not be omitted from HydrateOptions. According to the TanStack Query API, HydrateOptions does not include a shouldDehydrateQuery property - that property belongs to DehydrateOptions. This appears to be a copy-paste error.

Apply this diff to fix the type:

-  hydrateOptions?: Omit<HydrateOptions, 'shouldDehydrateQuery'>
+  hydrateOptions?: HydrateOptions
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
hydrateOptions?: Omit<HydrateOptions, 'shouldDehydrateQuery'>
hydrateOptions?: HydrateOptions
🤖 Prompt for AI Agents
In packages/router-ssr-query-core/src/index.ts around line 25, the
hydrateOptions type currently omits 'shouldDehydrateQuery' which is incorrect
because HydrateOptions does not include that property (it's on
DehydrateOptions); change the type so hydrateOptions is either HydrateOptions or
a partial/omit that excludes only properties that actually exist on
HydrateOptions (remove the Omit<..., 'shouldDehydrateQuery'> wrapper) or
explicitly type it as HydrateOptions | Partial<HydrateOptions> depending on
desired optionality, ensuring the type no longer references
'shouldDehydrateQuery'.

.read()
.then(async function handle({ done, value }) {
queryHydrate(queryClient, value)
queryHydrate(queryClient, value, hydrateOptions)
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick

Add null/undefined check before hydrating streamed values.

When reading from the query stream, the value could potentially be undefined in edge cases (e.g., if the stream is closed prematurely or contains no data). This could cause queryHydrate to fail silently or throw an error.

Apply this diff to add a safety check:

-          queryHydrate(queryClient, value, hydrateOptions)
+          if (value) {
+            queryHydrate(queryClient, value, hydrateOptions)
+          }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
queryHydrate(queryClient, value, hydrateOptions)
if (value) {
queryHydrate(queryClient, value, hydrateOptions)
}
🤖 Prompt for AI Agents
In packages/router-ssr-query-core/src/index.ts around line 135, the call to
queryHydrate(queryClient, value, hydrateOptions) needs a null/undefined guard
because streamed values may be undefined; update the code to check if value !==
null && value !== undefined (or use value != null) before calling queryHydrate,
and skip or safely handle/return when the value is missing (optionally log or
noop) to prevent errors from attempting to hydrate an empty value.

@schiller-manuel
Copy link
Contributor

can you please provide a full example where this is needed?

@luixo
Copy link
Author

luixo commented Sep 23, 2025

can you please provide a full example where this is needed?

Just use any non-serializable value in the query data (create a class that holds a value inside of itself).
The usage for hydration / dehydration options is described in Tanstack Query docs

If you still need a full example - can you provide with a minimal boilerplate I should modify?

@schiller-manuel
Copy link
Contributor

setting up a serializationAdapter in start should solve this as well.

what are you putting into hydrateOptions for your use case?

@luixo
Copy link
Author

luixo commented Sep 23, 2025

setting up a serializationAdapter in start should solve this as well.

Is it implemented yet?

what are you putting into hydrateOptions for your use case?

{ serializeData: transformer.serialize } for dehydrateOptions and { deserializeData: transformer.deserialize } for hydrateOptions.

Transformer is a custom extended SuperJSON instance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants