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

React 18 SSR Support #8365

Closed
wintercounter opened this issue Jun 9, 2021 · 10 comments
Closed

React 18 SSR Support #8365

wintercounter opened this issue Jun 9, 2021 · 10 comments

Comments

@wintercounter
Copy link

I'd like to start a discussion about React 18 and its changes to Suspense/SSR and what that means to us Apollo users. It was always a pain point for me that I couldn't do proper bundle splitting (w/o 3rd party tools) due to the lack of Suspense support during SSR.

With React 18 we get SSR Suspense support, and it seems like the new pipeToNodeWritable API can completely eliminate getDataFromTree (at least in its current form).

The new API will be something like this. (https://codesandbox.io/s/github/facebook/react/tree/master/fixtures/ssr2?file=/server/render.js)

const {startWriting, abort} = pipeToNodeWritable(
    <DataProvider data={data}>
      <App assets={assets} />
    </DataProvider>,
    res,
    {
      onReadyToStream() {
        res.statusCode = didError ? 500 : 200;
        res.setHeader('Content-type', 'text/html');
        res.write('<!DOCTYPE html>');
        startWriting();
      },
    }
  );

This has an interesting problem: where and how we will pass INITIAL_APOLLO_STATE on. The previous common practice was to simply get the data from the cache, put it on window in the markup, load it on the client-side during hydration.

For me, it looks like that we will be able to do this without any extra code/workaround now, and we can simply include the above process in our code. Something like:

<html>
    <body>
        ...
        <script dangerouslySetInnerHTML={{ __html: `
            window.INITIAL_APOLLO_STATE = ${myInMemoryCacheInstance.extract()}
        ` }} />
    </body>
</html>

I haven't experimented with it yet, but I'll soon and get back with the results.

@brendanmoore
Copy link

<html>
    <body>
        ...
        <script dangerouslySetInnerHTML={{ __html: `
            window.INITIAL_APOLLO_STATE = ${myInMemoryCacheInstance.extract()}
        ` }} />
    </body>
</html>

This is great! I was thinking about a similar solution, where and how that <script /> is added is up to ApolloProvider (or similar) and it should "just work"

@wintercounter
Copy link
Author

Unfortunately, it's not so easy. The problem is that you'll need Suspense + React.lazy to make this work. If React won't provide any hook level API for suspense then we will still need our own wrappers. Also, this cannot work at all using streaming. The new APIs will provide onReadyToStream and onCompleteAll during SSR. onCompleteAll does support dynamic imports through Suspense + Lazy, but that's all, it won't wait for our queries to resolve first.

@brendanmoore
Copy link

Ah okay that is unfortunate. I guess in my head I assumed we could "throw" a suspender and the server-side suspense would wait for it to resolve, we'd add a new <script />, and each new one would blat the previous

@wintercounter
Copy link
Author

wintercounter commented Feb 6, 2022

Last week I decided to give another try to the topic, so I dug deep into the topic. I've made work Apollo SSR with React 18 and Suspense for lazy loading together, and actually, it's not hard at all. I decided to share my method.

  1. Update React to v18.
  2. Use a custom getDataFromTree function. Apollo uses renderToStaticMarkup by default. The problem is that it will strip out all the necessary markers for hydration (in case you just need this for SEO reasons, just skip this step, you won't hydrate in that case).
const getDataFromTree = (tree, context) => {
    return getMarkupFromTree({
        tree,
        context,
        renderFunction: require('react-dom/server').renderToString
    })
}
  1. Add Suspense and lazy components as necessary to your codebase.
  2. Use the new hydrateRoot method on client side.
  3. Profit.

There is one gotcha, however.

On the first render, no data/tree will be returned. Once a lazy module was loaded, it'll start working fine. So this means after page refresh it'll work correctly. To overcome this issue I simply created a separate file on server-side that will load all the available lazy modules upon initialization.

In case you have lazy(() => import('./components/moduleA')) somewhere in you codebase. You must have require('./components/moduleA') somewhere before SSR happens so the module is already in cache.

I don't know if this is a limitation of React's SSR, Apollo's getMarkupFromTree or it's the result of swc's transpilation to CommonJS, this should be investigated further.

Seems like returning an immediately resolved promise for the module keeps rendering "sync" somehow.

Anyway, I have a working bundle splitting + Apollo + SSR combo finally!


UPDATE:

I was excited so I started to migrate a larger codebase. Turns out the above is just half of this module caching story. Seems like I need to have as many reloads as many lazy components I use in the codebase. I'll get the correct markup and preloaded state only after that. It's pretty interesting, I wonder what is happening exactly here.


UPDATE 2:

Turns out React is caching the resolved lazy values for later use. See here: https://github.com/facebook/react/blob/a724a3b578dce77d427bef313102a4d0e978d9b4/packages/react-dom/src/server/ReactPartialRenderer.js#L1296

I checked the values, and it's simply can be mocked. I'm going to try it tomorrow and come back with the results.


UPDATE 3:
I couldn't wait, I tried now :)
Works perfectly fine.

I created a preCache.ts file that I load at the initialization stage of the server. These modules are only having lazy exports.

import * as cards from '@/cards'
import * as layouts from '@/layouts'
import * as screens from '@/screens'
import * as sections from '@/sections'

const modules = [
    ...Object.entries(cards),
    ...Object.entries(layouts),
    ...Object.entries(screens),
    ...Object.entries(sections)
]

modules.forEach(m => {
    const [name, mod] = m
    if (!mod._importPath) {
        console.log('Error, no _importPath for module', name)
        return
    }
    mod._payload._status = 1
    mod._payload._result = require(mod._importPath)
})

This is what the lazy exports look like. I just wanted to keep the import names at the same place, but it's up to you how you patch your module later.

export const Search = lazy(() => import('./Search'))
Search._importPath = '@/sections/Search'

UPDATE 4:
Found one more issue. All lazy components need to be wrapped with Suspense, otherwise, renderToString won't return the required markers in the markup and hydration will fail.

I create my own lazy function that solves this, so far so good.

import React, { Suspense, lazy as _lazy } from 'react'

const lazy = (componentFn, importPath) => {
    const LazyMod = _lazy(componentFn)
    LazyMod._importPath = importPath
    const mod = props => (
        <Suspense fallback={null}>
            <LazyMod {...props} />
        </Suspense>
    )
    mod._lazyMod = LazyMod
    return mod
}

export default lazy

@meierchris
Copy link

@wintercounter Could you maybe share a small codesandbox for your working bundle splitting + Apollo + SSR combo? That would be great.

@wintercounter
Copy link
Author

If you provide me a basic Apollo SSR setup, I'll adjust it, but I don't really want to set up the base for this :) I really hate Codesandbox, it's buggy, slow, and annoying to work with :D

@wintercounter
Copy link
Author

@meierchris

Meanwhile, I have quickly put together a demo: https://github.com/wintercounter/react18-apollo-ssr-bundle-split-demo
You can run it using GitPod or locally. I added some basic readme about the setup. Please let me know how it went.

Since I posted this solution here, I started to use it in production on a large codebase, I don't have any issues.

@kiyasov
Copy link

kiyasov commented Feb 22, 2022

Apollo will support threaded rendering?

@jpvajda
Copy link
Contributor

jpvajda commented May 11, 2022

#9627

@jerelmiller
Copy link
Member

jerelmiller commented Oct 25, 2022

Hey all 👋

Just wanted you to be aware that we’ve just opened up an RFC (#10231) where we have a detailed approach to React suspense and SSR. We plan to continue the conversation over there so please have a look! Thanks for all the discussion so far!

@apollographql apollographql locked and limited conversation to collaborators Oct 25, 2022
@jpvajda jpvajda modified the milestones: Release 3.10, Release 3.8 Nov 1, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.