Skip to content

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

Merged
owlstronaut merged 1 commit intorelease/v11from
backport/v11/9309
May 6, 2026
Merged

fix(arborist): clean up orphan top-level symlinks in linked strategy#9315
owlstronaut merged 1 commit intorelease/v11from
backport/v11/9309

Conversation

@github-actions
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot commented May 6, 2026

Backport of #9309 to release/v11.

…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 owlstronaut merged commit c3ea2cf into release/v11 May 6, 2026
17 checks passed
@owlstronaut owlstronaut deleted the backport/v11/9309 branch May 6, 2026 15:08
@github-actions github-actions Bot mentioned this pull request May 6, 2026
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.

2 participants