Skip to content

Commit dc43591

Browse files
authored
fix(arborist): don't fetch packuments for uninstallable optional peer dependencies (#9877)
1 parent 51c2bf8 commit dc43591

2 files changed

Lines changed: 159 additions & 3 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1607,6 +1607,16 @@ This is a one-time fix-up, please be patient...
16071607

16081608
if (!edge.to) {
16091609
if (!parentEdge) {
1610+
// the peer is missing from the virtual root; check the real tree before skipping.
1611+
// we can avoid a fetch for an optional peer, or a compatible provider, though
1612+
// an incompatible provider still has to be resolved here so that the
1613+
// optional peer set nests instead of displacing required peers.
1614+
if (edge.type === 'peerOptional') {
1615+
const current = node.parent.sourceReference.resolve(edge.name)
1616+
if (!current || current.satisfies(edge)) {
1617+
continue
1618+
}
1619+
}
16101620
// easy, just put the thing there
16111621
await this.#nodeFromEdge(edge, node.parent, null, required)
16121622
continue

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

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2355,9 +2355,42 @@ t.test('detect conflicts in transitive peerOptional deps', async t => {
23552355
createRegistry(t, true)
23562356
const tree = await buildIdeal(path)
23572357
t.matchSnapshot(printTree(tree))
2358-
const name = '@isaacs/test-conflicted-optional-peer-dep-peer'
2359-
const peers = tree.inventory.query('name', name)
2358+
const peerName = '@isaacs/test-conflicted-optional-peer-dep-peer'
2359+
const requiredHostName = '@isaacs/test-conflicted-optional-peer-dep-has-peer'
2360+
const optionalHostName = '@isaacs/test-conflicted-optional-peer-dep-has-peer-optional'
2361+
const optionalMetaName = '@isaacs/test-conflicted-optional-peer-dep-meta-peer-optional'
2362+
2363+
const peers = tree.inventory.query('name', peerName)
23602364
t.equal(peers.size, 2, 'installed the peer dep twice to avoid conflict')
2365+
2366+
const rootPeer = tree.children.get(peerName)
2367+
const requiredHost = tree.children.get(requiredHostName)
2368+
const optionalMeta = tree.children.get(optionalMetaName)
2369+
const optionalHost = optionalMeta?.children.get(optionalHostName)
2370+
const nestedPeer = optionalMeta?.children.get(peerName)
2371+
2372+
t.equal(rootPeer?.version, '1.0.0', 'required peer retains the root slot')
2373+
t.equal(
2374+
requiredHost?.edgesOut.get(peerName)?.to,
2375+
rootPeer,
2376+
'required peer edge resolves to the root provider'
2377+
)
2378+
2379+
t.notOk(
2380+
tree.children.get(optionalHostName),
2381+
'optional peer dependent is not hoisted to the root'
2382+
)
2383+
t.ok(optionalHost, 'optional peer dependent is nested under its branch')
2384+
t.equal(
2385+
nestedPeer?.version,
2386+
'2.0.0',
2387+
'compatible optional peer is nested with its dependent'
2388+
)
2389+
t.equal(
2390+
optionalHost?.edgesOut.get(peerName)?.to,
2391+
nestedPeer,
2392+
'optional peer edge resolves to its nested provider'
2393+
)
23612394
})
23622395

23632396
await t.test('omit peerOptionals when not needed for conflicts', async t => {
@@ -4885,6 +4918,119 @@ t.test('circular peer back-off does not crash when node is detached mid-resoluti
48854918
'backs off to plugin@1.0.0 to satisfy the optional peer instead of crashing')
48864919
})
48874920

4921+
t.test('does not fetch packuments for peerOptional deps that will not be installed', async t => {
4922+
const registry = createRegistry(t, false)
4923+
4924+
const hostPack = registry.packument({
4925+
name: 'host',
4926+
version: '1.0.0',
4927+
peerDependencies: { plugin: '^1.0.0' },
4928+
peerDependenciesMeta: { plugin: { optional: true } },
4929+
})
4930+
const hostManifest = registry.manifest({ name: 'host', packuments: [hostPack] })
4931+
await registry.package({ manifest: hostManifest })
4932+
4933+
const path = t.testdir({
4934+
'package.json': JSON.stringify({
4935+
dependencies: { host: '^1.0.0' },
4936+
}),
4937+
})
4938+
4939+
const arb = newArb(path)
4940+
const tree = await arb.buildIdealTree()
4941+
4942+
t.equal(tree.children.get('host').version, '1.0.0', 'installed host')
4943+
t.equal(tree.children.get('plugin'), undefined, 'did not install the optional peer')
4944+
const edge = tree.children.get('host').edgesOut.get('plugin')
4945+
t.equal(edge.type, 'peerOptional')
4946+
t.equal(edge.to, null, 'peerOptional edge left unresolved')
4947+
t.ok(edge.valid, 'missing peerOptional edge is valid')
4948+
})
4949+
4950+
t.test('resolves peerOptional deps installed by another dependent', async t => {
4951+
const registry = createRegistry(t, false)
4952+
4953+
const hostPack = registry.packument({
4954+
name: 'host',
4955+
version: '1.0.0',
4956+
peerDependencies: { plugin: '^1.0.0' },
4957+
peerDependenciesMeta: { plugin: { optional: true } },
4958+
})
4959+
const hostManifest = registry.manifest({ name: 'host', packuments: [hostPack] })
4960+
await registry.package({ manifest: hostManifest })
4961+
4962+
const otherPack = registry.packument({
4963+
name: 'other',
4964+
version: '1.0.0',
4965+
dependencies: { plugin: '^1.0.0' },
4966+
})
4967+
const otherManifest = registry.manifest({ name: 'other', packuments: [otherPack] })
4968+
await registry.package({ manifest: otherManifest })
4969+
4970+
const pluginManifest = registry.manifest({ name: 'plugin' })
4971+
await registry.package({ manifest: pluginManifest })
4972+
4973+
const path = t.testdir({
4974+
'package.json': JSON.stringify({
4975+
dependencies: { host: '^1.0.0', other: '^1.0.0' },
4976+
}),
4977+
})
4978+
4979+
const arb = newArb(path)
4980+
const tree = await arb.buildIdealTree()
4981+
4982+
const plugin = tree.children.get('plugin')
4983+
t.ok(plugin, 'installed the plugin for the dependent that requires it')
4984+
const edge = tree.children.get('host').edgesOut.get('plugin')
4985+
t.equal(edge.type, 'peerOptional')
4986+
t.equal(edge.to, plugin, 'peerOptional edge resolved to the installed plugin')
4987+
t.ok(edge.valid, 'peerOptional edge is valid')
4988+
})
4989+
4990+
t.test('does not fetch packuments for peerOptional deps satisfied by the actual tree', async t => {
4991+
const registry = createRegistry(t, false)
4992+
4993+
const hostPack = registry.packument({
4994+
name: 'host',
4995+
version: '1.0.0',
4996+
peerDependencies: { plugin: '^1.0.0' },
4997+
peerDependenciesMeta: { plugin: { optional: true } },
4998+
})
4999+
const hostManifest = registry.manifest({ name: 'host', packuments: [hostPack] })
5000+
await registry.package({ manifest: hostManifest })
5001+
5002+
const path = t.testdir({
5003+
node_modules: {
5004+
other: {
5005+
'package.json': JSON.stringify({
5006+
name: 'other',
5007+
version: '1.0.0',
5008+
dependencies: { plugin: '^1.0.0' },
5009+
}),
5010+
},
5011+
plugin: {
5012+
'package.json': JSON.stringify({
5013+
name: 'plugin',
5014+
version: '1.0.0',
5015+
}),
5016+
},
5017+
},
5018+
'package.json': JSON.stringify({
5019+
dependencies: { other: '^1.0.0' },
5020+
}),
5021+
})
5022+
5023+
const arb = newArb(path)
5024+
const tree = await arb.buildIdealTree({ add: ['host@^1.0.0'] })
5025+
5026+
const plugin = tree.children.get('plugin')
5027+
t.equal(plugin.version, '1.0.0', 'kept the already installed plugin')
5028+
const edge = tree.children.get('host').edgesOut.get('plugin')
5029+
t.equal(edge.type, 'peerOptional')
5030+
t.equal(edge.to, plugin, 'peerOptional edge resolved to the existing plugin')
5031+
t.ok(edge.valid, 'peerOptional edge is valid')
5032+
})
5033+
48885034
t.test('peerOptional prefers existing tree node over registry fetch (#9249)', async t => {
48895035
// Reproduction: ts-jest has peerOptional jest-util@"^29||^30".
48905036
// @types/jest@28 → expect@28 → jest-util@28 placed at root first.
@@ -4930,7 +5076,7 @@ t.test('peerOptional prefers existing tree node over registry fetch (#9249)', as
49305076
// Only publish 28, 29, and 30.
49315077
const jestUtilPacks = registry.packuments(['28.0.0', '29.0.0', '30.0.0'], 'jest-util')
49325078
const jestUtilManifest = registry.manifest({ name: 'jest-util', packuments: jestUtilPacks })
4933-
await registry.package({ manifest: jestUtilManifest, times: 3 })
5079+
await registry.package({ manifest: jestUtilManifest, times: 2 })
49345080

49355081
const path = t.testdir({
49365082
'package.json': JSON.stringify({

0 commit comments

Comments
 (0)