RegExp look-behinds bit me in the behind

One of the biggest pitfalls of writing JavaScript for the web is the browser compatibility. New language spec comes out every year, Web APIs keep evolving, and every browser has a different set of features it supports. In the meantime web is flooded with resources about the new shiny features, and we sometimes forget to curb our enthusiasm.
At least, that’s what happened to me with RegExp look-behinds on a toy project recently.

This post is a by-product of the frustration I felt while rewriting my solution. First, I talk about two main groups of JS browser compatibility issues, why the distinction between them matters, and what I think is the best solution to catch compatibility bugs earlier. Spoiler Alert: I still don’t read CanIUse with my morning coffee. The solution consists of: ESLint, browserslist, eslint-plugin-compat, and eslint-plugin-es.

But to fully appreciate it, we need to step back a little and make sure we understand the problem first.

Web Standards vs ECMAScript

JS code can be incompatible with the browser for two different reasons:

Table 1: Examples of Web API vs ECMAScript
FeatureCategory
setTimeout / setIntervalWeb
Service WorkersWeb
fetchWeb
PromisesWeb first, ES6 later
const, letES6
arrow function expressions (=>)ES6
RegExp look-behindsES9
Async iteratorsES9

This distinction is important because there are other runtimes than browsers. And packages that deal with compatibility focus either on Web API or on ECMAScript.

To illustrate: core-js, the source of polyfills in babel-preset-env, focuses mainly on ES APIs, so it doesn’t polyfill things like fetch.
And eslint-plugin-compat focuses on Web APIs, so it often doesn’t warn about the usage of features like RegExp look-behinds.

Note: I use word focus intentionally to leave a bit of a wiggle room since both the core-js and eslint-plugin-compat aren’t strict when drawing these lines.

Why Lint at All?

In an ideal scenario, we could transpile/polyfill any code without negatively affecting performance. We would set up the build pipeline, and that would be the end of our problems.

But some code can’t be transpiled/polyfilled - RegExp look-behinds, Service Workers, etc.

Other code can be transpiled, but maybe we can’t afford it with our budget, so we disallow it.

Or we have just introduced a dependency on an API requiring a polyfill. We want to be notified about it and make a conscious decision whether it’s worth it or not.

In all of these cases, the sooner we find out, the better, and ESLint is the best tool for the job.

Solution

browserslist

Awesome package with a simple and declarative interface that is using data from CanIUse. It doesn’t do anything on its own, but linters can use it to determine which features to disallow, and transpilers can use it to find out which parts of the code to polyfill/transpile. Just declare what browsers you target and move on!

package.json

{
  ...
  "browserslist": [">0.25%"]
}

Intersting fact: On the day of publishing this article, the Internet Explorer 11 is still used by 1.42% of the internet users, 0.49% behind the most popular version of Edge. Source: caniuse.com/usage-table.

eslint-plugin-compat

ESLint plugin that integrates with browserslist and which lints usage of Web APIs against the browsers we target.

We can allow polyfilled APIs by adding them to the settings.polyfills array (see Adding Polyfills).

.eslintrc.js

module.exports = {
  extends: [
    "plugin:compat/recommended",
  ],
  settings: {
    // Allow polyfilled APIs
    polyfills: ["fetch"]
  }
}

As I mentioned earlier, it focuses mainly on Web API compatibility. From version 3.8.x (current @next version on npm), it goes even further and skips all the ES checks if a project contains TS/Babel config file.

So we need one more package that lints ES APIs compatibility.

eslint-plugin-es

You guessed it! An ESLint plugin that focuses on detecting ECMAScript compatibility. It contains rules for all the ES features (>= ES5), grouped by the ES version.

It doesn’t use browserslist, so we have to configure it manually. We can disallow all the features from any given ES version and then granularly allow only specific APIs.

.babelrc.js

module.exports = {
  env: {
    browser: true,
  },
  plugins: ["es"],
  extends: [
    // Disallow all the new features
    "plugin:es/no-2018",
    "plugin:es/no-2017",
    "plugin:es/no-2016",
  ],
  parserOptions: {
    ecmaVersion: 2018,
  },
  rules: {
    // Allow only specific features
    "es/no-rest-spread-properties": "off" // 2018
    "es/no-object-entries": "off"         // 2017
  },
};

Wrapping Up

Linting can’t replace testing in various browsers, but it helps with discovering most of the JS compatibility bugs earlier.

Thank you for reading!

Bonus

While writing this post, I bumped into a handy ESLint rule called no-restricted-syntax. You can create new inline rules with it. Here’s an example usage which detects RegExp look-behinds:

.eslintrc.js

module.exports = {
  // ...
  rules: [
    "no-restricted-syntax": [
      2,
      {
        selector: "Literal[regex][raw=/\\(?<.+\\)/]",
        message: "No RegExp look-behinds",
      },
    ]
  ]
}