Inside the pain of monorepos and hoisting

node Dec 10, 2021

Working in a monorepo comes with a long list of pros, and a few cons. One of the most painful cons, when it comes to working in a specifically JavaScript based monorepo, is the pain that comes from hoisting.

Here's a video that talks about it, and you can also keep reading to learn more!

What's this hoisting thing?

As we all know, node_modules is a deep dark place with lots and lots of stuff. That problem is even more massive in a large monorepo.

Let's take a step back for a sec and take a look at what happens when you require something.

node module resolution

If you take a read through the docs, you'll find this...

require(X) from module at path Y
1. If X is a core module,
   a. return the core module
   b. STOP
2. If X begins with '/'
   a. set Y to be the filesystem root
3. If X begins with './' or '/' or '../'
   a. LOAD_AS_FILE(Y + X)
   b. LOAD_AS_DIRECTORY(Y + X)
   c. THROW "not found"
4. If X begins with '#'
   a. LOAD_PACKAGE_IMPORTS(X, dirname(Y))
5. LOAD_PACKAGE_SELF(X, dirname(Y))
6. LOAD_NODE_MODULES(X, dirname(Y))
7. THROW "not found"

What this is basically saying is...

If you require X, see if it exists in node, things like fs, child_process, etc.

If you start require('/X'); you're at the file system root.

If you require('./X'); then see if .X is file, then a directory.

...and this is the interesting one...

If you do require('X'); and it's not a node thing, then keep traversing the file system looking in every node_modules along the way.

Hoisting

Package managers such as yarn and npm implemented a hoisting algorithm as a part of their different workspaces implementations.

What this hoisting does is, it scans your package.json files across your workspaces and figures out what the most common versions of dependencies are.

If you have 50 packages, and 47 of them are using react 16.9.0, but 3 are using 16.13.0, it'll "hoist" the common version of react, 16.9.0 to the top level ./node_modules directory. That way you don't have to have 50 different versions of react in your project, the import React or require('react') calls will simply pull from the root node_modules/react one in the case of 16.9.0, or ./packages/*/node_modules/react for the 3 cases of 16.13.0.

Voila, space saved.

However, the plot thickens...

Monorepo structure

So, most monorepo structures have a root package.json, and a packages folder.

./packages/foo/
./packages/foo/node_modules
./packages/bar/
./packages/bar/node_modules
./package.json
./node_modules

Let's say we're working with the ./packages/foo/ and it does an import React from 'react';.

Following the node module resolution from above, it'll eventually look in the ./packages/foo/node_modules directory. If it's not there, it'll look at ../packages/node_modules/, which definitely should not be there, and then it'll look at the ./node_modules directory and see if it's there.

Ok, so that seems fine, where can we go wrong here?

Well, a couple of different bad things can happen here.

Let's say the foo/package.json has "react": "^16.9.0".

Then, let's say over in bar, they forgot to add react in the package.json, but in bar/src/index.ts someone does an import React..., and it also happens that bar used a feature that only exists in the 16.13.0 version of React.

What's gonna happen? Welp, because of the fact that bar doesn't have a react in its package.json, the node module resolution algorithm will kick in and look up to the ../../node_modules/ directory and grab react from there.

But OH NOES, that code won't work at runtime because the code written in bar needs that fancy new feature in React 16.13! This is a problem that is also nicely summed up in the Rush docs as a "Phantom Dependency" and a "Doppleganger".

Real world ouchies

Here's a real world example of how this came into play in a recent release pipeline failure.

In the monorepo supported by my team, the most common version of jest-environment-jsdom that was hoisted to the root node_modules folder in the repo was 24.9.0.

A pull request when in which added jest-environment-jsdom 26.10.0 to one of the packages in the ./packages folder. Well, what happened was, there were a couple of other places using that same version across the repo so, yarn in its attempt to de-duplicate by hoisting decided to switch the hoisted version to 26.10.0!

Here's where things got bad.

Let's say the package which added jest-environment-jsdom was called, cool-button. The pull request for adding the new dependency will get pushed to CI, and the CI server does a check for all the places where cool-button is used, and start running tests, builds, etc on the dependencies in the repo to make sure that the changes cool-button doesn't break any of it's downstream dependencies.

Ok, PR is green, all looks great!

Well, let's say there's another package called cool-color-picker. And cool-color-picker had some tests which was more or less like...

it('should pull from memory storage when localStorage isnt there', () => {
  expect(storageUtilThing.get('item')).toBeTruthy();
})

Well, in this crazy insane case... cool-color-picker was relying on the hoisted jest-environment-jsdom version, 24.9.0. Well, that particular version used jsdom 11.11.0. In that particular version of jsdom there was NO local storage support in the jsdom environment, so the test would pass.

However! In the new version of 26.10.0 of jest-environment-jsdom, the version of jsdom gets bumped to 16.x.x and it just so happens every so ironically that version 11.12.0 of jsdom implemented localStorage. See Changelog.

All the sudden, the test is now failing because the test had previously assumed that there was no localStorage support in jsdom without some kind of 3rd party mocking like jest-localstorage-mock.

What's worse here is, because cool-color-picker isn't in the dependency graph of cool-button, it never got tested as a part of the pull request.

Therefore, the change landed and attempted to go through the release pipeline.

THANKFULLY, although also painfully, the release pipeline currently does a "build the world" strategy regardless of what changed.

It's here where the release pipeline which always builds all the packages that the failure happened and broke releases for a few hours until we figured out what happened which involved LOTS of code spelunking.

So what next?

Well, in an ideal world we'll soon be switching to a "strict" package manager like the one we're currently building called midgard-yarn-strict, which will eventually migrate much of it's feature set into the implementation of Isolated Mode in NPM. Yarn v2 or later can also solve this problem with plug and play, or pnpm as well, but we're currently focusing on the former of the solutions with NPM and midgard yarn strict.

A strict package manager such as these options will do a couple of things.

First of all, the packages are forced to correctly declare their dependencies in the package.json so as to eliminate the phantom dependency / doppleganger problem.

Secondly, the root node_modules folder can be moved to a different location on disk, and pointed to with symlinks.

./packages/foo/
./packages/foo/node_modules/react  -> node_modules/.store/react-16.9.0
./packages/bar/
./packages/bar/node_modules/react -> node_modules/.store/react-16.13.0
./package.json

# This breaks the node module resoution
./node_modules/.store
./node_modules/.store/react-16.13.0
./node_modules/.store/react-16.9.0

By moving the node_modules folder somewhere else, and simply symlinking all of the package's dependencies to the new location, the hoisting algorithm breaks.

So, in the previous case where the bar package had forgotten to declare react in its package, bar can no longer rely on hoisting!

Other things you can do to help are implementing a couple of solutions like DepCheck to make based on scanning your code that your dependencies are all correctly declared. There's also a tool called TypeSync which does something similar for the @types packages to make sure they're present. The hoisting problem with@types packages presents a whole different set of complexities because their usage isn't in code necessarily, but only in the TypeScript setup.

Conclusion

Hoisting helped a lot in solving some problems for many repos out there. However, the moment your monorepo starts to scale a bit, you'll inevitably run into these problems.

Tags