[BUG] npm install silently nests dependencies due to greedy peer resolution; --prefer-dedupe does not help
Nobody has claimed this yet.
- 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:
- 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 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→ 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
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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