question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

React 18 SSR Support

See original GitHub issue

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.

Issue Analytics

  • State:closed
  • Created 2 years ago
  • Reactions:13
  • Comments:10 (2 by maintainers)

github_iconTop GitHub Comments

10reactions
wintercountercommented, Feb 9, 2022

@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.

7reactions
wintercountercommented, Feb 7, 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
Read more comments on GitHub >

github_iconTop Results From Across the Web

A Hands-on Guide for a Server-Side Rendering React 18 App
Exploring SSR with React 18, Create React App 5, and React Router 6. ... fully support Suspense on the server and Streaming SSR....
Read more >
React 18: Streaming SSR - Next.js
Supported Browsers and Features · Handling Scripts. Routing ... Deploy the app/ directory example to try Streaming SSR. OverviewReact Server Components ...
Read more >
maxam2017/react-18-ssr - GitHub
React 18 + SSR. After React v18 is published, I think it's time to build React app with server-side rendering (SSR) once with...
Read more >
Deep dive into the new Suspense Server-side Rendering ...
Thanks to the new Suspense SSR architecture in React 18, which provides a solution to all the problems! We break the work, instead...
Read more >
React v18.0 – React Blog
In React 18, we've added support for Suspense on the server and expanded ... Fix context providers in SSR when handling multiple requests....
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found