Skip to content

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

@codefactor

Description

@codefactor

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Bugthing that needs fixingNeeds Triageneeds review for next steps

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions