Skip to content

Commit 74eff59

Browse files
fix(arborist): avoid crash when peer back-off detaches a node (#9770)
Installing a package whose peers form a cycle with an already-installed optional peer could crash with `TypeError: Cannot read properties of null (reading 'explain')` instead of resolving or reporting a real conflict. A minimal trigger: `vite@8.1.4` declares an optional peer on `@vitejs/devtools`, `@vitejs/devtools` peers back on `vite`, so installing vite and then adding devtools crashes. The root cause is in `#loadPeerSet`. While resolving a package's peer edge through the parent's edge, the recursive `#nodeFromEdge` call can place a compatible peer that replaces and detaches the current node from the tree mid-iteration. The now-invalid edge then reached `#failPeerConflict`, whose `#explainPeerConflict` calls `node.resolve(edge.name).explain()` on the detached node. `resolve()` returns `null` for a node no longer in the tree, so `.explain()` threw. A detached node has been superseded by a compatible peer, so there is no real conflict to report. The fix adds a guard that stops processing when the node has been detached, right before `#failPeerConflict`, mirroring the existing top-of-loop detachment check. This lets the install complete by keeping the compatible peer that replaced the node (for the reproduction, `@vitejs/devtools` backs off to a version that satisfies vite's optional peer range) rather than crashing or raising a spurious `ERESOLVE`. ## References Fixes #5222 Closes #4787
1 parent 882d0b2 commit 74eff59

2 files changed

Lines changed: 53 additions & 0 deletions

File tree

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1639,6 +1639,12 @@ This is a one-time fix-up, please be patient...
16391639
continue
16401640
}
16411641

1642+
// The recursion above may replace node with a compatible peer, detaching it.
1643+
// A detached node has no real conflict to report, so stop instead of crashing on it.
1644+
if (!node.parent) {
1645+
break
1646+
}
1647+
16421648
// problem
16431649
this.#failPeerConflict(edge, parentEdge)
16441650
}

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4838,6 +4838,53 @@ t.test('add does not leave invalid peerOptional edges when save=false', async t
48384838
}, 'lockfile-mutating add should reject instead of keeping an invalid peerOptional edge')
48394839
})
48404840

4841+
t.test('circular peer back-off does not crash when node is detached mid-resolution (#5222)', async t => {
4842+
// host (installed) has optional peer plugin@^1.0.0. Adding plugin resolves plugin@2.0.0,
4843+
// whose peer set replaces it with plugin@1.0.0 to satisfy host, detaching plugin@2 mid-loop.
4844+
// Previously the invalid edge then crashed #explainPeerConflict on the detached node.
4845+
const registry = createRegistry(t, false)
4846+
4847+
const hostPack = registry.packument({
4848+
name: 'host',
4849+
version: '1.0.0',
4850+
peerDependencies: { plugin: '^1.0.0' },
4851+
peerDependenciesMeta: { plugin: { optional: true } },
4852+
})
4853+
const hostManifest = registry.manifest({ name: 'host', packuments: [hostPack] })
4854+
await registry.package({ manifest: hostManifest, times: 2 })
4855+
4856+
const pluginPacks = [
4857+
registry.packument({ name: 'plugin', version: '1.0.0', peerDependencies: { host: '*' } }),
4858+
registry.packument({ name: 'plugin', version: '2.0.0', peerDependencies: { host: '*' } }),
4859+
]
4860+
const pluginManifest = registry.manifest({ name: 'plugin', packuments: pluginPacks })
4861+
await registry.package({ manifest: pluginManifest, times: 2 })
4862+
4863+
const path = t.testdir({
4864+
'package.json': JSON.stringify({
4865+
name: 'test-5222',
4866+
version: '1.0.0',
4867+
devDependencies: { host: '^1.0.0' },
4868+
}),
4869+
node_modules: {
4870+
host: {
4871+
'package.json': JSON.stringify({
4872+
name: 'host',
4873+
version: '1.0.0',
4874+
peerDependencies: { plugin: '^1.0.0' },
4875+
peerDependenciesMeta: { plugin: { optional: true } },
4876+
}),
4877+
},
4878+
},
4879+
})
4880+
4881+
const arb = newArb(path)
4882+
const tree = await arb.buildIdealTree({ add: ['plugin'], saveType: 'dev' })
4883+
4884+
t.equal(tree.children.get('plugin').version, '1.0.0',
4885+
'backs off to plugin@1.0.0 to satisfy the optional peer instead of crashing')
4886+
})
4887+
48414888
t.test('peerOptional prefers existing tree node over registry fetch (#9249)', async t => {
48424889
// Reproduction: ts-jest has peerOptional jest-util@"^29||^30".
48434890
// @types/jest@28 → expect@28 → jest-util@28 placed at root first.

0 commit comments

Comments
 (0)