Visitar URL original
Unrelated workspace change moves an in-range direct dependency to a lower version (regression from v11) · Issue #16417 · pnpm/pnpm · GitHub
Skip to content

Unrelated workspace change moves an in-range direct dependency to a lower version (regression from v11) #16417

Closed

Description

@mcharles-atlassian

Last pnpm version that worked

11.28.2

pnpm version

12.8.2

Code to reproduce the issue

A workspace with three projects. a depends on @babel/preset-typescript@^7.17.12, b pins it exactly at 7.27.1, and c is unrelated. No lockfile edits are made at any point.

mkdir repro && cd repro
mkdir -p packages/a packages/b packages/c
printf 'packages:\n  - packages/*\n' > pnpm-workspace.yaml
echo '{"name":"repro","private":true}' > package.json

# 1. a + c: a resolves @babel/preset-typescript to 7.29.7
echo '{"name":"a","private":true,"dependencies":{"@babel/core":"^7.24.0","@babel/preset-typescript":"^7.17.12"}}' > packages/a/package.json
echo '{"name":"c","private":true,"dependencies":{"is-number":"^7.0.0"}}' > packages/c/package.json
pnpm install --lockfile-only
grep -A2 "'@babel/preset-typescript':" pnpm-lock.yaml | head -3

# 2. add b, which pins 7.27.1 exactly: a stays on 7.29.7
echo '{"name":"b","private":true,"dependencies":{"@babel/core":"^7.24.0","@babel/preset-typescript":"7.27.1"}}' > packages/b/package.json
pnpm install --lockfile-only
grep -A2 "'@babel/preset-typescript':" pnpm-lock.yaml | head -3

# 3. change only c: a moves to 7.27.1 on pnpm 12
echo '{"name":"c","private":true,"dependencies":{"is-number":"^7.0.0","is-odd":"^3.0.1"}}' > packages/c/package.json
pnpm install --lockfile-only
grep -A2 "'@babel/preset-typescript':" pnpm-lock.yaml | head -3

a's locked @babel/preset-typescript after each step (a's specifier is ^7.17.12 throughout):

Step 11.28.2 12.0.0 to 12.8.2
1. a + c 7.29.7 7.29.7
2. add b (pins 7.27.1) 7.29.7 7.29.7
3. change only c 7.29.7 7.27.1

The result is the same with a full pnpm install (with node_modules) and with --trust-lockfile, so neither flag is involved. --lockfile-only is used above only because it's faster.

Expected behavior

a's manifest and lockfile entry are unchanged and the locked 7.29.7 still satisfies ^7.17.12, so re-resolving the workspace for an unrelated change to c should keep a on 7.29.7, as pnpm 11.28.2 does.

Actual behavior

pnpm 12 moves a from 7.29.7 to 7.27.1, the version b pins exactly. That is lower than the highest locked version satisfying a's range. Any change to any project in a workspace can therefore rewrite unrelated projects' direct dependencies.

Additional information

Related: #15382 (fixing #7191) stopped secondary dependencies from being pulled to a sibling workspace's version. This is the same leak at depth 0, where a project's direct dependency is pulled to a sibling's exact pin.

From reading the source (not verified):

  • v11: a direct dependency reuses the importer's locked reference when it satisfies the wanted range (resolveDependencies.ts#L1879-L1897). The package requester also narrows preferred versions to the current version when not updating (packageRequester.ts#L192).
  • v12: pin_locked_version only applies when depth > 0 (locked_versions.rs#L103). A direct dependency keeps its locked version only when its whole locked subtree is reusable. A cycle makes the subtree non-reusable (reuse.rs#L368), and @babel/core -> @babel/helper-module-transforms -> @babel/core is one.
  • The re-pick: the direct dependency is then chosen by preferred-version weight. The importer's own range is skipped (preferred_versions.rs#L161). 7.27.1 gets the locked-version weight plus the direct-dependency weight from b's exact specifier, so it beats 7.29.7.
  • Control case: in the same workspace, a direct dependency without a cycle (semver, with b pinning a lower exact version) keeps its locked version on 12.x.

Impact: in a large workspace (~7,000 projects, one shared lockfile), changing any single manifest re-picks many unrelated projects' direct dependencies. --trust-lockfile, --fix-lockfile, resolutionMode and dedupeDirectDeps don't prevent it.

Suggested fix: pin depth-0 edges to their locked version when it satisfies the range and the importer's specifier is unchanged, matching v11. We prototyped this on main (a673627) in about 40 lines:

  • Keep flag: edge_opts sets a flag for depth-0 edges that pass keeps_locked_version, when not updating.
  • Picker: pick_from_registry then narrows that package's preferred selectors to the locked version from current_pkg, as v11's package requester does.
  • Specifier: it isn't rewritten, which is what the depth-0 exclusion in pin_locked_version avoids.

The prototype fixes the repro above: a keeps 7.29.7, and b still gets 7.27.1. We haven't run the test suite on it yet. The edge memo key would also need to include the new flag.

If you can confirm this behaviour is unintended, we're happy to work on the fix and open a PR with regression tests.

Node.js version

v24.14.1

Operating System

macOS

Contributing a fix

  • I am working on a fix

Activity

  1. self-assigned this
    on Sep 30, 2026
  2. zkochan commented on Sep 30, 2026

    @zkochan
    Member

    Opened #16424 to fix this regression. Direct dependencies now prefer their own satisfying locked versions when a cyclic subtree requires fresh resolution. Explicit updates and deduplication still work, and dependency specifiers remain intact.

    The PR covers the reported issue, with regression tests for normal and lockfile-only installs. The regression failed before the fix; all 1,291 selected tests and local checks now pass. The PR is a draft while CI runs.


    Written by an agent (Codex, GPT-6).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions