query icon indicating copy to clipboard operation
query copied to clipboard

Next.js: exception encountered in prefetchInfiniteQuery causes useSuspenseInfiniteQuery to fail

Open robertzlatarski opened this issue 9 months ago • 2 comments

Describe the bug

I believe there is some weird behaviour when a prefetchInfiniteQuery fails in a server component and useSuspenseInfiniteQuery retries to fetch the data on the client. The exception thrown is: Uncaught TypeError: Cannot read properties of undefined (reading 'length'). I have spent some time debugging into this, it feels like after the failure on the server, the client retries to fetch the data and upon a successful response, it doesn't modify the response to be in the expected {pages: [], pageParams:[]} format, but treats it like a normal query response.

I have also noticed that query options passed are ignored (such as retry: 0).

Your minimal, reproducible example

https://codesandbox.io/p/devbox/9lyl7g

Steps to reproduce

The sandbox has an API endpoint that is setup to fail randomly. Verify that the hardcoded endpoint path is correct in pokemon.ts.

  1. Restart sandbox browser until prefetch has failed
  2. Observe an exception being thrown when a retry to /api request succeeds

Expected behavior

I expect it to work fine.

How often does this bug happen?

Always

Screenshots or Videos

Image

Platform

  • OS: MacOS
  • Chrome
  • Dependencies:

"next": "14.2.15", "react": "18.3.1", "react-dom": "18.3.1"

Tanstack Query adapter

None

TanStack Query version

5.62.7

TypeScript version

No response

Additional context

No response

robertzlatarski avatar Mar 17 '25 16:03 robertzlatarski

you’re right. when you’re not awaiting data on the server, we’re streaming the promise down. However, on the client, we try to pick up the promise by calling query.fetch here:

https://github.com/TanStack/query/blob/6ca0eb776c5b1e9499fbd079b899aeaf9e93a1c9/packages/query-core/src/hydration.ts#L235-L237

However, for infinite queries, we need to attach the infiniteQueryBehavior, like we do in the queryClient:

https://github.com/TanStack/query/blob/4d8c27b4487b0fd637fdfe2ad208f5f5d055b3a6/packages/query-core/src/queryClient.ts#L475-L481

because that is what attaches the mechanism to transform data into { pages, pageParams }.

the conceptual problem I’m having now is that hydration doesn’t know about infinite / non-infinite query. There’s no information stored in the cache that knows about this.

here’s a more streamlined reproduction where the fetch always fails on the server and always succeeds on the client:

https://codesandbox.io/p/devbox/reverent-tree-forked-kwkm8p

@Ephem @juliusmarminge FYI

TkDodo avatar Mar 18 '25 14:03 TkDodo

Interesting! Just like with the max update depth bug I think this is another argument for not passing the promise itself down, we need to wrap it with more metadata.

Ephem avatar Mar 18 '25 21:03 Ephem

I'm having this exact same error with useInfiniteQuery, in a prodcution app. Is there any recommended workaround until this is fixed?

reggiegutter avatar Jun 30 '25 18:06 reggiegutter

@TkDodo Is this being looked into? I haven't been able to solve it on my own yet.

reggiegutter avatar Jul 22 '25 20:07 reggiegutter

the workaround is to not stream promises for infinite queries, but to await them on the server

TkDodo avatar Jul 23 '25 10:07 TkDodo

The core of this issue seems to lie in the failure of prefetchInfiniteQuery. If await is not used, a pending promise is dehydrated and passed to the client. This appears to lead to a problem where the structure ends up as that of a regular query rather than an infinite query.

This seems to happen because, during hydration, query.fetch(undefined, { initialPromise }) is called. Since the first argument is undefined, the options (including queryFn) are not passed. As a result, within ensureQueryFn, when there is no queryFn and only an initialPromise, it seems to simply return the rejected promise. On the client side, retries likely do not trigger the infiniteQueryBehavior data transformation, which may be the cause of the issue.

I spent quite a while debugging this problem. I first noticed that dehydrateQuery contained query.options.behavior. However, when the query was created during hydration, there was no behavior. After that, query.fetch() was called, and the retryer returned the existing promise. Ultimately, as mentioned above, the root cause seems to be that hydration has no clear way to distinguish between an infinite query and a regular query.

To address this, I tried adding a property to DehydratedQuery that marks whether it is infinite (for example, a queryType: 'infinite' field). Then, during hydration, I checked this flag and applied infiniteQueryBehavior() if it was an infinite query. However, the problem still seemed to persist.

The reason, as far as I could tell, is that even with infiniteQueryBehavior applied, when query.fetch(undefined, { initialPromise }) is executed, the first argument remains undefined, so the queryFn is never passed. In ensureQueryFn, the already rejected promise is simply reused.

As a result, no new fetch occurs, and the rejected promise continues to be reused. Even though the observer on the client holds the correct queryFn, it does not appear to be invoked because the query is stuck in a rejected state from hydration.

In other words, the data transformation logic of infiniteQueryBehavior (the pages and pageParams structure) never really has a chance to run.

This issue likely cannot be solved merely by adding infiniteQueryBehavior. A more fundamental fix seems to be required—specifically, handling how rejected promises are treated and how the queryFn is passed during hydration.

joseph0926 avatar Sep 05 '25 14:09 joseph0926

I’ve opened PR https://github.com/TanStack/query/pull/9633 to try addressing this issue. I’m not entirely certain it resolves the problem, but I wanted to give it a try.

joseph0926 avatar Sep 09 '25 09:09 joseph0926