Why Replace React when bundling?
Tags: javascript, react
Published: 2020-01-08 14:20:52

Hi! I’m Ben Greenier — I’m an engineer at Microsoft working to create awesome open source projects with our partners. We get to [create software](https://github.com/microsoft) to help solve really tricky problems, and [share our stories](https://www.microsoft.com/developerblog) as we go. This means that as part of my job I get to play with lots of new technologies, learn how to use them, and help other folks do the same.

Lately I've been working on a browser app called [Overlayed](https://overlayed.app) - it helps broadcasters interact with their viewers in new ways, using overlays. Under the hood, Overlayed is powered by user-defined modules (using [ESM](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules)), that export React components. You can learn more about that, [here](https://dev.to/bengreenier/introducing-overlayed-streamer-overlays-powered-by-react-17ja) - but it's not what this post is about.

Recently I've been investigating replacing React in bundled code for the browser. A friend asked me why I needed to do this - shouldn't the bundler do the correct thing? __This post is about my specific use-case__, where-in the bundler can't do the correct thing, because it isn't aware of what's happening.

The specific bundler I'm using is [rollup](https://rollupjs.org]) - it's very good at creating [ESM bundles for the modern web](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules). When rollup runs, it [tree-shakes](https://rollupjs.org/guide/en/#tree-shaking) your code, scope-hoisting shared dependencies as it goes. Take a look at this example:

```
# module-1.js
import React from 'react'

export default React.createElement("p", undefined, "hello module-1");
```

```
# module-2.js
import React from 'react'

export default React.createElement("p", undefined, "hello module-2");
```

```
# app-entrypoint.js
import React from 'react'
import moduleOne from './module-1'
import moduleTwo from './module-2'

React.createElement("div", undefined, [moduleOne, moduleTwo]);
```

Don't worry too much about the code itself, we're more interested in the `import` statements, and their implications. If you were to step through this code the way an interpreter would, you'd probably do this:

+ Import React (into `app-entrypoint.js` scope)
+ Import Module 1 (into `app-entrypoint.js` scope)
+ Import React (into `module-1.js` scope)
+ Import Module 2 (into `app-entrypoint.js` scope)
+ Import React (into `module-2.js` scope)

As you can see, you're trying to get React three times! Of course, many JavaScript runtimes (like node, for example) use [a module cache](https://nodejs.org/api/modules.html#modules_require_cache) to prevent "actually" loading React many times, but to my knowledge this isn't possible in a browser - so your interpreter needs to evaluate the contents of React three times. This is where bundling (with scope-hoisting) helps us.

Rollup can statically analyze the above code, and realize that many things will need React. Therefore, when it creates a bundle (recall that a bundle contains all dependencies and the authored source) it can include React once, and effectively pass "references" to it in all cases. In other words, scope-hosting gives us:

+ Import React (into an isolated scope, let's call it `bundled` scope)
+ Reference React from `bundled` scope (into `app-entrypoint.js` scope)
+ Import Module 1 (into `app-entrypoint.js` scope)
+ Reference React from `bundled` scope (into `module-1.js` scope)
+ Import Module 2 (into `app-entrypoint.js` scope)
+ Reference React from `bundled` scope (into `module-2.js` scope)

As a result, only one instance of React is included, meaning our bundled source size is smaller (only one copy of React, not three). This is good news, because it means our browser needs to download and interpret less code. And it's all supported "for free" with Rollup - how great!

Now we can talk about why I'm [investigating replacing these imports](https://dev.to/bengreenier/replacing-js-imports-with-rollup-10ol) for [Overlayed](https://overlayed.app). Overlayed has an architecture that allows for third-party developers to create plugins. This is great for extensibility, but bad for bundling.

Recall that in the example above we use static analysis to determine what can be scope-hoisted. If Rollup can't determine what is being loaded when it runs (during the "build" phase of Overlayed) it can't choose to only import one copy. This presents a problem with the plugin architecture - if a plugin depends on React, and is "built" using a separate run of Rollup (as a plugin is a separate project, maintained by a third-part developer) it won't know that it's being bundled for Overlayed (and therefore will already have a copy of React) and will include a copy. This eventually leads to a slow experience for plugins, because they all contain (and load/interpret) React, even though we already have an instance loaded.

To workaround this issue, we can write a rollup plugin (or use an existing one) to replace React in the plugin's bundle, with a small "shim" that simply references React in the parent scope. We can be confident the parent scope will contain React, as plugins are only designed to be loaded by Overlayed - they won't run anywhere else.

Take the example code above. If we introduce the following as a "shim" module:

```
# react-shim.js
export default globalThis.React
```

Bundle our code with a plugin that rewrites `import React from 'react'` to `import React from './react-shim'`, and split `module-1.js` off into it's own third-party plugin (with it's own build) we end up with the following flow:

__Overlayed app build:__

+ Import React (into an isolated scope, let's call it `bundled` scope)
+ Reference React from `bundled` (into `app-entrypoint.js` scope)
+ Import Module 2 (into `app-entrypoint.js` scope)
+ Reference React from `bundled` scope (into `module-2.js` scope)

__Module 1 build:__

+ Import React from `./react-shim`
+ Configure global reference (Referencing React from  `bundled` above)
+ Reference React from `bundled` (above)
+ Import Module 1 (into `app-entrypoint.js` scope)
+ Reference React from `bundled` scope (above, into `module-1.js` scope)

By replacing React with an explicit reference in the "Module 1 build", we're able to remove React from the plugin bundle, while still loading the correct instance of React at runtime, from the parent (Overlayed) environment.

Phew! This got complicated quickly. Hopefully this can help to clarify why Overlayed isn't able to leverage the "free" scope-hoisting of React in the plugin case. If it's still not quite clear, let me know in the comments. Perhaps some revisions will be needed.

Thanks for reading,

💙🌈
-Ben

P.S: Photo by [Rural Explorer](https://unsplash.com/@ruralexplorer?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/s/photos/tree-shake?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)