DEV Community

Cover image for Why Replace React when bundling?
Ben Greenier
Ben Greenier

Posted on

Why Replace React when bundling?

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 to help solve really tricky problems, and share our stories 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 - it helps broadcasters interact with their viewers in new ways, using overlays. Under the hood, Overlayed is powered by user-defined modules (using ESM), that export React components. You can learn more about that, here - 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 - it's very good at creating ESM bundles for the modern web. When rollup runs, it tree-shakes 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");
Enter fullscreen mode Exit fullscreen mode
# module-2.js
import React from 'react'

export default React.createElement("p", undefined, "hello module-2");
Enter fullscreen mode Exit fullscreen mode
# app-entrypoint.js
import React from 'react'
import moduleOne from './module-1'
import moduleTwo from './module-2'

React.createElement("div", undefined, [moduleOne, moduleTwo]);
Enter fullscreen mode Exit fullscreen mode

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 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 for Overlayed. 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
Enter fullscreen mode Exit fullscreen mode

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 on Unsplash

Top comments (0)