Skip to content

fix(arborist): skip extraneous fsChildren in linked-strategy reify#9332

Open
manzoorwanijk wants to merge 1 commit intonpm:latestfrom
manzoorwanijk:fix/linked-strategy-recreates-removed-workspace-dir
Open

fix(arborist): skip extraneous fsChildren in linked-strategy reify#9332
manzoorwanijk wants to merge 1 commit intonpm:latestfrom
manzoorwanijk:fix/linked-strategy-recreates-removed-workspace-dir

Conversation

@manzoorwanijk
Copy link
Copy Markdown
Contributor

When using install-strategy=linked, removing a workspace from the project (deleting its directory and dropping its entry from the root package.json's workspaces array) does not actually clean it up: the next npm install re-creates the deleted directory as <wsdir>/node_modules/<name> -> .. — an empty directory shell with a self-loop symlink and no source files.
The hoisted (default) install strategy is unaffected: the directory stays gone.

The root cause is in IsolatedReifier#makeIdealGraph in workspaces/arborist/lib/arborist/isolated-reifier.js.
On every install, loadVirtual reads the previous lockfile and re-creates a node for every entry, including a link: true entry for a workspace that has just been removed from the manifest.
That node has no incoming workspace edge from the root, so calcDepFlags correctly marks it extraneous, but it still ends up in idealTree.fsChildren because its path is a descendant of the project root.
makeIdealGraph then iterated fsChildren blindly to populate idealGraph.workspaces, treating the orphan exactly like a real workspace.
createIsolatedTree materialised it as an IsolatedNode plus an IsolatedLink and, because the orphan is not in #rootDeclaredDeps, took the self-link branch — writing the workspace directory and the node_modules/<name> -> .. symlink back to disk.

Fixed by filtering out extraneous fsChildren before the sweep that populates idealGraph.workspaces.
A real workspace declared in package.json's workspaces array is reachable via a workspace-type edge from the root, so it is never extraneous.
A local file: dep target that legitimately lives inside the project tree is reachable via the Link in node_modules, so it is also never extraneous.
The only fsChildren entries that show up as extraneous are precisely the ghost workspaces left over in the lockfile after the user removed them — which is exactly what we want to skip.

The sibling queue walk (which seeds idealGraph.external from edgesOut) is intentionally left alone: the orphan has no edges out and no incoming references, so it falls through the isLocalFileDep / external branches harmlessly.
This keeps the change surgical to the one place where fsChildren is treated as the source of truth for "this is a workspace."

Companion to #9330 (lockfile prune): with the prune fix in place, the orphan no longer appears in package-lock.json's packages map; with this fix in place, the linked strategy no longer re-materialises the orphan on disk.
Either fix on its own is incomplete — the lockfile prune does not stop reify from re-creating the directory, and this fix does not stop the lockfile from carrying forward the entry.

The regression test seeds a project with two workspaces under install-strategy=linked, removes one workspace's directory and its entry from package.json, runs reify again, and asserts the directory stays gone.
The test fails before the fix (the directory and its self-loop symlink come back) and passes after.

References

Fixes #9331
Related to #9330, #5463

@manzoorwanijk manzoorwanijk requested review from a team as code owners May 8, 2026 18:51
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] install-strategy=linked re-creates a removed workspace's directory on npm install

1 participant