Build a scalable front-end with Rush monorepo and React — ESLint + Lint Staged
2021-08-18 • 8 min read
––– views
This is the 3rd part of the blog series "Build a scalable front-end with Rush monorepo and React"
Part 1: Monorepo setup, import projects with preserving git history, add Prettier
Part 2: Create build tools package with Webpack and Jest
Part 3: Add shared ESLint configuration and use it with lint-staged
Part 4: Setup a deployment workflow with Github Actions and Netlify.
Part 5: Add VSCode configurations for a better development experience.
TL;DR
If you're interested in just see the code, you can find it here: https://github.com/abereghici/rush-monorepo-boilerplate
If you want to see an example with Rush used in a real, large project, you can look at ITwin.js, an open-source project developed by Bentley Systems.
ESLint is a dominant tool for linting TypeScript and JavaScript code. We'll use it together with Lint Staged to achieve the goal "Enforced rules for code quality" we defined in Part 1.
ESLint works with a set of rules you define. If you already have a configuration for ESLint that you like, you can add it in our next setup. We'll be using AirBnB’s ESLint config, which is the most common rules list for JavaScript projects. As of mid-2021, it gets over 2.7 million downloads per week from NPM.
Build eslint-config package
Let's start by creating a folder named eslint-config
in packages
and creating package.json
file.
mkdir packages/eslint-config
touch packages/eslint-config/package.json
Paste the following content to packages/eslint-config/package.json
:
{
"name": "@monorepo/eslint-config",
"version": "1.0.0",
"description": "Shared eslint rules",
"main": "index.js",
"scripts": {
"build": ""
},
"dependencies": {
"@babel/eslint-parser": "~7.14.4",
"@babel/eslint-plugin": "~7.13.16",
"@babel/preset-react": "~7.13.13",
"@typescript-eslint/eslint-plugin": "^4.26.1",
"@typescript-eslint/parser": "^4.26.1",
"babel-eslint": "~10.1.0",
"eslint-config-airbnb": "^18.2.1",
"eslint-config-prettier": "^7.1.0",
"eslint-config-react-app": "~6.0.0",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-flowtype": "^5.2.1",
"eslint-plugin-jest": "^24.1.5",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.3.1",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
"eslint-plugin-testing-library": "^3.9.2"
},
"devDependencies": {
"read-pkg-up": "7.0.1",
"semver": "~7.3.5"
},
"peerDependencies": {
"eslint": "^7.28.0",
"typescript": "^4.3.5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
}
Here we added all dependencies we need for our ESLint config.
Now, let's create a config.js
file where we'll define ESLint configurations, non-related to rules.
const fs = require('fs');
const path = require('path');
const tsConfig = fs.existsSync('tsconfig.json')
? path.resolve('tsconfig.json')
: undefined;
module.exports = {
parser: '@babel/eslint-parser',
parserOptions: {
babelOptions: {
presets: ['@babel/preset-react'],
},
requireConfigFile: false,
ecmaVersion: 2021,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
env: {
es6: true,
jest: true,
browser: true,
},
globals: {
globals: true,
shallow: true,
render: true,
mount: true,
},
overrides: [
{
files: ['**/*.ts?(x)'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2021,
sourceType: 'module',
project: tsConfig,
ecmaFeatures: {
jsx: true,
},
warnOnUnsupportedTypeScriptVersion: true,
},
},
],
};
We'll split ESLint rules in multiple files. In base.js
file we'll define the main rules
that can be applied to all packages. In react.js
will be the React-specific rules.
We might have packages that doesn't use React, so we'll use only the base
rules.
Create a base.js
file and add:
module.exports = {
extends: ['airbnb', 'prettier'],
plugins: ['prettier'],
rules: {
camelcase: 'error',
semi: ['error', 'always'],
quotes: [
'error',
'single',
{
allowTemplateLiterals: true,
avoidEscape: true,
},
],
},
overrides: [
{
files: ['**/*.ts?(x)'],
extends: [
'prettier/@typescript-eslint',
'plugin:@typescript-eslint/recommended',
],
rules: {},
},
],
};
Here we're extending airbnb
and prettier
configurations. Here you can include
other base rules you would like to use.
In react.js
add the following:
const readPkgUp = require('read-pkg-up');
const semver = require('semver');
let oldestSupportedReactVersion = '17.0.1';
// Get react version from package.json and used it in lint configuration
try {
const pkg = readPkgUp.sync({ normalize: true });
const allDeps = Object.assign(
{ react: '17.0.1' },
pkg.peerDependencies,
pkg.devDependencies,
pkg.dependencies
);
oldestSupportedReactVersion = semver
.validRange(allDeps.react)
.replace(/[>=<|]/g, ' ')
.split(' ')
.filter(Boolean)
.sort(semver.compare)[0];
} catch (error) {
// ignore error
}
module.exports = {
extends: [
'react-app',
'react-app/jest',
'prettier/react',
'plugin:testing-library/recommended',
'plugin:testing-library/react',
],
plugins: ['react', 'react-hooks', 'testing-library', 'prettier'],
settings: {
react: {
version: oldestSupportedReactVersion,
},
},
rules: {
'react/jsx-fragments': ['error', 'element'],
'react-hooks/rules-of-hooks': 'error',
},
overrides: [
{
files: ['**/*.ts?(x)'],
rules: {
'react/jsx-filename-extension': [
1,
{
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
],
},
},
],
};
We have to provide a react
version to react-app
configuration. Instead of hardcoding it
we'll use read-pkg-up
to get the version from package.json
. semver
is used to help us
picking the right version.
Last step is to define the entry-point of our configurations. Create a index.js
file and add:
module.exports = {
extends: ['./config.js', './base.js'],
};
Add lint command to react-scripts
ESLint can be used in a variety of ways. You can install it on every package or
create a lint
script that runs ESLint bin for you. I'm feeling more comfortable
with the second approach. We can control the ESLint version in one place which makes
the upgrade process easier.
We'll need few util functions for lint
script, so create an index.js
file inside of
packages/react-scripts/scripts/utils
and add the following:
const fs = require('fs');
const path = require('path');
const which = require('which');
const readPkgUp = require('read-pkg-up');
const { path: pkgPath } = readPkgUp.sync({
cwd: fs.realpathSync(process.cwd()),
});
const appDirectory = path.dirname(pkgPath);
const fromRoot = (...p) => path.join(appDirectory, ...p);
function resolveBin(
modName,
{ executable = modName, cwd = process.cwd() } = {}
) {
let pathFromWhich;
try {
pathFromWhich = fs.realpathSync(which.sync(executable));
if (pathFromWhich && pathFromWhich.includes('.CMD')) return pathFromWhich;
} catch (_error) {
// ignore _error
}
try {
const modPkgPath = require.resolve(`${modName}/package.json`);
const modPkgDir = path.dirname(modPkgPath);
const { bin } = require(modPkgPath);
const binPath = typeof bin === 'string' ? bin : bin[executable];
const fullPathToBin = path.join(modPkgDir, binPath);
if (fullPathToBin === pathFromWhich) {
return executable;
}
return fullPathToBin.replace(cwd, '.');
} catch (error) {
if (pathFromWhich) {
return executable;
}
throw error;
}
}
module.exports = {
resolveBin,
fromRoot,
appDirectory,
};
The most important function here is resolveBin
that will try to resolve the binary
for a given module.
Create lint.js
file inside of packages/react-scripts/scripts
and add the following:
const spawn = require('react-dev-utils/crossSpawn');
const yargsParser = require('yargs-parser');
const { resolveBin, fromRoot, appDirectory } = require('./utils');
let args = process.argv.slice(2);
const parsedArgs = yargsParser(args);
const cache = args.includes('--no-cache')
? []
: [
'--cache',
'--cache-location',
fromRoot('node_modules/.cache/.eslintcache'),
];
const files = parsedArgs._;
const relativeEslintNodeModules = 'node_modules/@monorepo/eslint-config';
const pluginsDirectory = `${appDirectory}/${relativeEslintNodeModules}`;
const resolvePluginsRelativeTo = [
'--resolve-plugins-relative-to',
pluginsDirectory,
];
const result = spawn.sync(
resolveBin('eslint'),
[
...cache,
...files,
...resolvePluginsRelativeTo,
'--no-error-on-unmatched-pattern',
],
{ stdio: 'inherit' }
);
process.exit(result.status);
In packages/react-scripts/bin/react-scripts.js
register the lint
command:
. . .
const scriptIndex = args.findIndex(
x => x === 'build' || x === 'start' || x === 'lint' || x === 'test'
);
. . .
. . .
if (['build', 'start', 'lint', 'test'].includes(script)) {
. . .
Now, add our new dependencies in packages/react-scripts/package.json
:
. . .
"which": "~2.0.2",
"read-pkg-up": "7.0.1",
"yargs-parser": "~20.2.7",
"eslint": "^7.28.0"
. . .
Lint script in action
Our lint
script is ready, now let's run it in react-app
project.
Create a new file named .eslintrc.js
and add the following:
module.exports = {
extends: ['@monorepo/eslint-config', '@monorepo/eslint-config/react'],
};
Inside package.json
add eslint-config
as dependency:
. . .
"@monorepo/eslint-config": "1.0.0"
. . .
In scripts
section add lint
command:
...
"lint": "react-scripts lint src"
...
Run rush update
following by rushx lint
. At this point you should see a bunch of
ESLint errors. As an exercise, you can try to fix them by enabling / disabling some rules
in eslint-config
or modify react-app
project to make it pass the linting.
Add lint-staged command to react-scripts
We'll follow the same approach as we did with lint
script. Create lint-staged.js
file inside
of packages/react-scripts/scripts
and add the following:
const spawn = require('react-dev-utils/crossSpawn');
const { resolveBin } = require('./utils');
const args = process.argv.slice(2);
result = spawn.sync(resolveBin('lint-staged'), [...args], {
stdio: 'inherit',
});
process.exit(result.status);
Add lint-staged
as dependency in package.json
:
...
"lint-staged": "~11.0.0"
...
Open packages/react-scripts/bin/react-scripts.js
and register lint-staged
command.
Next step is to register a lint-staged
rush command in common/config/command-line.json
,
as we did with prettier
command in Part 1.
{
"name": "lint-staged",
"commandKind": "bulk",
"summary": "Run lint-staged on each package",
"description": "Iterates through each package in the monorepo and runs the 'lint-staged' script",
"enableParallelism": false,
"ignoreMissingScript": true,
"ignoreDependencyOrder": true,
"allowWarningsInSuccessfulBuild": true
},
Now, let's run lint-staged
command on git pre-commit
hook.
Open common/git-hooks/pre-commit
and add the append to the end of the file:
node common/scripts/install-run-rush.js lint-staged || exit $?
Lint staged in action
Let's define what tasks we want lint-staged
to run for react-app
project.
Open package.json
of react-app
and add the configuration for lint-staged
:
"lint-staged": {
"src/**/*.{ts,tsx}": [
"react-scripts lint --fix --",
"react-scripts test --findRelatedTests --watchAll=false --silent"
],
},
Also in package.json
add the new lint-staged
script:
"lint-staged": "react-scripts lint-staged"
Now, on each commit lint-staged
will lint our files and will run tests for related files.
Run rush install
to register our command, then rush update
and let's commit
our changes to see everything in action.
If you encountered any issues during the process, you can see the code related to this post here.
Let's go to the next part where we'll see how to use Github Actions with Netlify to automate our deployment workflow.