Skip to content
This repository has been archived by the owner on Jun 8, 2021. It is now read-only.

Investigate SSR for RTK Query #88

Closed
markerikson opened this issue Dec 5, 2020 · 25 comments · Fixed by #105
Closed

Investigate SSR for RTK Query #88

markerikson opened this issue Dec 5, 2020 · 25 comments · Fixed by #105
Labels
discussion Requires further discussion help wanted Extra attention is needed

Comments

@markerikson
Copy link
Collaborator

markerikson commented Dec 5, 2020

We had a couple questions about "will RTK Query support SSR?"

None of us maintainers have any real experience with SSR. So, requests for more details from the community:

  • How do you currently handle SSR work with vanilla Redux / RTK?
  • What's the data flow sequence like there?
  • How does SSR work with non-Redux options like React-Query, SWR, Urql, or Apollo?
  • What use cases would you like to have working here?

Possibly related docs/issues:

@msutkowski msutkowski added discussion Requires further discussion help wanted Extra attention is needed labels Dec 5, 2020
@JNaftali
Copy link

JNaftali commented Dec 5, 2020

When I've done SSR with Redux in the past the basic flow has been

  • dispatch actions on the server (awaiting async thunk actions as necessary: not sure how that would work with other middlewares)
  • render your react app (rendering on the server is single pass)
  • serialize the state of the redux store as JSON (sent to the client serialized in the dom or via api call)
  • use that json as the initial state of the redux store when rendering on the client

There are 2 basic approaches for how the libs you mentioned support SSR.

  1. Fetch initial data outside of React then pass it into the tree via props
  2. Multiple single-pass renders. The first render kicks off fetching (which is then stored in some external data cache outside of the react tree). Once fetches have resolved a second render happens. This time the data is in the cache so your app renders fully.

The first approach has the advantage of being relatively simple to implement and reason about, but because you have to fetch the initial data yourself it can be difficult to know what data deeply nested components need. On the other hand, multiple renders can be very tricky to implement (especially if you're working within the confines of something like Nextjs). Eventually Suspense for Data Fetching™ will give us the utility of the second approach with the simplicity of the first and we can all stop talking about this and take naps instead.

Of the libs you mentioned, only SWR doesn't have any guide or support for the second approach. All support the first approach (React-Query and SWR hooks accept initial data, Urql and Apollo caches can be populated before being provided in the react tree). https://github.com/FormidableLabs/react-ssr-prepass might be a useful resource for y'all - it's what urql uses to do the multiple renders thing, but has no urql-specific bits in it.

It looks like the first approach should be possible with RTK Query now - just await dispatch(api.getWhatever.initialize()) before rendering the react app on the server. I can try to whip up a simple nextjs example of that to see if I'm completely off-base

@JNaftali
Copy link

JNaftali commented Dec 5, 2020

K, that was harder than expected but easier than I feared 😅. My efforts can be found at https://github.com/JNaftali/rtk-query-nextjs-example or https://codesandbox.io/s/sad-sun-deczo - data is successfully fetched both server side and client side.

2 issues I ran into:

  • Something about fetchBaseQuery does not play well with whatever nextjs uses to mimic fetch on the server (https://www.npmjs.com/package/isomorphic-fetch maybe?). I worked around it, but what I did is def not production ready. Assuming this problem also happens when using node-fetch (which is pmuch the standard for using fetch in node) this should probably be fixed in RTK Query if you're interested in supporting SSR. Restore the default baseQuery in src/services/pokemon.js to reproduce.
  • Nextjs for some reason has a stick up it's butt about returning undefined from getServerSideProps. I wrote a removeUndefined util to swap in null instead for now. This issue is definitely nextjs specific, up to you if you care.

Most of src/store.js that wasn't copied from the RTK Query docs was copied from https://github.com/vercel/next.js/blob/canary/examples/with-redux/store.js

@awareness481
Copy link

awareness481 commented Dec 5, 2020

Used the with-redux-toolkit Next's example as well. Here's my repo So far so well, it seems to work without errors.

  • Redux is initialized at server/side, you can also dispatch on the server.
  • You then pass the redux state via app props.
  • RTK-Query is simply fetching on the client-side and seems to work correctly.

I was initially worried you might have to do window type check or check if the component has mounted. [1] Problem is, I don't think someone should be using RTK-Query on the server-side right (thats why I'm using RTK client-side)? I think If need be, I'd rather use fetch and pass that to the rtk-query reducer as initial state.

Let me know If I can do anything else that might be helpful.

Edit:

[1]: I have only used swr on the client-side with Next.js but react-query seems to provide some functionality for fetching on the server-side and then using react-query client-side: https://react-query.tanstack.com/docs/guides/ssr#integrating-with-nextjs

React Query can upgrade or hydrate those queries with the full functionality of the library. This includes refetching those queries on the client if they have become stale since the time they were rendered on the server.

@Ephem
Copy link

Ephem commented Dec 5, 2020

I think @JNaftali summarized the gist of it perfectly! ❤️ Having implemented the react-query SSR-solution, I'll go ahead and add some extra detail/considerations that came up when doing that. This is tricky stuff to both grok and write about, so please ask questions or grab me for a chat if I'm not expressing myself clearly enough. 😁

Hydration on page navigation

While there are a lot of detail and caveats that goes into this, I think RTK Query can support SSR in a bunch of cases in its existing form, with an important exception, Next.js getStaticProps and getServerSideProps. The reason is that Redux currently only supports hydrating data on store creation. So, these would currently work for the initial page load but I don't think the current API's allows for implementing a good solution for page navigations. @JNaftali tries to work around this in the demo by creating a new store on each page navigation with the state from the existing store merged with the state from getServerSideProps. This is a very clever way to try to work around limitations in the existing API, but I don't think it will work for a few reasons, main one being that all existing subscriptions will be towards the old store.

Even if the subscriptions could be moved to the new store, I think performance would be bad and I think another issue would be any asynchronous work currently pending in middlewares.

This really is complex and I'm struggling for a way to express it in a way that makes sense, but, the key thing to note here is that both getServerSideProps and getStaticProps run on each page navigation and they run on the server. Since we want to use the existing API-abstractions from RTK Query to fetch data even in those functions, there needs to be a way to hydrate/merge the serialized output of those functions into an existing store on the client side.

Another caveat with Next.js here is that there are currently no place where you can receive data from the server and do something with it before render. You can see this in the official with-redux example for Next.js (used in @JNaftali's demo), in that the Redux store actually gets created inside of render. The same goes for any hydration that needs to happen on page navigation, any solution needs to happen inside of render. It can't happen in an effect, since those does not run on the server, so initial page render would break for SSR.

The React Query SSR-docs might be a decent source of inspiration for how we designed the API there.

The Hydration tests could also be valuable to look at, at least what different kinds of cases we are trying to cover.

Yet another caveat with Next.js is that since these functions run on the server where you don't have access to client state, you can't really know if you need to fetch new data or not, you will always fetch data on page navigation even if you have "fresh" data in the cache on the client. Not a huge deal and something you kind of have to live with. You might have to account for the case where the client already has fresher data and avoiding merging the "older" data from the server though..

More notes on hydration

  • You need to create a new store in each getServerSideProps/getStaticProps for the API-abstractions to work. The pattern to document here should probably be to create a "minimal store" with only the RTK Query-reducers added in, to avoid having to create and dehydrate an entire store each time?
  • You currently de/rehydrate the timestamps etc as well as the data. I think this is nice, since that way determining when a query is "stale" is based on when it was actually fetched, even if that was on the server. This does require the server and client to have the correct time though or you might run into bugs. Also, make sure you use UTC timestamps to avoid timezone pains.
  • It might be worth thinking about how errors plays into hydration. Error objects are often hard to serialize/deserialize, is there a way for users to catch errors and convert them into something serializeable before they get stored in the state? Should queries with errors be de/rehydrated at all by default, or refetched on the client instead? Should users be able to control this? Also, some errors might be catastrophic and need to send a 500-error or something, while some can be recovered.

Refetching queries after initial render on the client

This is something I'm not sure if RTK Query handles today? I don't really have time to test, but if a query has become stale since it was fetched on the server (for example if the html is cached in a CDN, but the query has a cachetime that is shorter), you probably want to refetch that on the client after the initial render has happened?

@Ephem
Copy link

Ephem commented Dec 5, 2020

Oh, and a note on a part of @JNaftali's example that I know from experience can be hard to understand when not used to Next.js and SSR. On the initial page render that happens on the server, there are actually two stores getting created. One store is created in getServerSideProps when that runs. This store gets dehydrated and then a new store is created inside of render which rehydrates that data. So there is actually a de/rehydration going on between two stores on the server as well.

On the client, yet another store is created which is also initialized with the data from getServerSideProps.

@phryneas
Copy link
Collaborator

phryneas commented Dec 5, 2020

I'll probably need to re-read all this a few more times over the next days (and try stuff out) to completely wrap my head around it, so this are my first thoughts that might be based on completely wrong assumptions.

Errors

For errors, we don't have to assume non-serializable data for now. Everything that is thrown in baseQuery runs through createAsyncThunks miniSerializeError function that strips away everything unexpected/non-serializable.
Errors from non-2xx do not go this way, but in baseFetchQuery they just contain the request status and response body.
Other baseQuery implementations could theoretically return non-serializable errors there, but in that case, RTK's serializableStateInvariantMiddleware would warn in that case.

Rehydration/cache merging

If I have understood this correctly, my theory would be that we could prevent a lot problems by not trying to hydrate/rehydrate the api as a part of the full redux store here (potentially overriding values), but maybe dispatching some kind of rehydrateSSRCache action with server-side cache data. That could be merged selectively by the according reducers into potentially pre-existing api cache state.

That action would probably contain the current api state on the server and current timestamp of the server. This way, the client could just calculate a const diff = clientTimeStamp - serverTimeStamp, adjust all received cache value timestamps accordingly and then only override state that is newer.
Component<->endpoint subscriptions would not be synced, but immediately re-established on the client by rendering anyways. There would need to be a grace period of a few seconds after such a re-hydration-action during which the middleware pruning "state state" would simply pause, to allow all component subscriptions to settle into a good state.

@awareness481
Copy link

This really is complex and I'm struggling for a way to express it in a way that makes sense, but, the key thing to note here is that both getServerSideProps and getStaticProps run on each page navigation and they run on the server

@Ephem

One small correction here. getStaticProps only runs on each page nagivation on dev mode. In production it only runs once, during the build time.

@Ephem
Copy link

Ephem commented Dec 5, 2020

I'll probably need to re-read all this a few more times over the next days (and try stuff out) to completely wrap my head around it, so this are my first thoughts that might be based on completely wrong assumptions.

This has taken me years to wrap my head around, it's a really complex domain and hard to communicate. 😄 That said, from your response it does seem you've definitely understood the gist of it!

For errors, we don't have to assume non-serializable data for now

Awesome! I haven't looked at how this is handled in RTK/RTK Query at all so might very well be a non-issue, just thought I'd point out that it's something extra to think about when it comes to SSR.

... but maybe dispatching some kind of rehydrateSSRCache action with server-side cache data

I wanted to focus on the challenges and not go into solutions too much to keep the post manageable, but I definitely think this is the way to go! It's nice that this approach keeps this concern separated into RTK Query.

Since I don't know of any way currently to do this outside of render in Next.js right now, I imagine you might want to abstract away dispatching that action into a useHydrate-hook or <Hydrate>-component or whatever you think makes sense for RTK Query (React Query provides both) to hide the ugliness and avoid teaching users it's okay to dispatch in render..

This way, the client could just calculate a const diff = clientTimeStamp - serverTimeStamp

The problem with this is that the response might be cached indefinitely between the server rendering the html markup and the client hydrating it, so the serverTimeStamp would be outdated. I haven't come up with a way to solve this other than just relying on the server and client having their time set properly. 🤷

@Ephem
Copy link

Ephem commented Dec 5, 2020

@awareness481 Good point, thanks for clarifying!

I think what I meant to say was that from the clients perspective, the code that fetches the data from getStaticProps that was generated during build time runs on every page navigation, so the challenge is still the same. The data that was generated at build still needs to be rehydrated during page navigation. Still a very good clarification, there is so much nuance with this stuff. 🙈

Edit: This made me realize that getStaticProps is probably a better/simpler way than CDN caching to think about the server/client timestamps and the value of hydrating those as well. 😄

@JNaftali
Copy link

JNaftali commented Dec 12, 2020

@Ephem's comments were, indeed, right on the money. To sidestep the issues about how nextjs does ssr I decided to make another example which should be free of such bugs using Razzle - https://github.com/JNaftali/razzle-rtk-query-example. Razzle, if you're not familiar, has much less magic than nextjs - it's pmuch just a preconfigured version of webpack + express.

I believe (although I am not certain) that it would still be possible to do this using nextjs, but when configuring the store I would have to make the reducer handle a global action to replace the state wholesale (since in nextjs I wouldn't be able to get the server rendered data in time to pass it in to preloadedState as I initialize the store).

Notably I still get an error when using fetchBaseQuery - something about how RTK query uses signals is not available in node and I don't know what.

Hope this is helpful!

@msutkowski
Copy link
Member

msutkowski commented Dec 12, 2020

@Ephem's comments were, indeed, right on the money. To sidestep the issues about how nextjs does ssr I decided to make another example which should be free of such bugs using Razzle - https://github.com/JNaftali/razzle-rtk-query-example. Razzle, if you're not familiar, has much less magic than nextjs - it's pmuch just a preconfigured version of webpack + express.

I believe (although I am not certain) that it would still be possible to do this using nextjs, but when configuring the store I would have to make the reducer handle a global action to replace the state wholesale (since in nextjs I wouldn't be able to get the server rendered data in time to pass it in to preloadedState as I initialize the store).

Notably I still get an error when using fetchBaseQuery - something about how RTK query uses signals is not available in node and I don't know what.

Hope this is helpful!

Can you share a link to this repo or CSB? If you're using node-fetch, the signal usage should be the same.

@JNaftali
Copy link

@msutkowski I made a branch that demonstrates the error - https://github.com/JNaftali/razzle-rtk-query-example/tree/fetchbasequery-not-working. I also copied the error (as copied from the redux state) into a gist - https://gist.github.com/JNaftali/6b7ca6be39a1374c346c70cd1883bba5

@phryneas
Copy link
Collaborator

Hmm. Seems like your node-fetch polyfill is kicking in too late.
If there is no AbortController available, RTK has a fallback:

  const AC =
    typeof AbortController !== 'undefined'
      ? AbortController
      : class implements AbortController {
          signal: AbortSignal = {
            aborted: false,
            addEventListener() {},
            dispatchEvent() {
              return false
            },
            onabort() {},
            removeEventListener() {}
          }
          abort() {
            if (process.env.NODE_ENV !== 'production') {
              if (!displayedWarning) {
                displayedWarning = true
                console.info(
                  `This platform does not implement AbortController. 
If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'.`
                )
              }
            }
          }
        }

and that does not seem to be compatible with node-fetch. So you have to make sure that node-fetch is imported (and their AbortController is available globally) before createAsyncThunk is called the first time.

@msutkowski
Copy link
Member

msutkowski commented Dec 12, 2020

@phryneas @JNaftali Just looked through this. The quickest fix is to swap isomorphic-fetch for cross-fetch.

The longer version is to override the razzle config to not mangle output in the terser plugin, which doesn't seem worth it to me. The redux-toolkit types are correct like @phryneas says, but the razzle config is dropping the class when mangling.

Relevant issue: node-fetch/node-fetch#784

@JNaftali
Copy link

I hadn't heard of cross-fetch! If that works better I'd be happy to use it. Not sure it'd be so easy to switch if I were using something like Nextjs that comes with isomorphic-unfetch built in, but for razzle that seems like a fine solution. Thanks!

@msutkowski
Copy link
Member

@JNaftali I'm going to do some experimenting this weekend and see what I can come up with. I've only used Next for prototypes and debugging misc. things, so my exposure is pretty limited. Thanks for providing the repo to help us solve the razzle case.

@phryneas
Copy link
Collaborator

phryneas commented Dec 12, 2020

Another solution might really be just to import "isomorphic-fetch" at top of all files that use createAsyncThunk.

PS: apparently not. isomorphic-fetch never sets the global AbortController 🤦

if (!global.fetch) {
	global.fetch = module.exports;
	global.Response = realFetch.Response;
	global.Headers = realFetch.Headers;
	global.Request = realFetch.Request;
}

@JNaftali
Copy link

JNaftali commented Dec 12, 2020

Couldn't figure out how to get cross-fetch to solve all my problems - the polyfill version (import 'cross-fetch/polyfill') had the same problem as isomorphic-fetch, and if there's a way to pass a fetch implementation for fetchBaseQuery to use I couldn't figure it out.

Putting the following at the top of my services/pokemon.ts file does indeed seem to fix things:

import fetch, { Headers, Request, Response } from 'node-fetch';
import AbortController from 'abort-controller';

global.fetch = fetch as any;
global.Headers = Headers as any;
global.Request = Request as any;
global.Response = Response as any;
global.AbortController = AbortController;

I'm confused why putting that at the top of server.tsx (or index.ts) doesn't work though. It's the entrypoint! It runs before everything else by definition! Maybe there's something about modules in JS that I don't understand 🤔

@msutkowski
Copy link
Member

@JNaftali Thanks for diving into this. I didn't actually try to yarn start:prod 🤦 . I'm going to open a quick PR to allow fetchBaseQuery to take a custom fetch fn, which solves all of that, minus the AbortController problem. I did try this with cross-fetch as well, and it also works, but that also doesn't set a global AbortController.

I'll try to get a Next.js repro going as well to see what steps are needed there.

image

Feel free to ping me on Discord in Reactiflux if you run into any more issues :)

@Ephem
Copy link

Ephem commented Dec 13, 2020

@msutkowski Nice work! My understanding was that this issue was broader than just the fetchBaseQuery-thing though, was it accidentally closed prematurely?

@phryneas
Copy link
Collaborator

Probably an auto-close due to this issue being mentioned in the closed PR :)

@phryneas phryneas reopened this Dec 13, 2020
@msutkowski
Copy link
Member

Yup, sorry about that. Shouldn't have been closed.

@gfortaine
Copy link
Contributor

gfortaine commented Jan 18, 2021

Hello everybody,

Many thanks for this very insightful thread. As rightly catched by @JNaftali , it looks like that there are at least 2 bugs to enable SSR with Next.js :

  1. first one is to get rid of the AbortSignal error : https://gist.github.com/JNaftali/6b7ca6be39a1374c346c70cd1883bba5
  2. the second one seems related to the error field in the apiSlice. Indeed, it seems that error is explicitly set to undefined when the request is fulfilled. However, it appears that error shouldn't return undefined to be able to be supported by getServerSideProps in Next.js (see : getServerSideProps error: "Reason: `undefined` cannot be serialized as JSON. Please use `null` or omit this value all together." vercel/next.js#11209). By the way, a PR has been prepared to address it : SSR Support: delete substate.error property (Next.js getServerSideProps) #152

@gfortaine
Copy link
Contributor

@msutkowski It looks like that Node 15 has built-in support for AbortController and AbortSignal : axios/axios#3506

Thus, by using next branch with Node 15, it seems that we finally managed to get SSR working with RTK Query 🎉 :

https://github.com/gfortaine/rtk-query-nextjs-example

@phryneas
Copy link
Collaborator

I'm closing everything here now. If there's any need for further discussion, that should take place in a new issue over at https://github.com/reduxjs/redux-toolkit/

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
discussion Requires further discussion help wanted Extra attention is needed
Projects
None yet
Development

Successfully merging a pull request may close this issue.

7 participants