fix: when automatically installing peer deps, prefer the version found in the root of the workspace (#9835)

This commit is contained in:
Zoltan Kochan
2025-08-07 14:07:13 +02:00
committed by GitHub
parent c926e379bd
commit aa24e7fe7f
6 changed files with 112 additions and 5 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/resolve-dependencies": patch
"pnpm": patch
---
When automatically installing missing peer dependencies, prefer versions that are already present in the direct dependencies of the root workspace package [#9835](https://github.com/pnpm/pnpm/pull/9835).

View File

@@ -331,6 +331,85 @@ test('peer dependency is resolved from the dependencies of the workspace root pr
}
})
test('peer dependency is resolved from the dependencies of the workspace root project even if there are other versions of the peer dependency present in the dependency graph', async () => {
const projects = preparePackages([
{
location: '.',
package: { name: 'root' },
},
{
location: 'pkg',
package: {},
},
{
location: 'pkg2',
package: {},
},
])
const allProjects: ProjectOptions[] = [
{
buildIndex: 0,
manifest: {
name: 'root',
version: '1.0.0',
dependencies: {
ajv: '4.10.0',
},
},
rootDir: process.cwd() as ProjectRootDir,
},
{
buildIndex: 0,
manifest: {
name: 'pkg',
version: '1.0.0',
dependencies: {
'ajv-keywords': '1.5.0',
},
},
rootDir: path.resolve('pkg') as ProjectRootDir,
},
{
buildIndex: 0,
manifest: {
name: 'pkg2',
version: '1.0.0',
dependencies: {
ajv: '5.0.0',
},
},
rootDir: path.resolve('pkg2') as ProjectRootDir,
},
]
const reporter = jest.fn()
await mutateModules([
{
mutation: 'install',
rootDir: process.cwd() as ProjectRootDir,
},
{
mutation: 'install',
rootDir: path.resolve('pkg') as ProjectRootDir,
},
{
mutation: 'install',
rootDir: path.resolve('pkg2') as ProjectRootDir,
},
], testDefaults({ allProjects, reporter, resolvePeersFromWorkspaceRoot: true }))
expect(reporter).not.toHaveBeenCalledWith(expect.objectContaining({
name: 'pnpm:peer-dependency-issues',
}))
{
const lockfile = projects.root.readLockfile()
expect(lockfile.importers.pkg?.dependencies?.['ajv-keywords'].version).toBe('1.5.0(ajv@4.10.0)')
}
})
test('warning is reported when cannot resolve peer dependency for non-top-level dependency', async () => {
prepareEmpty()
await addDistTag({ package: '@pnpm.e2e/abc-parent-with-ab', version: '1.0.0', distTag: 'latest' })

View File

@@ -1,15 +1,22 @@
import { type PreferredVersions } from '@pnpm/resolver-base'
import semver from 'semver'
import { type PkgAddressOrLink } from './resolveDependencies'
export function hoistPeers (
missingRequiredPeers: Array<[string, { range: string }]>,
opts: {
autoInstallPeers: boolean
allPreferredVersions?: PreferredVersions
}
workspaceRootDeps: PkgAddressOrLink[]
},
missingRequiredPeers: Array<[string, { range: string }]>
): Record<string, string> {
const dependencies: Record<string, string> = {}
for (const [peerName, { range }] of missingRequiredPeers) {
const rootDep = opts.workspaceRootDeps.find((rootDep) => rootDep.alias === peerName)
if (rootDep?.version) {
dependencies[peerName] = rootDep.version
continue
}
if (opts.allPreferredVersions![peerName]) {
const versions: string[] = []
const nonVersions: string[] = []

View File

@@ -145,6 +145,7 @@ export interface ResolutionContext {
forceFullResolution: boolean
ignoreScripts?: boolean
resolvedPkgsById: ResolvedPkgsById
resolvePeersFromWorkspaceRoot?: boolean
outdatedDependencies: Record<PkgResolutionId, string>
childrenByParentId: ChildrenByParentId
patchedDependencies?: PatchGroupRecord
@@ -320,6 +321,18 @@ export async function resolveRootDependencies (
time,
}
}
let workspaceRootDeps!: PkgAddressOrLink[]
if (ctx.resolvePeersFromWorkspaceRoot) {
const rootImporterIndex = importers.findIndex(({ options }) => options.parentIds[0] === '.')
workspaceRootDeps = pkgAddressesByImportersWithoutPeers[rootImporterIndex]?.pkgAddresses ?? []
} else {
workspaceRootDeps = []
}
const _hoistPeers = hoistPeers.bind(null, {
autoInstallPeers: ctx.autoInstallPeers,
allPreferredVersions: ctx.allPreferredVersions,
workspaceRootDeps,
})
/* eslint-disable no-await-in-loop */
while (true) {
const allMissingOptionalPeersByImporters = await Promise.all(pkgAddressesByImportersWithoutPeers.map(async (importerResolutionResult, index) => {
@@ -357,7 +370,7 @@ export async function resolveRootDependencies (
}
}
if (!missingRequiredPeers.length) break
const dependencies = hoistPeers(missingRequiredPeers, ctx)
const dependencies = _hoistPeers(missingRequiredPeers)
if (!Object.keys(dependencies).length) break
const wantedDependencies = getNonDevWantedDependencies({ dependencies })

View File

@@ -180,6 +180,7 @@ export async function resolveDependencyTree<T> (
readPackageHook: opts.hooks.readPackage,
registries: opts.registries,
resolvedPkgsById: {} as ResolvedPkgsById,
resolvePeersFromWorkspaceRoot: opts.resolvePeersFromWorkspaceRoot,
resolutionMode: opts.resolutionMode,
skipped: wantedToBeSkippedPackageIds,
storeController: opts.storeController,

View File

@@ -1,14 +1,15 @@
import { hoistPeers, getHoistableOptionalPeers } from '../lib/hoistPeers'
test('hoistPeers picks an already available prerelease version', () => {
expect(hoistPeers([['foo', { range: '*' }]], {
expect(hoistPeers({
autoInstallPeers: false,
allPreferredVersions: {
foo: {
'1.0.0-beta.0': 'version',
},
},
})).toStrictEqual({
workspaceRootDeps: [],
}, [['foo', { range: '*' }]])).toStrictEqual({
foo: '1.0.0-beta.0',
})
})