Skip to content

fix(arborist): clean up orphan top-level symlinks in linked strategy#9309

Open
manzoorwanijk wants to merge 6 commits intonpm:latestfrom
manzoorwanijk:fix/linked-orphan-top-level-links
Open

fix(arborist): clean up orphan top-level symlinks in linked strategy#9309
manzoorwanijk wants to merge 6 commits intonpm:latestfrom
manzoorwanijk:fix/linked-orphan-top-level-links

Conversation

@manzoorwanijk
Copy link
Copy Markdown
Contributor

In continuation of our exploration of using install-strategy=linked in the Gutenberg monorepo, which powers the WordPress Block Editor.

When using install-strategy=linked, removing a dependency leaves a dangling symlink at node_modules/<pkg> (and <workspace>/node_modules/<pkg> for workspace deps).
The store entry under node_modules/.store/<pkg>@… is correctly cleaned up by #cleanOrphanedStoreEntries, but the top-level link pointing into it is left behind, so require('<pkg>') fails with Cannot find module even though the entry still appears in node_modules/.

The root cause is the same family of issue as #9106.
#buildLinkedActualForDiff builds the synthetic actual tree from the ideal tree, so any dependency that exists on disk but is no longer in the ideal tree is never compared, and the diff produces no REMOVE action for its top-level symlink.

Fixed by extending #cleanOrphanedStoreEntries to also collect, per node_modules directory (root and each workspace), the set of valid top-level link names from the ideal tree, then sweeping each directory and removing any symlink whose name is not in that set.
The root node_modules and every workspace's node_modules (via idealTree.fsChildren) are always seeded into the sweep, so the case of removing the last dependency from the project root or from a workspace still triggers cleanup, including when the workspace itself is declared as a root dependency and therefore has its self-link at the root rather than under its own node_modules.

The sweep is restricted to symlinks whose target resolves inside the project root, so it covers both store links (e.g. node_modules/eslint -> .store/...) and workspace self-links that no longer belong (e.g. node_modules/a -> ../packages/a after a is undeclared) without touching symlinks that point outside the project, such as those created by npm link <global-pkg> without --save.
Real directories and npm-managed entries (.bin, .store, .package-lock.json) are left alone.
The workspace self-link inside its own node_modules (e.g. packages/a/node_modules/a -> ..) is in the ideal tree as a non-store link, so it's preserved.

The sweep also respects the install mode:

  • It is skipped entirely for dryRun and packageLockOnly installs, both of which short-circuit #reifyPackages and must not mutate node_modules.
  • For workspace-filtered installs (npm install -w <ws> --install-strategy=linked), the set of node_modules directories to sweep is restricted to the workspaces named in --workspace, so dropped dependencies from the in-scope workspace get cleaned up while out-of-scope workspaces and the project root are left untouched.

IsolatedNode/IsolatedLink locations are built with path.join, which uses backslashes on Windows; locations are normalized to forward slashes inside the sweep so the parser works on both POSIX and Windows.

Trade-off

This aligns the linked strategy with npm's normal node_modules-is-managed model: any in-project symlink that isn't in the ideal tree is treated as orphaned, matching what happens today under the default install strategy.
A consequence is that hand-made or unsaved npm link symlinks pointing to other paths inside the project root (e.g. node_modules/foo -> ../examples/foo) are also swept, since npm doesn't currently record which links it owns and they are indistinguishable from workspace self-links by target alone.
A more discriminating ownership check (recording managed link names in the hidden lockfile and only sweeping those) is a worthwhile follow-up but materially larger than this fix.

References

Fixes #9308
Related to #9106

@manzoorwanijk manzoorwanijk force-pushed the fix/linked-orphan-top-level-links branch from 939dacd to b217762 Compare May 5, 2026 06:16
@manzoorwanijk manzoorwanijk marked this pull request as ready for review May 5, 2026 06:19
@manzoorwanijk manzoorwanijk requested a review from a team as a code owner May 5, 2026 06:19
Copy link
Copy Markdown
Contributor

@owlstronaut owlstronaut left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the change to sweep hand-made symlinks in the project root, i think we should have a regression test to make it obvious that was an intentional decision. Otherwise I'm good with this change

// For an unfiltered install, sweep the project root and every workspace's node_modules even if no top-level links remain (e.g. last dep was just uninstalled).
// For a filtered install (npm install -w <ws>), restrict the sweep to the in-scope workspaces so out-of-scope workspaces and the project root are left untouched, mirroring what the diff would do.
const filteredNames = this.options.workspaces
const isFiltered = Array.isArray(filteredNames) && filteredNames.length > 0
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This treats non-empty --workspace filter as "no root sweep", but the diff scope also pulls root deps in when --include-workspace-root is set - so dropping a root dep in that mode still leaves a dangling top level link

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 81e4d5b. Thanks

…and document intra-project symlink trade-off
@manzoorwanijk
Copy link
Copy Markdown
Contributor Author

With the change to sweep hand-made symlinks in the project root, i think we should have a regression test to make it obvious that was an intentional decision.

I have added it in 81e4d5b

@manzoorwanijk manzoorwanijk requested a review from owlstronaut May 6, 2026 04:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] linked strategy: removing a dependency leaves a dangling symlink in node_modules

2 participants