Skip to content

[BUG] linked strategy: npm install leaves dangling symlinks after store hash recalculation #9106

@manzoorwanijk

Description

@manzoorwanijk

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:

  1. #buildLinkedActualForDiff builds 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 sees ideal.resolved === actual.resolved and returns no action needed.

  2. #cleanOrphanedStoreEntries then removes old .store/ directories that are no longer in the ideal tree.

  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions