Running React Native everywhere: Browser Extensions & Electron
Matteo Mazzarolo
Posted on September 28, 2021
TL;DR
Fourth part of the "Running React Native everywhere" series: a tutorial about structuring your monorepo to run multiple React Native apps targeting different platforms.
This time, we'll focus on running React Native in an Electron app and in a browser extension.
About web-based platforms
⚠️ This post is more of a fun experiment than a real tutorial :)
I'm not aware of many React Native for Web apps running in Electron in production (besides Ordinary Puzzles and DevHub). And I've never heard of anyone running React Native for Web in a browser extension before.
Now that we added support for React Native on the web, we can leverage web-based frameworks to run our web app on different platforms:
- With Electron, we can build cross-platform desktop apps to run our React Native for Web app.
- With the WebExtension API (for Firefox) and the Chrome Extension API (for Chrome, Edge, Opera, and Vivaldi), we can run our React Native for Web app in a browser extension.
In both cases, we'll re-use our web app workspace as the foundation.
If you’re not familiar with web development this section will feel somewhat different from the rest of the tutorial because we won't work with anything really specific to React Native.
This is more about adding support for Electron and a browser extension to a web app. Still, I think it's still a valuable example of how our React Native JavaScript code can run everywhere.
Electron
Electron is a popular framework for building cross-platform desktop apps with JavaScript, HTML, and CSS.
Many popular apps like Visual Studio Code or Slack are built with Electron.
Let's start by addressing the elephant in the room: yes, Electron apps can (and often do) perform poorly and not fit in with the rest of the operative system. That said, Electron is still a valid option for shipping desktop apps on platforms not yet supported by React Native (e.g., Linux) or if you don't want to (or can't) deal with Windows/macOS native code.
This tutorial will show you the bare minimum setup required to develop your React Native for Web app on Electron.
If you're interested in a more in-depth tutorial, please check "Building a desktop application using Electron and Create React App".
Let's start by duplicating the React Native for Web workspace into a new electron
one.
From the packages/
directory, run:
cp -R web electron && cd electron
Add the following dependencies (most of them are here only to simplify the development flow):
yarn add -D concurrently cross-env electron electronmon wait-on
-
concurrently
: Run multiple commands concurrently. We'll use it to run both the Electron process and the React app in watch mode. -
cross-env
: Run scripts that set and use environment variables across different platforms. We'll use it to make our scripts compatible with both Unix and Windows OSes. -
electron
: The core framework for creating the app. -
electronmon
: Likenodemon
, but for the Electron process. Allows watching and reloading our Electron app. -
wait-on
: Utility to wait for files, ports, sockets, etc. We'll use it to wait for the React app to be built before we open the Electron app (while developing).
The next step is creating Electron's main script. This script controls the main process, which runs in a full Node.js environment and is responsible for managing your app's lifecycle, displaying native interfaces, performing privileged operations, and managing renderer processes.
Create a new electron.js
file in public/
:
// Module to control the application lifecycle and the native browser window.
const { app, BrowserWindow } = require("electron");
const url = require("url");
// Create the native browser window.
function createWindow() {
const mainWindow = new BrowserWindow({
width: 800,
height: 600
});
// In production, set the initial browser path to the local bundle generated
// by the Create React App build process.
// In development, set it to localhost to allow live/hot-reloading.
const appURL = app.isPackaged
? url.format({
pathname: path.join(__dirname, "index.html"),
protocol: "file:",
slashes: true,
})
: "http://localhost:3000";
mainWindow.loadURL(appURL);
// Automatically open Chrome's DevTools in development mode.
if (!app.isPackaged) {
mainWindow.webContents.openDevTools();
}
}
// This method will be called when Electron has finished its initialization and
// is ready to create the browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
createWindow();
app.on("activate", function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
// Quit when all windows are closed, except on macOS.
// There, it's common for applications and their menu bar to stay active until
// the user quits explicitly with Cmd + Q.
app.on("window-all-closed", function () {
if (process.platform !== "darwin") {
app.quit();
}
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.
Then, we need to make a few changes to package.json
:
- Rename the app from
@my-app/web
to@my-app/electron
. - Add the
main
entry. During execution, Electron will look for the script we created above in themain
field of the app’spackage.json
. - Update the
homepage
property. We need to enforce Create React App to infer a relative root path in the generated HTML file. This is a requirement because we're not going to serve the HTML file; it will be loaded directly by Electron. To do so, we can set thehomepage
property of thepackage.json
to./
(see Building For Relative Paths in the Create React App documentation for more details). - Define a script to build the Create React App and start the Electron process in watch mode.
{
- "name": "@my-app/web",
+ "name": "@my-app/electron",
"version": "0.0.0",
"private": true,
+ "homepage": "./",
+ "main": "./public/electron.js",
"scripts": {
- "start": "craco start",
+ "start": "concurrently -k \"cross-env BROWSER=none craco start\" \"wait-on http://localhost:3000 && electronmon .\"",
"build": "craco build"
},
The start
script might look a bit confusing now, so here's a breakdown of what it does:
-
concurrently -k
invokes the subsequent commands in parallel, and kills both of them when the process is stopped. -
cross-env BROWSER=none yarn start
sets theBROWSER=none
environment variables (usingcross-env
for Windows compatibility) to disable the automatic opening of the browser and invokes thestart
script, which runs the Create React App build in watch-mode. -
wait-on http://localhost:3000 && electronmon .
waits for the Create React App dev-server to serve the app on localhost:3000, and then invokeselectronmon .
to start the Electron add in watch-mode.
⚠️ Adding a
build
script to our Electron app requires some additional work that, for the sake of simplicity, I glossed over in this blog post. Please check "Building a desktop application using Electron and Create React App" for a more in-depth guide.
Finally, add the electron:start
script to the root package.json
:
"scripts": {
"electron:start": "yarn workspace @my-app/electron start"
},
And run it to start developing your Electron app:
Browser Extension
Extensions, or add-ons, can modify and enhance the capability of a browser.
There are two primary standards used for building browser extensions:
- Chrome/Chromium's extension API, supported by Chromium-based browsers (such as Google Chrome, Microsoft Edge, Opera, Vivaldi)
- The WebExtensions API, supported by Firefox addons (and, in a limited way, by the latest version of Safari).
These two technologies are, to a large extent, compatible.
In most cases, extensions written for Chromium-based browsers run in Firefox with just a few changes.
Extensions are created using web-based technologies: HTML, CSS, and JavaScript. They can take advantage of the same web APIs as JavaScript on a web page, but extensions also have access to their own set of JavaScript APIs.
Since we already have a working web app, we just need a couple of tweaks to use it as the foundation for our browser extension.
Let's start by duplicating the React Native for Web workspace (packages/web
) into a new packages/browser-ext
one.
From the packages/
directory, run:
cp -R web browser-ext && cd browser-ext
Every browser extension requires a manifest (manifest.json
) to be identified by the browser. A manifest contains basic metadata such as its name, version, and the permissions it requires. It also provides pointers to other files in the extension.
By default, Create React App creates a Web App manifest in the /public
dir. This default manifest is part of the technologies that power Progressive Web Apps (PWA) and follows an entirely different standard from the Extension API manifest we need.
So, let's replace the content of public/manifest.json
with our own extension manifest.
This new manifest tells the browser we're building a popup extension and that its entry point is located at browser-ext/public/index.html
:
{
"name": "My Extension",
"version": "1.0.0",
"manifest_version": 2,
"browser_action": {
"default_popup": "index.html"
}
}
Then, we need a tiny tweak for the start
and build
scripts:
Out-of-the-box, Create React App embeds an inline script into index.html
of the production build.
This is a small chunk of Webpack runtime logic used to load and run the application, which is embedded in our build/index.html
file to save an additional network request on web apps. Unfortunately, it also breaks the extension usage by violating the web extension API Content Security Policy (CSP), which doesn't allow loading external scripts into the extension.
The easiest way to solve this issue is by turning off the inline script by the INLINE_RUNTIME_CHUNK
environment variable to false
:
{
- "name": "@my-app/web",
+ "name": "@my-app/browser-ext",
"version": "0.0.0",
"private": true,
"scripts": {
- "start": "craco start",
+ "start": "INLINE_RUNTIME_CHUNK=false craco start",
- "build": "craco build",
+ "build": "INLINE_RUNTIME_CHUNK=false craco build"
},
Finally, add the start
and build
script to root's package.json
:
"scripts": {
"browser-ext:start": "yarn workspace @my-app/browser-ext start",
"browser-ext:build": "yarn workspace @my-app/browser-ext build"
},
We can now run browser-ext:start
and add the browser extension to the browser to develop it (see "Install and manage extensions" for details):
What we've done so far is just the bare minimum work required to make the browser extension run.
As your next step, I'd suggest you to:
- Clean up the
public
dir, making sure to keep there onlymanifest.json
andindex.html
. - Remove the Service Worker and the Web Vitals scripts installed by default by Create React App (they won't work in a browser extension).
- Tweak the
start
script to enable hot-reloading. - Get familiar with the browser extension APIs (and limitations).
Compatibility and platform-specific code
As always, please keep in mind that every platform has its limitations.
Be it Electron or a browser extension, we shouldn't expect every API exposed by React Native for Web to work out-of-the-box.
Something worth noticing is that, even if we're targeting different platforms/frameworks, the React Native Platform
API will always detect the OS as "web"
because it's not aware of whether a React Native for Web app is running in a website, in Electron, or in a browser extension.
A possible workaround for this issue is to inject a more specific target platform as an environment variable:
const webpack = require("webpack");
const { getWebpackTools } = require("react-native-monorepo-tools");
const monorepoWebpackTools = getWebpackTools();
module.exports = {
webpack: {
configure: (webpackConfig) => {
// Allow importing from external workspaces.
monorepoWebpackTools.enableWorkspacesResolution(webpackConfig);
// Ensure nohoisted libraries are resolved from this workspace.
monorepoWebpackTools.addNohoistAliases(webpackConfig);
return webpackConfig;
},
plugins: [
// Inject the "__DEV__" global variable.
new webpack.DefinePlugin({
__DEV__: process.env.NODE_ENV !== "production",
}),
+ // Inject the "__SUBPLATFORM__" global variable.
+ new webpack.DefinePlugin({
+ __SUBPLATFORM__: JSON.stringify("electron"), // Or "browser-ext"
+ }),
],
},
};
In the app
workspace, we can then check the __SUBPLATFORM__
global variable to detect whether we're running in a web page, in Electron, or in a browser extension.
What's next?
When I started writing this series, I envisioned this post as the last one of the tutorials.
Still, in the next few days I'll write a FAQs post to ensure the most common questions and answers about the series are captured in a single location. So, please, stay tuned!
If you somehow managed to read through this entire series, hats off to you!
I hope what I've shown you may give you some ideas about approaching a multi-platform project of your own.
I surely learned a lot while experimenting with it.
Thanks to the React + React Native team and community for building all these fantastic tools! ♥
For feedback and questions, feel free to start a discussion on the React Native Universal Monorepo's discussions page or send me a direct message.
- Overview
- Monorepo setup
- Android & iOS
- Windows & macOS
- The web
- Browser extensions & Electron (☜ you're here)
Originally published at mmazzarolo.com
Posted on September 28, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.