Mazzarolo MatteoMazzarolo Matteo

React Native monorepo for every platform: Browser Extensions & Electron

By Mazzarolo Matteo


TL;DR

Fourth part of the "React Native monorepo for every platform" 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:

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
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
yarn add -D concurrently cross-env electron electronmon wait-on

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/:

my-app/packages/electron/public/electron.js
// 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.
my-app/packages/electron/public/electron.js
// 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:

my-app/packages/electron/package.json
 {
-  "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"
   },
my-app/packages/electron/package.json
 {
-  "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:

⚠️ 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:

my-app/package.json
"scripts": {
  "electron:start": "yarn workspace @my-app/electron start"
},
my-app/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:

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
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:

my-app/packages/browser-ext/public/manifest.json
{
  "name": "My Extension",
  "version": "1.0.0",
  "manifest_version": 2,
  "browser_action": {
    "default_popup": "index.html"
  }
}
my-app/packages/browser-ext/public/manifest.json
{
  "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:

my-app/packages/browser-ext/package.json
 {
-  "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"
   },
my-app/packages/browser-ext/package.json
 {
-  "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:

my-app/package.json
"scripts": {
  "browser-ext:start": "yarn workspace @my-app/browser-ext start",
  "browser-ext:build": "yarn workspace @my-app/browser-ext build"
},
my-app/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:

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:

my-app/packages/electron/craco.config.js
 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"
+      }),
     ],
   },
 };
my-app/packages/electron/craco.config.js
 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.