Skip to content

fix(deps-resolver): dedupePeerDependents collapses cyclic peer snapshots#1

Draft
astegmaier wants to merge 2 commits into
mainfrom
fix/dedupe-peer-cycles
Draft

fix(deps-resolver): dedupePeerDependents collapses cyclic peer snapshots#1
astegmaier wants to merge 2 commits into
mainfrom
fix/dedupe-peer-cycles

Conversation

@astegmaier

@astegmaier astegmaier commented Jun 9, 2026

Copy link
Copy Markdown
Owner

fix(deps-resolver): dedupePeerDependents collapses cyclic peer snapshots

Fixes #11834dedupePeerDependents failed to collapse two snapshots of the same package when the package was part of a peer-dependency cycle and one of the cycle members activated an optional transitive peer in only some workspace projects.

The canonical real-world case is webpackterser-webpack-plugin with optional esbuild: some projects in a workspace use esbuild and some don't, so pnpm produces two snapshots of each cycle member differing only in the propagated (esbuild@…) peer suffix. Both snapshots are functionally equivalent and could safely point at the richer one — but dedupePeerDependents failed to merge them because of how the strict pairwise check works.


Tl;dr — what the fix does

Two surgical changes in installing/deps-resolver/src/resolvePeers.ts:

  1. A new cycle-breaking phase inside deduplicateAll. When the pairwise strict-superset pass stalls (produces an empty map but the duplicate groups aren't empty), we optimistically tentatively map every duplicate to its richest sibling, then drop any tentative mapping that doesn't validate under the other tentative mappings (fixed-point loop). This breaks N-cycles by deciding all cycle members atomically rather than pairwise.
  2. A small but critical fix in the existing pairwise pass (deduplicateDepPaths): the winner of a pairwise merge used to be removed from the "unresolved" set even though it could still be merged against other surviving winners of the same group on a later recursion. With multi-cycle workspaces (in a large monorepo at Microsoft I tested against) this hid surviving duplicates from the cycle-breaking phase. Fix: keep the winner alive in the unresolved set; only drop it when its group really has size ≤ 1.

There's also a follow-up bookkeeping fix: the final returned map is chain-compressed across recursion levels so that a single lookup (allDepPathsMap[depPath]) always returns the terminal winner.

Total surface area: ~70 lines added, ~3 lines changed.


The bug, in detail

Setup

installing/deps-resolver/src/resolvePeers.ts is the final phase of resolvePeers. After peers have been resolved, the resolver runs a deduplication pass over snapshots that share the same pkgIdWithPatchHash (i.e. same package + same patch). For each such group, deduplicateDepPaths walks pairs and asks isCompatibleAndHasMoreDeps(A, B):

Does A have at least as many deps as B, all of B's children's depPaths present in A's children, and all of B's resolvedPeerNames present in A?

If yes, B is mapped to A (the strict superset). Children pointing at B are rewritten to point at A. Recurse. Repeat until either everything is merged or no progress is being made.

The reproduction (playground-pnpm-circular-peers-bug)

Two workspace projects:

  • packages/a depends on webpack and terser-webpack-plugin.
  • packages/b depends on webpack, terser-webpack-plugin, and esbuild.

webpack declares terser-webpack-plugin as a regular dep that's also an optional peer (looping back). terser-webpack-plugin declares webpack as a required peer and esbuild as an optional peer.

Without the fix, the lockfile contains:

snapshots:
  terser-webpack-plugin@5.6.0(esbuild@0.27.7)(webpack@5.107.0(esbuild@0.27.7)):
    …
  terser-webpack-plugin@5.6.0(webpack@5.107.0):
    …
  webpack@5.107.0:
    …
  webpack@5.107.0(esbuild@0.27.7):
    …

Four snapshots where there should be two.

Why the existing dedup pass can't handle it

The two webpack snapshots differ only in their terser-webpack-plugin child:

  • webpack@5.107.0's child set = { T_plain }
  • webpack@5.107.0(esbuild)'s child set = { T_with_e, esbuild }

The strict subset check: is { T_plain } ⊆ { T_with_e, esbuild }? NoT_plain isn't in the right-hand set; the depPaths are literally different.

Symmetric problem for the two terser-webpack-plugin snapshots: T_plain's child webpack: W_plain isn't in T_with_e's child set { W_with_e, esbuild }.

Neither pair satisfies the strict check before children are rewritten. The recursion never produces a non-empty map. The pass terminates with both groups still duplicated.

How general is this?

Any peer cycle (2-cycle, 3-cycle, longer) involving optional peers exhibits this. The 2-cycle is just the simplest instance. A large monorepo at Microsoft hits the same pattern but more elaborately: webpackterser-webpack-plugin cycle, four webpack snapshots distinguished by combinations of @swc/core, esbuild, and webpack-cli peers.

The class of broken cases: same-pkgId snapshots whose only structural difference is a child pointing into another same-pkgId duplicate group whose merge is itself blocked by this group's merge. The check is intrinsically symmetric: neither pair can resolve until the other does.


The fix, line by line

All changes are in installing/deps-resolver/src/resolvePeers.ts.

Change 1: deduplicateDepPaths — don't drop winners prematurely

   for (const depPaths of duplicates) {
     const unresolvedDepPaths = new Set(depPaths.values())
     let currentDepPaths = [...depPaths].sort(depCountSorter)

     while (currentDepPaths.length) {
       const depPath1 = currentDepPaths.pop()!
       const nextDepPaths = []
       while (currentDepPaths.length) {
         const depPath2 = currentDepPaths.pop()!
         if (isCompatibleAndHasMoreDeps(depGraph, depPath1, depPath2)) {
           depPathsMap[depPath2] = depPath1
-          unresolvedDepPaths.delete(depPath1)
+          // Keep depPath1 (the winner) in unresolvedDepPaths so that, on a
+          // subsequent recursion of deduplicateAll, it can still be compared
+          // against other surviving members of the same dedup group. Without
+          // this, peer cycles like webpack ↔ terser-webpack-plugin leave two
+          // winners (W3 and W4) that never get re-considered together, and
+          // the dedup pass terminates prematurely. See #11834.
           unresolvedDepPaths.delete(depPath2)
         } else {
           nextDepPaths.push(depPath2)
         }
       }
       nextDepPaths.push(...currentDepPaths)
       currentDepPaths = nextDepPaths.sort(depCountSorter)
     }

-    if (unresolvedDepPaths.size) {
+    if (unresolvedDepPaths.size > 1) {
       remainingDuplicates.push(unresolvedDepPaths)
     }
   }

Why this matters. In a large monorepo at Microsoft I tested against, there are four webpack snapshots:

  • W1 = webpack@5.105.3(@swc/core)
  • W2 = webpack@5.105.3(esbuild)
  • W3 = webpack@5.105.3(esbuild)(webpack-cli) ← richest
  • W4 = webpack@5.105.3(webpack-cli)

W3 is a strict superset of W2; W4 is a strict superset of W1. Without the fix, the pairwise pass merges W2→W3 and W1→W4, then removes both W3 and W4 from unresolvedDepPaths (the old code dropped the winners, treating the group as "resolved"). The group is dropped from remainingDuplicates. The cycle phase never sees that W3 and W4 are themselves a duplicate that could be merged together (W4 → W3).

With the fix, after the pairwise pass unresolvedDepPaths = { W3, W4 }. The group survives into the next recursion. The standard pairwise pass on {W3, W4} fails the strict check (their webpack-cli children differ until that group is also resolved). That's the cue for the cycle phase to fire.

The if (unresolvedDepPaths.size > 1) guard ensures we don't push singleton groups (no pairs to compare).

This is its own bug, independent of the cycle phase. Even with a perfect cycle-breaking phase, the cycle phase wouldn't have been called with the right inputs in the Microsoft monorepo because the standard pass thought it was done.

Change 2: cycle phase in deduplicateAll

export function deduplicateAll<T extends PartialResolvedPackage> (
  depGraph: GenericDependenciesGraphWithResolvedChildren<T>,
  duplicates: Array<Set<DepPath>>
): Record<DepPath, DepPath> {
  const { depPathsMap, remainingDuplicates } = deduplicateDepPaths(duplicates, depGraph)
  let effectiveMap = depPathsMap
  let effectiveRemaining = remainingDuplicates

  // When the pairwise strict-superset pass made zero progress, the remaining
  // groups may form a peer cycle: two same-package snapshots whose only
  // children difference is the matching pair of snapshots in another group
  // that is itself blocked on this one. Neither pair satisfies the strict
  // check before children are rewritten. Break the cycle by tentatively
  // mapping all members of each group to their richest member, then
  // dropping any mapping that doesn't hold once the other tentative
  // mappings are applied.
  if (Object.keys(effectiveMap).length === 0 && remainingDuplicates.length > 0) {
    const cycleMap = deduplicatePeerCycles(depGraph, remainingDuplicates)
    if (Object.keys(cycleMap).length > 0) {
      effectiveMap = cycleMap
      effectiveRemaining = remainingDuplicates
        .map((group) => new Set([...group].filter((dp) => !cycleMap[dp])))
        .filter((group) => group.size > 1)
    }
  }

  if (Object.keys(effectiveMap).length === 0) {
    return effectiveMap
  }
  
}

What this does. Only when the standard pass makes zero progress AND remaining groups exist do we invoke the cycle phase. This minimizes risk: every existing test path is exercised through the unchanged standard code first. The cycle phase only sees inputs the standard pass already rejected.

After the cycle phase, we remove the mapped (losing) members from each remaining group and keep only groups with ≥ 2 survivors for the recursive call.

Change 3: deduplicatePeerCycles — optimistic candidates

function deduplicatePeerCycles<T extends PartialResolvedPackage> (
  depGraph: GenericDependenciesGraphWithResolvedChildren<T>,
  duplicates: Array<Set<DepPath>>
): Record<DepPath, DepPath> {
  const candidates: Record<DepPath, DepPath> = {}
  for (const group of duplicates) {
    const sorted = [...group].sort((a, b) => {
      const diff = nodeDepsCount(depGraph[b]) - nodeDepsCount(depGraph[a])
      return diff !== 0 ? diff : a.localeCompare(b)
    })
    const winner = sorted[0]
    for (let i = 1; i < sorted.length; i++) {
      candidates[sorted[i]] = winner
    }
  }

  let changed = true
  while (changed) {
    changed = false
    for (const loser of Object.keys(candidates) as DepPath[]) {
      if (!isCompatibleUnderMap(depGraph, candidates[loser], loser, candidates)) {
        delete candidates[loser]
        changed = true
      }
    }
  }
  return candidates
}

The algorithm.

  1. For each surviving duplicate group, sort members by node-dep count descending (ties broken alphabetically for determinism). The richest member is the tentative winner; every other member is tentatively mapped to it.
  2. Iterate the tentative map. For each (loser → winner), run isCompatibleUnderMap (next section) — a relaxed compat check that substitutes child depPaths through the current tentative map before testing subset-containment.
  3. If a mapping fails validation, drop it. Repeat until stable.

Why it's correct. Each surviving mapping is, by construction, a real strict-superset under the actual map that will be applied. If a mapping was wrong, validation eventually catches it (no longer compatible under the reduced map), and dropping that mapping potentially invalidates others, which the fixed-point loop catches in subsequent iterations.

Why it's general. Works for arbitrary N-cycles. Doesn't depend on the cycle members forming a specific shape. Falls back gracefully when a cycle truly can't be reduced — some mappings get dropped but the others still merge. Returns an empty map in pathological cases, in which case nothing changes (status quo).

Why it's safe. It only ever runs on groups the standard pass rejected, and only emits mappings that survive validation against the substituted-children compat check. The worst case is "no extra dedup occurs" — the lockfile remains as it was, which is the pre-fix behavior anyway.

Change 4: isCompatibleUnderMap — strict check with substitution

function isCompatibleUnderMap<T extends PartialResolvedPackage> (
  depGraph: GenericDependenciesGraphWithResolvedChildren<T>,
  winnerDepPath: DepPath,
  loserDepPath: DepPath,
  map: Record<DepPath, DepPath>
): boolean {
  const winner = depGraph[winnerDepPath]
  const loser = depGraph[loserDepPath]
  if (nodeDepsCount(winner) < nodeDepsCount(loser)) return false

  const winnerChildren = new Set<DepPath>()
  for (const childDepPath of Object.values<DepPath>(winner.children)) {
    winnerChildren.add(map[childDepPath] ?? childDepPath)
  }
  for (const childDepPath of Object.values<DepPath>(loser.children)) {
    const mapped = map[childDepPath] ?? childDepPath
    if (!winnerChildren.has(mapped)) return false
  }
  for (const peerName of loser.resolvedPeerNames) {
    if (!winner.resolvedPeerNames.has(peerName)) return false
  }
  return true
}

The change from the existing isCompatibleAndHasMoreDeps. Identical structure (node-dep count check, child subset check, resolved-peer subset check) — but every child depPath is looked up through map before comparison. If child.children[alias] = X and X is tentatively mapped to Y, we treat the child as if it pointed at Y.

Why this is the right relaxation. It's precisely the substitution that will happen to the graph when map is committed. If the substituted-child check passes, the post-rewrite graph really will have the loser's children ⊆ the winner's children. No looser than necessary.

Why this isn't dangerous for genuinely different children. If a loser has a child whose pkgIdWithPatchHash is different from anything in the winner's children, that child won't appear in map (it's not in any duplicate group with a winner's child), so the lookup returns the original depPath unchanged, and the subset check fails as before. The relaxation only fires when the children are themselves part of a duplicate group — exactly the case we want to handle.

Change 5: chain compression at the recursion boundary

  for (const node of Object.values(depGraph)) {
    for (const [alias, childDepPath] of Object.entries<DepPath>(node.children)) {
      if (effectiveMap[childDepPath]) {
        node.children[alias] = effectiveMap[childDepPath]
      }
    }
  }
  const innerMap = deduplicateAll(depGraph, effectiveRemaining)
  // Compose with results from deeper recursion levels so that any depPath that
  // maps via effectiveMap to a node that was itself merged away in a later
  // iteration ends up pointing directly at the terminal winner. Required for
  // callers that apply allDepPathsMap with a single lookup (e.g., the importer
  // direct-deps rewrite).
  for (const k of Object.keys(effectiveMap) as DepPath[]) {
    const v = effectiveMap[k]
    if (innerMap[v] != null) {
      effectiveMap[k] = innerMap[v]
    }
  }
  return {
    ...innerMap,
    ...effectiveMap,
  }

Why this is needed. Take the Microsoft monorepo in concrete terms:

  • iter1 standard pass produces { W2 → W3, W1 → W4, … }.
  • Children of nodes in depGraph are rewritten: anything pointing at W2 now points at W3, anything at W1 now points at W4. Recurse.
  • iter2 sees {W3, W4} (kept alive by fix fix(deps-resolver): dedupePeerDependents collapses cyclic peer snapshots #1). Standard pass fails (their children's webpack-cli peers differ until that group is resolved). Cycle phase produces { W4 → W3, WC_simple → WC_rich, … }.
  • iter2 returns its inner map: { W4 → W3, WC_simple → WC_rich, … }.

Now we splat iter1's map and iter2's map together: { W4: W3, WC_simple: WC_rich, W2: W3, W1: W4, … }.

Caller looks up allDepPathsMap[W1] and gets W4. WrongW4 itself was merged away in iter2. The terminal winner of W1 is W3, not W4.

The fix: before returning, walk effectiveMap (the outer iter's map) and rewrite each value through innerMap (the next iter's map). After composition, effectiveMap[W1] = W3. The single-lookup callers (importer direct-deps rewrite at lines 192–196 of resolvePeers.ts) get the right answer.

The children-rewrite loop right above this doesn't need chain compression because it's iterative: each level applies its own map. But the externally-applied map is single-hop, so the map itself must be chain-free.

Change 6: tiny one-line export

-function deduplicateAll<T extends PartialResolvedPackage> (
+export function deduplicateAll<T extends PartialResolvedPackage> (

Required so the new unit test can call it directly without bundling all of resolvePeers.


Tests

Unit tests — installing/deps-resolver/test/deduplicatePeerCycles.test.ts (new file)

Three tests, ~150 lines total:

  1. deduplicateAll collapses two mutually-peer-dependent snapshots even when one has an optional peer activated — constructs the minimal cycle (W_plain ↔ T_plain and W_with_e ↔ T_with_e with optional esbuild peer) and asserts the map collapses both groups to their richer members.
  2. deduplicateAll cycle phase does not collapse same-pkgId snapshots whose children genuinely differ outside the cycle — constructs a case where the optional peer is itself a different version (esbuild@0.20 vs esbuild@0.27) and asserts the cycle phase correctly rejects the merge (post-hoc validation drops the tentative mappings).
  3. deduplicateAll standard pass still wins when one duplicate is a strict superset — regression check that the cycle phase doesn't fire when the standard pass can already make progress.

Run them:

cd ~/projects/pnpm
pnpm --filter @pnpm/installing.deps-resolver test -- deduplicatePeerCycles

E2E test — installing/deps-installer/test/install/cyclicPeerDeduplication.ts (new file)

Exercises the whole resolver pipeline through mutateModules, using @pnpm/testing.mock-agent to intercept registry HTTP calls. Three fake packages — @pnpm.e2e/cycle-peer-a, …-b, …-c — mirror the webpack/terser/esbuild trio. Two workspace projects, one activates the optional peer, one doesn't. Asserts that exactly one snapshot of A and one snapshot of B exist after dedupePeerDependents runs, and they both contain the (c@1.0.0) suffix (i.e. the richer member wins).

Running and debugging the e2e test

The e2e tests live in the @pnpm/installing.deps-installer package. They use a Rust-built pnpm-registry binary for registry simulation (already built in CI).

cd ~/projects/pnpm
pnpm --filter @pnpm/installing.deps-installer test -- test/install/cyclicPeerDeduplication.ts

If you've never built the registry binary locally:

cd ~/projects/pnpm
cargo build -p pnpm-registry          # requires Rust toolchain via rustup

Debugging tips:

  1. See the generated lockfile. Add a console.log(fs.readFileSync(path.resolve('pnpm-lock.yaml'), 'utf8')) right before the expect calls. The lockfile is generated in a temp dir under os.tmpdir() — you can find it with process.cwd() from inside the test, or set lockfileDir to a known location.
  2. Inspect the dep graph. Add a console.error inside deduplicateAll to dump the map and the remaining duplicates. The test runs in-process so logs flow straight to the terminal.
  3. Inspect the mock agent. getMockAgent() returns the MockAgent instance — set MockAgent.disableNetConnect() and add agent.assertNoPendingInterceptors() to ensure no unexpected requests.
  4. Toggle off the fix temporarily. Comment out the cycle phase block in deduplicateAll and re-run; the assertions should fail with 2 snapshots of A and 2 of B — confirming the test really exercises the bug.

Fixture handoff to pnpm/registry-mock

The e2e test in this PR uses @pnpm/testing.mock-agent and stubs the registry responses inline — it does not depend on real fixtures being published. That makes it self-contained.

However, several other pnpm e2e tests use real fixture packages published from pnpm/registry-mock. For consistency with that pattern (and to allow future tests that want to exercise the cycle through the registry-mock stack rather than the undici stack), I've prepared three minimal fixture package.json files in this PR's session-state folder:

fixtures/
  cycle-peer-a/package.json  → depends on cycle-peer-b
  cycle-peer-b/package.json  → peer-deps on cycle-peer-a (required) + cycle-peer-c (optional)
  cycle-peer-c/package.json  → leaf, no deps

To stage them for the registry-mock repo, run something like:

cd ~/projects
git clone https://github.com/pnpm/registry-mock.git registry-mock-fork
cd registry-mock-fork
mkdir -p packages/cycle-peer-a packages/cycle-peer-b packages/cycle-peer-c
cp ~/.copilot/session-state/4bb4c21d-7579-4fa8-8785-491c8225500e/files/fixtures/cycle-peer-a/package.json packages/cycle-peer-a/
cp ~/.copilot/session-state/4bb4c21d-7579-4fa8-8785-491c8225500e/files/fixtures/cycle-peer-b/package.json packages/cycle-peer-b/
cp ~/.copilot/session-state/4bb4c21d-7579-4fa8-8785-491c8225500e/files/fixtures/cycle-peer-c/package.json packages/cycle-peer-c/
# follow repo's CONTRIBUTING for publishing

(Adjust the exact packages/… directory layout to match what registry-mock uses — at the time of writing it's a flat tree of package.json files under fixtures/ or packages/.)

This handoff is optional for landing the current PR.


Verification — what I ran and what I saw

Repository tests

cd ~/projects/pnpm
pnpm install
pnpm --filter pnpm run compile

# Targeted tests (the package this PR touches)
pnpm --filter @pnpm/installing.deps-resolver test
# → 10 test suites passed, 63 tests passed (3 of which are the new ones)

# Larger e2e coverage of dedup behavior
pnpm --filter @pnpm/installing.deps-installer test -- test/install/peerDependencies.ts
# → 67 passed, 1 skipped (unchanged from baseline)

# The new e2e test
pnpm --filter @pnpm/installing.deps-installer test -- test/install/cyclicPeerDeduplication.ts
# → 1 passed

Playground reproduction

~/projects/playground-pnpm-circular-peers-bug (the original minimal repro from pnpm#11834):

cd ~/projects/playground-pnpm-circular-peers-bug
rm -rf node_modules pnpm-lock.yaml
node ~/projects/pnpm/pnpm/dist/pnpm.mjs install --lockfile-only

# Snapshots after fix:
#   terser-webpack-plugin@5.6.0(esbuild@0.27.7)(webpack@5.107.0(esbuild@0.27.7))
#   webpack@5.107.0(esbuild@0.27.7)
# → 1 webpack snapshot, 1 terser snapshot. Before fix: 2 + 2.

Real-world test — a large monorepo at Microsoft

A large internal monorepo (~5400 transitive packages) that hit this bug hard and had to apply a packageExtensions workaround to force-promote esbuild everywhere.

cd /path/to/monorepo
# (workaround already commented out for testing)
rm -rf node_modules pnpm-lock.yaml
cp pnpm-lock.yaml.bak pnpm-lock.yaml      # warm start from the pre-fix lockfile
pd install --lockfile-only --prefer-offline    # `pd` = local dev binary; see "Linking the local build" below

# webpack@5.105.3 snapshots: 4 → 1
# terser-webpack-plugin@5.4.0 snapshots: 2 → 1
# webpack-cli@4.9.2 snapshots: 2 → 1

The one snapshot per package is the maximally-decorated one (with all peer suffixes applied), which is the safe choice.


Linking the local build to your consumer project (for manual testing)

The pnpm repo ships an officially-supported developer workflow for exactly this. Use it.

Option A (strongly recommended): the pd dev binary

The repo's CONTRIBUTING.md describes a tiny helper package at pnpm/dev/ that:

  • Discovers every workspace package.
  • Uses an esbuild plugin to rewrite @pnpm/* imports straight at the source src/index.ts files, so it picks up your live TypeScript edits with no pnpm --filter pnpm run compile step in between.
  • Bundles in memory and execs with --pm-on-fail=ignore, which bypasses any packageManager field mismatch automatically — no COREPACK_* env vars needed.

One-time setup:

cd ~/projects/pnpm
pnpm install
pnpm add ./pnpm/dev -g                # registers a global `pd` binary
pnpm run compile                       # initial build, only needed once

Then in any consumer project:

cd /path/to/monorepo
pd install                             # uses your live local pnpm source
pd install --lockfile-only             # fast lockfile-only run for resolver work
pd add some-package                    # any subcommand works

A few important properties:

  • pd reports itself as version 1100.0.10 (the marker in pnpm/dev/package.json), which is how you confirm you're not accidentally hitting a global pnpm binary.
  • No need to edit the consumer project's package.json — the packageManager: "pnpm@…" field is silently honored-or-ignored by pd's --pm-on-fail=ignore flag.
  • Every pd invocation re-bundles via esbuild (typically ~5s for cold, ~1s for warm cache). No manual rebuild step.

Verifying you're running the local build:

pd --version
# → 1100.0.10

For a stronger guarantee, add a temporary console.error('[DEBUG] local pd is running') near the top of installing/deps-resolver/src/resolvePeers.ts and watch for it in pd install --lockfile-only output. Because pd reads from source, no rebuild is needed for the marker to appear.

Option B (alternative): run the bundled CLI directly with node

Useful if you don't want a global pd install or you want to test the bundled code path (which is what real users run):

# 1. Build the bundled CLI.
cd ~/projects/pnpm
pnpm install
pnpm --filter pnpm run compile          # produces ~/projects/pnpm/pnpm/dist/pnpm.mjs

# 2. Run any pnpm subcommand from inside the consumer project.
cd /path/to/monorepo
cp package.json package.json.bak
node -e "let p=require('./package.json');delete p.packageManager;require('fs').writeFileSync('package.json',JSON.stringify(p,null,2))"
rm -rf node_modules pnpm-lock.yaml
node ~/projects/pnpm/pnpm/dist/pnpm.mjs install --lockfile-only
mv package.json.bak package.json

Unlike pd, this does require a rebuild (pnpm --filter pnpm run compile) after each source change, and requires you to bypass the packageManager check yourself.

Option C (alternative): symlink onto PATH

Same as B but lets you type pnpm-local instead of the long node …/pnpm.mjs invocation:

cd ~/projects/pnpm && pnpm --filter pnpm run compile
cat > ~/bin/pnpm-local <<'EOF'
#!/usr/bin/env bash
exec node ~/projects/pnpm/pnpm/dist/pnpm.mjs "$@"
EOF
chmod +x ~/bin/pnpm-local
cd /path/to/monorepo
COREPACK_ENABLE_STRICT=0 pnpm-local install --lockfile-only

Option D (corepack-aware, via tarball + local HTTP server)

If you want corepack to manage the local build the same way it manages a real release — i.e., keep the packageManager field in the consumer project's package.json and have corepack itself fetch & cache the local binary — you can use a custom URL with corepack's COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=1 escape hatch.

Important constraint. corepack documents two custom URL forms:

  • .js URL → "interpreted as a CommonJS module". This does not work for pnpm, because the bundled pnpm/dist/pnpm.mjs is ESM ("type": "module" in pnpm/package.json).
  • .tgz URL → "interpreted as a package, and the bin field of package.json will be used to determine which file to use in the archive". This is the path that works.

Also: corepack uses Node's fetch() to download the URL, which does not accept file:// URLs (tested — errors with TypeError: fetch failed from undici). So you have to serve the tarball over HTTP (any local server works).

Verified working recipe:

# 1. Pack the local pnpm build into a tarball.
cd ~/projects/pnpm/pnpm
pnpm --filter pnpm run compile          # rebuild dist/ first
pnpm pack                                # produces pnpm-<version>.tgz here, e.g. pnpm-11.3.0.tgz

# 2. Compute the sha512 hash (corepack requires an integrity hash in the URL).
shasum -a 512 pnpm-11.3.0.tgz
# → 143a1c50f5...de056  pnpm-11.3.0.tgz
#   (copy the hex string before the filename)

# 3. Serve the tarball over plain HTTP from any directory containing it.
#    Pick any free port. Leave this running in a separate terminal.
cd ~/projects/pnpm/pnpm
python3 -m http.server 9876 --bind 127.0.0.1
# (or: npx http-server -p 9876, or any equivalent)

# 4. In the consumer project, set packageManager to the URL form:
cd /path/to/monorepo
cp package.json package.json.bak
# Replace <HASH> with the hex from step 2 (no "sha512." prefix in the shasum
# output — corepack expects sha512.<hex>):
node -e '
  const fs = require("fs");
  const p = JSON.parse(fs.readFileSync("package.json"));
  p.packageManager = "pnpm@http://127.0.0.1:9876/pnpm-11.3.0.tgz#sha512.143a1c50f5...de056";
  fs.writeFileSync("package.json", JSON.stringify(p, null, 2));
'

# 5. Run pnpm. The COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=1 env var is mandatory
#    because the URL is not a registry URL.
COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=1 pnpm install --lockfile-only

# 6. Restore when done.
cd /path/to/monorepo
mv package.json.bak package.json

Iteration friction. corepack caches the resolved URL under ~/.cache/node/corepack/v1/pnpm/<urlencoded URL>/. If you change the source and re-pack, the cached binary will be reused unless you either:

  • Change the sha512 (which will happen automatically because the tarball contents differ) and update the URL hash in package.json. Corepack will re-fetch when the hash doesn't match the cached entry.
  • Or clear the cache: rm -rf ~/.cache/node/corepack/v1/pnpm/http*.

For a tight edit-build-test loop, this is more friction than Option A's "just run pd install". Use Option D only when you want to faithfully test the corepack-managed code path itself (e.g., to confirm the binary works end-to-end through corepack's verification gates).

Option E (Verdaccio/local registry): publish the patched build

Heavier weight. Only worth it if you're testing in many separate consumer projects. Use pnpm-deploy-local-registry or verdaccio — beyond scope here.

Verifying it's actually your build

Print the version:

node ~/projects/pnpm/pnpm/dist/pnpm.mjs --version
# → 11.3.0 (matches the pre-release version in pnpm/package.json)

Or add a temporary console.error('[DEBUG] my local build is running') near the top of installing/deps-resolver/src/resolvePeers.ts, rebuild, and look for it in install output.

Restoring the consumer project after testing

cd /path/to/monorepo
git checkout pnpm-lock.yaml pnpm-workspace.yaml package.json

(Or undo whatever backups you made. If you used /path/to/monorepo/{pnpm-workspace,package,pnpm-lock}.yaml.bak, the .bak files were already restored to their original names as part of this PR's verification.)


Tradeoff analysis — why this approach over the alternatives

This was the option I went with after considering five alternatives. Briefly:

Option Diff size Risk Generality Verdict
A. Optimistic + post-hoc validation cycle phase (this PR) ~70 LOC added Low (cycle phase only fires when standard pass stalls; validation prevents over-acceptance; falls back to status quo on failure) Arbitrary N-cycles ✅ chosen
B. Permissive child match everywhere + post-hoc validation ~5 LOC Medium (changes the hot path in isCompatibleAndHasMoreDeps; post-hoc validation would have to revert children rewrites for dropped mappings, complex) Same Rejected: less surgical
C. SCC analysis on dedup-dependency graph ~200 LOC Low correctness, high implementation effort Optimal Rejected: overengineered
D. Change suffix encoding Massive (lockfile format) Very high N/A Rejected: out of scope for a bug fix
E. Skip post-hoc validation ~30 LOC High (easy to construct cases where validation matters) Same Rejected: can produce wrong dedups

Option A's key property: it only ever runs on inputs the standard pass already rejected, and only emits mappings that pass a stricter post-hoc check. The worst case is "no extra dedup occurs" — which is exactly the pre-fix behavior. There's no path by which it can make the output worse.


pacquet parity

pacquet/AGENTS.md requires user-visible behavior changes in pnpm to land in pacquet too. Not applicable here: pacquet's dedupe_peer_dependents config field is plumbed through (pacquet/crates/config/src/lib.rs:596, pacquet/crates/config/src/workspace_yaml.rs:156) but the actual dedup pass in pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs hasn't been ported from resolvePeers.ts yet — the file ends with peer resolution proper, no deduplicateAll/deduplicateDepPaths equivalent. When pacquet ports the dedup pass, the port should include the cycle-breaking phase from this PR.

I verified this by grepping pacquet/ for deduplicate_all / dedupe_peer_dependents: the only matches in code paths beyond config are test files that pass the flag through.


Changeset

---
"@pnpm/installing.deps-resolver": patch
"pnpm": patch
---

Fixed `dedupePeerDependents` failing to merge two snapshots of the same
package when there is a peer-dependency cycle between two packages and
one of them activates an optional transitive peer only in some workspace
projects (e.g., `webpack` ↔ `terser-webpack-plugin` with optional
`esbuild`) [#11834](https://github.com/pnpm/pnpm/issues/11834).

Patch bump rationale: this is a bug fix, not a new feature or behavior change. No user-facing API changes. Lockfile shapes change in the direction the user already asked for (fewer duplicates when dedupePeerDependents: true); no migration needed.


Files changed

File What changed
installing/deps-resolver/src/resolvePeers.ts Added deduplicatePeerCycles, isCompatibleUnderMap; new cycle phase branch in deduplicateAll; fixed winner-deletion in deduplicateDepPaths; chain-compression after recursion; exported deduplicateAll for tests
installing/deps-resolver/test/deduplicatePeerCycles.test.ts New file — 3 unit tests
installing/deps-installer/test/install/cyclicPeerDeduplication.ts New file — 1 e2e test using mock-agent
.changeset/dedupe-peer-cycles.md New file — patch bumps for @pnpm/installing.deps-resolver and pnpm

Written by an agent (Copilot CLI, claude-opus-4.7-1m-internal).

…dents

The pairwise strict-superset pass in deduplicateAll silently bailed on
N-cycles of peer-dependency mutual references (e.g., webpack <-> terser-
webpack-plugin with optional esbuild), leaving multiple snapshots of the
same package in the lockfile when they only differed in propagated peer
suffixes.

Add a cycle-breaking phase that optimistically maps every duplicate to
its richest sibling, then revalidates each tentative mapping under the
*other* tentative mappings via a fixed-point loop, so an N-cycle is
decided atomically rather than pairwise.

Also fix a winner-deletion bug in deduplicateDepPaths that prematurely
dropped the surviving winner from the unresolved set even when its
group still had other surviving members it could merge with, and add
chain composition at the recursion boundary so callers' single-lookup
map application always returns the terminal winner.

Refs: pnpm#11834

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Relocate the pnpm#11834 dedupePeerDependents cycle fix into the new pnpm11/
layout. The deduplicateAll cycle-breaking phase, the deduplicateDepPaths
winner-keep change, and both tests now live under pnpm11/installing/...
alongside main's determinism tie-break in depCountSorter.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

1 participant