Fine-tune Dependency Versions in Your JavaScript Monorepo

Asís García
Trabe
Published in
5 min readJun 22, 2020
Photo by Anton Darius on Unsplash

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:

“components” package depends on SVGR, which depends on Prettier 2.0.2 or 1.9.1. Our “tools” package depends on version 1.9.1.
The two “conflicting” Prettier dependencies.

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:

prettier@2.0.2 gets hoisted to the root node_modules, while prettier@1.9.1 is installed inside the “tools” node_modules.
The versions installed on disk.

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:

The dependency tree, adding a docs package which depends on Storybook, which in turn depends on Prettier ^2.0.5.
The new dependency tree after adding storybook.

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:

Dependency tree: components depends on @svgr/cli, which depends on @svgr/plugin-prettier, which depends on prettier
Virtual path to the prettier dependency

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:

Root node_modules layout. prettier@1.9.1 is installed at the root level, other versions inside internal node_modules.
The final layout inside the node_modules directory at the project root. The layout is simplified (it does not reflect the real layout of svgr or storybook packages).

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.

--

--