Fix go to definition and hot reload in a react typescript monorepo
Make hot-reload and go to definition work in a react typescript Monorepo with Lerna , Yarn and Craco using path mapping
TL;DR
- The example monorepo is hosted on github here
- These are lessons I learned trying to solve hot reload and go to definition on our company’s front-end monorepo
- Working branch on the company’s monorepo here
Justification
- The ability to jump through code implementation in dev mode makes it easy to debug and/or orient to a code base. An example is finding modules/implementation from the router.
- Immediate feedback on code change helps minimise a developers feedback loop and helps maintain coding flow.
- A developer’s workflow should be optimised for iteration. Wash down this point with this Dan Abramov conference talk.
root
├── package.json
├── tsconfig.json
└── packages
├── base-react-package
│ ├── src
│ │ └── index.tsx
│ ├── package.json
│ └── tsconfig.json
├── package-a
│ ├── src
│ │ └── index.tsx
│ ├── package.json
│ └── tsconfig.json
└── package-b
├── src
│ └── index.ts
├── package.json
└── tsconfig.json
Take an example of a monorepo with the above setup: A base react typescript package, and two independent typescript packages packageA and packageB that export react and typescript components. Each package has their own dependencies (package.json) and typescript compiler configurations (tsconfig.json). The base-react-package
consumes package-a
which in turn consume package-b.
The root level monorepo is called “root” and thus all the monorepo packages are prepended with “@root”, this is monorepo convention.
Assuming you wanted to import an AndyBernard
component from package-a
into the base-react-package
, you’d do it as follows:
/* base-react-package/src/index.ts */
import { AndyBernard } from “@root/package-a”;
A typical package.json in package-a
exports the module as:
/* package-a/package.json */
"name": "@root/package-a",
"main": "dist/index.js",
N/B: The “main” config option points to the /dist build directory and not the /src dev directory. If we try to follow the definition, the AndyBernard
import will be resolved to the /dist directory of package-a
. Since the directory does not exist until the project is built, it must be compiled before it can run. This also means that, even in dev mode, the project must be re-compiled every time to see new changes. This is not very ideal for development.
Solution: help the IDE and typescript compiler to resolve the monorepo modules in dev mode
Go To Definition
We want the IDE, and typescript compiler to point to the raw files during development but to the build files at runtime/production. We achieve this by having two tsconfig.json files: one for development, and the other for production.
We want the development tsconfig.json file to have similar configs to the build tsconfig.json but for different module resolution mechanism (point to raw files during dev and build files during prod). For this we create a base tsconfig.base.json
file as below (or just rename the default tsconfig.json 🤷🏿♂️):
This will act as both the production tsconfig.json, and as the base config file from which we’ll extend the dev config.
For development, we need extra information to help typescript resolve modules to their source code.
N/B: The built in typescript support in vscode, and other IDE’s use tsconfig.json by default and thus we need to create it with that exact naming.
We are going to use typescript’s path mapping feature to point typescript to additional lookup locations in dev mode. We do this by declaring a “paths” config under the “compilerOptions” config in the created tsconfig.json with either all modules to be resolved or dynamic glob matcher for all modules to be resolved.
N/b:
- We extend tsconfig.base.json, this means we borrow all it’s functionality.
- We use a dynamic glob matcher (
@root/*
) to match all package names that begin with@root/package-name
and map their imports to thepackages/package-name/src
directory. - The “
baseUrl
” config in tsconfig.base.json is important for module resolution. All modules are resolved relative to this directory.
Unfortunately, the include
and outDir
config options (needed for build) can’t be hoisted and need to be declared at package level (they are resolved relatively to the config they’re in). These need to be in their own, package-level, tsconfig.build.json that extends the base tsconfig.base.json.
To make typescript compiler pick this file, instead of the root level tsconfig.json during production, we need to pass it to the typescript compile tsc
command using the --project
flag. Like so:
Done! we can now jump to code definition (/src)in development and use the build (/dist) files in production. Try it by cloning the repo mentioned above and jumping to definition before installing dependencies.
Hot-Reload
Hot reload boils down to a single problem: by default, react is configured to only import/watch for file changes inside the /src directory. React scripts reloads the development server when these changes are detected. Our monorepo modules are imported from outside the /src directory and thus not watched by react. We have to refresh the browser manually when we make changes inside our non-react packages (assuming we’ve set up path mapping as above thus no need for recompilation).
Solution: modify the base-react-package to watch for changes outside /src
For a self configured react setup (or in nextjs) we can readily override webpack config to allow watching files outside /src. This is not so for create react app whose webpack config settings are: Preconfigured and hidden so that you can focus on the code
To override these changes in CRA we have two options, 1) Eject , or 2) Use a third party package. We’ll opt for the later as the former has some unintended consequences. The third party package we’ll use is craco.
Steps:
- Install craco as a dev dependency
yarn add -D @craco/craco
- Create a craco config file on the base-react-package:
N/B:
- Line 9 removes the
ModuleScopePlugin
which throws an error when we try to import something outside of /src - Line 12 injects the tsconfig-paths-webpack-plugin to help webpack resolve our path mappings.
- Line 15 helps babel watch and compile changes on the fly for files outside the /src directory
3. Replace react-scripts
in package.json with craco
:
Done! Restart the dev server and try changing contents of package-b then wait for magic.