apollo-client icon indicating copy to clipboard operation
apollo-client copied to clipboard

Using toPromise in a tanstack router loader loses internal queryRef

Open jeremyoverman opened this issue 11 months ago • 14 comments

Issue Description

When using a preload query from createQueryPreloader in a tanstack router loader method and .toPromise(), the resolved queryRef is lost when navigating between routes, but not on refresh.

For example, given this apollo setup:

import { ApolloClient, createQueryPreloader, InMemoryCache } from "@apollo/client";

export const apollo = new ApolloClient({
  uri: "https://rickandmortyapi.com/graphql",
  cache: new InMemoryCache(),
});

export const preloadQuery = createQueryPreloader(apollo);

And this route:

import { graphql } from '@/gql'
import { preloadQuery } from '@/utils/graphql'
import { useReadQuery } from '@apollo/client'
import { createFileRoute, useLoaderData } from '@tanstack/react-router'

const GetRickDocument = graphql(`
  query GetRick {
    characters(page: 1, filter: { name: "Rick" }) {
      results {
        id
        name
      }
    }
  }
`)

export const Route = createFileRoute('/rick')({
  component: RouteComponent,
  loader: async () => {
    // If I don't use toPromise, it works as expected but the query doesn't block
    // the loader.
    //
    // const queryRef = preloadQuery(GetRickDocument);

    const queryRef = await preloadQuery(GetRickDocument).toPromise();
    
    // Looks correct even when navigating pages
    console.log('queryRef from loader', queryRef);
    
    return queryRef;
  }
})

function RouteComponent() {
  const queryRef = useLoaderData({ from: Route.id })

  // Missing internal refs when navigating pages (but not on refresh)
  console.log('queryRef from component', queryRef)

  const { data: { characters }} = useReadQuery(queryRef);

  return (
    <div>
      <h1>Ricks</h1>
      <ul>
        {characters?.results?.map(character => (
          <li key={character?.id}>{character?.name}</li>
        ))}
      </ul>
    </div>
  )
}

When navigating directly to http://localhost:3000/rick, or refreshing the page, the page loads as expected. However, when navigating to the page via a link, I get the error:

Expected a QueryRef object, but got something else instead.

Logging out the queryRef from the loader shows the expected output:

queryRef from loader {Symbol(apollo.internal.queryRef): InternalQueryReference2, Symbol(apollo.internal.refPromise): Promise, toPromise: ƒ}

However the queryRef in the component seems to lose it's internal reference:


rick.tsx:38 queryRef from component {Symbol(apollo.internal.queryRef): InternalQueryReference2, Symbol(apollo.internal.refPromise): Promise, toPromise: ƒ}
rick.tsx:38 queryRef from component {Symbol(apollo.internal.queryRef): InternalQueryReference2, Symbol(apollo.internal.refPromise): Promise, toPromise: ƒ}
rick.tsx:38 queryRef from component {toPromise: ƒ}
rick.tsx:38 queryRef from component {toPromise: ƒ}

Link to Reproduction

https://github.com/jeremyoverman/apollo-loader-demo

Reproduction Steps

  1. Clone the reproduction repo
  2. npm install
  3. npm run dev
  4. Navigate to either Rick or Morty
  5. Notice the Expected a QueryRef object, but got something else instead. error
  6. Check the console to see the incorrect value of queryRef from component
  7. Refresh the page, notice it loads as expected

@apollo/client version

3.13.8

jeremyoverman avatar May 13 '25 18:05 jeremyoverman

Hey @jeremyoverman 👋

I'll need to play around with this some more, but I'd be curious what happens if you return queryRef in an object:

export const Route = createFileRoute('/rick')({
  component: RouteComponent,
  loader: async () => {
    const queryRef = await preloadQuery(GetRickDocument).toPromise();
    
    return { queryRef };
  }
})

function RouteComponent() {
  const { queryRef } = Route.useLoaderData({ from: Route.id })

  // ...
}

I'm not super familiar with TanStack Router yet as I've mostly used React Router, but I wonder if its doing some sort of promise detection on object properties and this is causing that queryRef to break apart in your component (this is just my theory, but I don't have concrete evidence for this). Let me know if that changes anything!

jerelmiller avatar May 13 '25 20:05 jerelmiller

Hey @jerelmiller 👋

Just gave that a shot and no luck, still the same issue. I believe Tanstack router does have some promise handling in the loader though, but I'm not entirely familiar with the inner workings there either 🤔

Just gave returning the promise itself a shot without awaiting it as well, but have the same issue.

export const Route = createFileRoute('/rick')({
  component: RouteComponent,
  loader: () => preloadQuery(GetRickDocument).toPromise(),
})

function RouteComponent() {
  const queryRef = useLoaderData({ from: Route.id })

  const { data: { characters }} = useReadQuery(queryRef);

  // ...

Thanks for checking this out!

jeremyoverman avatar May 13 '25 20:05 jeremyoverman

Dang. the only other thing I could think of without diving in is the fact that queryRef includes symbol keys and perhaps these are getting stripped out by the router when returned by the loader (since symbols aren't serializable, this would make sense).

You might consider trying out our TanStack Start integration which is built for streaming SSR but I believe should work with plain TanStack router (@phryneas please correct me if I'm wrong here). I believe that integration will ensure the queryRef is properly serializable to avoid this issue (in case this is the cause of this issue).

jerelmiller avatar May 13 '25 20:05 jerelmiller

For what it’s worth I did give apollo-client-integrations/packages/tanstack-start at main · apollographql/apollo-client-integrations · GitHub a try but to no avail. Here is my implementation if @phryneas wanted to give it a glance.

client.ts

import { createQueryPreloader } from '@apollo/client';
import { ApolloClient, InMemoryCache } from '@apollo/client-integration-tanstack-start';

export const client = new ApolloClient({
  cache: new InMemoryCache(),
});

export const preloadQuery = createQueryPreloader(client);

router.ts

import { createRouter as createTanStackRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
import { routerWithApolloClient } from '@apollo/client-integration-tanstack-start';
import { createQueryPreloader } from '@apollo/client';
import { client } from './providers/apollo-provider/client';

export function createRouter() {
  const router = createTanStackRouter({
    routeTree,
    defaultPreload: 'intent',
    scrollRestoration: true,
    defaultStructuralSharing: true,
    // Let Apollo handle caching in loaders instead of Tanstack Router https://tanstack.com/router/v1/docs/framework/react/guide/preloading#preloading-with-external-libraries
    defaultPreloadStaleTime: 0,
    context: {
      // auth will initially be undefined
      // We'll be passing down the auth state from within a React component
      auth: undefined!,
      apolloClient: client,
      preloadQuery: createQueryPreloader(client) as any, <--- Couldn't find the correct type for this
    },
  });

  return routerWithApolloClient(router, client);
}

// Register the router instance for type safety
declare module '@tanstack/react-router' {
  interface Register {
    router: ReturnType<typeof createRouter>;
  }
}

app.ts

import { RouterProvider } from '@tanstack/react-router';
import Auth0Provider from './providers/auth0-provider';
import { createRouter } from './router.ts';
import { useAuth0, User } from '@auth0/auth0-react';
import ApolloProvider from './providers/apollo-provider/apollo-provider.tsx';

const router = createRouter();
function InnerApp() {
  const auth = useAuth0();
  if (auth.isLoading) {
    return null;
  }

  return (
    <RouterProvider
      router={router} <-- using the client-integration-tanstack-start router
      context={{
        // There should always be an ID (sub)
        auth: auth as ReturnType<typeof useAuth0<User & { sub: NonNullable<User['sub']> }>>,
      }}
    />
  );
}

export default function App() {
  return (
    <Auth0Provider
    ...
    >
      <ApolloProvider>
        <InnerApp />
      </ApolloProvider>
    </Auth0Provider>
  );
}

$dealId.ts

...

  loader: ({ params, context }) => {
    return context.preloadQuery(DealQuery, {
      variables: { dealId: params.dealId },
    });
  },

...

  const dealQueryRef = Route.useLoaderData();
  console.log({ dealQueryRef });
  const { data } = useReadQuery(dealQueryRef);

This console.log would give me alternating responses between

Image and Image

The latter giving me the

Error message
Expected a QueryRef object, but got something else instead.

when I tried to use the queryRef in the useReadQuery

tldr; There was no difference for me in outcome when using the @apollo/client-integrations-tanstack-start and just using @apollo/client

Just to reiterate I am using Vite with React and just TanStack Router, not TanStack Start.

BrennenRocks avatar May 22 '25 22:05 BrennenRocks

I just pinged Manuel Schiller from the TanStack Router maintainers, and he noted that this could be caused by defaultStructuralSharing - could you try setting this to false?

phryneas avatar May 23 '25 08:05 phryneas

And I've opened a PR over in TanStack Router that discusses this and suggests different solutions:

https://github.com/TanStack/router/pull/4237

phryneas avatar May 23 '25 10:05 phryneas

I just pinged Manuel Schiller from the TanStack Router maintainers, and he noted that this could be caused by defaultStructuralSharing - could you try setting this to false?

This worked both when using @apollo/client-integrations-tanstack-start and without 🙏

BrennenRocks avatar May 23 '25 13:05 BrennenRocks

Great! Just as a heads-up: you don't need the @apollo/client-integrations-tanstack-start package if you only use @tanstack/router without any of the SSR features of @tanstack/start.

phryneas avatar May 23 '25 13:05 phryneas

Yeah I'm going to move forward not using the @apollo/client-integrations-tanstack-start package. Let me know if I can help with anything else. It seems that defaultStructuralSharing is a good option to have on so this would be a game changer

Thank you!

BrennenRocks avatar May 23 '25 13:05 BrennenRocks

@BrennenRocks the TanStack Router PR I have open will probably be merged in the next few days - that said, queryRefs don't benefit at all from structural sharing. A queryRef represents a network request. Either you have a new queryRef or you don't.

Only if you also passed other values from loaders, those could benefit. With Apollo Client you'd have other ways of reducing rerenders.

phryneas avatar May 23 '25 13:05 phryneas

I will probably turn on defaultStructuralSharing on a route by route basis when I'm not using useReadQuery

BrennenRocks avatar May 23 '25 13:05 BrennenRocks

This should now be fixed in TanStack Router 1.120.10, even with defaultStructuralSharing enabled. Could you please verify that it solves the problem for you?

phryneas avatar May 24 '25 10:05 phryneas

Confirmed working in @tanstack/[email protected] with defaultStructuralSharing: true at the createRouter level!

BrennenRocks avatar May 25 '25 05:05 BrennenRocks

@jeremyoverman before I close this as solved, could you please verify that it solves the problem for you too?

phryneas avatar May 26 '25 09:05 phryneas

We're closing this issue now but feel free to ping the maintainers or open a new issue if you still need support. Thank you!

github-actions[bot] avatar Jun 27 '25 05:06 github-actions[bot]

This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. For general questions, we recommend using our Community Forum or Stack Overflow.

github-actions[bot] avatar Jul 28 '25 00:07 github-actions[bot]