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 useshamefully-hoist
, just usepublic-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 theafterAllResolved
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 peerDependencies
in 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.yaml
is 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
.