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