Skip to content

[BUG] 11.12.1 peerOptional dependencies marked extraneous and removed #9249

Open

Description

@everett1992

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

  1. everett1992 commented on Apr 17, 2026

    @everett1992
    ContributorAuthor

    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.

  2. everett1992 commented on Apr 22, 2026

    @everett1992
    ContributorAuthor

    I'm struggling with the semantics of peerOptional dependencies.

    Here's my understanding of what happens

    1. initial install, no lockfile

      1. resolving @types/jest, jest, ts-jest
      2. places @types/jest -> expect -> jest-util@28 at nm/jest-util
      3. places jest -> (21 various jest pkgs) -> jest-util@29 at nm/(21 various jest pkgs)/nm/jest-util
      4. places ts-jest
        1. checking validity of placement
        2. ts-jest -peerOptional> jest-util is not valid because nm/jest-util is 28 but ^29||^30 is required
        3. edge is queued to fix
      5. edge is fixed by resolving ^29||^30 to latest version, placing nm/jest-util@30, moving 28 to nm/@types/jest/nm/jest-util
    2. subsequent install, with lockfile

      1. npm sees nm/jest-util@30
      2. only edges satisfied by 30 are peerOptional edges, marked extraneous
      3. pruned
      4. rest of tree is untouched, leaving nm/(21 various jest pkgs)/nm/jest-util@29, and nm/@types/jest/nm/jest-util@28
      5. ts-jest's jest-util is left unsatisfied, which is okay for a peerOptional

    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

    nm/@types/jest/nm/jest-util@28 // Moved from nm/jest-util
    nm/jest/nm/jest-util@29
    nm/ts-jest
    // nm/jest-util is _empty_ ts-jest peerOptional jest-util is unsatisfied.
    

    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.

    nm/@types/jest/nm/jest-util@28 // Moved from nm/jest-util
    nm/ts-jest
    nm/jest-util@29  // Moved from nm/jest as optimization
    

    I don't see an easy way to implement this pattern - to force nm/jest-util to be empty and displace 28.

    Footnotes

    1. 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. ↩

  3. everett1992 commented on Apr 22, 2026

    @everett1992
    ContributorAuthor

    t.test('peerOptionals that are devDeps or explicit request', async t => {

    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 with

    {
      "peerDependencies": {
        "abbrev": "^4.0.0",
        "wrappy": "*"
      },
      "devDependencies": {
        "wrappy": "*"
      },
      "peerDependenciesMeta": {
        "wrappy": {
          "optional": true
        },
        "abbrev": {
          "optional": true
        }
      }
    
    $ npm install abbrev && ls node_modules
    added 2 packages, and audited 3 packages in 214ms
    abbrev  wrappy
    

    But if I run npm install abbrev again it's removed and only wrappy is installed

    npm install abbrev && ls node_modules
    removed 1 package, and audited 2 packages in 227ms
    wrappy
    

    Removing 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 abbrev normally 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Bugthing that needs fixingNeeds Triageneeds review for next steps

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions