npm link, peerDependencies and webpack

If you are developing a node library and using it via npm link, you can run in to issues with duplicate dependencies.

Consider 3 node packages: my-app, my-lib and third-party-lib.

my-app has a dependency on my-lib. my-lib is added to my-app via npm link.

my-lib has third-party-lib listed in:

  • devDependencies, because when developing my-lib it is needed for tests and examples.
  • peerDependencies, because it expects my-app to already include it as a dependency.

When a developer works on my-lib, they install devDependencies.

When running my-app to test against my-lib, the developer uses npm link.

The dependency file tree now looks like this:

├─┬ my-app
│ └─┬ node_modules
│ └─┬── my-lib(symlink)
│ └── third-party-lib
└─┬ my-lib
└─┬ node_modules
└── third-party-lib

node dependency resolution means that my-lib uses a different copy of third-party-lib to the one used by my-app.

This is a problem if:

  • my-app generates a bundle (e.g. using webpack) and you want to avoid duplication to reduce file size.
  • third-party-lib expects a single instance (e.g. React).

Similarly, if you don’t install devDependencies on my-lib:

├─┬ my-app
│ └─┬ node_modules
│ ├── my-lib(symlink)
│ └── third-party-lib
└── my-lib

my-lib will no longer be able to resolve third-party-lib under this tree and webpack will complain when bundling.

symlink

Node has a --preserve-symlinks option, so that at runtime it treats my-lib as a subfolder of my-app/node_modules rather than it’s actual file path, though in the commit message this feature is listed as being a temporary solution:

This should be considered to be a temporary solution until we figure out how to solve the symlinked peer dependency problem in a more general way that does not break everything else

If you run node using the --preserve-symlinks flag (or webpack with resolve.symlinks set to false) then the file tree instead looks like this:

└─┬ my-app
└─┬ node_modules
├─┬ my-lib(symlink)
│ └─┬ node_modules
│ └── third-party-lib
└── third-party-lib

If you don’t install devDependencies in my-lib under this tree then third-party-lib will be correctly resolved, but you get the same duplication issue when devDependencies are installed. Running npm dedupe before building seems to be a possible solution for this, but unfortunately it does not process linked modules. In addition, you may not want to remove the linked package’s devDependencies if you want to develop both packages at the same time.

So, although preserve-symlinks seems to have been created to resolve issues like this, it isn’t foolproof for this scenario so and we need to find a different solution.

Lerna

Lerna resolves this with hoisting, but this solution is specific to a monorepo.

Webpack solution

The solution I’m using for webpack, for non monorepos, consists of 2 steps:

  1. in the webpack config for my-app, add webpack aliases for known duplicated packages so that they always resolve to the my-app node_modules folder.
  2. add the duplicate-package-checker webpack plugin to warn or error if a package is duplicated in future.

Webpack aliases

resolve: {
alias: {
react: path.resolve('./node_modules/react'),
'react-dom': path.resolve('./node_modules/react-dom')
}

or

const mapToFolder = (dependencies, folder) =>
dependencies.reduce((acc, dependency) => {
return {
[dependency]: path.resolve(`${folder}/${dependency}`),
...acc
}
}, {});

...
resolve: {
alias: {
...mapToFolder(['react', 'react-dom'], './node_modules')
}
}

Duplicate Package Checker

I feel that just adding webpack aliases manually is a bit hacky as if more dependencies are added in future then they could easily be duplicated in the build without the developer realising. To manage this, we can add a check in the webpack build to ensure there are no duplicate packages.

Caveats

If multiple versions of a depencency are used by several other dependencies then adding the webpack alias will break npm’s semver management.

As noted by duplicate-package-checker:

Note: Aliasing packages with different major versions may break your app. Use only if you’re sure that all required versions are compatible, at least in the context of your app

Update November 2018: It looks as though a solution may be close, in the form of Yarn PnP —https://twitter.com/arcanis/status/1052305689886875648

Sources/history

8 February 2013 — npm introduces peerDependencies.

15 April 2014 — npm#5080 npm link and peerDependencies issues raised,

4 August 2014 — npm#5875 again,

25 March 2015 — npm#7742 and again.

10 April 2015 — webpack#966, issue relating to npm link.

3 May 2016 — preserve-symlinks flag added to node.

7 August 2016 — create-react-app#393 React development issues.

7 September 2016 — resolve.symlinks added to webpack/enhanced-resolve.

18 September 2016 — create-react-app#675 issues relating to multiple instances of React summarised on create-react-app.

28 November 2016 — create-react-app#1107 npm link workflow.

25 January 2017 — lerna#529 hoisting as a monorepo solution.