Skip to content

Commit 0629fbf

Browse files
authored
fix: prefer existing tree nodes for peerOptional deps (#9249) (#9283)
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..
1 parent d36945d commit 0629fbf

2 files changed

Lines changed: 167 additions & 2 deletions

File tree

workspaces/arborist/lib/arborist/build-ideal-tree.js

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -898,8 +898,21 @@ This is a one-time fix-up, please be patient...
898898
// be forced to agree on a version of z.
899899
const required = new Set([edge.from])
900900
const parent = edge.peer ? virtualRoot : null
901-
const dep = vrDep && vrDep.satisfies(edge) ? vrDep
902-
: await this.#nodeFromEdge(edge, parent, null, required)
901+
let dep = vrDep && vrDep.satisfies(edge) ? vrDep : null
902+
903+
// 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. See npm/cli#9249.
904+
// 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.
905+
const skipExistingShortcut = this[_updateNames].includes(edge.name)
906+
|| this.#explicitRequests.has(edge)
907+
|| (edge.to && this.auditReport?.isVulnerable(edge.to))
908+
if (!dep && edge.type === 'peerOptional' && !skipExistingShortcut) {
909+
dep = this.#findHoistableNode(
910+
/* istanbul ignore next - resolveParent is always set for non-root nodes */
911+
edge.from.resolveParent || edge.from, edge)
912+
}
913+
if (!dep) {
914+
dep = await this.#nodeFromEdge(edge, parent, null, required)
915+
}
903916

904917
/* istanbul ignore next */
905918
debug(() => {
@@ -1029,6 +1042,24 @@ This is a one-time fix-up, please be patient...
10291042
return this.#buildDepStep()
10301043
}
10311044

1045+
// BFS descendants of `root` for a node satisfying `edge`.
1046+
// Prefers nodes closer to root. Skips bundled nodes.
1047+
#findHoistableNode (root, edge) {
1048+
const queue = [...root.children.values()]
1049+
while (queue.length) {
1050+
const node = queue.shift()
1051+
if (node.name === edge.name
1052+
&& !node.inDepBundle
1053+
&& node.satisfies(edge)) {
1054+
return node
1055+
}
1056+
for (const child of node.children.values()) {
1057+
queue.push(child)
1058+
}
1059+
}
1060+
return null
1061+
}
1062+
10321063
// loads a node from an edge, and then loads its peer deps (and their peer deps, on down the line) into a virtual root parent.
10331064
async #nodeFromEdge (edge, parent_, secondEdge, required) {
10341065
// create a virtual root node with the same deps as the node that is requesting this one, so that we can get all the peer deps in a context where they're likely to be resolvable.

workspaces/arborist/test/arborist/build-ideal-tree.js

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4482,6 +4482,140 @@ t.test('skip invalid peerOptional edges in problemEdges when save=false (#8726)'
44824482
t.ok(tree.children.get('util'), 'util is in the tree')
44834483
})
44844484

4485+
t.test('peerOptional prefers existing tree node over registry fetch (#9249)', async t => {
4486+
// Reproduction: ts-jest has peerOptional jest-util@"^29||^30".
4487+
// @types/jest@28 → expect@28 → jest-util@28 placed at root first.
4488+
// jest@29 → jest-util@29 nested (root slot taken by @28).
4489+
// ts-jest re-queued, peerOptional jest-util resolves to root @28 → INVALID.
4490+
// Without fix: #nodeFromEdge fetches jest-util@30 (latest ^29||^30), blocks @29.
4491+
// With fix: #findHoistableNode finds nested @29, PlaceDep hoists it to root.
4492+
const registry = createRegistry(t, false)
4493+
4494+
const jestPack = registry.packument({
4495+
name: 'jest',
4496+
version: '29.0.0',
4497+
dependencies: { 'jest-util': '^29.0.0' },
4498+
})
4499+
const jestManifest = registry.manifest({ name: 'jest', packuments: [jestPack] })
4500+
await registry.package({ manifest: jestManifest })
4501+
4502+
const tsJestPack = registry.packument({
4503+
name: 'ts-jest',
4504+
version: '29.0.0',
4505+
peerDependencies: { jest: '^29.0.0', 'jest-util': '^29.0.0 || ^30.0.0' },
4506+
peerDependenciesMeta: { 'jest-util': { optional: true } },
4507+
})
4508+
const tsJestManifest = registry.manifest({ name: 'ts-jest', packuments: [tsJestPack] })
4509+
await registry.package({ manifest: tsJestManifest })
4510+
4511+
const expectPack = registry.packument({
4512+
name: 'expect',
4513+
version: '28.0.0',
4514+
dependencies: { 'jest-util': '^28.0.0' },
4515+
})
4516+
const expectManifest = registry.manifest({ name: 'expect', packuments: [expectPack] })
4517+
await registry.package({ manifest: expectManifest })
4518+
4519+
const atTypesPack = registry.packument({
4520+
name: '@types/jest',
4521+
version: '28.0.0',
4522+
dependencies: { expect: '^28.0.0' },
4523+
})
4524+
const atTypesManifest = registry.manifest({ name: '@types/jest', packuments: [atTypesPack] })
4525+
await registry.package({ manifest: atTypesManifest })
4526+
4527+
// Only publish 28, 29, and 30.
4528+
const jestUtilPacks = registry.packuments(['28.0.0', '29.0.0', '30.0.0'], 'jest-util')
4529+
const jestUtilManifest = registry.manifest({ name: 'jest-util', packuments: jestUtilPacks })
4530+
await registry.package({ manifest: jestUtilManifest, times: 3 })
4531+
4532+
const path = t.testdir({
4533+
'package.json': JSON.stringify({
4534+
dependencies: {
4535+
jest: '^29.0.0',
4536+
'ts-jest': '^29.0.0',
4537+
'@types/jest': '^28.0.0',
4538+
},
4539+
}),
4540+
})
4541+
4542+
const arb = newArb(path)
4543+
const tree = await arb.buildIdealTree()
4544+
4545+
// jest-util@29 at root — found via #findHoistableNode, not fetched as @30
4546+
t.equal(tree.children.get('jest-util').version, '29.0.0',
4547+
'jest-util@29 hoisted to root from nested location')
4548+
4549+
// ts-jest's peerOptional resolved to @29 from the tree, not @30 from registry
4550+
const tsJest = tree.children.get('ts-jest')
4551+
const peerOptEdge = tsJest.edgesOut.get('jest-util')
4552+
t.equal(peerOptEdge.to.version, '29.0.0',
4553+
'ts-jest peerOptional jest-util resolved to @29')
4554+
4555+
// jest-util@28 nested under expect (incompatible with root @29)
4556+
const expectNode = [...tree.inventory.query('name', 'expect')][0]
4557+
t.equal(expectNode?.children?.get('jest-util')?.version, '28.0.0',
4558+
'jest-util@28 nested under expect')
4559+
})
4560+
4561+
t.test('peerOptional skips dedupe shortcut when update.names includes the dep', async t => {
4562+
// Same scenario as above, but with update: { names: ['jest-util'] }.
4563+
// skipExistingShortcut=true so #findHoistableNode is NOT called;
4564+
// #nodeFromEdge fetches from registry, getting jest-util@30 (latest matching ^29||^30).
4565+
const registry = createRegistry(t, false)
4566+
4567+
const jestPack = registry.packument({
4568+
name: 'jest',
4569+
version: '29.0.0',
4570+
dependencies: { 'jest-util': '^29.0.0' },
4571+
})
4572+
await registry.package({ manifest: registry.manifest({ name: 'jest', packuments: [jestPack] }) })
4573+
4574+
const tsJestPack = registry.packument({
4575+
name: 'ts-jest',
4576+
version: '29.0.0',
4577+
peerDependencies: { jest: '^29.0.0', 'jest-util': '^29.0.0 || ^30.0.0' },
4578+
peerDependenciesMeta: { 'jest-util': { optional: true } },
4579+
})
4580+
await registry.package({ manifest: registry.manifest({ name: 'ts-jest', packuments: [tsJestPack] }) })
4581+
4582+
const expectPack = registry.packument({
4583+
name: 'expect',
4584+
version: '28.0.0',
4585+
dependencies: { 'jest-util': '^28.0.0' },
4586+
})
4587+
await registry.package({ manifest: registry.manifest({ name: 'expect', packuments: [expectPack] }) })
4588+
4589+
const atTypesPack = registry.packument({
4590+
name: '@types/jest',
4591+
version: '28.0.0',
4592+
dependencies: { expect: '^28.0.0' },
4593+
})
4594+
await registry.package({ manifest: registry.manifest({ name: '@types/jest', packuments: [atTypesPack] }) })
4595+
4596+
const jestUtilPacks = registry.packuments(['28.0.0', '29.0.0', '30.0.0'], 'jest-util')
4597+
await registry.package({ manifest: registry.manifest({ name: 'jest-util', packuments: jestUtilPacks }), times: 3 })
4598+
4599+
const path = t.testdir({
4600+
'package.json': JSON.stringify({
4601+
dependencies: {
4602+
jest: '^29.0.0',
4603+
'ts-jest': '^29.0.0',
4604+
'@types/jest': '^28.0.0',
4605+
},
4606+
}),
4607+
})
4608+
4609+
const arb = newArb(path)
4610+
const tree = await arb.buildIdealTree({ update: { names: ['jest-util'] } })
4611+
4612+
// With skipExistingShortcut=true, #nodeFromEdge fetches from registry
4613+
// so jest-util@30 (latest matching ^29||^30) is used instead of deduping @29
4614+
const tsJest = tree.children.get('ts-jest')
4615+
const peerOptEdge = tsJest.edgesOut.get('jest-util')
4616+
t.equal(peerOptEdge.to?.version, '30.0.0', 'peerOptional jest-util refetched to @30, not deduped to @29')
4617+
})
4618+
44854619
t.test('overrides with bundledDependencies', async t => {
44864620
t.test('does not infinite loop with bundledDependencies and overrides', async t => {
44874621
// https://github.com/npm/cli/issues/9227

0 commit comments

Comments
 (0)