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.
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).
git clone <this-repo>
cd npm-peer-dedup-bug
./reproduce.shRequires: 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.
✗ 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
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 |
Traced via instrumented arborist source code:
-
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, so the 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. npm never checks the existing tree.
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
preferDedupeflag is only consulted incheckCanPlaceCurrent()— which handles "keep vs replace at target level" - It is never consulted during
#nodeFromEdge→#nodeFromSpecwhere the actual version is fetched from the registry canPlacePeershardcodespreferDedupe: trueat line 385, but this only affectscheckCanPlaceCurrent(empty in our case →checkCanPlaceNoCurrentis called instead)
- Root pins a package at an older version (
widget-fw@1.0.0) - A peer dep (
widget-compat) exists in 2+ versions — one compatible with root's pin, one not - The incompatible version uses a tilde range on the pinned package (
~1.1.0won't match1.0.0) - An intermediate package (
toolkit) has a peer dep onwidget-fwbut no dep onwidget-compat - The package being placed (
renderer) haspeerDeps: { "widget-compat": "^1.0.0" } - Root already has
widget-compat@1.0.0installed
- #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.
npm dedupeafter installoverridesin package.json:"widget-compat": "$widget-compat"--legacy-peer-deps(skips all peer validation)
npm@10.9.4
node@v22.21.1