fix(query-core,react-query): preserve infinite query type through failed SSR hydration#9633
fix(query-core,react-query): preserve infinite query type through failed SSR hydration#9633joseph0926 wants to merge 1 commit intoTanStack:mainfrom
Conversation
…led SSR hydration
WalkthroughAdds isInfiniteQuery propagation through dehydration/hydration, exposes setters/getters on Query, and adjusts fetch-cancellation logic for hydrated infinite queries. Extends tests for hydration and SSR with infinite queries and adds a public export for InfiniteQueryObserver. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor App
participant ServerCache as QueryClient (Server)
participant Dehydrate as dehydrate()
participant Hydrate as hydrate()
participant ClientCache as QueryClient (Client)
participant Query as Query
App->>ServerCache: prefetchInfiniteQuery / prefetchQuery
ServerCache->>Dehydrate: dehydrate()
Dehydrate-->>App: dehydratedState (isInfiniteQuery flags)
App->>Hydrate: hydrate(dehydratedState, ClientCache)
Hydrate->>ClientCache: upsert queries
Hydrate->>Query: setIsInfiniteQuery(true) when flagged
Hydrate-->>App: client ready with flags preserved
sequenceDiagram
autonumber
participant Observer as InfiniteQueryObserver
participant Query as Query
participant Retryer as Retryer
participant Source as Fetch Source
Observer->>Query: fetch({ cancelRefetch })
alt Hydrated infinite w/o data and cancelRefetch
Query->>Retryer: check pending
Query-->>Observer: cancel silently
else Has data and cancelRefetch
Query-->>Observer: cancel silently
else Retry in progress
Query->>Retryer: continue retries
Retryer->>Source: refetch as configured
Retryer-->>Observer: promise resolves/rejects
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested labels
Suggested reviewers
Pre-merge checks (4 passed, 1 warning)❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Poem
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.
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. ✨ Finishing Touches
🧪 Generate unit tests
Comment |
|
View your CI Pipeline Execution ↗ for commit 909d953
☁️ Nx Cloud last updated this comment at |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #9633 +/- ##
===========================================
+ Coverage 45.50% 59.63% +14.13%
===========================================
Files 209 138 -71
Lines 8377 5629 -2748
Branches 1897 1529 -368
===========================================
- Hits 3812 3357 -455
+ Misses 4118 1968 -2150
+ Partials 447 304 -143 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (5)
packages/query-core/src/query.ts (1)
407-425: Stuck hydrated infinite guard: solid; consider a small robustness tweakLogic correctly cancels the “pending retryer from hydration” when an infinite-aware refetch arrives with
cancelRefetch: true. To make it resilient if callers omit theoptionsargument, prefer the effective behavior source:- const isStuckHydratedInfinite = - this.#isInfiniteQuery && - this.state.data === undefined && - this.#retryer?.status() === 'pending' && - options?.behavior && - fetchOptions?.cancelRefetch + const effectiveBehavior = options?.behavior ?? this.options.behavior + const isStuckHydratedInfinite = + this.#isInfiniteQuery && + this.state.data === undefined && + this.#retryer?.status() === 'pending' && + !!effectiveBehavior && + fetchOptions?.cancelRefetchThis keeps the intent while handling
fetch(undefined, { cancelRefetch: true })call sites.packages/react-query/src/__tests__/ssr-hydration.test.tsx (4)
271-333: Strengthen assertions, remove console.log, and clean up clients
- Assert that the hydrated query is flagged infinite before refetch.
- Drop console log noise.
- Validate first page payload.
- Clear clients to avoid cross-test leakage.
@@ - const clientQueryClient = new QueryClient() - hydrate(clientQueryClient, dehydrated) + const clientQueryClient = new QueryClient() + hydrate(clientQueryClient, dehydrated) + // Ensure infinite semantics were preserved on hydration + const hydratedQuery = clientQueryClient + .getQueryCache() + .find({ queryKey: ['posts'] }) + expect((hydratedQuery as any).isInfiniteQuery?.()).toBe(true) @@ - expect(() => { - const pageCount = initialResult.data?.pages?.length ?? 0 - console.log('Initial page count:', pageCount) - }).not.toThrow() + expect(() => { + // Safe to touch .pages even if undefined + const _ = initialResult.data?.pages?.length ?? 0 + }).not.toThrow() @@ - expect(afterRefetch.data?.pages).toHaveLength(1) + expect(afterRefetch.data?.pages).toHaveLength(1) + expect(afterRefetch.data?.pages[0]).toEqual({ + posts: ['Post 1-1', 'Post 1-2'], + nextCursor: 2, + }) @@ expect(afterNextPage.data?.pages[1]).toEqual({ posts: ['Post 2-1', 'Post 2-2'], nextCursor: 3, }) + clientQueryClient.clear() + serverClient.clear()
334-391: Add teardown to prevent state leakage across testsClear clients at the end to avoid retained caches/timers under fake timers.
@@ - expect(result2.data?.pages[0]).toEqual({ data: 'user2-0' }) + expect(result2.data?.pages[0]).toEqual({ data: 'user2-0' }) + clientQueryClient.clear() + serverClient.clear()
418-467: Confirm infinite flag for mixed hydration and add teardownExplicitly assert infinite type on the infinite query and clear clients.
@@ - expect(infiniteResult.data?.pages).toBeDefined() - expect(regularResult).toBe('regular') + expect(infiniteResult.data?.pages).toBeDefined() + expect(regularResult).toBe('regular') + const infQuery = clientQueryClient + .getQueryCache() + .find({ queryKey: ['infinite'] }) + expect((infQuery as any).isInfiniteQuery?.()).toBe(true) + clientQueryClient.clear() + serverClient.clear()
469-506: Assert infinite flag on success path and clean upAdd a direct check that hydration preserved infinite semantics and clear clients.
@@ - expect(result.data?.pages[0]).toEqual({ data: 'page-0', next: 1 }) + expect(result.data?.pages[0]).toEqual({ data: 'page-0', next: 1 }) + const hydratedQuery = clientQueryClient + .getQueryCache() + .find({ queryKey: ['success'] }) + expect((hydratedQuery as any).isInfiniteQuery?.()).toBe(true) + clientQueryClient.clear() + serverClient.clear()
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
packages/query-core/src/__tests__/hydration.test.tsx(1 hunks)packages/query-core/src/hydration.ts(4 hunks)packages/query-core/src/query.ts(4 hunks)packages/react-query/src/__tests__/ssr-hydration.test.tsx(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
packages/query-core/src/query.ts (1)
packages/query-core/src/queryObserver.ts (2)
options(377-383)fetchOptions(334-351)
packages/query-core/src/hydration.ts (1)
packages/query-core/src/query.ts (1)
isInfiniteQuery(203-205)
packages/react-query/src/__tests__/ssr-hydration.test.tsx (1)
packages/query-core/src/hydration.ts (2)
dehydrate(124-165)hydrate(167-283)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
- GitHub Check: Preview
- GitHub Check: Test
🔇 Additional comments (12)
packages/query-core/src/hydration.ts (2)
44-55: Dehydrated payload extension looks goodAdding
isInfiniteQuery?: booleanis backward-compatible and keeps JSON shape stable. No issues here.
108-109: Marking infinite queries during dehydration is correct, but relies onoptions.behaviorheuristicUsing
query.options.behavioris a good proxy for infinite queries in core. If other custom behaviors exist in your ecosystem, verify this won’t mislabel them as infinite.Do you want a follow-up test that dehydrates a non-infinite custom behavior to ensure the flag remains unset?
packages/query-core/src/__tests__/hydration.test.tsx (6)
1403-1434: Great: preserves infinite type on failed promise hydrationCovers the regression path well.
1436-1467: Good: dehydration marks only infinite queriesValidates selective flagging; nice separation from regular queries.
1469-1499: Good: end-to-end flag preservation across hydrationEnsures round-trip correctness for failed infinite queries.
1500-1533: Good: JSON round-trip of the flagCovers common SSR flow with serialization.
1534-1577: Good: no impact on regular queriesPrevents accidental behavior changes for non-infinite.
1578-1604: Good: nested keys scenarioValidates realistic key shapes.
packages/query-core/src/query.ts (3)
178-179: Private flag for infinite queries: OKInternal-only storage is appropriate.
203-206: Getter is minimal and clearExposing as
boolean | undefinedis fine and matches tests.
257-260: Setter is fineNo side effects required; simple assignment works.
packages/react-query/src/__tests__/ssr-hydration.test.tsx (1)
6-6: Public InfiniteQueryObserver import looks goodImporting from the public index aligns tests with the intended API surface.
| ({ | ||
| queryKey, | ||
| state, | ||
| queryHash, | ||
| meta, | ||
| promise, | ||
| dehydratedAt, | ||
| isInfiniteQuery, | ||
| }) => { | ||
| const syncData = promise ? tryResolveSync(promise) : undefined | ||
| const rawData = state.data === undefined ? syncData?.data : state.data | ||
| const data = rawData === undefined ? rawData : deserializeData(rawData) |
There was a problem hiding this comment.
🛠️ Refactor suggestion
Also set isInfiniteQuery on pre-existing queries during hydration
Right now, setIsInfiniteQuery(true) is only called when a new Query is built. If a Query already exists in the cache (e.g., user code created it before hydrate()), the flag isn’t applied, and the retry/cancel logic in Query.fetch won’t kick in for that edge case.
Apply the flag for both new and existing queries:
)
- )
-
- if (isInfiniteQuery) {
- query.setIsInfiniteQuery(true)
- }
- }
+ )
+ }
+
+ if (isInfiniteQuery) {
+ query.setIsInfiniteQuery(true)
+ }Also applies to: 259-262
🤖 Prompt for AI Agents
In packages/query-core/src/hydration.ts around lines 201-212 (and likewise
adjust lines 259-262), the hydration path only calls setIsInfiniteQuery(true)
when building a new Query, leaving pre-existing queries without the flag; update
the logic so that after resolving or obtaining the Query instance (both in the
new-query branch and the existing-query branch) you call
query.setIsInfiniteQuery(Boolean(isInfiniteQuery)) (or
query.setIsInfiniteQuery(isInfiniteQuery) if already boolean) so the flag is
applied regardless of whether the Query was created during hydration or already
existed in the cache.
| it('should handle regular query (non-infinite) after hydration', async () => { | ||
| const serverClient = new QueryClient({ | ||
| defaultOptions: { | ||
| dehydrate: { shouldDehydrateQuery: () => true }, | ||
| }, | ||
| }) | ||
|
|
||
| await serverClient | ||
| .prefetchQuery({ | ||
| queryKey: ['regular'], | ||
| queryFn: () => Promise.reject(new Error('Failed')), | ||
| retry: false, | ||
| }) | ||
| .catch(() => {}) | ||
|
|
||
| const dehydrated = dehydrate(serverClient) | ||
| const clientQueryClient = new QueryClient() | ||
| hydrate(clientQueryClient, dehydrated) | ||
|
|
||
| const query = clientQueryClient | ||
| .getQueryCache() | ||
| .find({ queryKey: ['regular'] }) | ||
| expect((query as any)?.__isInfiniteQuery).toBeUndefined() | ||
| }) |
There was a problem hiding this comment.
💡 Verification agent
🧩 Analysis chain
Fix meaningless assertion; verify via isInfiniteQuery() instead
__isInfiniteQuery does not exist and this test would pass even if the query were incorrectly marked. Assert via the getter.
@@
- expect((query as any)?.__isInfiniteQuery).toBeUndefined()
+ // Verify via the getter rather than a non-existent slot
+ expect((query as any).isInfiniteQuery?.()).toBe(false)
+ clientQueryClient.clear()
+ serverClient.clear()Run to confirm the getter exists on Query:
🏁 Script executed:
#!/bin/bash
rg -nP -C2 '\bisInfiniteQuery\s*\(' packages/query-core/src/query.tsLength of output: 108
Replace __isInfiniteQuery check with the public isInfiniteQuery getter
Use the property (not a method) to assert the query type and clean up clients:
- expect((query as any)?.__isInfiniteQuery).toBeUndefined()
+ expect((query as any).isInfiniteQuery).toBe(false)
+ clientQueryClient.clear()
+ serverClient.clear()📝 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.
| it('should handle regular query (non-infinite) after hydration', async () => { | |
| const serverClient = new QueryClient({ | |
| defaultOptions: { | |
| dehydrate: { shouldDehydrateQuery: () => true }, | |
| }, | |
| }) | |
| await serverClient | |
| .prefetchQuery({ | |
| queryKey: ['regular'], | |
| queryFn: () => Promise.reject(new Error('Failed')), | |
| retry: false, | |
| }) | |
| .catch(() => {}) | |
| const dehydrated = dehydrate(serverClient) | |
| const clientQueryClient = new QueryClient() | |
| hydrate(clientQueryClient, dehydrated) | |
| const query = clientQueryClient | |
| .getQueryCache() | |
| .find({ queryKey: ['regular'] }) | |
| expect((query as any)?.__isInfiniteQuery).toBeUndefined() | |
| }) | |
| it('should handle regular query (non-infinite) after hydration', async () => { | |
| const serverClient = new QueryClient({ | |
| defaultOptions: { | |
| dehydrate: { shouldDehydrateQuery: () => true }, | |
| }, | |
| }) | |
| await serverClient | |
| .prefetchQuery({ | |
| queryKey: ['regular'], | |
| queryFn: () => Promise.reject(new Error('Failed')), | |
| retry: false, | |
| }) | |
| .catch(() => {}) | |
| const dehydrated = dehydrate(serverClient) | |
| const clientQueryClient = new QueryClient() | |
| hydrate(clientQueryClient, dehydrated) | |
| const query = clientQueryClient | |
| .getQueryCache() | |
| .find({ queryKey: ['regular'] }) | |
| expect((query as any).isInfiniteQuery).toBe(false) | |
| clientQueryClient.clear() | |
| serverClient.clear() | |
| }) |
🤖 Prompt for AI Agents
In packages/react-query/src/__tests__/ssr-hydration.test.tsx around lines 393 to
416, replace the internal check using __isInfiniteQuery with the public
isInfiniteQuery getter on the Query instance (e.g.,
expect(query?.isInfiniteQuery).toBeUndefined()) and ensure test cleans up
created QueryClient instances by clearing/destroying serverClient and
clientQueryClient after use to avoid leaks.
|
serializing the information about “infinite” query seems fine, though I think we probably shouldn’t do this with a flag, but maybe with a what I don’t understand is why it isn’t enough to just set this should be enough, and should be the solution we strive towards. |
You're absolutely right. |
Sorry this sounds absolutely like something that claude code would say. I have nothing against using AI as an assistant but please let me know if everything you do is just coming straight from AI and this account is just a proxy for it. |
That's how Deepl translates. |
|
I’m not a native English speaker, But I learned a lot from you during my time contributing, and it was truly enjoyable. |
|
@TkDodo |
|
Let me start by saying that I’m very sorry for how my comment came across. I too am not a native speaker, so I understand the troubles. Re-reading my comment now it’s clear to me that it was inappropriate.
Yes, you’re right, that would’ve been the better question, and I should’ve phrased it that way. What I found odd in this interaction is that I tried to outline in the linked issue what I think the problem is and what a potential solution could look like. Then, this PR shows up that implements a different approach, which I think would fail in the normal case of a successfully streamed infinite query because it is absolutely necessary that we attach the infinite query behavior in all cases. So when I point out pretty much the exact same thing that I said on the issue again in the PR, I’m met with “you’re absolutely right”. This is typical for AI interactions: One says something, AI does something else, you say the exact same thing again, and get an “you’re absolutely right, thanks for pointing that out. I will fix it that way” response. That gave me the feeling that the whole conversation is being held by AI, and that I might be wasting my time here. However, I shouldn’t have written that unreflected comment, and again, I’m sorry for that. |
|
@TkDodo To explain the reasons behind your concerns, I did read your comment on the issue. However, I became overly focused on this specific part
Because I fixated on that line, I framed the problem as “if we just solve this piece, the whole thing will work,” and I proceeded from that assumption. Approaching it that way led me down a different path from your proposed solution. After receiving your feedback and re-reading your comment carefully, I realized that my direction was aimed at addressing a specific symptom, while your approach both resolves that symptom and tackles the root cause. That is why I said I would make changes in response to your feedback. Having read your detailed explanation, I fully understand the concern and how the misunderstanding arose. Thank you again for taking the time to explain everything so clearly. I was a bit emotional as well, I sincerely apologize for that. Thanks again. |
Fixes #8825
Problem
When prefetchInfiniteQuery fails on the server and useSuspenseInfiniteQuery later retries on the client, the response is treated as a regular query rather than the expected infinite structure ({ pages: [], pageParams: [] }). As a result, accessors like data.pages can throw runtime errors (for example, “Cannot read properties of undefined (reading 'length')”).
Maybe Root cause
During SSR, a failed infinite query loses its infinite-query type information at dehydration time. The hydrated instance therefore lacks the behavior that marks it as infinite. When a retry occurs on the client, the retryer can remain pending, and even after InfiniteQueryObserver attaches the proper behavior, the data is not transformed into the infinite shape. Together, these conditions cause the client recovery path to return a regular query payload instead of { pages, pageParams }.
Solution
The fix preserves infinite-query type information across the hydration boundary and ensures the retryer can progress for hydrated infinite queries. Concretely, a new isInfiniteQuery flag is added to the dehydrate/hydrate flow so the query is recognized as infinite after hydration. In addition, when a hydrated infinite query is found with a pending retryer, the fetch logic now allows cancellation and re-start when the correct behavior is attached—even if no prior data exists—so the subsequent retry yields the properly transformed infinite structure.
Changes
In query-core, DehydratedQuery now includes an optional isInfiniteQuery flag, which dehydrateQuery sets for infinite queries and hydrate carries onto the Query instance. The Query class maintains this via a private #isInfiniteQuery field with corresponding accessors. The fetch path has been updated so hydrated infinite queries with a stuck retryer can transition cleanly once behavior is applied. In react-query, comprehensive tests exercise SSR hydration and recovery paths to verify that the infinite data shape is preserved across the boundary.
Tests
The new suite covers failed SSR hydration with subsequent client recovery, mixtures of infinite and regular queries, race conditions between hydration and observer attachment, and structural validation of { pages, pageParams }, including edge cases such as nested keys and multiple concurrent queries. All existing tests pass unchanged.
Example
Notes
The change is backward-compatible and adds only negligible overhead, because the new flag is relevant solely for hydrated queries under the described conditions.
Summary by CodeRabbit
New Features
Bug Fixes