fix: respect peer dep range in hoistPeers when preferred versions exist (#10655)

* 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.
This commit is contained in:
Zoltan Kochan
2026-02-22 22:03:53 +01:00
parent 23eb4a6141
commit 54c4fc4fb4
4 changed files with 141 additions and 1 deletions

View File

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

View File

@@ -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'])
}
})

View File

@@ -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
}

View File

@@ -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'],