Play with React Concurrent Mode with Your Gatsby Site

So the React team released curious cat version for concurrent mode, and I want to try that with my personal sites and side projects, only to realize that by using Gatsby I do not have direct access to my ReactDOM.render(), which I am supposed to change.

TL;DR

Put in your gatsby-browser.js the following:

Solution by Fredrik Höglund:

// gatsby-browser.js
const ReactDOM = require('react-dom');

exports.replaceHydrateFunction = () => {
  return (element, container, callback) => {
    ReactDOM.createRoot(container, {
      hydrate: true,
      hydrationOptions: { onHydrated: callback },
    }).render(element);
  };
};

Notes

A quick search landed me on this issue, which brings me to Gatsby's Browser APIs. And in particular, its replaceHydrateFunction. This function is meant for customized hydration on SSR. It just so happens that it becomes our chance to swap out the ReactDOM.render() call. Gatsby will call what we return as the replacement.

exports.replaceHydrateFunction = () => {
  return (element, container, callback) => {
    console.log('rendering!');
    ReactDOM.render(element, container, callback);
  };
};

And from React's official docs on Concurrent Mode, this is what we should change:

import ReactDOM from 'react-dom';

// If you previously had:
// ReactDOM.render(<App />, document.getElementById('root'));
// You can opt into Concurrent Mode by writing:

ReactDOM.createRoot(document.getElementById('root')).render(<App />);

My initial attempt was to write my tweak like this:

exports.replaceHydrateFunction = () => {
  return (element, container) => {
    ReactDOM.createRoot(container).render(element);
  };
};

It works. But on local build only. On production build, my page content gets duplicated, as you can see in this preview.

~~Out of my amateurish understanding around Gatsby, this looks like a DOM hydration issue. I now have a blurry understanding about why this following code works, but I probably should not be misleading people. If anybody has a better understanding, please teach me 🙆🏻‍♀️~~

I later on realize the problem was probably due to not calling ReactDOM.hydrate, which is supposed to be the default behavior. Below has been updated to some code that works:

exports.replaceHydrateFunction = () => {
  return (element, container, callback) => {
    ReactDOM.hydrate(element, container, callback);
    ReactDOM.createRoot(container).render(
      process.env.NODE_ENV === 'production' ? callback(element) : element
    );
  };
};

But Fredrik Höglund pointed out that this is problematic, because

In legacy mode, ReactDOM.render and ReactDOM.hydrate are two separate functions. In concurrent mode, there is only one function, ReactDOM.createRoot, but with an option for hydrate. Src if curious

So a more proper fix will be as follows:

exports.replaceHydrateFunction = () => {
  return (element, container, callback) => {
    ReactDOM.createRoot(container, {
      hydrate: true,
      hydrationOptions: { onHydrated: callback },
    }).render(element);
  };
};

And you can check out the twitter discussion.

More notes

Don't read these.

When and how does Gatsby call render?

It calls the renderer which is the return of replaceHydrateFunction, defaults to ReactDOM.hydrate. Then, it will call onInitialClientRender, which is the third parameter, callback, in the return function of replaceHydrateFunction.

What does ReactDOM.hydrate do in a Gatsby site?

According to the Gatsby documentation on DOM hydration:

hydrate() is a ReactDOM function which is the same as render(), except that instead of generating a new DOM tree and inserting it into the document, it expects that a React DOM already exists with exactly the same structure as the React Model. It therefore descends this tree and attaches the appropriate event listeners to it so that it becomes a live React DOM. Since your HTML was rendered with exactly the same code as you're running in your browser, these will (and have to) match perfectly.

Checking our Gatsby site's public directory, those are some uglified HTMLs.

The hydration occurs on the <div id="___gatsby">...</div> element defined in default-html.js.

But I still don't understand what caused the duplication above -.- It duplicates the child element of <div id="___gatsby">...</div>.

What happens if the DOM isn't properly hydrated or onInitialClientRender isn't properly called?

  • event handlers don't get attached
  • css-in-js doesn't get inserted

Links