npm / npm/cli

[BUG] npm install silently nests dependencies due to greedy peer resolution; --prefer-dedupe does not help

Open
#9,547 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Bug Needs Triage
Dominant language
JavaScript
Stars
10.1k
Forks
4.7k
Avg merge
2d 2h
Merged PRs (30d)
19

Description

Is there an existing issue for this?
  • I have searched the existing issues
This issue exists in the latest npm version
  • I am using the latest npm
This is not just a request to bump a dependency for a CVE
  • This is not solely a request to bump a dependency for a CVE
Current Behavior

When npm install resolves a transitive peer dependency, it 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 its own tilde-ranged peer dependency that conflicts with a pinned root package, npm silently nests the parent package instead of hoisting it.

There is no error or warning. The tree is valid but suboptimal — the same package exists at multiple levels.

npm dedupe immediately fixes the duplication, proving the flat tree IS valid.

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

Expected Behavior

npm install should produce the same (or equivalent) tree as npm install && npm dedupe when the valid flat tree exists.

At minimum: the --prefer-dedupe flag is documented as "prefer to reuse an already-installed version if it satisfies the range, even if a newer version is also valid." Since root already has the compatible version installed, --prefer-dedupe should cause npm to use it instead of fetching latest from registry. Currently it does not.

Steps To Reproduce

Self-contained reproduction: https://github.com/codefactor/npm-peer-dedup-bug

git clone https://github.com/codefactor/npm-peer-dedup-bug.git
cd npm-peer-dedup-bug
./reproduce.sh

The script starts a local Verdaccio registry, publishes 8 package versions, and runs 4 tests. No network access to npmjs.org needed.

Output:

✗ BUG CONFIRMED: renderer 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 (2 instances)

Minimal package structure:

Package Versions Key fields
widget-fw 1.0.0, 1.1.0 (empty)
widget-compat 1.0.0 peerDeps: { "widget-fw": "~1.0.0" } ← compatible
widget-compat 1.1.0 peerDeps: { "widget-fw": "~1.1.0" } ← incompatible
renderer 1.0.0 (no peers)
renderer 1.1.0 peerDeps: { "widget-fw": "^1.0.0", "widget-compat": "^1.0.0" }
toolkit 1.0.0 deps: { "renderer": "^1.0.0" }
toolkit 2.0.0 deps: { "renderer": "^1.1.0" }, peerDeps: { "widget-fw": "^1.0.0" }

Steps:

  1. Install baseline: toolkit@1.0.0, widget-fw@1.0.0, widget-compat@1.0.0, renderer@1.0.0 → flat tree ✓
  2. Bump toolkit to ^2.0.0, renderer to ^1.0.0npm install → renderer duplicated ✗
  3. npm dedupe → fixed ✓
Root Cause (traced via instrumented arborist)
  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 → 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 with root's 1.0.0. npm never checks the existing tree when resolving the peer version.

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

The preferDedupe flag is only consulted in checkCanPlaceCurrent() — which handles "keep vs replace at target level." It is never consulted during the virtual tree peer resolution in #nodeFromEdge#nodeFromSpec where the actual version is fetched from the registry packument.

canPlacePeers at line 385 hardcodes preferDedupe: true for peer checks — but this only affects checkCanPlaceCurrent. In our case the target level is empty (checkCanPlaceNoCurrent is called instead), so preferDedupe is irrelevant.

Difference from #7022

Issue #7022 manifests as an ERESOLVE error (install fails). Our case is worse: install succeeds silently with duplicates. No error, no warning — just increased bundle size and potential runtime module-identity issues.

Suggested Fix

In #nodeFromEdge or #loadPeerSet (build-ideal-tree.js L1276-1340), when resolving a peer dependency range: if preferDedupe is set (or unconditionally for peers), check whether a version already exists in the actual tree that satisfies the range. If so, prefer it over fetching latest from registry.

Environment
  • npm: 10.9.4
  • Node.js: v22.21.1
  • OS Name: macOS (Darwin 25.5.0)
  • System Model Name: MacBook Pro
  • npm config:
; node bin location = /Users/user/.nvm/versions/node/v22.21.1/bin/node
; node version = v22.21.1
; npm local prefix = /Users/user/project
; npm version = 10.9.4

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by running reproduce.sh in the linked self-contained repository, then read workspaces/arborist/lib/arborist/build-ideal-tree.js around #nodeFromEdge and #loadPeerSet, followed by workspaces/arborist/lib/can-place-dep.js. Trace how peer versions are selected when an actual compatible version already exists. Done means the reproduction installs the flat tree, and --prefer-dedupe no longer leaves duplicate renderer instances.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, nodejs
Domain
cli, devtools, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.