Peer dependencies in a pnpm monorepo

A look at peer dependencies in a pnpm monorepo with multiple apps and different versions of third party libraries in use across different apps.

This post takes inspiration from Jonathan Creamer’s informative post on hoisting pain in a monorepo. Specifically how npm and yarn are opinionated about hoisting the most common versions of libs to the root level node_modules of a monorepo, and the dangers of phantom dependencies.

In our situation, a monolithic codebase was being refactored into smaller apps and shared packages, contained within a monorepo. We started by moving the monolithic codebase into an app folder within the monorepo.

So we were going from something like this:

monolith
├── ui-components
│   ├── button.ts
├── screens
│   ├── screen-a
│   ├── screen-b
│   ├── screen-c
└── package.json

to something like this:

monorepo
├── apps
│   ├── monolith-app
│       ├── screens
│           ├── screen-a
│           ├── screen-b
│       └── package.json
│   ├── app-c
│       ├── screens
│           ├── screen-c
│       └── package.json
├── packages
│   ├── ui-components
│       ├── button.ts
│       ├── rollup.config
│       └── package.json
└── package.json

Note that the shared package is just for ui-components. This is because we want to split the monolith into user journey verticals that only share ui-components and encapsulate everything else.

It’s also important to note that this isn’t a big bang approach, but rather a way to gradually migrate monolithic code to multiple apps and packages over a period of time. This means we will need to support multiple versions of third party libraries at the same time.

To clarify, here’s a brief summary of reasons for the refactor:

  • to encapsulate frontend journeys into discrete apps
  • to maintain a consistent look-and-feel across these apps with a shared ui-components package
  • to support different frontend frameworks and dependencies (and versions) in different apps, but all in one overall place

And here’s our main tooling for the monorepo:

  • pnpm package manager (to benefit from faster package installation using hard links and symlinks)
  • Turborepo to give us nice monorepo tooling for caching builds and defining build dependencies between packages and apps etc (to be discussed in a separate post)

pnpm configuration

As mentioned above, pnpm is used for speed of module installation (and encapsulation of direct dependencies). It also has many other features that are useful for monorepos. A lot of these are documented in the docs for the .npmrc file. I would recommend a long leisurely read of these docs to appreciate how much you can do with pnpm in a monorepo.

Also here’s another useful blog by Adam Coster that details recommended settings for the .npmrc file in pnpm.

Peer dependencies

Because we do want to share the ui-components package across different apps, that have implemented different versions of libraries, we do need to think about peer dependencies early on.

The apps all use React, but the older monolithic code needs an earlier major version of React (v17) and the newer encapsulated journey apps use the latest major version of React (v18).

Peer dependencies allow some shared code (e.g. a shared ui-components package in a monorepo) to expect to find a third party dependency (e.g. react) in an app when the shared code is imported and used in the app.

So when we define react in the peerDependencies of the package.json for the ui-components package, we also set the semvar value to mean “this ui-component package will work with this version(s) of react which it is expecting to find in the app where it is imported”.

But what about when we are developing the ui-components package by itself (rather than using it as an imported package in an app, where it finds react happily waiting for it). Well usually you also define the third party dependencies for your shared code in the devDependencies section of the package.json. This means that the ui-components package installs react for development purposes but does not bundle it for production use (instead finding it available in the app where the package is used).

So far so good, however things get a little bit more complicated when using pnpm and a monorepo.

pnpm and peer dependencies

You can read about the linking processes of pnpm, when installing node_modules here. It is also worth reading the advanced docs on the symlinking phase of node_modules creation that follows the hard-linking phase.

You will notice in the symlinking phase explanation that this only applies to packages without peer dependencies. But it is still worth reading this prior to reading how peers are resolved for an in depth understanding of how the node_modules are constructed when peer dependencies are involved.

Put simply, whenever pnpm creates node_modules folders of dependencies it uses a combination of hard links and symlinks to:

  • only actually install one version of a dependency
  • to link to it from other packages via symlinks
  • to use a specific folder structure to keep packages and their dependencies grouped together but to not expose transitive dependencies that could become phantom dependencies

Building on this, with peer dependencies, pnpm’s grouping of dependency hard links and symlinks has to be duplicated for every potential combination of different versions of those peer dependencies. The duplications are still only links rather than actual files so it doesn’t impact installation speed or disk space, but it does become a bit more complicated.

There are also some settings for peer dependencies in pnpm’s .npmrc configuration. It is worth being aware of what is defaulting to true (auto-install-peers, dedupe-peer-dependents, resolve-peers-from-workplace-root) and what’s false (strict-peer-dependencies).

pnpm and peer dependencies - continued

Getting back to the point, here’s a pnpm monorepo demo to illustrate the scenario.

The structure of the project is roughly as follows. N.B. the actual repo uses Vite for the apps and Rollup for the shared packages, so there are a few config files missing in the below diagram. There is also a .pnpmfile.cjs which I will mention later.

monorepo
├── apps
│   ├── app-A
│       ├── src
│           ├── index
│       └── package.json (installs React 17)
│   ├── app-A
│       ├── src
│           ├── index
│       └── package.json (installs React 18)
├── packages
│   ├── ui-components
│       ├── src
│           ├── Button.tsx (has React 17 || 18 as a peer dependency)
│       └── package.json
├── package.json
└── pnpm-workspace.yaml

As you can see this demo app broadly illustrates the monolith-to-monorepo scenario. app-A can be seen as the legacy code which still requires React 17. app-B can be seen as the new encapsulated journey app which uses React 18. Both apps use the shared ui-components package which in the demo just contains Button and AlertButton components. The ui-components package has a peer dependency on React 17. Also note that there is not a .npmrc file in the root, so we are accepting all the defaults for .npmrc settings.

A side-note at this point is that early on in our real-life refactor we set the shamefully-hoist flag to true in the .npmrc settings as a way to quickly resolve all of the monorepo dependencies. But the moment we created a new app with a different version of a peer dependency we ran into problems, as this created a flat module structure and we were unable to control how pnpm was resolving to different dependency versions, and we had lots of phantom dependencies. So (as the docs say) don’t use shamefully-hoist, just use public-hoist sparingly if you really need to.

As mentioned earlier, pnpm is doing some fairly complex processing of peer dependencies when it finds them defined in a package.json file, in order to make them work with its model of links and custom package folder structure.

At this point there are two other important configurations to make in order for following to work:

Firstly we want to get mismatched peer dependency warnings on pnpm install if the version of (e.g) react that the shared package expects is not correct in the consuming app. It took me a while to find the right info in the docs for this. You need to use the dependenciesMeta > injected config in the package.json of your apps as detailed here.

Also in this section of the docs is info on the second requirement - to auto-build the shared package everytime you run pnpm install. For this you need to use a prepare script - NB a postinstall script seems to throw an error if you trying to build an injected dependency right after installing it.

You can see these two configurations in the pnpm monorepo demo.

Also worth noting that before I found the info on dependenciesMeta > injected I tried rolling my own warnings with a hacky script in .pnpmfile.cjs in the afterAllResolved hook. I’ve left this script in the demo code unused but worth a look if you need to hook into pnpm’s install lifecycle (also see the docs on this).

Now let’s run a fresh pnpm install on the pnpm monorepo demo.

If the peer dependencies for the shared ui-components package are defined as below then you won’t get any warnings during pnpm install when you install/link the same shared package into one app using React 17 and another using React 18.

 "peerDependencies": {
    "react": "17 || 18",
    "react-dom": "17 || 18"
  }

You should also inspect pnpm-lock.yaml after an install to see what pnpm has actually done. In the ui-components shared package, it’s worth noting that if the peer dependencies (react and react-dom) are only defined as peerDependenciesin the package.json then they are defined as dependencies rather than devDependencies in the pnpm-lock.yaml. However if you define your peers both in peerDependencies and devDependencies then they will be defined in pnpm-lock.yaml as devDependencies.

Since we are externalizing these peer deps when we build our shared package with rollup (using the external prop in the rollup config) then it isn’t a problem locally if the peers become defined as dependencies in pnpm-lock.yaml, but this could be an issue in CI. Therefore it is recommended to define your shared package peer dependencies as both peerDependencies and devDependencies as mentioned earlier.

Another thing to note in pnpm-lock.yamlis that if you semantically version your peer dependencies to be a range or one of two versions then pnpm will install the latest version of your peer (for development). So if we define "react": "17 || 18" in our peerDependencies and devDependencies then version 18.x will be installed/linked in the node_modules of the shared package.

To test if we do get peer dependency version warnings we can now change the peerDependencies in the ui-components package from:

 "peerDependencies": {
    "react": "17 || 18",
    "react-dom": "17 || 18"
  }

to:

 "peerDependencies": {
    "react": "17",
    "react-dom": "17"
  }

Now you should see warnings as one of the apps provides React 18 but the shared package is now expecting only React 17.

apps/app-B
└─┬ ui-components-package 0.1.0
  ├── ✕ unmet peer react@"17 ": found 18.2.0
  └── ✕ unmet peer react-dom@17: found 18.2.0

A final point is that if you don’t want these warnings then you can make your peer dependency versions optional using the peerDependenciesMeta configuration in package.json.

Written on February 8, 2024