fix(deps-resolver): preserve locked optional peer candidates#12075
Conversation
📝 WalkthroughWalkthroughThis PR fixes optional peer version hoisting to correctly interpret both plain and weighted selectors from preferred-versions data in TypeScript and Rust implementations, and adds tests plus a changeset documenting preservation of locked optional peer versions. ChangesOptional Peer Weighted Selector Fix
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #12075 +/- ##
==========================================
+ Coverage 87.34% 87.44% +0.09%
==========================================
Files 255 260 +5
Lines 28703 29348 +645
==========================================
+ Hits 25072 25662 +590
- Misses 3631 3686 +55 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Integrated-Benchmark Report (Linux)Scenario: Isolated linker: fresh restore, cold cache + cold store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 2.33221562882,
"stddev": 0.22022663667128778,
"median": 2.2669443515200003,
"user": 2.12597652,
"system": 2.5458724999999998,
"min": 2.1550482360200003,
"max": 2.8390907690200002,
"times": [
2.27702716802,
2.24044244902,
2.17054196502,
2.16817997202,
2.1550482360200003,
2.29008697502,
2.8390907690200002,
2.60787585602,
2.31700136302,
2.25686153502
]
},
{
"command": "pacquet@main",
"mean": 2.22301902762,
"stddev": 0.11459668696064226,
"median": 2.2273257080200004,
"user": 2.12129222,
"system": 2.5546171,
"min": 2.02657176702,
"max": 2.3840219780200003,
"times": [
2.38367107002,
2.30700625702,
2.22825099402,
2.24482007302,
2.14083261502,
2.02657176702,
2.3840219780200003,
2.12027108202,
2.22640042202,
2.16834401802
]
}
]
}Scenario: Isolated linker: fresh restore, hot cache + hot store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 0.73709806146,
"stddev": 0.027484550702244533,
"median": 0.72686321676,
"user": 0.28246328000000004,
"system": 1.05516062,
"min": 0.71403575976,
"max": 0.80803699476,
"times": [
0.80803699476,
0.72196529876,
0.72610797676,
0.71403575976,
0.73497920076,
0.7220034697600001,
0.72761845676,
0.7419581187600001,
0.75355104476,
0.72072429376
]
},
{
"command": "pacquet@main",
"mean": 0.7517503614600001,
"stddev": 0.055881293375075455,
"median": 0.73562971726,
"user": 0.28577398,
"system": 1.04586432,
"min": 0.70004085176,
"max": 0.89000911276,
"times": [
0.79662848576,
0.75773305676,
0.73847262376,
0.70004085176,
0.75160061276,
0.72232407276,
0.7121789067600001,
0.71572908076,
0.7327868107600001,
0.89000911276
]
}
]
}Scenario: Isolated linker: fresh install, cold cache + cold store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 2.28371708762,
"stddev": 0.02904933266216712,
"median": 2.27883238322,
"user": 3.1042825,
"system": 2.43265438,
"min": 2.23959685822,
"max": 2.31951170322,
"times": [
2.28354721222,
2.31101716822,
2.27411755422,
2.25144324922,
2.26385004722,
2.30730354522,
2.31936897322,
2.2674145652199997,
2.23959685822,
2.31951170322
]
},
{
"command": "pacquet@main",
"mean": 2.2860784679200004,
"stddev": 0.04939959415002907,
"median": 2.28113937922,
"user": 3.1217373999999998,
"system": 2.4471434800000003,
"min": 2.2303482132199997,
"max": 2.40326833622,
"times": [
2.2632239842199997,
2.28407381722,
2.30529048422,
2.23989270022,
2.2513647952199998,
2.29062656222,
2.27820494122,
2.40326833622,
2.2303482132199997,
2.31449084522
]
}
]
}Scenario: Isolated linker: fresh install, hot cache + hot store
BENCHMARK_REPORT.json{
"results": [
{
"command": "pacquet@HEAD",
"mean": 1.47963833086,
"stddev": 0.09660572657841789,
"median": 1.48735719676,
"user": 1.41983936,
"system": 1.46895722,
"min": 1.36758179876,
"max": 1.6920600137600001,
"times": [
1.5114887517600002,
1.45687873676,
1.6920600137600001,
1.48919043576,
1.50233033676,
1.4855239577600001,
1.36758179876,
1.5359045637600002,
1.3876472367600001,
1.36777747676
]
},
{
"command": "pacquet@main",
"mean": 1.44695960006,
"stddev": 0.09772426176472364,
"median": 1.42016868076,
"user": 1.41432146,
"system": 1.4606811199999998,
"min": 1.36361160876,
"max": 1.70563313876,
"times": [
1.38454454176,
1.38479640476,
1.48364462376,
1.36361160876,
1.70563313876,
1.41709423176,
1.41130695376,
1.43649065376,
1.45923071376,
1.4232431297600001
]
}
]
} |
|
| Branch | pr/12075 |
| Testbed | pacquet |
Click to view all benchmark results
| Benchmark | Latency | Benchmark Result milliseconds (ms) (Result Δ%) | Upper Boundary milliseconds (ms) (Limit %) |
|---|---|---|---|
| isolated-linker.fresh-install.cold-cache.cold-store | 📈 view plot 🚷 view threshold | 2,283.72 ms(-2.71%)Baseline: 2,347.32 ms | 2,816.78 ms (81.08%) |
| isolated-linker.fresh-install.hot-cache.hot-store | 📈 view plot 🚷 view threshold | 1,479.64 ms(-2.45%)Baseline: 1,516.76 ms | 1,820.11 ms (81.29%) |
| isolated-linker.fresh-restore.cold-cache.cold-store | 📈 view plot 🚷 view threshold | 2,332.22 ms(+12.33%)Baseline: 2,076.26 ms | 2,491.51 ms (93.61%) |
| isolated-linker.fresh-restore.hot-cache.hot-store | 📈 view plot 🚷 view threshold | 737.10 ms(+11.20%)Baseline: 662.89 ms | 795.46 ms (92.66%) |
🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Fetch PR details
gh pr view 12075 --repo pnpm/pnpm --json title,body,files,additions,deletions,commits
# Fetch the diff
gh pr diff 12075 --repo pnpm/pnpmLength of output: 10117
Review SummaryCore Fix —
|
Review Summary by QodoPreserve locked optional peer candidates during dependency resolution
WalkthroughsDescription• Fix optional peer candidate selection to accept both plain and weighted version entries • Prevent unnecessary lockfile rewrites when locked optional peers remain valid • Add regression tests for weighted version selector handling in both TypeScript and Rust • Update documentation links to reflect current pnpm repository state Diagramflowchart LR
A["Version Selectors"] -->|Plain Entry| B["VersionSelectorType::Version"]
A -->|Weighted Entry| C["VersionSelectorWithWeight"]
B --> D["Filter & Select"]
C --> D
D -->|Highest Satisfying| E["Preserve Locked Peer"]
File Changes1. pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs
|
There was a problem hiding this comment.
Pull request overview
Fixes a deps-resolver lockfile regeneration issue where pnpm could rewrite an already-locked optional peer version even though the existing choice still satisfies the optional peer range(s). The root cause is that optional-peer candidate filtering only accepted “plain” 'version' selectors and inadvertently skipped “weighted” { selectorType: 'version', weight: ... } selectors seeded from the lockfile.
Changes:
- Treat both plain and weighted
'version'selectors as eligible candidates when selecting hoistable optional peers (TypeScript + Rust pacquet port). - Add regression tests in both the pnpm Jest suite and pacquet’s Rust tests to cover weighted version selectors.
- Add a changeset to release the fix as a patch for
@pnpm/installing.deps-resolverandpnpm.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
installing/deps-resolver/src/hoistPeers.ts |
Accepts weighted version selectors when computing getHoistableOptionalPeers candidates. |
installing/deps-resolver/test/hoistPeers.test.ts |
Adds a regression test ensuring weighted version selectors are considered for optional peers. |
pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs |
Aligns pacquet’s optional-peer candidate filtering with pnpm (plain + weighted selectors). |
pacquet/crates/resolving-deps-resolver/src/hoist_peers/tests.rs |
Adds a matching Rust regression test for weighted version selectors in optional peers. |
.changeset/steady-optional-peers.md |
Declares patch releases for the affected packages. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Thanks for the contribution. I wonder if we need a bigger e2e test covering this. |
ff44a6c to
d3d2ccc
Compare
Add resolve-flow regression tests in both stacks for preserving an already-locked optional peer version when a sibling workspace package declares a lower version. The existing unit tests call the picker directly with a hand-built preferred-versions map; these drive the real resolve path so the weighted lockfile selector is actually threaded into the candidate set. - pnpm: a two-project workspace install where the optional peer locks at 1.0.1, then a sibling drops to 1.0.0 and the lock must hold. - pacquet: resolve_importer seeded with a plain 1.0.0 and a weighted 1.0.1 selector, asserting the importer keeps 1.0.1. Both fail against the pre-fix picker and pass with it.
|
@zkochan yeah this behavior was super confusing for me to track down internally as well. It showed up after I aligned Vite to use a single version of @types/node in our repo (which downgraded jsdom in a bunch of places 🙃 ). An e2e test is a good idea! |
|
Congrats on merging your first pull request! 🎉🎉🎉 |
…default (#12083) ## Summary Preserve compatible peer contexts already recorded in the lockfile during a writable re-resolution. A fresh install still resolves peers normally. When a lockfile already records multiple valid peer contexts, pnpm keeps those contexts instead of collapsing them into one compatible context and rewriting unrelated lockfile entries. ## Why [#12075](#12075) fixed optional-peer candidate selection: pnpm no longer discards a compatible optional-peer version merely because it came from the lockfile. This PR addresses a separate source of lockfile churn. A writable install could still replace one valid peer context with another valid peer context even when the existing provider remained present and satisfied the peer range. Public reproduction: <https://github.com/sharmila-oai/pnpm-optional-peer-lockfile-repro> The nested reproduction starts with two valid `vitest@3.2.4` contexts: ```text context-low -> vitest@3.2.4(jsdom@26.1.0) context-high -> vitest@3.2.4(jsdom@27.4.0) ``` Running a writable lockfile regeneration should retain both contexts: ```sh ./reproduce-nested-context.sh ``` ## Behavior pnpm reuses a locked peer provider only when: - The provider is still present in the current dependency graph. - The provider still satisfies the peer range. Current manifest choices remain authoritative. In particular, pnpm does not replace: - A newly added direct peer provider. - An explicitly updated direct peer provider. - A changed nested provider. - A direct provider installed through an alias. The reuse pass runs only when the dependency tree contains locked peer contexts, so fresh installs do not pay for a second peer-resolution pass. ## Tradeoff This change favors lockfile stability over reducing the number of peer contexts. A writable install may retain multiple compatible peer contexts where a fresh install would select one. ## Implementation The resolver performs its normal peer-resolution pass first. When the dependency tree contains locked peer contexts, it performs a second pass that may reuse compatible provider paths from the lockfile while respecting current manifest choices. pacquet now mirrors this behavior. Its lockfile-reuse path rebuilds child dependencies from the package manifest and skips peer dependencies recorded in the snapshot, so the peer pass derives each dependency instance's peer context. --------- Co-authored-by: Zoltan Kochan <z@kochan.io>
Summary
Preserve compatible optional-peer versions already recorded in the lockfile
when pnpm re-resolves a workspace.
Reproduction
Public reproduction:
https://github.com/sharmila-oai/pnpm-optional-peer-lockfile-repro
The workspace contains:
Vitest declares
jsdomas an optional peer dependency.The committed lockfile was generated when another workspace package also
depended on
jsdom@27.4.0. At that time, pnpm selected the higher compatibleversion for Vitest:
That additional direct dependency was then removed from its
package.json,without regenerating the lockfile. This simulates a normal manifest edit.
Running:
unnecessarily rewrites Vitest's still-valid optional-peer context:
Both versions satisfy Vitest's optional peer range. The existing
27.4.0resolution remains valid and should not be discarded while pnpm updates the
lockfile.
Cause
Preferred versions loaded from the wanted lockfile are stored as weighted
selectors:
getHoistableOptionalPeers()only recognized the plain string form:As a result, it ignored the compatible locked
27.4.0candidate. It only saw26.1.0, which was rediscovered frompackages/older/package.json, andrewrote the peer context.
Fix
Normalize the selector before checking its type, matching the handling already
used by
hoistPeers()for required peers:This restores lockfile-seeded versions to the candidate set. It does not add a
new preference rule or force pnpm to keep every locked version. Optional-peer
auto-installation continues to choose the highest version satisfying every
recorded peer range.
The equivalent fix is included in pacquet, pnpm's Rust port.
Validation
pnpm@11.4.0and the patched CLI.and
cargo nextestchecks.Written by an agent (Codex, GPT-5).
Sent by Codex