Skip to content

fix(deps-resolver): preserve locked optional peer candidates#12075

Merged
zkochan merged 3 commits into
pnpm:mainfrom
sharmila-oai:dev/eacc-1812-weighted-optional-peers
Jun 2, 2026
Merged

fix(deps-resolver): preserve locked optional peer candidates#12075
zkochan merged 3 commits into
pnpm:mainfrom
sharmila-oai:dev/eacc-1812-weighted-optional-peers

Conversation

@sharmila-oai

@sharmila-oai sharmila-oai commented May 29, 2026

Copy link
Copy Markdown
Contributor

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:

packages/uses-vitest -> vitest@3.2.4
packages/older       -> jsdom@26.1.0

Vitest declares jsdom as 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 compatible
version for Vitest:

vitest@3.2.4(jsdom@27.4.0)

That additional direct dependency was then removed from its package.json,
without regenerating the lockfile. This simulates a normal manifest edit.

Running:

pnpm install --lockfile-only --no-frozen-lockfile

unnecessarily rewrites Vitest's still-valid optional-peer context:

-        version: 3.2.4(jsdom@27.4.0)
+        version: 3.2.4(jsdom@26.1.0)

Both versions satisfy Vitest's optional peer range. The existing 27.4.0
resolution 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:

{ selectorType: 'version', weight: EXISTING_VERSION_SELECTOR_WEIGHT }

getHoistableOptionalPeers() only recognized the plain string form:

specType === 'version'

As a result, it ignored the compatible locked 27.4.0 candidate. It only saw
26.1.0, which was rediscovered from packages/older/package.json, and
rewrote the peer context.

Fix

Normalize the selector before checking its type, matching the handling already
used by hoistPeers() for required peers:

const specType = typeof selector === 'string'
  ? selector
  : selector.selectorType

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

  • Added matching TypeScript and Rust regression tests.
  • Verified the public reproduction against pnpm@11.4.0 and the patched CLI.
  • Ran the focused TypeScript resolver checks and pacquet test, clippy, format,
    and cargo nextest checks.

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

Sent by Codex

@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This 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.

Changes

Optional Peer Weighted Selector Fix

Layer / File(s) Summary
Release notes
.changeset/steady-optional-peers.md
Patch releases announced for @pnpm/installing.deps-resolver and pnpm documenting preservation of optional peer versions from lockfiles.
TypeScript selector extraction and integration tests
installing/deps-resolver/src/hoistPeers.ts, installing/deps-resolver/test/hoistPeers.test.ts, installing/deps-installer/test/install/autoInstallPeers.ts
getHoistableOptionalPeers now destructures [version, selector] and derives specType from selector (handling object selectors with selectorType); added unit and workspace regression tests verifying weighted selectors and that a locked optional peer is preserved.
Rust selector filtering and resolver tests
pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs, pacquet/crates/resolving-deps-resolver/src/hoist_peers/tests.rs, pacquet/crates/resolving-deps-resolver/src/resolve_importer/tests.rs
get_hoistable_optional_peers docs updated to include weighted selectors; logic now extracts selector_type from both VersionSelectorEntry::Plain and VersionSelectorEntry::Weighted and skips non-version selectors. Added unit and resolver regression tests seeding plain and weighted selectors to assert preservation of locked optional peer versions.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • pnpm/pnpm#11784: Touches optional-peer hoisting logic and weighted version selector interpretation similar to this change.

Suggested reviewers

  • zkochan

Poem

🐰 A weighted peer hops into view,
No longer overlooked, now handled true,
Plain and weighted selectors both sing,
The lockfile's choice kept safe in spring,
Hooray — the resolver's found its cue!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing the deps-resolver to preserve locked optional peer candidates that are stored as weighted entries.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter

codecov-commenter commented May 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 87.44%. Comparing base (ae6e077) to head (817b41d).
⚠️ Report is 2 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.332 ± 0.220 2.155 2.839 1.05 ± 0.11
pacquet@main 2.223 ± 0.115 2.027 2.384 1.00
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

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 737.1 ± 27.5 714.0 808.0 1.00
pacquet@main 751.8 ± 55.9 700.0 890.0 1.02 ± 0.08
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

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.284 ± 0.029 2.240 2.320 1.00
pacquet@main 2.286 ± 0.049 2.230 2.403 1.00 ± 0.03
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

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.480 ± 0.097 1.368 1.692 1.02 ± 0.10
pacquet@main 1.447 ± 0.098 1.364 1.706 1.00
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
      ]
    }
  ]
}

@github-actions

github-actions Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12075
Testbedpacquet
Click to view all benchmark results
BenchmarkLatencyBenchmark 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%)
🐰 View full continuous benchmarking report in Bencher

@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
🧩 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/pnpm

Length of output: 10117



@sharmila-oai: I've reviewed the full diff. The fix is minimal, targeted, and correct. Here's my analysis:


Review Summary

Core Fix — installing/deps-resolver/src/hoistPeers.ts

// Before
for (const [version, specType] of Object.entries(allPreferredVersions[...]))
// `specType` was assumed to be a plain string, but lockfile-seeded entries are objects

// After
for (const [version, selector] of Object.entries(allPreferredVersions[...]))
  const specType = typeof selector === 'string' ? selector : selector.selectorType

✅ The fix correctly normalizes both shapes — plain strings produced by the in-flight resolver walk and weighted objects seeded from the lockfile — before the specType === 'version' guard. This is the minimal, correct change needed.

Rust Mirror — hoist_peers.rs

The Rust variant previously excluded VersionSelectorEntry::Weighted entirely via matches!(entry, VersionSelectorEntry::Plain(...)). The new match arm properly extracts selector_type from both variants. ✅

Tests

Both the TypeScript and Rust tests cover the exact regression scenario (one plain entry at 26.1.0 and one weighted entry at 27.4.0, both satisfying *), expecting 27.4.0 to be selected. This precisely validates the bug fix. ✅


Observations

1. weight field is intentionally unused in selection.
The weighted entry's weight field is extracted but discarded — only selectorType matters for the eligibility check. The highest semver-satisfying version still wins, regardless of weight. This is the correct conservative scope for a bug fix, but worth an explicit comment in the code so future readers understand why weight is not consulted here.

2. Codecov shows +1 uncovered line.
The new continue path for a weighted entry whose selectorType !== 'version' (e.g., a weighted range entry) is not tested. It's a low-risk branch, but a supplementary test would eliminate the gap:

test('getHoistableOptionalPeers skips weighted non-version selectors', () => {
  expect(getHoistableOptionalPeers(
    { jsdom: ['*'] },
    { jsdom: { '27.4.0': { selectorType: 'range', weight: 1 } } }
  )).toStrictEqual({})
})

3. Removed upstream line references in Rust doc comment.
The previous doc comment linked to specific lines in resolveDependencies.ts and getPreferredVersionsFromLockfileAndManifests. These links are removed in favor of a shorter comment. This is reasonable given the fix aligns behavior, but the original links were useful for tracing cross-language parity. Not a blocker.

4. Changeset is appropriate — both @pnpm/installing.deps-resolver and pnpm are bumped at patch. ✅


Verdict

The fix is correct and the tests are well-targeted. The two nits above (coverage gap for weighted non-version selectors, and a clarifying comment on why weight is unused) are minor. The core logic change is sound and the scope is appropriately narrow.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@sharmila-oai sharmila-oai marked this pull request as ready for review June 1, 2026 22:02
@sharmila-oai sharmila-oai requested a review from zkochan as a code owner June 1, 2026 22:02
Copilot AI review requested due to automatic review settings June 1, 2026 22:02
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Review Summary by Qodo

Preserve locked optional peer candidates during dependency resolution

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• 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
Diagram
flowchart 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"]

Loading

Grey Divider

File Changes

1. pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs 🐞 Bug fix +13/-14

Accept weighted version selectors in optional peer hoisting

• Modified get_hoistable_optional_peers to accept both plain and weighted version selector entries
• Changed selector type extraction to handle VersionSelectorEntry::Weighted in addition to plain
 entries
• Updated documentation to clarify that both plain and weighted entries are eligible for optional
 peer selection
• Updated upstream pnpm repository link to current commit hash

pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs


2. pacquet/crates/resolving-deps-resolver/src/hoist_peers/tests.rs 🧪 Tests +23/-0

Add weighted version selector regression test

• Added regression test get_hoistable_optional_peers_handles_version_selector_with_weight
• Test verifies that weighted version entries from lockfile are considered alongside plain entries
• Confirms highest satisfying version is selected when both plain and weighted candidates exist

pacquet/crates/resolving-deps-resolver/src/hoist_peers/tests.rs


3. installing/deps-resolver/src/hoistPeers.ts 🐞 Bug fix +2/-1

Support weighted version selectors in TypeScript implementation

• Modified loop to extract selectorType from both plain string and weighted object formats
• Changed variable name from specType to selector for clarity before type extraction
• Added logic to handle weighted selector objects with selectorType property

installing/deps-resolver/src/hoistPeers.ts


View more (2)
4. installing/deps-resolver/test/hoistPeers.test.ts 🧪 Tests +13/-0

Add weighted version selector regression test

• Added regression test getHoistableOptionalPeers handles version selector with weight
• Test validates that weighted version entries are properly considered during optional peer
 selection
• Confirms highest version is selected when both plain and weighted candidates satisfy ranges

installing/deps-resolver/test/hoistPeers.test.ts


5. .changeset/steady-optional-peers.md 📝 Documentation +6/-0

Add changeset for optional peer preservation fix

• Created changeset entry documenting the bug fix
• Marks patch version bump for @pnpm/installing.deps-resolver and pnpm
• Describes fix as preserving compatible optional peer versions from lockfile during re-resolution

.changeset/steady-optional-peers.md


Grey Divider

Qodo Logo

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-resolver and pnpm.

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.

@zkochan

zkochan commented Jun 1, 2026

Copy link
Copy Markdown
Member

Thanks for the contribution. I wonder if we need a bigger e2e test covering this.

@zkochan zkochan force-pushed the dev/eacc-1812-weighted-optional-peers branch from ff44a6c to d3d2ccc Compare June 1, 2026 22:51
@zkochan zkochan changed the title [deps-resolver] Preserve locked optional peer candidates fix(deps-resolver): preserve locked optional peer candidates Jun 1, 2026
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.
Copilot AI review requested due to automatic review settings June 1, 2026 23:15
@zkochan zkochan enabled auto-merge (squash) June 1, 2026 23:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

@sharmila-oai

sharmila-oai commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

@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! I can add one in a separate PR. nvm you got it!

@zkochan zkochan disabled auto-merge June 2, 2026 05:30
@zkochan zkochan merged commit 122ab0a into pnpm:main Jun 2, 2026
26 of 27 checks passed
@welcome

welcome Bot commented Jun 2, 2026

Copy link
Copy Markdown

Congrats on merging your first pull request! 🎉🎉🎉

@zkochan zkochan mentioned this pull request Jun 2, 2026
zkochan added a commit that referenced this pull request Jun 2, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants