-
Notifications
You must be signed in to change notification settings - Fork 4.3k
[BUG] linked strategy: npm install leaves dangling symlinks after store hash recalculation #9106
Description
Is there an existing issue for this?
- I have searched the existing issues
This issue exists in the latest npm version
- I am using the latest npm
Current Behavior
When using install-strategy=linked, running npm install --save-dev <new-package> after an initial install leaves dangling symlinks in node_modules/ for existing packages whose store hashes changed.
The store hash (the suffix in .store/pkg@version-HASH/) is recalculated when the dependency graph changes. npm creates new .store/ directories with updated hashes and removes the old ones, but does not update the symlinks that still reference the old hash paths. This breaks node_modules/.bin/ entries and any require() calls for the affected packages.
Discovery
Found while testing the last release (v10.9.6) in Gutenberg. After npm ci followed by npm install --save-dev storybook@^10.1.11 @storybook/test-runner@0.24.2 playwright, the wait-on binary became unresolvable due to a dangling symlink — see CI failure.
Expected Behavior
After npm install --save-dev, all symlinks in node_modules/ and node_modules/.bin/ should point to valid targets in .store/.
Root Cause
The store hash is calculated in getKey by hashing the entire dependency subtree — package names, versions, resolved URLs, and their full dependency paths. Adding any package changes the graph, which changes hashes for existing packages.
During reification:
-
#buildLinkedActualForDiffbuilds a synthetic actual tree from the ideal tree's children rather than reading the actual filesystem state. This means the synthetic "actual" already has the new resolved paths (containing new hashes), so the diff engine seesideal.resolved === actual.resolvedand returns no action needed. -
#cleanOrphanedStoreEntriesthen removes old.store/directories that are no longer in the ideal tree. -
Result: symlinks still point to the old (now deleted)
.store/paths → dangling.
The key issue is that the diff comparison (getAction) cannot detect that symlinks need updating because #buildLinkedActualForDiff doesn't reflect actual on-disk symlink targets.
Steps To Reproduce
mkdir test-dangling && cd test-dangling
cat > package.json << 'EOF'
{
"name": "test-dangling",
"version": "1.0.0",
"devDependencies": {
"wait-on": "^8.0.1"
}
}
EOF
echo "install-strategy=linked" > .npmrc
# 1. Install
npm install
# 2. Verify symlink works
readlink node_modules/wait-on
# → .store/wait-on@8.0.5-RRJvnGlW6s4zr0GHYWoIKg/node_modules/wait-on ✅
node -e "require('wait-on'); console.log('OK')" # ✅
# 3. Install an additional package (changes the dependency graph)
npm install --save-dev express@4
# 4. Symlink is now dangling
readlink node_modules/wait-on
# → .store/wait-on@8.0.5-RRJvnGlW6s4zr0GHYWoIKg/node_modules/wait-on (old hash, unchanged)
ls node_modules/.store/ | grep wait-on
# wait-on@8.0.5-PnUP7Imr-963_DBuS2pz3A (NEW hash — old dir deleted)
node -e "require('wait-on')"
# Error: Cannot find module 'wait-on' ❌Environment
Reproduced on both v10 and latest:
- npm: 10.9.6 (
release/v10) and 11.11.1 (latest) - Node.js: v22.20.0
- OS Name: macOS (Darwin 25.3.0), Ubuntu 24.04
- npm config:
install-strategy=linked