query icon indicating copy to clipboard operation
query copied to clipboard

Race condition causing hydration error when streaming with server components

Open brettpostin opened this issue 3 months ago • 8 comments

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

  1. Pull and run repo
  2. 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

brettpostin avatar Sep 11 '25 12:09 brettpostin

I'm not 100% sure, but it looks like it could be related to my issue: https://github.com/TanStack/query/issues/9399

Icestonks avatar Sep 11 '25 16:09 Icestonks

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.

brettpostin avatar Sep 12 '25 08:09 brettpostin

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!

Ephem avatar Sep 14 '25 18:09 Ephem

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 fetchStatus on a useSuspenseQuery ~~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.

Ephem avatar Sep 21 '25 19:09 Ephem

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 avatar Sep 23 '25 12:09 TkDodo

@TkDodo Thanks for that extra context, I had already been thinking along those lines too and that's a good confirmation. 👍

Ephem avatar Sep 23 '25 14:09 Ephem

@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

dBianchii avatar Oct 01 '25 17:10 dBianchii

I believe this is the same issue I'm seeing with the void prefetch pattern.

lyleunderwood avatar Oct 23 '25 15:10 lyleunderwood