Is there an existing issue for this?
This issue exists in the latest npm version
This is not just 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:
- Install baseline:
toolkit@1.0.0, widget-fw@1.0.0, widget-compat@1.0.0, renderer@1.0.0 → flat tree ✓
- Bump toolkit to
^2.0.0, renderer to ^1.0.0 → npm install → renderer duplicated ✗
npm dedupe → fixed ✓
Root Cause (traced via instrumented arborist)
-
PlaceDep(renderer@1.1.0) starts at toolkit (via deepestNestingTarget — toolkit has a regular dep on renderer)
-
canPlacePeers resolves renderer's peer widget-compat@^1.0.0 to 1.1.0 (latest from registry via #loadPeerSet → #nodeFromEdge → #nodeFromSpec)
-
widget-compat@1.1.0 has peerDeps: { "widget-fw": "~1.1.0" }
-
deepestNestingTarget(toolkit, "widget-fw") returns ROOT (toolkit has a peer dep on widget-fw → walk continues past it)
-
At ROOT: widget-fw@1.0.0 does NOT satisfy ~1.1.0 → CONFLICT
-
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
Is there an existing issue for this?
This issue exists in the latest npm version
This is not just a request to bump a dependency for a CVE
Current Behavior
When
npm installresolves 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 dedupeimmediately fixes the duplication, proving the flat tree IS valid.--prefer-dedupedoes NOT fix it (see analysis below).Expected Behavior
npm installshould produce the same (or equivalent) tree asnpm install && npm dedupewhen the valid flat tree exists.At minimum: the
--prefer-dedupeflag 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-dedupeshould 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.shThe script starts a local Verdaccio registry, publishes 8 package versions, and runs 4 tests. No network access to npmjs.org needed.
Output:
Minimal package structure:
widget-fwwidget-compatpeerDeps: { "widget-fw": "~1.0.0" }← compatiblewidget-compatpeerDeps: { "widget-fw": "~1.1.0" }← incompatiblerendererrendererpeerDeps: { "widget-fw": "^1.0.0", "widget-compat": "^1.0.0" }toolkitdeps: { "renderer": "^1.0.0" }toolkitdeps: { "renderer": "^1.1.0" }, peerDeps: { "widget-fw": "^1.0.0" }Steps:
toolkit@1.0.0, widget-fw@1.0.0, widget-compat@1.0.0, renderer@1.0.0→ flat tree ✓^2.0.0, renderer to^1.0.0→npm install→ renderer duplicated ✗npm dedupe→ fixed ✓Root Cause (traced via instrumented arborist)
PlaceDep(renderer@1.1.0)starts attoolkit(viadeepestNestingTarget— toolkit has a regular dep on renderer)canPlacePeersresolves renderer's peerwidget-compat@^1.0.0to 1.1.0 (latest from registry via#loadPeerSet→#nodeFromEdge→#nodeFromSpec)widget-compat@1.1.0haspeerDeps: { "widget-fw": "~1.1.0" }deepestNestingTarget(toolkit, "widget-fw")returns ROOT (toolkit has a peer dep on widget-fw → walk continues past it)At ROOT:
widget-fw@1.0.0does NOT satisfy~1.1.0→ CONFLICTCONFLICT breaks PlaceDep's ancestry walk → renderer gets nested
What npm misses: Root already has
widget-compat@1.0.0which satisfies^1.0.0AND whose own peer (widget-fw@~1.0.0) is compatible with root's1.0.0. npm never checks the existing tree when resolving the peer version.Why
--prefer-dedupeShould Fix This But Doesn'tThe
preferDedupeflag is only consulted incheckCanPlaceCurrent()— which handles "keep vs replace at target level." It is never consulted during the virtual tree peer resolution in#nodeFromEdge→#nodeFromSpecwhere the actual version is fetched from the registry packument.canPlacePeersat line 385 hardcodespreferDedupe: truefor peer checks — but this only affectscheckCanPlaceCurrent. In our case the target level is empty (checkCanPlaceNoCurrentis called instead), sopreferDedupeis 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
#nodeFromEdgeor#loadPeerSet(build-ideal-tree.js L1276-1340), when resolving a peer dependency range: ifpreferDedupeis 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