Current Behavior
pruneProjectGraph mutates the ProjectGraph it is given. switchNodeToHoisted reassigns node.name on the node object that is shared by reference with the source graph, so the source graph is left with a node stored under key npm:<pkg>@<version> whose name is now npm:<pkg>.
Every subsequent prune against that same graph object then resolves the node by key, registers it in the builder under the mutated name, and the dependency edge no longer resolves:
NX An error occurred while creating pruned lockfile
Original error: Target project does not exist: npm:cookie@1.1.1
Error: Target project does not exist: npm:cookie@1.1.1
at validateCommonDependencyRules (nx/dist/src/project-graph/project-graph-builder.js:325:15)
at validateDependency (nx/dist/src/project-graph/project-graph-builder.js:316:5)
at ProjectGraphBuilder.addDependency (nx/dist/src/project-graph/project-graph-builder.js:191:9)
at ProjectGraphBuilder.addStaticDependency (nx/dist/src/project-graph/project-graph-builder.js:90:14)
at nx/dist/src/plugins/js/lock-file/project-graph-pruning.js:115:17
at traverseNode (nx/dist/src/plugins/js/lock-file/project-graph-pruning.js:112:36)
Nx swallows the error and writes the full workspace lockfile into dist/apps/<app>/. That is not benign for Docker builds: the generated package.json has the pruned dependency set while the emitted lockfile has the root one, so the image stage fails with
ERR_PNPM_OUTDATED_LOCKFILE: Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with <ROOT>/package.json
* 72 dependencies were removed: ...
Preconditions: a package resolves to more than one version in the tree, and at least one project prunes down to a single version of it (triggering the rehoist) while another project still needs the multi-version shape.
This looks like the unexplained cause behind #20421, which was closed as not planned for lack of a reproduction (note the reporter there also saw it only on the second compile, not the first — consistent with a first prune poisoning the shared graph). It is distinct from #34322, which was the closest === undefined crash in rehoistNodes and is already fixed.
Expected Behavior
pruneProjectGraph should be a pure function of (graph, packageJson). Pruning for project A must not change the result of pruning for project B, and the caller-supplied ProjectGraph must not be mutated.
GitHub Repo
No response — the reproduction below is standalone and needs no workspace.
Steps to Reproduce
Save as repro.js in any directory where nx is installed, then node repro.js:
const { pruneProjectGraph } = require("nx/src/plugins/js/lock-file/project-graph-pruning");
const ext = (name, packageName, version) => ({
type: "npm",
name,
data: { version, packageName, hash: `sha512-${packageName}${version}` },
});
const graph = {
nodes: {},
externalNodes: {
"npm:cookie": ext("npm:cookie", "cookie", "2.0.1"),
"npm:cookie@1.1.1": ext("npm:cookie@1.1.1", "cookie", "1.1.1"),
"npm:light-my-request": ext("npm:light-my-request", "light-my-request", "6.6.0"),
},
dependencies: {
"npm:light-my-request": [
{ source: "npm:light-my-request", target: "npm:cookie@1.1.1", type: "static" },
],
},
};
// app-b does NOT depend on cookie directly -> 1.1.1 is the only version left -> rehoisted
const appB = { name: "app-b", dependencies: { "light-my-request": "6.6.0" } };
// app-a depends on cookie 2.0.1 directly -> both versions must stay
const appA = { name: "app-a", dependencies: { cookie: "2.0.1", "light-my-request": "6.6.0" } };
const nameOf = () => graph.externalNodes["npm:cookie@1.1.1"].name;
console.log("before :", nameOf());
pruneProjectGraph(graph, appB);
console.log("after prune(B) :", nameOf(), " <-- source graph mutated");
try {
pruneProjectGraph(graph, appA);
console.log("after prune(A) : OK");
} catch (e) {
console.log("after prune(A) : THROWS ->", e.message);
}
Actual output on nx 23.1.0:
before : npm:cookie@1.1.1
after prune(B) : npm:cookie <-- source graph mutated
after prune(A) : THROWS -> Target project does not exist: npm:cookie@1.1.1
Expected: prune(A) succeeds, and nameOf() still reports npm:cookie@1.1.1 after prune(B).
In a real workspace this surfaces as nx run-many --target=build (or nx build <app> with generatePackageJson) succeeding for the first project and failing pruning for later ones. Ordering decides which project breaks: pruning [api, worker, scheduler] fails on scheduler, and pruning [scheduler, worker, api] on the same graph fails on worker and api.
Nx Report
Node : 24.18.0
OS : win32-x64
Native Target : x86_64-windows
pnpm : 11.14.0
daemon : Available
nx : 23.1.0
@nx/js : 23.1.0
@nx/eslint : 23.1.0
@nx/workspace : 23.1.0
@nx/jest : 23.1.0
@nx/devkit : 23.1.0
@nx/eslint-plugin : 23.1.0
@nx/nest : 23.1.0
@nx/node : 23.1.0
@nx/vite : 23.1.0
@nx/vitest : 23.1.0
@nx/web : 23.1.0
@nx/webpack : 23.1.0
@nx/docker : 23.1.0
typescript : 6.0.2
Also reproduces on linux-x64 (GitHub Actions runner, Docker build).
Failure Logs
See above.
Package Manager Version
pnpm 11.14.0
Operating System
Additional Information
The mutation is in switchNodeToHoisted (nx/src/plugins/js/lock-file/project-graph-pruning.ts):
function switchNodeToHoisted(node, builder, invBuilder) {
const previousName = node.name;
...
builder.removeNode(node.name);
invBuilder.removeNode(node.name);
// modify the node and re-add it
node.name = `npm:${node.data.packageName}`; // <-- mutates the node owned by the source graph
builder.addExternalNode(node);
invBuilder.addExternalNode(node);
...
}
traverseNode passes the node straight from graph.externalNodes into builder.addExternalNode(node), so builder and source graph share the same object.
Suggested fix — rehoist onto a copy instead of mutating in place:
const hoisted = { ...node, name: `npm:${node.data.packageName}` };
builder.addExternalNode(hoisted);
invBuilder.addExternalNode(hoisted);
and use hoisted.name for the subsequent addStaticDependency calls. Deep-copying the graph on entry to pruneProjectGraph would also work but is more expensive on large graphs.
I verified 23.2.0-beta.2 and 23.2.0-canary.20260724-9c735d4 ship an identical project-graph-pruning.js, and patching either build in still reproduces, so this is not fixed on the current pre-release line.
Current Behavior
pruneProjectGraphmutates theProjectGraphit is given.switchNodeToHoistedreassignsnode.nameon the node object that is shared by reference with the source graph, so the source graph is left with a node stored under keynpm:<pkg>@<version>whosenameis nownpm:<pkg>.Every subsequent prune against that same graph object then resolves the node by key, registers it in the builder under the mutated
name, and the dependency edge no longer resolves:Nx swallows the error and writes the full workspace lockfile into
dist/apps/<app>/. That is not benign for Docker builds: the generatedpackage.jsonhas the pruned dependency set while the emitted lockfile has the root one, so the image stage fails withPreconditions: a package resolves to more than one version in the tree, and at least one project prunes down to a single version of it (triggering the rehoist) while another project still needs the multi-version shape.
This looks like the unexplained cause behind #20421, which was closed as
not plannedfor lack of a reproduction (note the reporter there also saw it only on the second compile, not the first — consistent with a first prune poisoning the shared graph). It is distinct from #34322, which was theclosest === undefinedcrash inrehoistNodesand is already fixed.Expected Behavior
pruneProjectGraphshould be a pure function of(graph, packageJson). Pruning for project A must not change the result of pruning for project B, and the caller-suppliedProjectGraphmust not be mutated.GitHub Repo
No response — the reproduction below is standalone and needs no workspace.
Steps to Reproduce
Save as
repro.jsin any directory wherenxis installed, thennode repro.js:Actual output on nx 23.1.0:
Expected:
prune(A)succeeds, andnameOf()still reportsnpm:cookie@1.1.1afterprune(B).In a real workspace this surfaces as
nx run-many --target=build(ornx build <app>withgeneratePackageJson) succeeding for the first project and failing pruning for later ones. Ordering decides which project breaks: pruning[api, worker, scheduler]fails onscheduler, and pruning[scheduler, worker, api]on the same graph fails onworkerandapi.Nx Report
Also reproduces on
linux-x64(GitHub Actions runner, Docker build).Failure Logs
See above.
Package Manager Version
pnpm 11.14.0
Operating System
Additional Information
The mutation is in
switchNodeToHoisted(nx/src/plugins/js/lock-file/project-graph-pruning.ts):traverseNodepasses the node straight fromgraph.externalNodesintobuilder.addExternalNode(node), so builder and source graph share the same object.Suggested fix — rehoist onto a copy instead of mutating in place:
and use
hoisted.namefor the subsequentaddStaticDependencycalls. Deep-copying the graph on entry topruneProjectGraphwould also work but is more expensive on large graphs.I verified
23.2.0-beta.2and23.2.0-canary.20260724-9c735d4ship an identicalproject-graph-pruning.js, and patching either build in still reproduces, so this is not fixed on the current pre-release line.