Fine-tune Dependency Versions in Your JavaScript Monorepo
We’ve written before about how we use Yarn workspaces and Lerna to setup monorepos. In this post I want to focus on how we solve the problems caused when different packages in the monorepo depend (directly or transitively) on different versions of the same dependency.
A real-world scenario
One of our projects is a monorepo with 8 different packages. One of those packages provides tooling to format JavaScript code using Prettier, version 1.19.1, and also to check the code formatting using ESLint. That tooling is provided to the end users (so we must treat it as part of our public API), and we also use it to format and lint the code in the monorepo.
An unwanted (but “compatible”) upgrade
Our problems start when we add SVGR as a new dependency to one of our packages (a library of React components). We use SVGR as a CLI tool to generate React components from SVG files. To format the generated code, SVGR uses Prettier, so it depends on it.
The next image shows the relevant parts of our dependency tree:
Although the package.json file in SVGR declares a dependency with prettier@^2.0.2
or prettier@^1.9.1
, Yarn always hoists prettier@2.0.2
to thenode_modules
directory in the root of the monorepo, and installs prettier@1.9.1
at the tools
package level:
Having “the wrong” version of Prettier in the root affects the version our code editors see, and we end up with a lot of problems due to automatic format on save: while the linter in our CI and our Husky pre-push hooks uses prettier@1.9.1
, our editors format the code with prettier@2.0.2
.
We could fix that changing our dependency to prettier@2.0.2
, right? The problem is that Prettier 2 introduces some breaking changes. Updating the version we use in our tooling would force us to release a new major version of our packages, and our users to reformat their code. And we can’t do that 😅, so we’re stuck with v1.9.1.
Using Yarn “resolutions”
Yarn’s selective version resolutions feature lets you override the version used for a given dependency. You just need to add a resolutions
field to your package.json
file.
So, to fix our problem, we update our rootpackage.json
adding:
// package.json
"resolutions": {
"prettier": "1.9.1"
}
After doing that, our project will end up with a single version of Prettier (v1.9.1) installed at the root level, fixing our problems while still making SVGR happy. Yay! 🎉
An unwanted (and “incompatible”) upgrade
Now the problem appears after adding Storybook 6 (beta) as a dependency in our docs
package:
Storybook depends on Prettier version ^2.0.5, but we’re overriding that dependency and forcing prettier@1.9.1
. This causes Storybook to complain, as it tries to use a feature that does not exist in older versions of Prettier.
Using Yarn “nohoist”
If we remove the resolutions
field, Yarn will fallback to the default resolution mechanism. As we saw in the previous section, we would end up with prettier@2.0.5
installed in the root node_modules
directory, but we need prettier@1.9.1
there.
Enter nohoist
. With it, you can tell Yarn to stop hoisting some dependencies to the project root, keeping them installed as local dependencies where they are required.
To configure it, you have to use the workspaces.nohoist
field in your root package.json
file. In our project we want to prevent the Prettier versions required by Storybook (in the docs
package) and SVGR (in the components
package) from being hoisted, so we add the following configuration to our package.json
:
// package.json
{
"private": true,
"workspaces": {
"packages": ["packages/*"],
"nohoist": [
"docs/**/prettier",
"docs/**/prettier/**",
"components/**/prettier",
"components/**/prettier/**"
]
}
...
}
The nohoist
field must contain a list of nohoist rules. Each one of them is a glob pattern that will match against virtual paths in the dependency tree. For example, the full “path” to the Prettier dependency in SVGR is:
Using yarn why <package>
, you can see which versions of a given package are installed. Yarn will tell you for each installed version, which other packages caused that version to be installed. You can use that information to determine the “path” to a problematic dependency.
The nohoist
value shown above tells Yarn not to hoist Prettier (nor any of its dependencies) when the dependency comes from the components
or the docs
package. That way, yarn will only hoist the version from our tools
package, again solving our problems 😃.
The other versions of Prettier will be installed in thenode_modules
directories inside the packages requiring them:
Summing up
The sheer number of dependencies per node package, and the fast evolution of many of them, can easily lead to conflicting dependencies. Given the flexibility of node’s resolution mechanism, and the way Yarn workspaces uses it, sometimes you might end up with the wrong dependency being installed.
In this post we’ve seen how using Yarn’s resolutions
and nohoist
mechanisms, you can tweak the resolution algorithm, and hopefully fix your problems.