Skip to content

codefactor/npm-peer-dedup-bug

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 

Repository files navigation

npm Peer Dependency Deduplication Bug

Minimal reproduction of a bug where npm install silently nests (duplicates) a dependency that should be hoisted. The valid flat tree exists — proven by npm dedupe immediately fixing it — but npm install fails to find it due to greedy peer resolution.

The Bug

When resolving transitive peer dependencies, npm picks the latest version from the registry that satisfies the range, even when a compatible version is already installed at root. If the latest version has a tilde-ranged peer dependency that conflicts with a pinned root package, npm silently nests instead of hoisting.

--prefer-dedupe does NOT fix this (see analysis below).

Quick Reproduction

git clone <this-repo>
cd npm-peer-dedup-bug
./reproduce.sh

Requires: Node.js 18+ with npm 10.x (tested on npm 10.9.4, node 22.21.1)

The script uses a local Verdaccio registry (started automatically) — no network access to npmjs.org needed.

Expected Output

✗ BUG CONFIRMED: lib-user has 2 non-deduped instances (should be 1)
✓ npm dedupe fixed it — proving the flat tree IS valid
✓ --legacy-peer-deps avoids the bug
✗ --prefer-dedupe does NOT fix it

Package Structure

app-outer (root)
├── widget-fw@1.0.0        (pinned)
├── widget-compat@1.0.0    (pinned, peerDeps: widget-fw@~1.0.0 ✓)
├── renderer@1.0.0         (should be hoisted here)
└── toolkit@1.12.0         (peerDeps: widget-fw@^1.0.0)
    └── renderer@1.1.0     ← BUG: nested duplicate
Package Role Real-world analogue
app-outer Root application Any app (e.g. people-profile)
toolkit Intermediate dep with peer on widget-fw @xweb/manifest-toolkit
renderer The duplicated package @xweb/metadata-renderer
widget-fw Pinned at root @ui5/webcomponents-react
widget-compat Two versions with tilde peers on widget-fw @ui5/webcomponents-react-compat

Root Cause

Traced via instrumented arborist source code:

  1. PlaceDep(renderer@1.1.0) starts at toolkit (via deepestNestingTarget — toolkit has a regular dep on renderer)

  2. canPlacePeers resolves renderer's peer widget-compat@^1.0.0 to 1.1.0 (latest from registry) via #loadPeerSet#nodeFromEdge#nodeFromSpec

  3. widget-compat@1.1.0 has peerDeps: { "widget-fw": "~1.1.0" }

  4. deepestNestingTarget(toolkit, "widget-fw") returns ROOT (toolkit has a peer dep on widget-fw, so the walk continues past it)

  5. At ROOT: widget-fw@1.0.0 does NOT satisfy ~1.1.0CONFLICT

  6. CONFLICT breaks PlaceDep's ancestry walk → renderer gets nested

What npm misses: Root already has widget-compat@1.0.0 which satisfies ^1.0.0 AND whose own peer (widget-fw@~1.0.0) is compatible. npm never checks the existing tree.

Why --prefer-dedupe Should Fix This But Doesn't

The documentation says:

Tells npm to prefer deduplicating packages rather than installing newer versions. With --prefer-dedupe, npm instead prefers to reuse an already-installed version if it satisfies the range, even if a newer version is also valid.

Root has widget-compat@1.0.0 which satisfies ^1.0.0. The flag should cause npm to choose 1.0.0 over 1.1.0. But:

  • The preferDedupe flag is only consulted in checkCanPlaceCurrent() — which handles "keep vs replace at target level"
  • It is never consulted during #nodeFromEdge#nodeFromSpec where the actual version is fetched from the registry
  • canPlacePeers hardcodes preferDedupe: true at line 385, but this only affects checkCanPlaceCurrent (empty in our case → checkCanPlaceNoCurrent is called instead)

Trigger Conditions (all must be true)

  1. Root pins a package at an older version (widget-fw@1.0.0)
  2. A peer dep (widget-compat) exists in 2+ versions — one compatible with root's pin, one not
  3. The incompatible version uses a tilde range on the pinned package (~1.1.0 won't match 1.0.0)
  4. An intermediate package (toolkit) has a peer dep on widget-fw but no dep on widget-compat
  5. The package being placed (renderer) has peerDeps: { "widget-compat": "^1.0.0" }
  6. Root already has widget-compat@1.0.0 installed

Related Issues

  • #7022 — Same root cause (greedy latest resolution) but manifests as ERESOLVE error instead of silent nesting
  • #4267 — Same mechanism, also ERESOLVE

Our case is arguably worse: install succeeds silently with duplicates. No error, no warning.

Workarounds

  1. npm dedupe after install
  2. overrides in package.json: "widget-compat": "$widget-compat"
  3. --legacy-peer-deps (skips all peer validation)

Environment

npm@10.9.4
node@v22.21.1

About

Minimal reproduction of npm install silently nesting dependencies due to greedy peer resolution — npm dedupe fixes it, --prefer-dedupe does not

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages