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