Search code examples
reactjstypescriptapollo

How to properly rehydrate serverside Apollo cache when using `renderToPipeableStream`?


I am doing SSR with Reacts renderToPipeableStream and use ApolloClient to fetch data in components. In order to rehydrate the serverside cache in the client i need to extract the cache and expose it on the global window object using an inline script in the html sent by the server.

Thankfully React 18 handles Suspense during SSR and I can simply suspend rendering the script tag which exposes the cache until all requests are settled.

export const ApolloSSRCache = () => {
  Object.values(renderCache).forEach((wrapped) => wrapped());
  return (
    <script
      dangerouslySetInnerHTML={{
        __html: `window.__APOLLO_STATE__=${JSON.stringify(
          client.extract()
        ).replace(/</g, '\\u003c')};`,
      }}
    />
  );
};

The requests are wrapped in a suspender which throws the promise if it's not settled.

export function wrapPromise<T>(promise: Promise<T>): () => T {
  let status = 'pending';
  let response: T;
  const suspender = promise.then(
    (res) => {
      status = 'success';
      response = res;
    },
    (err) => {
      status = 'error';
      response = err;
    }
  );
  return () => {
    switch (status) {
      case 'pending':
        throw suspender;
      case 'error':
        throw response;
      default:
        return response;
    }
  };
}

This is working and all the requests made in any component during serverside rendering gets rehydrated into the client side cache which means the useQuery hook on the client can use the cache to immediately serve a response. This is neccessary otherwise you make twice the number of requests and may cause flickering if you show a loading state when fetching data on the client.

At the moment I have to keep track of requests / promises manually and wait until all of them are settled. It seems a bit hacky to keep track of all requests made by Apollo if Apollo already has a cache in place that knows about the state of every request.

Does anyone know if there's a proper way of exposing and rehydrating the cache when rendering to a stream?

It needs to work with Suspense because I use code splitting with dynamic imports..

Update: If you use useSuspenseQuery on the server side and put the script tag exposing your cache below the rendered children the cache will get populated fine because the components executing the query suspend.

Thus there's no need to manually suspend the tag as client.extract() will be called after all requests settled.

Update 2: useSuspenseQuery ignores {fetchPolicy: 'network-only'} and serves cached data during serverside rendering until the server is restarted.


Solution

  • There is no official way in React to do this at this point in time.

    If you want to know more about the background and the "missing features in React", I have a conference talk on that: https://portal.gitnation.org/contents/the-rocky-journey-of-data-fetching-libraries-in-reacts-new-streaming-ssr

    That said, it works quite well (with some hacks) in Next.js using the @apollo/experimental-nextjs-app-support package, and we're right now in the process of creating a new @apollo/client-react-streaming package that will also allow using that in other environments.

    We'll probably release that within the next few weeks.

    I'd recommend you to follow this PR that contains an integration test for Vite + renderToReadableStream (and the other 0.9.0 PRs) - renderToPipeableStream will be used slightly differently, you can see that with a version of React that has been extended with some "hacked hooks" in this integration test PR.

    All that said: we'll not support useQuery in streaming SSR. Apollo Client now ships with suspense-enabled hooks like useSuspenseQuery, so we focus on those instead. useQuery will only render once, and as a result will always render a loading state on the server - and you'd end up with a rehydration mismatch in the browser as a result if you tried to transport the data over for that.