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");
# 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 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 (intoapp-entrypoint.js
scope) - Import Module 1 (into
app-entrypoint.js
scope) - Reference React from
bundled
scope (intomodule-1.js
scope) - Import Module 2 (into
app-entrypoint.js
scope) - Reference React from
bundled
scope (intomodule-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
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
(intoapp-entrypoint.js
scope) - Import Module 2 (into
app-entrypoint.js
scope) - Reference React from
bundled
scope (intomodule-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, intomodule-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)