query: useSuspenseQuery infinite refetch after SSR (Next.js App Router)
Describe the bug
After a page using useSuspenseQuery is SSR’d in the Next.js app router, the query will infinitely refetch. This also occurs if a loading.tsx file is added.
Your minimal, reproducible example
https://github.com/mgreenw/tanstack-query-suspense-bug
Steps to reproduce
- Install deps
pnpm i - Run the server
pnpm dev - Go to
http://localhost:3000 - View the devtools network tab
Expected behavior
The query should only refetch at the expected times (window focus, mount, etc).
How often does this bug happen?
Every time
Screenshots or Videos
Platform
- OS: macOS 13 Ventura
- Browser: Firefox, Chrome, Safari
Tanstack Query adapter
react-query
TanStack Query version
5.0.0-rc.4
TypeScript version
5.2.2
Additional context
Found this first when fiddling with trpc in the Next.js app router. Seems to be affecting both v4 and v5.
About this issue
- Original URL
- State: closed
- Created 9 months ago
- Reactions: 5
- Comments: 19 (7 by maintainers)
As I mentioned in the Discord, I think I’ve figured out what’s going on here! I’d like to leave this issue open for now and we can close once I have a PR up with docs updates and the change to
@tanstack/react-query-next-experimental.Problem
<Suspense>is a tricky beast, especially during the initial render. As I’ve learned before,This can happen both on the server and on the client, but in this specific case it’s the client that’s causing the issue.
In particular, this is the offending pattern:
On the client, the call to
useSuspenseQueryis throwing a promise that resolves whenqueryClienthas fetched the data. However, because there is no suspense boundary between the creation ofqueryClientanduseSuspenseQuery, the entire tree is thrown away, including the creation ofqueryClient!Once the data fetching promise resolves, the tree is rendered again, and an entirely new
queryClientis created (with no data in the cache), leading touseSuspenseQuerythrowing another promise to refetch the data, etc. Infinite loop!Solution
The solution is to only make a single
queryClienton the client, and store it in a global variable so we don’t re-initialize it during the initial render, even if the tree suspends, throws everything away, and subsequently re-renders.I think this warrants an update to the Streaming SSR docs and examples in the repo. I’d be happy to open a PR to make these changes!
Example of
ClientProviders.tsx:Other Learnings
ReactQueryStreamedHydrationwas missingIn my initial reproduction, I was fully missing the
<ReactQueryStreamedHydration>component that is critical to ensuring the data fetched server-side is available to the client during hydration! This didn’t end up being the cause of the infinite loop, but it was clearly needed in order to properly hydrate the data.ReactQueryStreamedHydrationhas a bug / optimization (PR to come)Once the HTML / data was streamed to the client, I was still seeing the client suspend once before it finally rendered the page. I wouldn’t expect this because the client should already have all all the data it needs so it shouldn’t need to suspend. I tracked it down to an issue with
ReactQueryStreamedHydrationwhere it doesn’t actually hydrate the data until after the first client render (because it happens in auseEffect), which is TOO LATE! The data must be available during the first client render in order to avoid suspending. I was able to manually copy inReactQueryStreamedHydrationand make the changes needed for this, and I will open up a PR to make this change in the public package.This topic is not directly related to this issue, but is a real problem when we start using this pattern of data fetching with suspense in client components instead of using server components.
In order to fetch data on the server with authentication, we usually need to pass along some client headers like
cookiewith ourfetchrequests to our server. The Next.js app router supports getting headers and cookies in React Server Components (RSC), but because we want@tanstack/queryto run on both the server AND the client (in order to get all the goodies like window focus refetching), we would ideally like runuseSuspenseQueryin client components, which also support all the same suspense and Streaming SSR capabilities. HOWEVER, if we can’t access the request headers / cookies, then we can’t properly fetch authenticated data during Streaming SSR using this pattern!This has been brought up a number of times in this discussion, and has also been discussed in this StackOverflow post.
One potential solution is to use a server component to get the value of the
cookieheader and pass it to the client component as a prop. However, because of how RSC handles serialization across client / server boundaries, the value of the cookie will end up in the final rendered HTML. This isn’t great, especially if we are usinghttp-onlycookies!A really clever workaround is to use the same method of grabbing the cookie in an RSC, but then instead of passing the cookie directly to the client component, we instead store the cookie value in a server-only global global data structure and pass a lookup key to the client component. When the client component needs the value, it can grab it from this server-only data structure (ideally using a client provider). Because we are only passing a lookup key between the RSC and client component boundary, we don’t expose the actual cookie value in the rendered HTML. The main gotcha is that because these lookup keys and header values are only valid for the lifespan of the request, we need to clean them up in the global data structure once the request finishes (probably via a timer or TTL) so we don’t infinitely grow memory usage.
This technique has been successfully implemented in
next-client-cookies, and I have also been able to make a stripped-down recreation of this implementation which I can share soon.I also found this problem when implementing SSR in nextjs 13. Is this problem solved?
@nizioleque In your case sounds like you’ll need to create multiple global variables, one per provider instance.
This has just saved me on a demo project I’m putting together for work! Absolute Hero! 🦸🏻
thanks @mgreenw for the investigation and the thorough write-up ❤️
Works for me beautifully, thank you!
so you’re talking about client side transitions?
Yea, after playing around with it a bunch I concur this can resolve the infinite loop (though not when using
loading.tsx, oddly). However, I don’t want to have a suspense boundary because I want the data to fetch and render entirely on the server in this use case.My intuition (after trying and failing to get suspense working manually myself a number of times) is that, on the client side, React is running
useQuery, which throws a promise as expected. Then, when the promise resolves, it is coming back and re-runninguseQuery, but instead of using the original promise it is creating a new promise which is still pending. Then, infinite loop.From the source code, it is clear that
useQuerywill always create a new promise with suspense due to the.catchblock here: https://github.com/TanStack/query/blob/rc/packages/react-query/src/suspense.ts#L63C1-L63C1This StackOverflow answer provides a good explanation of the problem, but the solution is not exactly clear. How do we store the original promise without recreating it on each subsequent render without
useState,useRef,useMemo, etc (which are not available during suspended calls)?useIddoes not work here because it is not intended to be used as a cache key](https://github.com/facebook/react/issues/24669#issuecomment-1496672505). It doesn’t seem like the React team has an answer for this yet, and it seems to be blocking other libraries as well.Here’s how
react-streamingis dealing with this with a cache key and global “workaround cache”: https://github.com/brillout/react-streaming/blob/main/src/shared/useSuspense.ts#L67C38-L67C38note that with v5, it’s no longer allowed to pass
suspense:truetouseQuery. We’ve removed it from the type definitions.For some reason, the fix is to have at least one
<Suspense>boundary. I’m not really sure why that is. We “invoke” suspense by throwing a Promise. This has always been the way to integrate with suspense (until theusehook becomes stable), so I’m not sure what we’d need to change …