Enhancing Chrome Extension developer experience with CRA (create-react-app)

Introduction

Hello again, I hope all of you are doing okay and getting vaccinated so we can get rid of this effing pandemic.

Recently I've been coding a Chrome extension to scratch my itch with the way Chrome switches to the next tab when you close a tab and here I'll be documenting some useful things I learned along the way.

I won't delve into the basics of how a Chrome extension works, so you if you're new to it you can read these posts that explain it in a better way:

Table of Contents

Creating aliases for node

If you're like me, you don't like typing the same commands again and again and again. Since we're going to use npm to install the packages, I have some aliases for the most used commands.

You can install these aliases by just running the command in your terminal, but they'll be lost once that session ends. To make them permanent, add them to your ~/.bashrc or ~/.zshrc profile.

To install a package globally:

alias npi='npm i -g'

To install and save a package as a dev dependency:

alias npd='npm i -D'

To uninstall a package:

alias npu='npm un'

To run a custom script in your package.json:

alias npr='npm run'

To reload the profile from the terminal I use this command (for zsh):

alias ssz='source ~/.zshrc'

Creating browser extension project with CRA

We're going to create the project using the create-react-extension script:

npx create-react-app --scripts-version react-browser-extension-scripts --template browser-extension <project name>

This will configure the tools and file structure needed for the extension, namely the .html files (options, popup) as well their javascript files and the manifest.json.

You can run the extension with npm start then, once it builds you can go to your browser and open the chrome://extensions page. Once there you can click the "Developer mode" switch, click the "Load unpacked" button and select the dev folder generated by CRA.

Configuring the project to enhance the experience

Now that the extension is installed and you can test it, it's time to configure the project to suit our needs.

We are going to:

  • Install react-app-rewired
  • Configure VSCode and Webpack for alias support
  • Configure react-devtools
  • Add sourcemaps during development
  • Add eslintrc to change linting rules
  • Configure project for release

Installing and configuring react-app-rewired

Since CRA abstracts all the configuration, webpack and whatnot from you, if you want to modify or tweak a setting you need to eject the project and this is an irreversible operation. And once you do, you need to maintain the configuration and keep it updated by yourself, so this isn't recommended.

Enter react-app-rewired. What this package does is it allows you to hook into the Webpack config process so you can change settings, add loaders or plugins, and so on. It's like having all the pros of ejecting (mainly, access to the webpack.config.js) without actually ejecting.

Install the package by running npd react-app-rewired if you're using my alias from the previous section, otherwise:

npm install react-app-rewired --save-dev

Now you need to add a config-overrides.js at the root of your project (i.e: at the same level as the node_modules and src folders) where we will put our custom configuration.

Finally, change the scripts section of your package.json to use react-app-rewired instead of the react-scripts package:

/* in package.json */
"scripts": {
  "start": "react-app-rewired start",  
  "build": "react-app-rewired build",
  "test": "react-app-rewired test",
  "eject": "react-scripts eject"
}

Configure VSCode and Webpack for alias support

Now that react-app-rewired is configured, let's start hacking away.

Configuring VSCode for alias support

If you have a deep components structure, sometimes you may get tired of writing ./MyComponent or ../../MyParentComponent. VSCode has support for using aliases, so you can import your package with an alias, get intellisense and go to definition:

import MyComponent from "@Components/MyComponent"

In order to do so, add a jsconfig.json in the src folder of your project, which will tell the TypeScript Language Server from VSCode to do some nice things for us:

{
    "compilerOptions": {
        "baseUrl": ".",
        "module": "commonJS",
        "target": "es6",
        "sourceMap": true,
        "paths": {
            "@Config/*": ["config/*"],
            "@Components/*": ["components/*"],
            "@Containers/*": ["containers/*"],
            "@Handlers/*": ["handlers/*"],
            "@Utils/*": ["utils/*"],
            "@Style": ["style/style.js"]
        }
    },
    "typeAcquisition": {
        "include": ["chrome"]
    },
    "include": ["./**/*"],
    "exclude": ["node_modules"]
}

You can read about the compilerOptions here, but have a brief description of the most important ones:

  • baseUrl indicates the base path used for the paths property, the src folder in this case
  • paths is an array of in which you will configure how aliases are resolved when importing
  • typeAcquisition is required if you want intellisense for some packages, such as chrome apis in this case
  • include and exclude tells TypeScript which files are to be used for resolving and compiling

In order for the changes to take effect, you need to restart VSCode.

Configuring Webpack for alias support

Once the jsconfig.json is configured, you can import your packages using the alias import and get intellisense from VSCode as well as clicking F12 to go the file definition. But since webpack doesn't know about those alias, the project will not compile.

Let's modify our config-overrides.js to tell webpack about those aliases.

const path = require("path");

module.exports = function override(config) {
    config.resolve = {
        ...config.resolve,
        alias: {
            ...config.alias,
            "@Config": path.resolve(__dirname, "src/config"),
            "@Components": path.resolve(__dirname, "src/components"),
            "@Containers": path.resolve(__dirname, "src/containers"),           
            "@Utils": path.resolve(__dirname, "src/utils"),
            "@Style$": path.resolve(__dirname, "src/style/style.js"),
        },
    };

    return config;
};

What we are doing is getting a config object from the webpack.config.js used by react when compiling and running the app, and appending our custom aliases to the aliases collection in case any exists. Now you can save the file and run npm start in the console and you can start using your aliases.

Note:
Most aliases allow you to import by writing

import MyFileInsideTheFolder from "@MyAliasName/MyFileInsideTheFolder"

but if you want to import a specific file you can append '$' at the end and include the full path of the file as is seen with the styles.js file.
And then you can import file like this:

import Styles from "@Styles"

Configure react-devtools

Due to Chrome security policies, other extensions cannot access the code or markup of an extension. So if you want to use the React dev-tools with your extension, you need to install the stand-alone version of the tool:

npx react-devtools

This will install and run the dev-tools in a new Chrome frame, which is a web socket that will be listening in the port 8097.
But to actually use it, we need to do two things: add the script to the relevant html page and tell chrome to connect to it.

Copy the script and paste in the head of the html you want to use, in my case it's public/options.html:

<script src="http://localhost:8097"></script>

Now go into the public/manifest.json and paste this line at the end:

"content_security_policy": "script-src 'self' 'unsafe-eval' http://localhost:8097; object-src 'self'; connect-src ws://localhost:4000 ws://localhost:8097"

This line tells Chrome a few things related to our environment:

  • script-src refers to the origin of the scripts to be used by the extension

    • self tells to load scripts from the same origin
    • unsafe-eval tells to allow code to be run by eval (this is used by webpack to generate the sourcemaps)
    • http://localhost:8097 allow scripts coming from the React dev-tools
  • connect-src tells Chrome to allow some protocols (like websockets in this case) to connect to our app

    • http://localhost:8097 again, allow the React dev-tools to connect to our extension
    • ws://localhost:4000 this is used by webpack for the hot reload

You can read more about the Content Security Policy here.

Add sourcemaps during development

By default, webpack only emits the bundled files to the dev folder, in order to debug your code directly from chrome we can tel webpack to generate the source map from our code.

To do this, go to the config-overrides.js and add this line before returning the config:

config.devtool = "eval-source-map";

This will make our build slower, but will allow you to see you full source code in the Chrome dev tools.
More information about the different options for the source map generation here.

Add eslintrc to change linting rules

Sometimes ESLint complains about things it could ignore, like discards not being used or a parameter not being used, among other things. If you're a bit obsessive and don't like those complaints, you can add a .eslintrc.js (it may be a json, js or yaml) at the root of your project to configure the rules and behavior of ESLint.

if you haven't done so, install with:

npm install --save-dev eslint

Then run with npx to fire the assistant:

npx eslint --init

Once you're done configuring the options, ESLint will generate the .eslintrc for you (or you can manually add it if you already had ESLint installed).

To change a rule, simply add the rule to the rules array with the desired options. In my case, I modified the no-unused-vars to ignore discards (_):

rules: {
        "no-unused-vars": [
            "warn",
            {
                vars: "all",
                args: "after-used",
                ignoreRestSiblings: false,
                varsIgnorePattern: "_",
                argsIgnorePattern: "_",
            },
        ],

You can see a list of all the rules here.

Configure project for stagin/release

Finally, once you're ready to build and publish your app, we need to tell webpack to make some changes. I use a lot of console.log() during development to keep track of things like windows or tabs id, but I want them removed from the production script.

To do this, we're going to:

  • Add the customize-cra package to allow the injection of plugins and loaders
  • Add the transform-remove-console babel plugin to remove all console.* calls from our code
  • Disable the sourcemap generation

Install the packages with

npm install --save-dev customize-cra babel-plugin-transform-remove-console

Now, for customize-cra to work we need to modify the config-overrides.js file once again. The override method from customize-cra receives a list of functions, so we need to change the signature like this:

const path = require("path");
const { override, addBabelPlugin } = require("customize-cra");

module.exports = override(
);

Inside, we will tell it to load the transform-remove-console plugin:

const path = require("path");
const { override, addBabelPlugin } = require("customize-cra");

module.exports = override(
  addBabelPlugin("transform-remove-console")
);

Now, we are going to move the code we had before to a new function and add a call to it as part of the override list:

const path = require("path");
const { override, addBabelPlugin } = require("customize-cra");

module.exports = override(
  addBabelPlugin("transform-remove-console"), 
  (config, env) => customOverride(config, env)
);

function customOverride(config, env) {
    config.devtool = "eval-source-map";
    config.resolve = {
        ...config.resolve,
        alias: {
            ...config.alias,
            "@Config": path.resolve(__dirname, "src/config"),
            "@Components": path.resolve(__dirname, "src/components"),
            "@Containers": path.resolve(__dirname, "src/containers"),
            "@Handlers": path.resolve(__dirname, "src/handlers"),
            "@Utils": path.resolve(__dirname, "src/utils"),
            "@Style$": path.resolve(__dirname, "src/style/style.js"),
        },
    };  

    return config;
}

Finally, we need to tell webpack to remove the sourcemaps when we are building for an environment that isn't development, so our final config-overrides.js will look like this:

const path = require("path");
const { override, addBabelPlugin } = require("customize-cra");

module.exports = override(
  addBabelPlugin("transform-remove-console"),
  (config, env) => customOverride(config, env)
);

function customOverride(config, env) {
    config.devtool = "eval-source-map";
    config.resolve = {
        ...config.resolve,
        alias: {
            ...config.alias,
            "@Config": path.resolve(__dirname, "src/config"),
            "@Components": path.resolve(__dirname, "src/components"),
            "@Containers": path.resolve(__dirname, "src/containers"),
            "@Handlers": path.resolve(__dirname, "src/handlers"),
            "@Utils": path.resolve(__dirname, "src/utils"),
            "@Style$": path.resolve(__dirname, "src/style/style.js"),
        },
    };

    if (env !== "development") {
        config.devtool = false;
    }

    return config;
}

Conclusion

I spent many nights fighting with the packages until I finally got it working the way I wanted, so I hope this article is useful to you. Stay safe.

19