Repository navigation
[BUG] 11.12.1 peerOptional dependencies marked extraneous and removed #9249
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
Current Behavior
npm install with no node_modules or lockfile will create node_modules with nodes marked extraneous.
re-installing will remove those extraneous nodes.
In my case this leads to runtime failures when the module isn't found, and 21 duplicates when fewer would create a valid node_modules. I think the runtime failure is caused by a poorly defined dependency (ts-jest should not mark jest-util optional if it fails when it's not found)
But npm's behavior is weird, and causes packages that were building with npm 10.9.8 or 11.6.1 to fail to build.
Even when the build is successful there's more duplication than necessary.
Expected Behavior
I expect both installs to produce the same trees, or the second 'optimized' install to dedupe and hoist dependencies after removing the extraneous deps. I also expect npm to hoist the required dependency, not the optional dependency, or the hoist the dependency that satisfies more requirements and leads to less duplication.
Steps To Reproduce
- npm 11.12.1
Setup
cat > package.json << 'EOF'
{
"name": "repro",
"version": "1.0.0",
"private": true,
"devDependencies": {
"jest": "^29.7.0",
"ts-jest": "^29.1.4",
"@types/jest": "^28.1.8"
}
}
EOF
This is a weird set of dependencies that causes mixing packages from 28, 29, and 30. But I found packages like this in the wild when updating npm from 10 to 11.
Initial install
When we install dependencies there's a top-level node_modules/jest-util @ 30, which seems to be installed for the
optional peer dependency of ts-jest.
$ npm install
$ npm ls
├── @jest/pattern@30.0.1 extraneous
├── @types/jest@28.1.8
├── jest-util@30.3.0 extraneous
├── jest@29.7.0
└── ts-jest@29.4.9
$ npm explain jest-util@30 @jest/pattern@30
jest-util@30.3.0 extraneous
node_modules/jest-util
peerOptional jest-util@"^29.0.0 || ^30.0.0" from ts-jest@29.4.9
node_modules/ts-jest
dev ts-jest@"^29.1.4" from the root project
@jest/pattern@30.0.1 extraneous
node_modules/@jest/pattern
@jest/pattern@"30.0.1" from @jest/types@30.3.0
node_modules/jest-util/node_modules/@jest/types
@jest/types@"30.3.0" from jest-util@30.3.0 extraneous
node_modules/jest-util
peerOptional jest-util@"^29.0.0 || ^30.0.0" from ts-jest@29.4.9
node_modules/ts-jest
dev ts-jest@"^29.1.4" from the root project
The installed tree duplicates jest-util@29 21 times because node_modules/jest-util was claimed by v30, which only had one consumer, and was optional.
$ npm query '#jest-util' --json | jq '.[] | [.name, .version]' -c | sort | uniq -c | sort -nr
21 ["jest-util","29.7.0"]
1 ["jest-util","30.3.0"]
1 ["jest-util","28.1.3"]
Subsequent installs with package-lock.json
$ npm install
removed 8 packages, and audited 365 packages in 402ms
$ npm ls
repro@1.0.0
├── @types/jest@28.1.8
├── jest@29.7.0
└── ts-jest@29.4.9
$ npm explain jest-util@30 @jest/pattern@30
npm error No dependencies found matching jest-util@30, @jest/pattern@30
npm error A complete log of this run can be found in: /home/ANT.AMAZON.COM/calebev/.npm/_logs/2026-04-16T23_47_51_649Z-debug-0.log
$ npm ls jest-util
repro@1.0.0
├─┬ @types/jest@28.1.8
│ └─┬ expect@28.1.3
│ └── jest-util@28.1.3
├─┬ jest@29.7.0
│ └── jest-util@29.7.0 (... 25 more, collapsed)
└─┬ ts-jest@29.4.9
└─┬ @jest/transform@29.7.0
└── jest-util@29.7.0
$ ls node_modules/jest-util
ls: cannot access 'node_modules/jest-util': No such file or directory
$ npm query '#jest-util' --json | jq '.[] | [.name, .version]' -c | sort | uniq -c | sort -nr
21 ["jest-util","29.7.0"]
1 ["jest-util","28.1.3"]
On the second install node_modules/jest-util is gone, but jest-util 29 is still duplicated 21 times.
Now ts-jest's peer dependency is not satisfied.
This seems to be a regression since 11.6.1 (It does not remove node_modules/jest-util)
Environment
- npm: 11.12.1
- Node.js: 24.14.1
- OS Name: Ubuntu
- System Model Name: Lenovo
- npm config:
; none
Activity
everett1992 commentedon Apr 17, 2026
I have a few ideas how to fix this. I think the best improvement would be changing how npm creates the initial install. It shouldn't an otherwise non-required peerOptional dependency, it should pick the greatest compatible option that could be hoisted to a peer.
Next if we're removing extraneous dependencies npm should re-evaluate hosting a replacement. This could reduce install / load times by reducing duplication, and fix any missing peerOptionals. If it's too expensive to check replacements for all pruned deps, I think we should check whenever removing a peerOptional.
everett1992 commentedon Apr 22, 2026
I'm struggling with the semantics of peerOptional dependencies.
Here's my understanding of what happens
initial install, no lockfile
subsequent install, with lockfile
I think step 1.v is the most wrong, but step 5 causes the most pain for me upgrading to npm 11.
During 1.v npm shouldn't resolve the peerOptional to latest. I think the most semantically correct option would be to eject 28 and leave nm/jest-util empty
In this tree ts-jest peerOptional is not satisfied which I think is semantically valid1.
This tree doesn't solve any of the duplication of jest-util@29 under jest dependencies. An optimize / dedupe pass could recognize that there's a jest-util in the subtree of the parent that could be hoisted and reduce duplication, while having the side-effect of satisfying the jest-util dependency.
I don't see an easy way to implement this pattern - to force nm/jest-util to be empty and displace 28.
Footnotes
peerOptional is different from optionalDependencies in that npm will try to fetch and satisfy optionals, but not peerOptionals. npm will not error if the optional cannot be resolved, but it will always try to fetch them. peerOptionals are not installed by default, and are only satisfied if a the dependency is hoisted to them, or the parent declares a matching dependency. But peerOptionals should cause conflict resolution, or ERESOLVE to prevent the peerOptional from being resolved out of range. MISSING is okay, but INVALID is not. I'm less sure about what efforts npm should make to satisfy the peerOptionals. On one hand it seems wrong to allow transitive dependencies to be hoisted and satisfy the dependency; removing an unrelated dep could change the peerOptional features. On the otherhand, if we've already installed the peerOptional anywhere else in the tree should we re-use? I think the strictest implementation of peerOptionals would force MISSING unless the parent declares an intersecting edge. Npm would leave the location empty and prevent hoisting a dependency even if it satisfies the peerOptional. ↩
everett1992 commentedon Apr 22, 2026
cli/workspaces/arborist/test/arborist/build-ideal-tree.js
Line 2032 in 01d9acd
This test is interesting, it's suggesting that
npm install abbrev, in a package with peerOptional abbrev dependency, should install abbrev, because it's explicitly requested. The test passes, and I can reproduce it withBut if I run
npm install abbrevagain it's removed and only wrappy is installedRemoving either package-lock.json or node_modules causes only wrappy to be installed. Removing both causes wrappy and abbrev to be installed.
IMO the abbrev should not be saved and the test is wrong.
npm install abbrevnormally adds a prod dependency, so adding to node_modules makes sense. If you run install abbrev when abbrev is a dev dependency npm doesn't move abbrev to normal dependencies - it keeps it in dev. When it's a peerOptional it doesn't remove it from peer optional, so it should keep peerOptional semantics, and not be installed.fix: prefer existing tree nodes for peerOptional deps (npm#9249)
fix: prefer existing tree nodes for peerOptional deps (npm#9249)
fix: prefer existing tree nodes for peerOptional deps (#9249) (#9283)
fix: prefer existing tree nodes for peerOptional deps (#9249) (#9283)
chore(ci): pin npm@^11.14 + regenerate cross-platform lock
chore(ci): pin npm@^11.14 + regenerate cross-platform lock
chore(ci): pin npm@^11.14 + regen lock + ignore eslint>=10 in dependabot
fix(security): tighten CSP for ISR + dynamic, harden rate-limit + APP…
chore(ci): pin npm@^11.14 + regenerate cross-platform lock
Pin @swc/helpers under next-intl via package.json overrides
fix(conventional-changelog): add conventional-commits-filter to depen…