Race condition causing hydration error when streaming with server components
Describe the bug
I'm following the guidance here to implement streaming with server components.
I'm prefetching data like so (note, no await):
const streamingQueryClient = getQueryClient();
streamingQueryClient.prefetchQuery({
queryKey: ['roles-client-streaming'],
queryFn: () => getRoles(),
});
And inside a client component:
export default function ClientComponentWithStreaming() {
const { data, isFetching, status, isStale, fetchStatus } = useSuspenseQuery({
queryKey: ['roles-client-streaming'],
queryFn: getRolesClient,
});
console.log('data', data);
console.log('isFetching', isFetching);
console.log('status', status);
console.log('isStale', isStale);
console.log('fetchStatus', fetchStatus);
return (
<>
<ul className="list-disc pl-5 mt-4">
{data &&
data!.map((item: any) => (
<li key={item.id} className="mb-2">
{item.name}
</li>
))}
</ul>
{isFetching && <LoadingPanel />}
</>
);
}
The issue i'm seeing is that intermittently, isFetching and isStale are true on first client render. This causes a hydration error as it tries to render the LoadingPanel.
In my actual app, this is not intermittent and reproducible on every page load.
During server side rendering, the console logs are as follows:
data Array(1)
isFetching false
status success
isStale false
fetchStatus idle
So i would not expect the first client paint to be in a fetching state. However, when the hydration error occurs, the logs in the browser on the client show as:
data [{…}]
isFetching true
status success
isStale true
fetchStatus fetching
Under what conditions would the first client render be in a fetching state if the data is already prefetched via a promise?
Your minimal, reproducible example
https://github.com/brettpostin/tanstack-query-hydration-issue
Steps to reproduce
- Pull and run repo
- Keep refreshing until you hit the hydration error
Expected behavior
I would expect the first client paint to not be in a fetching state if the data is present in the cache from a prefetch.
How often does this bug happen?
Every time
Screenshots or Videos
No response
Platform
- OS: Windows
- Browser: Edge
- TanStack Query Version: 5.87.4
Tanstack Query adapter
react-query
TanStack Query version
5.87.4
TypeScript version
No response
Additional context
No response
I'm not 100% sure, but it looks like it could be related to my issue: https://github.com/TanStack/query/issues/9399
I'm not 100% sure, but it looks like it could be related to my issue: #9399
I did read through your issue but wasn't sure myself so thought i'd post my exact scenario.
Hmm, that other issue is with useQuery though, and I haven't seen this with useSuspenseQuery before. 🤔 Might still be the same root cause and fix ofc, but this is good additional data so thanks for the reproduction!
I've been investigating this a bit more and I have a hypothesis what is happening. I didn't have time to verify it all and wont have for a while longer, but I thought I'd share.
This might also apply in some version to #9399, I just found this an easier place to start debugging.
When hydrate() runs with a promise that has already resolved, it hydrates the data immediately, but it also calls query.fetch to set up a retryer:
// This doesn't actually fetch - it just creates a retryer
// which will re-use the passed `initialPromise`
// Note that we need to call these even when data was synchronously
// available, as we still need to set up the retryer
void query.fetch(undefined, {
// RSC transformed promises are not thenable
initialPromise: Promise.resolve(promise).then(deserializeData),
})
In fetch, this happens:
if (
this.state.fetchStatus === 'idle' ||
this.state.fetchMeta !== context.fetchOptions?.meta
) {
this.#dispatch({ type: 'fetch', meta: context.fetchOptions?.meta })
}
This puts the query in a fetching state again. Because the promise is already resolved, the retryer very quickly goes into the success state again, BUT, it does not resolve the promise synchronously like the hydrate() does.
This becomes a race condition, so when it resolves quickly enough (before rendering the component), everything is fine, but when it doesn't the component renders in a fetching state which causes the mismatch. At least that's the theory.
I should have caught this when building out the synchronous behaviour in hydrate(), but I guess I never hit the issue myself because all these needs to be fulfilled:
- Query that is awaited was so fast it was already successful on render
- The race condition happens
- You are using the
fetchStatuson auseSuspenseQuery~~OR all queries in a Suspense boundary was successful on render - Otherwise the fallback would be shown anyway and it wouldn't mismatch~~ - This last part isn't true, data is there so it never suspends in the first place, even if fetching
Busy week ahead, but I'll verify these findings, also against that other issue, and start thinking about a fix when I can. There are a few ways to fix this, but all have some complexity-tradeoffs so not sure of the best one yet.
Thanks @Ephem 🙏 . It’s worth noting that calling .fetch just to create a retryer and “pick up” the promise was likely not a good idea in hindsight. Not only does this not work with infinite queries (because the fetch behaviour is missing, but that’s a different issue), it also apparently causes this issue.
I think maybe extracting the behaviour we want (creating a retryer and passing an initialPromise to it) should be decoupled from .fetch entirely.
@TkDodo Thanks for that extra context, I had already been thinking along those lines too and that's a good confirmation. 👍
@Ephem any updates on this? Any workaround I can use while this is not fixed? It seems to me that what react query should do is always be in a "loading" state if the query was prefetched and not awaited
I believe this is the same issue I'm seeing with the void prefetch pattern.