From 7589245d283b4b68e447bf310ba4c9eb37ee995b Mon Sep 17 00:00:00 2001 From: Lonny Jepson Date: Tue, 28 Jul 2026 18:08:36 -0600 Subject: [PATCH] fix(arborist): handle links with cleared targets Reassigning a node's root clears `target` on every link pointing at it, leaving a link with `isLink` true but no target. Comparing such a link in `matches()` crashed with `TypeError: Cannot read properties of null (reading 'matches')`. A link whose target was detached still knows the realpath it pointed at, so fall back to comparing realpaths. Two such links then give the same answer they gave before their targets were cleared, instead of reporting a spurious mismatch. --- workspaces/arborist/lib/node.js | 11 ++++++++++- workspaces/arborist/test/node.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/workspaces/arborist/lib/node.js b/workspaces/arborist/lib/node.js index 13370a50ab475..cf7d06f5f9f13 100644 --- a/workspaces/arborist/lib/node.js +++ b/workspaces/arborist/lib/node.js @@ -1190,7 +1190,16 @@ class Node { // if they're links, they match if the targets match if (this.isLink) { - return node.isLink && this.target.matches(node.target) + if (!node.isLink) { + return false + } + // reassigning a target's root clears `target` on every link pointing at + // it, but the link still knows the realpath it pointed at, so fall back + // to that rather than dereferencing null. + if (!this.target || !node.target) { + return !!this.realpath && this.realpath === node.realpath + } + return this.target.matches(node.target) } // if they're two project root nodes, they're different if the paths differ diff --git a/workspaces/arborist/test/node.js b/workspaces/arborist/test/node.js index ec474e0b7d75e..8b10e2019292b 100644 --- a/workspaces/arborist/test/node.js +++ b/workspaces/arborist/test/node.js @@ -1449,6 +1449,34 @@ t.test('detect that two nodes are the same thing', async t => { check(a, b, true, 'links match if targets match') } + { + const root = new Node({ path: '/root', pkg: { name: 'root', version: '1.0.0' } }) + const target = new Node({ root, path: '/root/packages/x', pkg: { name: 'x', version: '1.2.3' } }) + const a = new Link({ root, parent: root, name: 'x', target }) + const b = new Node({ root, parent: root, name: 'b', pkg: { name: 'b', version: '1.0.0' } }) + // Nested link to the same target + const bx = new Link({ root, parent: b, name: 'x', target }) + target.root = new Node({ path: '/other-root' }) + t.equal(a.target, null, 'target was cleared') + t.equal(bx.target, null, 'nested link target was cleared') + check(a, bx, true, 'links with cleared targets match on realpath') + } + + { + const root = new Node({ path: '/root', pkg: { name: 'root', version: '1.0.0' } }) + const x = new Node({ root, path: '/root/packages/x', pkg: { name: 'x', version: '1.2.3' } }) + const y = new Node({ root, path: '/root/packages/y', pkg: { name: 'x', version: '1.2.3' } }) + const a = new Link({ root, parent: root, name: 'x', target: x }) + const b = new Node({ root, parent: root, name: 'b', pkg: { name: 'b', version: '1.0.0' } }) + // Nested link to a different target of the same name + const by = new Link({ root, parent: b, name: 'x', target: y }) + x.root = new Node({ path: '/other-root' }) + y.root = new Node({ path: '/another-root' }) + t.equal(a.target, null, 'target was cleared') + t.equal(by.target, null, 'nested link target was cleared') + check(a, by, false, 'cleared links with different realpaths do not match') + } + { const a = new Node({ path: '/foo', pkg: { name: 'x', version: '1.2.3' } }) const b = new Node({ path: '/foo', pkg: { name: 'x', version: '1.2.3' } })