React

single-spa – Unable to resolve bare specifier ‘react-dom/client’

single-spa – Unable to resolve bare specifier ‘react-dom/client’

Our whole setup involves multiple micro-frontends powered by single-spa and React configured to share libraries via dynamic imports using systemjs import maps. After updating to React 18, we encountered the following error:

Error: Unable to resolve bare specifier 'react-dom/client' from '/path/to/file.js'

Some background

Our setup is quite complex but it is based around the recommended setup by single-spa. We have the following import map.

{
    "imports": {
        "react": "/js/vendors/react/18.2.0/umd/react.production.min.js",
        "react-dom": "/js/vendors/react-dom/18.2.0/umd/react-dom.production.min.js",
        "react-dom/server": "/js/vendors/react-dom/18.2.0/umd/react-dom-server.browser.production.min.js",
        ...
    }
}

Then we configure Webpack to resolve these packages via dynamic import using the externals configuration.

"externals": [
    /^react$/,
    /^react\/lib.*/,
    /^react-dom$/,
    /.*react-dom.*/,
    ...
]

The react-dom package changed the way they render components. They now use react-dom/client instead of react-dom to create and render the root component.

import React from 'react';
import ReactDOMClient from 'react-dom/client';
import MainApp from './MainApp';

const root = ReactDOMClient.createRoot(
  document.getElementById('root') as HTMLElement
);

root.render(
  <MainApp />
)

No matter how I tried, I just can’t make it resolve to react-dom/client.

So I got my hands dirty.

Workaround

I inspected the code in react-dom/client and I found out that it is just a wrapper for createRoot and hydrateRoot functions from react-dom. I just re-created the wrapper inside my src and use it to initialize single-spa.

// src/ReactDOMClient.ts
const ReactDom = require('react-dom');

let createRoot;
let hydrateRoot;

if (process.env.NODE_ENV === 'production') {
  createRoot = ReactDom.createRoot;
  hydrateRoot = ReactDom.hydrateRoot;
} else {
  var i = ReactDom.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
  createRoot = function(c, o) {
    i.usingClientEntryPoint = true;
    try {
      return ReactDom.createRoot(c, o);
    } finally {
      i.usingClientEntryPoint = false;
    }
  };
  hydrateRoot = function(c, h, o) {
    i.usingClientEntryPoint = true;
    try {
      return ReactDom.hydrateRoot(c, h, o);
    } finally {
      i.usingClientEntryPoint = false;
    }
  };
}

export {
  createRoot,
  hydrateRoot
};

This is now my new single-spa lifecycle scripts.

// src/lifecycle.ts
import React from 'react';
import singleSpaReact from 'single-spa-react';
import ErrorBoundary from "./components/ErrorBoundary";
import MainApp from './MainApp';

const ReactDOMClient = require('./ReactDomClient');

const reactLifecycles = singleSpaReact({
  React,
  ReactDOMClient,
  rootComponent: MainApp,
  errorBoundary: ErrorBoundary
});

export const bootstrap = [
  reactLifecycles.bootstrap,
];

export const mount = [
  reactLifecycles.mount,
];

export const unmount = [
  reactLifecycles.unmount,
];

And now it works! I just have to replicate the same fix to gazillion micro-frontends more (just 4 actually) and deploy them all!

Featured image by RealToughCandy.com

Leave a reply

Your email address will not be published. Required fields are marked *