Skip to content

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

Open
everett1992 wants to merge 3 commits intonpm:latestfrom
everett1992:fix-9220-prefer-existing
Open

fix: prefer existing tree nodes for peerOptional deps (#9249)#9283
everett1992 wants to merge 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).

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