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

  1. Install deps pnpm i
  2. Run the server pnpm dev
  3. Go to http://localhost:3000
  4. 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

Screenshot 2023-10-07 at 6 50 24 PM

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)

Most upvoted comments

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,

“React may throw away a partially rendered tree if it suspends, and then start again from scratch” - Dan.

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:

// NOTE: No suspense boundary above this

// Make the query client as recommended in the docs
const [queryClient] = useState(() => new QueryClient());

// This THROWS a promise if the data isn't available
const query = queryClient.useSuspenseQuery();

On the client, the call to useSuspenseQuery is throwing a promise that resolves when queryClient has fetched the data. However, because there is no suspense boundary between the creation of queryClient and useSuspenseQuery, the entire tree is thrown away, including the creation of queryClient!

Once the data fetching promise resolves, the tree is rendered again, and an entirely new queryClient is created (with no data in the cache), leading to useSuspenseQuery throwing another promise to refetch the data, etc. Infinite loop!

Solution

The solution is to only make a single queryClient on 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:

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { ReactNode } from "react";


function makeQueryClient() {
  return new QueryClient({ /* ...opts */ });
}

let clientQueryClient: QueryClient | undefined = undefined;

function getQueryClient() {
  if (typeof window === "undefined") {
    // Server: always make a new query client
    return makeQueryClient();
  } else {
    // Browser: make a new query client if we don't already have one
    if (!clientQueryClient) clientQueryClient = makeQueryClient();
    return clientQueryClient;
  }
}

export const ClientProviders = ({ children }: { children: ReactNode }) => {
  const queryClient = getQueryClient();

  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
    </QueryClientProvider>
  );
};

Other Learnings

  1. ReactQueryStreamedHydration was missing

In 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.

  1. ReactQueryStreamedHydration has 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 ReactQueryStreamedHydration where it doesn’t actually hydrate the data until after the first client render (because it happens in a useEffect), 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 in ReactQueryStreamedHydration and make the changes needed for this, and I will open up a PR to make this change in the public package.

  1. Request headers and cookies are NOT available in client components, even when they render on the server

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 cookie with our fetch requests to our server. The Next.js app router supports getting headers and cookies in React Server Components (RSC), but because we want @tanstack/query to run on both the server AND the client (in order to get all the goodies like window focus refetching), we would ideally like run useSuspenseQuery in 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 cookie header 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 using http-only cookies!

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 ❤️

Example of ClientProviders.tsx:

"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { ReactNode } from "react";


function makeQueryClient() {
  return new QueryClient({ /* ...opts */ });
}

let clientQueryClient: QueryClient | undefined = undefined;

function getQueryClient() {
  if (typeof window === "undefined") {
    // Server: always make a new query client
    return makeQueryClient();
  } else {
    // Browser: make a new query client if we don't already have one
    if (!clientQueryClient) clientQueryClient = makeQueryClient();
    return clientQueryClient;
  }
}

export const ClientProviders = ({ children }: { children: ReactNode }) => {
  const queryClient = getQueryClient();

  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
    </QueryClientProvider>
  );
};

Works for me beautifully, thank you!

so you’re talking about client side transitions?

For some reason, the fix is to have at least one <Suspense> boundary.

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-running useQuery, 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 useQuery will always create a new promise with suspense due to the .catch block here: https://github.com/TanStack/query/blob/rc/packages/react-query/src/suspense.ts#L63C1-L63C1

This 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)? useId does 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-streaming is dealing with this with a cache key and global “workaround cache”: https://github.com/brillout/react-streaming/blob/main/src/shared/useSuspense.ts#L67C38-L67C38

Agreed that this likely would happen with useQuery and suspense: true, but I haven’t tried that.

note that with v5, it’s no longer allowed to pass suspense:true to useQuery. We’ve removed it from the type definitions.

This also occurs if a loading.tsx file is added.

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 the use hook becomes stable), so I’m not sure what we’d need to change …