query icon indicating copy to clipboard operation
query copied to clipboard

Hydration error thrown when using `PersistQueryClientProvider` and `Suspense`

Open ali-idrizi opened this issue 2 years ago • 6 comments

Describe the bug

My real use-case:

  • A modern Next.js app with authentication
  • The authentication is completely offloaded to the browser
  • The server does not have an auth token (a JWT, but knows if the user is potentially logged in if a refresh token is present)
  • When logged in, the server returns loaders for the UI parts that are user specific
  • The browser replaces them with actual data
  • The browser caches data in localStorage using PersistQueryClientProvider and createAsyncStoragePersister

However, the example attached isn't that complex, it's a minimal example which reproduces the issue.

For the case above, the browser might have instant access to cached authenticated data. For instance, let's say we want to print the user's email address. The user has logged in earlier and this data is cached in localStorage, and as mentioned above, the server doesn't have this data. This is what happens:

  • Both the server and the client call the same useQuery.
  • Server: Since this data is not prefetched, the status is always pending in the server, and a loader is returned
  • Client: The client hydrates, but it instantly has access to the cached user and prints the email address

There is a mismatch here since the server rendered loading, while the client the email address. This case seems to be handled well when a component is not in a Suspense, but when it is, the last step will throw hydration errors (whether that's a loading.tsx or just a manual <Suspense>).


I have added some logs, and here is what I get:

Component not in Suspense:

not in suspense {status: 'pending', data: undefined, isRestoring: true}
not in suspense {status: 'success', data: {…}, isRestoring: false}

Component in Suspense:

in suspense {status: 'success', data: {…}, isRestoring: true}
in suspense {status: 'success', data: {…}, isRestoring: false}

When the component within a Suspense is rendering for the first time, the status is success, which renders data differently from the server, which I believe is the cause of the hydration error.


From the repo, here is part of the code that handles the loading UI:

const { data, status } = useSomeData();

if (status === "pending") {
  return <main>loading</main>;
}

The workaround is:

const { data, status } = useSomeData();
const isRestoring = useIsRestoring();

if (status === "pending" || isRestoring) {
  return <main>loading</main>;
}

That works, and does not throw hydration errors. But I feel that is bad DX, and I would prefer not to have to do that for every query call.

Your minimal, reproducible example

https://github.com/ali-idrizi/react-query-suspense-hydration-error

Steps to reproduce

Unfortunately it's a bit difficult to test this on CodeSandbox, as it doesn't seems to throw any hydration errors for some reason. Even rendering a simple {typeof window === 'undefined' ? 'x' : 'y'} does not throw an error for me. I have attached a repo instead, and you might have to run this locally, but there is also a video recording below.

To see the error, you will have to load the page once, so the data gets cached in the browser, afterwards the issue occurs on every subsequent reload.

You can try:

  • Remove the loading.tsx file and notice no hydration errors thrown
  • Wrap the SomeData component in layout.tsx with a Suspense and notice the hydration error count increase

Expected behavior

Perhaps keep status as pending while isRestoring === true, similar to when a component is not within a Suspense?

How often does this bug happen?

Every time

Screenshots or Videos

https://github.com/TanStack/query/assets/20397725/4581ed80-60b1-4b36-b2b3-d16136f25bc1

Platform

  • Windows 10 (WSL)
  • Chrome 119

Tanstack Query adapter

react-query

TanStack Query version

5.12.2

TypeScript version

5.2.2

Additional context

No response

ali-idrizi avatar Dec 02 '23 12:12 ali-idrizi

I'm curious as to why the workaround "works". When queries are in restoring state, they should also be in pending state.

So status === "pending" || isRestoring should be exactly the same as status === 'pending'. I can see in the video that you have a case with isRestoring: true and some data available, where I'm not sure how this is possible, given that you don't server-side-render any data? Where is that data coming from - what value does it have? Is it data from the localstorage where isRestoring is also true? Because that shouldn't happen - when data is first available, isRestoring should already be false.

also, please try not creating the QueryClient in memo, but in state:

https://github.com/ali-idrizi/react-query-suspense-hydration-error/blob/b9c6ba928e6464282225827ef184369b1d6926d4/src/lib/providers/query.tsx#L13

https://tkdodo.eu/blog/use-state-for-one-time-initializations

TkDodo avatar Dec 03 '23 10:12 TkDodo

When queries are in restoring state, they should also be in pending state.

That's what I was thinking too, but as you can see from the logs, the data is indeed present and status success even though isRestoring is still true.

given that you don't server-side-render any data? Where is that data coming from - what value does it have?

In the repo, it's a simple function with some sleep:

const getSomeData = async () => {
  await new Promise((resolve) => setTimeout(resolve, 2500));

  return {
    message: "We got a message!",
  };
};

The hook that consumes it:

export const useSomeData = () => {
  return useQuery({
    queryKey: ["some", "data"],
    queryFn: () => getSomeData(),
  });
};

And the component that uses the custom hook:

export const SomeData: FC<Props> = ({ header, inSuspense }) => {
  const { data, status } = useSomeData();
  const isRestoring = useIsRestoring();

  console.log(inSuspense ? "in suspense" : "not in suspense", {
    status,
    data,
    isRestoring,
  });

  if (status === "pending")
    return <main>loading</main>;

  if (status === "error")
    return <main>error</main>;

  return ...
};

In my real use-case this data cannot be prefetch and dehydrated in the server, so it's only fetched in the browser, Meaning the query in the server will always be in pending state.

The component SomeData above, is rendered twice, once in a suspense and once outside. The one that's in a Suspense, is the one that throws the hydration error. The other one works great!

As you can see in the screen recording, the first load takes longer to render as the data is not cached. The second reload is much faster, as it's reading from localStorage.

please try not creating the QueryClient in memo

That's a good point. I have pushed a commit for this and tested it, but the issue still remains. Thanks for your reply.

ali-idrizi avatar Dec 03 '23 11:12 ali-idrizi

our code is this:

setIsRestoring(true)
persistQueryClientRestore(options).then(async () => {
  try {
    await refs.current.onSuccess?.()
  } finally {
    setIsRestoring(false)
  }
})
  • persistQueryClientRestore uses hydration to write to the cache
  • it's async because restoring the client can be asynchronous
  • we then also await the onSuccess callback.

So yeah I can see that there can be some render cycles where persistQueryClientRestore has already finished hydration, but isRestoring is still true. Thus, the component is rendering in success state. Not really sure how to stop that, maybe @Ephem has an idea?

Of course, blocking rendering completely with a PersistGate until restoring has completed is always an option:

import { useIsRestoring } from '@tanstack/react-query'

export function PersistGate({ children, fallback = null }) {
  const isRestoring = useIsRestoring()

  return isRestoring ? fallback : children
}

put that inside the Provider, but around your app:

<PersistQueryClientProvider>
  <PersistGate fallback="restoring from storage ...">
    <App />
  </PersistGate>
</PersistQueryClientProvider>

I'm not sure if there is a better way here

TkDodo avatar Dec 04 '23 08:12 TkDodo

Since isRestoring has true as initialState (so it's true during server rendering), means the server never returns any HTML. That would certainly solve the hydration errors, but it's not really a good option. I hope there's a better fix for this.

ali-idrizi avatar Dec 04 '23 09:12 ali-idrizi

means the server never returns any HTML.

that's pretty much the reason why the PersistGate isn't the default behaviour. I just don't understand what difference suspense makes here, and why we get this non-batched update here ...

TkDodo avatar Dec 04 '23 10:12 TkDodo

An even weirder thing, if you look at the entire logs in the linked repo:

not in suspense {status: 'pending', data: undefined, isRestoring: true}
not in suspense {status: 'success', data: {…}, isRestoring: false}
in suspense {status: 'success', data: {…}, isRestoring: true}
in suspense {status: 'success', data: {…}, isRestoring: true}
not in suspense {status: 'success', data: {…}, isRestoring: false}
in suspense {status: 'success', data: {…}, isRestoring: false}
not in suspense {status: 'success', data: {…}, isRestoring: false}
in suspense {status: 'success', data: {…}, isRestoring: false}

isRestoring is false in the second log, but in the third log when the component in suspense is rendering for the first time, it flips back to true. I am not sure what is happening here, but perhaps it can point to something?

ali-idrizi avatar Dec 04 '23 10:12 ali-idrizi