Skip to content

fix: prefer existing tree nodes for peerOptional deps (#9249)#9283

Merged
owlstronaut merged 3 commits intonpm:latestfrom
everett1992:fix-9220-prefer-existing
May 4, 2026
Merged

fix: prefer existing tree nodes for peerOptional deps (#9249)#9283
owlstronaut merged 3 commits intonpm:latestfrom
everett1992:fix-9220-prefer-existing

Conversation

@everett1992
Copy link
Copy Markdown
Contributor

When a peerOptional edge conflicts, search descendants for a satisfying node before fetching from the registry. This prevents extraneous packages from blocking hoisting of required deps.

This fixes 1/2 or 1/3 of #9249. Before this change a clean install would resolve nm/jest-util@30 when resolving the conflict at nm/jest-util between ts-jest's jest-util@^29||^30, and expect's ^28, which had been placed at root. #nodeFromEdge would create a brand new node, matching greatest ^30. A subequent install would mark nm/jest-util@30 as extraneous and prune it.
This tree is valid, but ts-jest's peerOptional jest-util is unsatisfied, while compatible jest-util are installed and duplicated.

This change reduces duplication and can prevent peerOptionals from actively installing.

  1. Now during initial installs npm will prefer hoisting a dependency to de-dupe a peerOptional conflict over creating a new extraneous edge.
  2. It doesn't solve the problem if there's no compatible version in the sub-tree. npm will still use #nodeFromEdge and install an extraneous edge.
  3. It doesn't fix installs from lockfiles generated before this fix. I think this is okay, because the trees are techincally valid, just not optimal.

I think a better solution to all three issues would be:

  • During problemEdge conflict resolution, npm would hoist nm/jest-util@28 under expect, without replacing it with anything. ts-jest's peerOptional jest-util would be unsatisfied. This creates the same tree as npm's second installs that prune extraneous.
  • Check for any dependencies that can be hosited. This can run during the initial install on problemEdge conflict resoultion, and in pruneIdealTree on any nodes that are removed.

I think this solves all three issues. I didn't implement it because I couldn't find a way to resolve the conflict by leaving a hole in the tree..

When a peerOptional edge conflicts, search descendants for a satisfying
node before fetching from the registry. This prevents extraneous packages
from blocking hoisting of required deps.
@everett1992 everett1992 requested a review from a team as a code owner April 26, 2026 20:56
@owlstronaut owlstronaut self-requested a review April 28, 2026 15:17
Comment on lines +907 to +909
if (!dep && edge.type === 'peerOptional') {
dep = this.#findHoistableNode(edge.from.resolveParent || edge.from, edge)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!dep && edge.type === 'peerOptional') {
dep = this.#findHoistableNode(edge.from.resolveParent || edge.from, edge)
}
// Skip the shortcut when the user has signaled an explicit re-fetch intent (npm update by name, explicit request, or audit fix), so we honor those signals rather than silently keeping the existing node.
const skipExistingShortcut = this[_updateNames].includes(edge.name)
|| this.#explicitRequests.has(edge)
|| (edge.to && this.auditReport?.isVulnerable(edge.to))
if (!dep && edge.type === 'peerOptional' && !skipExistingShortcut) {
dep = this.#findHoistableNode(edge.from.resolveParent || edge.from, edge)
}

I think if we add this check, we get to keep the dedupe behavior for normal installs while still letting npm update, npm audit fix, and explicit installs do their thing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only in problemEdges tho, wouldn't there be other chances earlier to apply requested updates?

Aside: I noticed there is a test that peerOptionals are installed when explicitly requested, and that still passes npm install a-peer-optional.

Tho I noticed that even before this change the test would fail on re-install, or when package-lock or node_modules was already present. That seems bad, npm's only respecting the explciit request when it's not building from a lock.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see those count as problems and land here.

Comment on lines +903 to +905
// A peerOptional conflict can be resolved by finding an existing
// node in the tree that satisfies the edge, avoiding a registry
// fetch that may introduce an extraneous package.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// A peerOptional conflict can be resolved by finding an existing
// node in the tree that satisfies the edge, avoiding a registry
// fetch that may introduce an extraneous package.
// A peerOptional conflict can be resolved by finding an existing node in the tree that satisfies the edge, avoiding a registry fetch that may introduce an extraneous package.

we try to break only at natural boundaries for screen reader accessibility

@everett1992
Copy link
Copy Markdown
Contributor Author

@owlstronaut Do you have ideas for alternative fixes? I really want a solution that fixes existing lockfiles, that have the old peerOptional conflict resolution, is an extraneous dependency that will be pruned.

This is causing friction updating to npm 11 (I know, I'm behind the curve).

@everett1992 everett1992 force-pushed the fix-9220-prefer-existing branch from 9970730 to 9b96929 Compare April 30, 2026 17:07
Copy link
Copy Markdown
Contributor

@owlstronaut owlstronaut left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have ideas currently about the existing lockfile issue... in the meantime i think this is still a meaningful improvement on its own

Comment on lines +4616 to +4617
t.ok(peerOptEdge, 'ts-jest has peerOptional edge for jest-util')
t.not(peerOptEdge.to?.version, undefined, 'peerOptional jest-util resolved')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
t.ok(peerOptEdge, 'ts-jest has peerOptional edge for jest-util')
t.not(peerOptEdge.to?.version, undefined, 'peerOptional jest-util resolved')
t.equal(peerOptEdge.to?.version, '30.0.0', 'peerOptional jest-util refetched to @30, not deduped to @29')

This would pass even without the !skipExistingShortcut guard

@everett1992 everett1992 force-pushed the fix-9220-prefer-existing branch from 9b96929 to 13270e2 Compare April 30, 2026 20:02
@everett1992
Copy link
Copy Markdown
Contributor Author

I don't have ideas currently about the existing lockfile issue... in the meantime i think this is still a meaningful improvement on its own

What do you think about running a dedupe pass when nodes are deleted by idealTreePrune?
Arborist could check for children of siblings that can be hoisted and replace the node.

@owlstronaut owlstronaut self-assigned this May 1, 2026
@owlstronaut
Copy link
Copy Markdown
Contributor

I don't have ideas currently about the existing lockfile issue... in the meantime i think this is still a meaningful improvement on its own

What do you think about running a dedupe pass when nodes are deleted by idealTreePrune? Arborist could check for children of siblings that can be hoisted and replace the node.

That seems like a promising direction. I'm worried that'll get expensive on big trees, but I'm open to a dedupe pass given we could profile the performance appropriately

@owlstronaut owlstronaut merged commit 0629fbf into npm:latest May 4, 2026
18 checks passed
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

⚠️ Backport to release/v11 failed.

This usually means the cherry-pick had conflicts. Please create a manual backport:

git fetch origin release/v11
git checkout -b backport/v11/9283 origin/release/v11
git cherry-pick -x 0629fbf736eafcb555428d96bd86a69f8e791d70
# resolve any conflicts, then:
git push origin backport/v11/9283
Error details
Command failed: git cherry-pick -x 0629fbf736eafcb555428d96bd86a69f8e791d70
error: could not apply 0629fbf73... fix: prefer existing tree nodes for peerOptional deps (#9249) (#9283)
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".
hint: You can instead skip this commit with "git cherry-pick --skip".
hint: To abort and get back to the state before "git cherry-pick",
hint: run "git cherry-pick --abort".
hint: Disable this message with "git config set advice.mergeConflict false"

owlstronaut pushed a commit that referenced this pull request May 4, 2026
When a peerOptional edge conflicts, search descendants for a satisfying
node before fetching from the registry. This prevents extraneous
packages from blocking hoisting of required deps.

This fixes 1/2 or 1/3 of #9249. Before this change a clean install would
resolve `nm/jest-util@30` when resolving the conflict at nm/jest-util
between ts-jest's jest-util@^29||^30, and expect's ^28, which had been
placed at root. `#nodeFromEdge` would create a brand new node, matching
greatest ^30. A subequent install would mark nm/jest-util@30 as
extraneous and prune it.
This tree is valid, but ts-jest's peerOptional jest-util is unsatisfied,
while compatible jest-util are installed and duplicated.

This change reduces duplication and can prevent peerOptionals from
actively installing.

1. Now during initial installs npm will prefer hoisting a dependency to
de-dupe a peerOptional conflict over creating a new extraneous edge.
2. It doesn't solve the problem if there's no compatible version in the
sub-tree. npm will still use `#nodeFromEdge` and install an extraneous
edge.
3. It doesn't fix installs from lockfiles generated before this fix. I
think this is okay, because the trees are techincally valid, just not
optimal.

 I think a better solution to all three issues would be:
* During problemEdge conflict resolution, npm would hoist
nm/jest-util@28 under expect, without replacing it with anything.
ts-jest's peerOptional jest-util would be unsatisfied. This creates the
same tree as npm's second installs that prune extraneous.
* Check for any dependencies that can be hosited. This can run during
the initial install on problemEdge conflict resoultion, and in
pruneIdealTree on any nodes that are removed.

I think this solves all three issues. I didn't implement it because I
couldn't find a way to resolve the conflict by leaving a hole in the
tree..

(cherry picked from commit 0629fbf)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants