fix(arborist): clean up orphan top-level symlinks in linked strategy#9309
Open
manzoorwanijk wants to merge 6 commits intonpm:latestfrom
Open
fix(arborist): clean up orphan top-level symlinks in linked strategy#9309manzoorwanijk wants to merge 6 commits intonpm:latestfrom
manzoorwanijk wants to merge 6 commits intonpm:latestfrom
Conversation
…red linked installs
…e location separators
939dacd to
b217762
Compare
owlstronaut
requested changes
May 5, 2026
Contributor
owlstronaut
left a comment
There was a problem hiding this comment.
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 |
Contributor
There was a problem hiding this comment.
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
…and document intra-project symlink trade-off
Contributor
Author
I have added it in 81e4d5b |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
In continuation of our exploration of using
install-strategy=linkedin the Gutenberg monorepo, which powers the WordPress Block Editor.When using
install-strategy=linked, removing a dependency leaves a dangling symlink atnode_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, sorequire('<pkg>')fails withCannot find moduleeven though the entry still appears innode_modules/.The root cause is the same family of issue as #9106.
#buildLinkedActualForDiffbuilds 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
#cleanOrphanedStoreEntriesto also collect, pernode_modulesdirectory (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_modulesand every workspace'snode_modules(viaidealTree.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 ownnode_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/aafterais undeclared) without touching symlinks that point outside the project, such as those created bynpm 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:
dryRunandpackageLockOnlyinstalls, both of which short-circuit#reifyPackagesand must not mutatenode_modules.npm install -w <ws> --install-strategy=linked), the set ofnode_modulesdirectories 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/IsolatedLinklocations are built withpath.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 linksymlinks 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