fix(arborist): clean up orphan top-level symlinks in linked strategy#9315
Merged
owlstronaut merged 1 commit intorelease/v11from May 6, 2026
Merged
fix(arborist): clean up orphan top-level symlinks in linked strategy#9315owlstronaut merged 1 commit intorelease/v11from
owlstronaut merged 1 commit intorelease/v11from
Conversation
…9309) In continuation of our exploration of using `install-strategy=linked` in the [Gutenberg monorepo](WordPress/gutenberg#75814), 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 (cherry picked from commit 076551b)
owlstronaut
approved these changes
May 6, 2026
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.
Backport of #9309 to
release/v11.