26
Build a scalable front-end with Rush monorepo and React — ESLint + Lint Staged
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(https://github.com/imodeljs/imodeljs), 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.
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'],
};
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"
. . .
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.
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 $?
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.
26