Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

queryFn erroneously called in useSuspenseQuery dependent on serial prefetch #8828

Open
TrevorBurnham opened this issue Mar 18, 2025 · 6 comments

Comments

@TrevorBurnham
Copy link

TrevorBurnham commented Mar 18, 2025

Describe the bug

I've been struggling to get the Suspense prefetch-with-streaming pattern working in my app when there are multiple prefetch queries (because one prefetch request depends on the result of the other).

The problem is that the queryFn gets called for the second useSuspenseQuery call during SSR. For example:

// function to generate options for the query to get a given Pokemon
export const getPokemonOptions = (id: number) =>
  queryOptions({
    queryKey: ["pokemon", id],
    queryFn: async () => {
      const response = await fetch(`https://pokeapi.co/api/v2/pokemon/${id}`);
      return response.json();
    },
  });

// code in the RSC to perform two serial fetch operations
void queryClient
  .fetchQuery({
    queryKey: ["favoritePokemonId"],
    queryFn: () =>
      // simulate database call to look up the user's favorite Pokemon
      new Promise<number>((resolve) =>
        setTimeout(() => resolve(Math.ceil(Math.random() * 100)), 500)
      ),
  })
  .then((favoritePokemonId) => {
    return queryClient.prefetchQuery(getPokemonOptions(favoritePokemonId));
  });

// code in the Suspense-wrapped client component to hydrate the data
const { data: favoritePokemonId } = useSuspenseQuery({
  queryKey: ["favoritePokemonId"],
  queryFn: (): number => {
    throw new Error("First queryFn in pokemon-info should never be called.");
  },
});
const { data: pokemon } = useSuspenseQuery({
  ...getPokemonOptions(favoritePokemonId),
  queryFn: () => {
    throw new Error("Second queryFn in pokemon-info should never be called");
  },
});

This code causes SSR to fail with the error Second queryFn in pokemon-info should never be called.

Interestingly, if you stub out the queryFn with a no-op function, the code seems to work, with the second useSuspenseQuery returning data immediately. That suggests that the Suspense boundary is correctly blocking the component with the useSuspenseQuery from rendering until the prefetch has completed, but then it's incorrectly trying to fetch the prefetched data again, even with staleTime: Infinity.

Your minimal, reproducible example

https://codesandbox.io/p/devbox/dreamy-sky-znq6jh?workspaceId=ws_CZBXqWgnzWZNdzjDui2die

Steps to reproduce

  1. Prefetch data serially without await to allow for streaming
  2. Use useSuspenseQuery for all prefetched data from a component inside of a <Suspense> boundary

Expected behavior

The clientFn provided to useSuspenseQuery should never be called during SSR, because the hook is for loading prefetched data. (It should only be called on the client after the data is invalidated.)

How often does this bug happen?

Every time

Platform

macOS, but reproducible in CodeSandbox. Reproducible under Next.js 15 w/ React 19, and Next.js 14 w/ React 18.3.

TanStack Query version

5.68.0

@TkDodo
Copy link
Collaborator

TkDodo commented Mar 19, 2025

when useSuspenseQuery for the second query renders in PokemonInfo, it doesn’t find anything for its query key in the cache, which is why it triggers the queryFn. That’s because very likely, streaming of the first query hasn’t finished yet by that time, so you haven’t yet kicked off prefetching in the RSC for that query.

You have to make sure that all promises are already started in the RSC when you render the client component. For dependent queries, this isn’t really possible because you don’t even know the queryKey yet.

So I think yo need to await the first prefech, and then you can void the second one.

@TrevorBurnham
Copy link
Author

when useSuspenseQuery for the second query renders in PokemonInfo, it doesn’t find anything for its query key in the cache, which is why it triggers the queryFn. That’s because very likely, streaming of the first query hasn’t finished yet by that time, so you haven’t yet kicked off prefetching in the RSC for that query.

The first time PokemonInfo renders, the first useSuspenseQuery hook returns data, so that streaming has completed.

However, I think you're right that this is a timing issue. What I'm expecting to happen is this:

  1. The component inside the Suspense boundary is blocked from rendering until the first prefetch call completes.
  2. As soon as the first prefetch call completes, a second prefetch call fires.
  3. The component inside the Suspense boundary continues to be blocked from rendering until that second prefetch call completes.

But what's happening instead is that as soon as the first prefetch call completes, the component inside the Suspense boundary renders.

One solution would be for me to combine the two prefetch queries into one by moving the serial request logic into queryFn:

// code in the RSC to perform two serial fetch operations
void queryClient
  .fetchQuery({
    queryKey: ["favoritePokemonIdAndPokemonData"],
    queryFn: () =>
      // simulate database call to look up the user's favorite Pokemon
      new Promise<number>((resolve) =>
        setTimeout(() => resolve(Math.ceil(Math.random() * 100)), 500)
      ).then((favoritePokemonId) => {
        return {
          favoritePokemonId,
          pokemonData: queryClient.prefetchQuery(getPokemonOptions(favoritePokemonId));
        };
      })
  })

That's not ideal, though. It means that the prefetch timing would be dictating what the data looks like when it's consumed.

Another solution would be for me to create my own context to provide the promise returned by TanStack Query's fetch functions, and then use that promise on the other side of the Suspense boundary.

But I think it would be better for TanStack Query to offer first-class support for serial prefetch queries. One way to do that would be with a function that does the promise-passing described above; let's call it suspendUntil. That would make for clearer prefetch code IMO:

// code in the RSC to perform two serial fetch operations
void queryClient.suspendUntil(async () => {
  const favoritePokemonId = await queryClient.fetchQuery({
    queryKey: ["favoritePokemonId"],
    queryFn: () =>
      // simulate database call to look up the user's favorite Pokemon
      new Promise<number>((resolve) =>
        setTimeout(() => resolve(Math.ceil(Math.random() * 100)), 500)
      ),
  })
  await queryClient.prefetchQuery(getPokemonOptions(favoritePokemonId));
})

@TrevorBurnham
Copy link
Author

By the way, the TanStack Query docs on Streaming with Server Components make it sound like this streaming multiple prefetches should "just work":

As the data for each Suspense boundary resolves, Next.js can render and stream the finished content to the browser… As of React Query v5.40.0, you don't have to await all prefetches for this to work, as pending Queries can also be dehydrated and sent to the client. This lets you kick off prefetches as early as possible without letting them block an entire Suspense boundary, and streams the data to the client as the query finishes.

And later in that doc:

Then, all we need to do is provide a HydrationBoundary, but we don't need to await prefetches anymore.

So I hope you can understand my confusion here. The doc says "prefetches" plural, but in practice the streaming-while-rendering pattern only works with a single non-async prefetch.

@TkDodo
Copy link
Collaborator

TkDodo commented Mar 26, 2025

but in practice the streaming-while-rendering pattern only works with a single non-async prefetch.

that’s not true. You can prefetch multiple things in parallel, like /posts and /profile without issues. What you’re talking about is dependent prefetching.

I don’t think we’ve ever really thought about dependent prefetching with streaming, which is why I haven’t closed this issue already. Right now, I would just say it’s not supported, don’t do it. Not sure what @Ephem’s take on this is, but I’d be curious :)

@Ephem
Copy link
Collaborator

Ephem commented Mar 27, 2025

I agree with the conclusion that we simply don't support this right now, but I do think we should if we can (I've also run into these cases and have had to work around them, like falling back to useQuery which is annoying). I think this is also heavily related to our discussions on supporting disabling useSuspenseQuery, which I also think we should try to find solutions too.

The way I would see a solution for this particular problem working is by some form of queryClient.delayedPrefetch(). You'd call this immediately which would create a temporary promise for the query. When something else unblocks it to actually run, or be skipped, we'd either "replace" the promise with the fetch one or resolve/reject it in a way that the useSuspenseQuery would act as being disabled.

@TrevorBurnham
Copy link
Author

A queryClient.delayedPrefetch() method makes sense to me. Aside from the dependent query scenario, there might be other reasons for needing to delay a query until some async task has completed. For example, Next.js has been moving toward using async methods for reading request headers so that SSR can start as soon as the request is received. You don't know who the user is until the request headers have been parsed, so if you want to prefetch any user-specific data, the optimal way to do it is:

readHeaders().then(/* prefetch based on headers... */)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants