From 417958b16b1b422096a8671c9e400a252fe4bc0c Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 23 Feb 2026 11:54:50 +0530 Subject: [PATCH 1/2] fix(arborist): skip postinstall scripts on store links in linked strategy The isStoreLink guard in rebuild.js was reading from node.target (the store entry) instead of node (the link), so it was always undefined. Both the store entry and its symlink ran scripts against the same directory in parallel, causing race conditions in packages like esbuild. Check node.isLink && node.target?.isInStore instead, which correctly skips store links while still running scripts for workspace links and store entries. --- workspaces/arborist/lib/arborist/rebuild.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/workspaces/arborist/lib/arborist/rebuild.js b/workspaces/arborist/lib/arborist/rebuild.js index eef557208208d..317cfc1df8a72 100644 --- a/workspaces/arborist/lib/arborist/rebuild.js +++ b/workspaces/arborist/lib/arborist/rebuild.js @@ -295,12 +295,12 @@ module.exports = cls => class Builder extends cls { devOptional, package: pkg, location, - isStoreLink, } = node.target // skip any that we know we'll be deleting - // or storeLinks - if (this[_trashList].has(path) || isStoreLink) { + // or links to store entries (their scripts run on the store + // entry itself, not through the link) + if (this[_trashList].has(path) || (node.isLink && node.target?.isInStore)) { return } From 1815776543b8ad50874b9c695faae0d8bf912be9 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Mon, 23 Feb 2026 11:54:56 +0530 Subject: [PATCH 2/2] Add tests --- workspaces/arborist/test/isolated-mode.js | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/workspaces/arborist/test/isolated-mode.js b/workspaces/arborist/test/isolated-mode.js index 8e2174fe61bfd..cd8434e638966 100644 --- a/workspaces/arborist/test/isolated-mode.js +++ b/workspaces/arborist/test/isolated-mode.js @@ -1571,6 +1571,36 @@ tap.test('postinstall scripts are run', async t => { t.ok(postInstallRanBar) }) +tap.test('postinstall scripts run once for store packages', async t => { + // Regression test: store links should not cause scripts to run twice. + // The store entry and its symlink both end up in the build queue, but + // only the store entry should run scripts. + const graph = { + registry: [ + { + name: 'which', + version: '1.0.0', + scripts: { + postinstall: 'node -e "var c=0;try{c=+fs.readFileSync(\'postinstall-count\',\'utf8\')}catch(e){};fs.writeFileSync(\'postinstall-count\',String(c+1))"', + }, + }, + ], + root: { + name: 'foo', version: '1.2.3', dependencies: { which: '1.0.0' }, + }, + } + + const { dir, registry } = await getRepo(graph) + + const cache = fs.mkdtempSync(`${getTempDir()}/test-`) + const arborist = new Arborist({ path: dir, registry, packumentCache: new Map(), cache }) + await arborist.reify({ installStrategy: 'linked' }) + + const whichDir = setupRequire(dir)('which') + const count = Number(fs.readFileSync(`${whichDir}/postinstall-count`, 'utf8')) + t.equal(count, 1, 'postinstall ran exactly once') +}) + tap.test('bins are installed', async t => { // Input of arborist const graph = {