Skip to content

pruneProjectGraph mutates node.name on the source graph, breaking every prune after the first when a package has multiple versions #36470

Description

@chuanghiduoc

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

  • macOS
  • Linux
  • Windows
  • Other (Please specify)

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions