From 54c4fc4fb49a07e994f23aad54802f4223644fc6 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Sun, 22 Feb 2026 22:03:53 +0100 Subject: [PATCH] fix: respect peer dep range in hoistPeers when preferred versions exist (#10655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: respect peer dep range in hoistPeers when preferred versions exist Previously, hoistPeers used semver.maxSatisfying(versions, '*') which picked the highest preferred version from the lockfile regardless of the peer dep range. This caused overrides that narrow a peer dep range to be ignored when a stale version existed in the lockfile. Now hoistPeers first tries semver.maxSatisfying(versions, range) to find a preferred version that satisfies the actual peer dep range. If none satisfies it and autoInstallPeers is enabled, it falls back to the range itself so pnpm resolves a matching version from the registry. * fix: only fall back to exact-version range for overrides, handle workspace: protocol - When no preferred version satisfies the peer dep range, only use the range directly if it is an exact version (e.g. "4.3.0" from an override). For semver ranges (e.g. "1", "^2.0.0"), fall back to the old behavior of picking the highest preferred version for deduplication. - Guard against workspace: protocol ranges that would cause semver.maxSatisfying to throw. - Add unit tests for hoisting deduplication and workspace: ranges. * fix: only apply range-constrained peer selection for exact versions The previous approach used semver.maxSatisfying(versions, range) for all peer dep ranges, which broke aliased-dependency deduplication — e.g. when three aliases of @pnpm.e2e/peer-c existed at 1.0.0, 1.0.1, and 2.0.0, range ^1.0.0 would pick 1.0.1 instead of 2.0.0. Now the range-aware logic only activates when the range is an exact version (semver.valid), which is the override case (e.g. "4.3.0"). Regular semver ranges fall back to picking the highest preferred version. --- .changeset/fix-hoist-peers-override.md | 5 ++ .../core/test/install/autoInstallPeers.ts | 36 ++++++++ .../resolve-dependencies/src/hoistPeers.ts | 19 ++++- .../test/hoistPeers.test.ts | 82 +++++++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-hoist-peers-override.md diff --git a/.changeset/fix-hoist-peers-override.md b/.changeset/fix-hoist-peers-override.md new file mode 100644 index 0000000000..c4a5cbe5a3 --- /dev/null +++ b/.changeset/fix-hoist-peers-override.md @@ -0,0 +1,5 @@ +--- +"@pnpm/resolve-dependencies": patch +--- + +Fix auto-installed peer dependencies ignoring overrides when a stale version exists in the lockfile. Previously, `hoistPeers` used `semver.maxSatisfying(versions, '*')` which picked the highest preferred version regardless of the peer dep range. Now it first tries `semver.maxSatisfying(versions, range)` to respect the actual range, falling back to exact-version ranges (e.g. from overrides) when no preferred version satisfies. Also handles `workspace:` protocol ranges safely. diff --git a/pkg-manager/core/test/install/autoInstallPeers.ts b/pkg-manager/core/test/install/autoInstallPeers.ts index eda77416bf..f3306c64c3 100644 --- a/pkg-manager/core/test/install/autoInstallPeers.ts +++ b/pkg-manager/core/test/install/autoInstallPeers.ts @@ -669,3 +669,39 @@ test('auto install peer of optional peer', async () => { ]) project.hasNot('@pnpm.e2e/peer-a') }) + +test('override narrows auto-installed peer dep range on subsequent install', async () => { + // Scenario: a lockfile has peer-c@1.0.1 from a previous install. + // Then the user adds an override to pin peer-c to 1.0.0. + // The override should be respected, not the stale lockfile version. + await addDistTag({ package: '@pnpm.e2e/peer-c', version: '1.0.1', distTag: 'latest' }) + const project = prepareEmpty() + + // Step 1: install without override — auto-installs peer-c@1.0.1 + const manifest = { + dependencies: { + '@pnpm.e2e/wants-peer-c-1': '1.0.0', + }, + } + await install(manifest, testDefaults({ autoInstallPeers: true })) + + { + const lockfile = project.readLockfile() + expect(lockfile.packages).toHaveProperty(['@pnpm.e2e/peer-c@1.0.1']) + } + + // Step 2: reinstall with override narrowing peer-c to 1.0.0 + const overrides = { '@pnpm.e2e/peer-c': '1.0.0' } + await mutateModulesInSingleProject({ + manifest, + mutation: 'install', + rootDir: process.cwd() as ProjectRootDir, + }, testDefaults({ autoInstallPeers: true, overrides })) + + { + const lockfile = project.readLockfile() + // peer-c should now be 1.0.0, not the stale 1.0.1 from the previous lockfile + expect(lockfile.packages).toHaveProperty(['@pnpm.e2e/peer-c@1.0.0']) + expect(lockfile.packages).not.toHaveProperty(['@pnpm.e2e/peer-c@1.0.1']) + } +}) diff --git a/pkg-manager/resolve-dependencies/src/hoistPeers.ts b/pkg-manager/resolve-dependencies/src/hoistPeers.ts index 7471bdf5a8..1f0c1e8d21 100644 --- a/pkg-manager/resolve-dependencies/src/hoistPeers.ts +++ b/pkg-manager/resolve-dependencies/src/hoistPeers.ts @@ -35,7 +35,24 @@ export function hoistPeers ( nonVersions.push(spec) } } - dependencies[peerName] = [semver.maxSatisfying(versions, '*', { includePrerelease: true }), ...nonVersions].join(' || ') + // When the range is an exact version (e.g. pinned by an override like "4.3.0"), + // try to find a preferred version that satisfies it. This prevents a stale + // higher version from the lockfile being picked over the overridden version. + // For regular semver ranges (e.g. "^1.0.0"), use the highest preferred + // version for deduplication. + const isExactVersion = semver.valid(range) != null + const satisfyingVersion = isExactVersion + ? semver.maxSatisfying(versions, range, { includePrerelease: true }) + : null + if (satisfyingVersion) { + dependencies[peerName] = [satisfyingVersion, ...nonVersions].join(' || ') + } else if (isExactVersion && opts.autoInstallPeers) { + // No preferred version satisfies the exact override version. + // Use the range directly so pnpm resolves it from the registry. + dependencies[peerName] = range + } else { + dependencies[peerName] = [semver.maxSatisfying(versions, '*', { includePrerelease: true }), ...nonVersions].join(' || ') + } } else if (opts.autoInstallPeers) { dependencies[peerName] = range } diff --git a/pkg-manager/resolve-dependencies/test/hoistPeers.test.ts b/pkg-manager/resolve-dependencies/test/hoistPeers.test.ts index d775921326..c922bc93c9 100644 --- a/pkg-manager/resolve-dependencies/test/hoistPeers.test.ts +++ b/pkg-manager/resolve-dependencies/test/hoistPeers.test.ts @@ -14,6 +14,88 @@ test('hoistPeers picks an already available prerelease version', () => { }) }) +test('hoistPeers respects peer dep range when preferred versions exist', () => { + // When an override narrows a peer dep range (e.g. chai: "4.3.0"), + // we should not pick a preferred version that doesn't satisfy it. + expect(hoistPeers({ + autoInstallPeers: true, + allPreferredVersions: { + chai: { + '5.2.1': 'version', + '4.3.0': 'version', + }, + }, + workspaceRootDeps: [], + }, [['chai', { range: '4.3.0' }]])).toStrictEqual({ + chai: '4.3.0', + }) +}) + +test('hoistPeers falls back to range when no preferred version satisfies it', () => { + // When no preferred version satisfies the overridden range, + // fall back to the range itself so pnpm resolves from the registry. + expect(hoistPeers({ + autoInstallPeers: true, + allPreferredVersions: { + chai: { + '5.2.1': 'version', + }, + }, + workspaceRootDeps: [], + }, [['chai', { range: '4.3.0' }]])).toStrictEqual({ + chai: '4.3.0', + }) +}) + +test('hoistPeers picks highest preferred version for deduplication when range is not exact', () => { + // For non-exact ranges (like ^2.0.0), hoistPeers picks the highest preferred + // version overall (for deduplication), not just the highest satisfying the range. + expect(hoistPeers({ + autoInstallPeers: true, + allPreferredVersions: { + foo: { + '2.0.0': 'version', + '2.1.0': 'version', + '3.0.0': 'version', + }, + }, + workspaceRootDeps: [], + }, [['foo', { range: '^2.0.0' }]])).toStrictEqual({ + foo: '3.0.0', + }) +}) + +test('hoistPeers reuses higher preferred version when range is not exact', () => { + // When the peer dep range is a semver range (not an exact version), + // prefer reusing a higher existing version for deduplication even if + // it doesn't satisfy the range. + expect(hoistPeers({ + autoInstallPeers: true, + allPreferredVersions: { + foo: { + '2.0.0': 'version', + }, + }, + workspaceRootDeps: [], + }, [['foo', { range: '1' }]])).toStrictEqual({ + foo: '2.0.0', + }) +}) + +test('hoistPeers handles workspace: protocol range without throwing', () => { + expect(hoistPeers({ + autoInstallPeers: true, + allPreferredVersions: { + foo: { + '1.0.0': 'version', + }, + }, + workspaceRootDeps: [], + }, [['foo', { range: 'workspace:*' }]])).toStrictEqual({ + foo: '1.0.0', + }) +}) + test('getHoistableOptionalPeers only picks a version that satisfies all optional ranges', () => { expect(getHoistableOptionalPeers({ foo: ['2', '2.1'],