Repository navigation
Description
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 -3a'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_versiononly applies whendepth > 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/coreis 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, withbpinning 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_optssets a flag for depth-0 edges that passkeeps_locked_version, when not updating. - Picker:
pick_from_registrythen narrows that package's preferred selectors to the locked version fromcurrent_pkg, as v11's package requester does. - Specifier: it isn't rewritten, which is what the depth-0 exclusion in
pin_locked_versionavoids.
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
zkochan commentedon Sep 30, 2026
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).
fix(deps): update dependencias no-major (#23)
chore(deps): update pnpm to v12.9.0
chore(deps): update pnpm to v12.9.0
chore(deps): update pnpm to v12.9.0
chore(deps): update pnpm to v12.9.0
chore(deps): update pnpm to v12.9.0