feat: add dedupePeers option to reduce peer dependency duplication (#11079)

When enabled, peer dependency suffixes use version-only identifiers
(name@version) instead of full dep paths, eliminating nested suffixes
like (foo@1.0.0(bar@2.0.0)). Transitive peers are still tracked but
identified by version instead of full dep path.

Backport of #11071 to v10.

Closes #11070
This commit is contained in:
Zoltan Kochan
2026-03-24 13:51:52 +01:00
committed by GitHub
parent cb17c44e55
commit 28204a4c9a
17 changed files with 426 additions and 13 deletions

View File

@@ -0,0 +1,10 @@
---
"@pnpm/resolve-dependencies": minor
"@pnpm/core": minor
"@pnpm/lockfile.types": minor
"@pnpm/lockfile.settings-checker": minor
"@pnpm/config": minor
"pnpm": minor
---
Added a new `dedupePeers` setting that reduces peer dependency duplication. When enabled, peer dependency suffixes use version-only identifiers (`name@version`) instead of full dep paths, eliminating nested suffixes like `(foo@1.0.0(bar@2.0.0))`. This dramatically reduces the number of package instances in projects with many recursive peer dependencies [#11070](https://github.com/pnpm/pnpm/issues/11070).

View File

@@ -187,6 +187,7 @@ export interface Config extends OptionsFromRootManifest {
onlyBuiltDependencies?: string[]
allowBuilds?: Record<string, boolean | string>
dedupePeerDependents?: boolean
dedupePeers?: boolean
patchesDir?: string
ignoreWorkspaceCycles?: boolean
disallowWorkspaceCycles?: boolean

View File

@@ -133,6 +133,7 @@ export async function getConfig (opts: {
'dangerously-allow-all-builds': false,
'deploy-all-files': false,
'dedupe-peer-dependents': true,
'dedupe-peers': false,
'dedupe-direct-deps': false,
'dedupe-injected-deps': true,
'disallow-workspace-cycles': false,

View File

@@ -15,6 +15,7 @@ export const types = Object.assign({
'dangerously-allow-all-builds': Boolean,
'deploy-all-files': Boolean,
'dedupe-peer-dependents': Boolean,
'dedupe-peers': Boolean,
'dedupe-direct-deps': Boolean,
'dedupe-injected-deps': Boolean,
dev: [null, true],

View File

@@ -10,6 +10,7 @@ export type ChangedField =
| 'packageExtensionsChecksum'
| 'ignoredOptionalDependencies'
| 'settings.autoInstallPeers'
| 'settings.dedupePeers'
| 'settings.excludeLinksFromLockfile'
| 'settings.peersSuffixMaxLength'
| 'settings.injectWorkspacePackages'
@@ -24,6 +25,7 @@ export function getOutdatedLockfileSetting (
ignoredOptionalDependencies,
patchedDependencies,
autoInstallPeers,
dedupePeers,
excludeLinksFromLockfile,
peersSuffixMaxLength,
pnpmfileChecksum,
@@ -35,6 +37,7 @@ export function getOutdatedLockfileSetting (
patchedDependencies?: Record<string, PatchFile>
ignoredOptionalDependencies?: string[]
autoInstallPeers?: boolean
dedupePeers?: boolean
excludeLinksFromLockfile?: boolean
peersSuffixMaxLength?: number
pnpmfileChecksum?: string
@@ -59,6 +62,9 @@ export function getOutdatedLockfileSetting (
if ((lockfile.settings?.autoInstallPeers != null && lockfile.settings.autoInstallPeers !== autoInstallPeers)) {
return 'settings.autoInstallPeers'
}
if (Boolean(lockfile.settings?.dedupePeers) !== Boolean(dedupePeers)) {
return 'settings.dedupePeers'
}
if (lockfile.settings?.excludeLinksFromLockfile != null && lockfile.settings.excludeLinksFromLockfile !== excludeLinksFromLockfile) {
return 'settings.excludeLinksFromLockfile'
}

View File

@@ -8,6 +8,7 @@ export * from './lockfileFileTypes.js'
export interface LockfileSettings {
autoInstallPeers?: boolean
dedupePeers?: boolean
excludeLinksFromLockfile?: boolean
peersSuffixMaxLength?: number
injectWorkspacePackages?: boolean

View File

@@ -11,6 +11,7 @@ export type ListMissingPeersOptions = Partial<GetContextOptions>
& Pick<InstallOptions, 'hooks'
| 'catalogs'
| 'dedupePeerDependents'
| 'dedupePeers'
| 'ignoreCompatibilityDb'
| 'linkWorkspacePackagesDepth'
| 'nodeVersion'

View File

@@ -150,6 +150,7 @@ export interface StrictInstallOptions {
dedupeDirectDeps: boolean
dedupeInjectedDeps: boolean
dedupePeerDependents: boolean
dedupePeers: boolean
extendNodePath: boolean
excludeLinksFromLockfile: boolean
confirmModulesPurge: boolean
@@ -271,6 +272,7 @@ const defaults = (opts: InstallOptions): StrictInstallOptions => {
resolveSymlinksInInjectedDirs: false,
dedupeDirectDeps: true,
dedupePeerDependents: true,
dedupePeers: false,
resolvePeersFromWorkspaceRoot: true,
extendNodePath: true,
ignoreWorkspaceCycles: false,

View File

@@ -430,6 +430,7 @@ export async function mutateModules (
const outdatedLockfileSettingName = getOutdatedLockfileSetting(ctx.wantedLockfile, {
autoInstallPeers: opts.autoInstallPeers,
catalogs: opts.catalogs,
dedupePeers: opts.dedupePeers || undefined,
injectWorkspacePackages: opts.injectWorkspacePackages,
excludeLinksFromLockfile: opts.excludeLinksFromLockfile,
peersSuffixMaxLength: opts.peersSuffixMaxLength,
@@ -453,6 +454,7 @@ export async function mutateModules (
if (needsFullResolution) {
ctx.wantedLockfile.settings = {
autoInstallPeers: opts.autoInstallPeers,
dedupePeers: opts.dedupePeers || undefined,
excludeLinksFromLockfile: opts.excludeLinksFromLockfile,
peersSuffixMaxLength: opts.peersSuffixMaxLength,
injectWorkspacePackages: opts.injectWorkspacePackages,
@@ -465,6 +467,7 @@ export async function mutateModules (
} else if (!frozenLockfile) {
ctx.wantedLockfile.settings = {
autoInstallPeers: opts.autoInstallPeers,
dedupePeers: opts.dedupePeers || undefined,
excludeLinksFromLockfile: opts.excludeLinksFromLockfile,
peersSuffixMaxLength: opts.peersSuffixMaxLength,
injectWorkspacePackages: opts.injectWorkspacePackages,
@@ -1191,6 +1194,7 @@ const _installInContext: InstallFunction = async (projects, ctx, opts) => {
dedupeDirectDeps: opts.dedupeDirectDeps,
dedupeInjectedDeps: opts.dedupeInjectedDeps,
dedupePeerDependents: opts.dedupePeerDependents,
dedupePeers: opts.dedupePeers,
dryRun: opts.lockfileOnly,
enableGlobalVirtualStore: opts.enableGlobalVirtualStore,
engineStrict: opts.engineStrict,

View File

@@ -2057,6 +2057,107 @@ test('detection of circular peer dependencies should not crash with aliased depe
expect(fs.existsSync(path.resolve(WANTED_LOCKFILE))).toBeTruthy()
})
// Covers https://github.com/pnpm/pnpm/issues/11070
test('dedupePeers: version-only peer suffixes without nested dep paths', async () => {
prepareEmpty()
await addDistTag({ package: '@pnpm.e2e/abc-parent-with-ab', version: '1.0.0', distTag: 'latest' })
await addDistTag({ package: '@pnpm.e2e/peer-a', version: '1.0.0', distTag: 'latest' })
const project = prepareEmpty()
await install({
dependencies: {
'@pnpm.e2e/abc-grand-parent': '1.0.0',
'@pnpm.e2e/peer-c': '1.0.0',
},
}, testDefaults({ dedupePeers: true, strictPeerDependencies: false }))
const lockfile = project.readLockfile()
// Transitive peers are included but with version-only IDs (no nesting).
// abc-grand-parent and abc-parent-with-ab get (peer-c@1.0.0) — not the full nested dep path.
expect(Object.keys(lockfile.snapshots).sort()).toStrictEqual([
'@pnpm.e2e/abc-grand-parent@1.0.0(@pnpm.e2e/peer-c@1.0.0)',
'@pnpm.e2e/abc-parent-with-ab@1.0.0(@pnpm.e2e/peer-c@1.0.0)',
'@pnpm.e2e/abc@1.0.0(@pnpm.e2e/peer-a@1.0.0)(@pnpm.e2e/peer-b@1.0.0)(@pnpm.e2e/peer-c@1.0.0)',
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0',
'@pnpm.e2e/peer-a@1.0.0',
'@pnpm.e2e/peer-b@1.0.0',
'@pnpm.e2e/peer-c@1.0.0',
'is-positive@1.0.0',
])
expect(lockfile.settings.dedupePeers).toBe(true)
})
// Covers https://github.com/pnpm/pnpm/issues/11070
test('dedupePeers: workspace projects with different peer versions get different instances', async () => {
await addDistTag({ package: '@pnpm.e2e/abc-parent-with-ab', version: '1.0.0', distTag: 'latest' })
await addDistTag({ package: '@pnpm.e2e/peer-a', version: '1.0.0', distTag: 'latest' })
const manifest1 = {
name: 'project-1',
dependencies: {
'@pnpm.e2e/abc-grand-parent': '1.0.0',
'@pnpm.e2e/peer-c': '1.0.0',
},
}
const manifest2 = {
name: 'project-2',
dependencies: {
'@pnpm.e2e/abc-grand-parent': '1.0.0',
'@pnpm.e2e/peer-c': '2.0.0',
},
}
preparePackages([
{ location: 'project-1', package: manifest1 },
{ location: 'project-2', package: manifest2 },
])
const importers: MutatedProject[] = [
{ mutation: 'install', rootDir: path.resolve('project-1') as ProjectRootDir },
{ mutation: 'install', rootDir: path.resolve('project-2') as ProjectRootDir },
]
const allProjects = [
{ buildIndex: 0, manifest: manifest1, rootDir: path.resolve('project-1') as ProjectRootDir },
{ buildIndex: 0, manifest: manifest2, rootDir: path.resolve('project-2') as ProjectRootDir },
]
await mutateModules(importers, testDefaults({
allProjects,
dedupePeers: true,
lockfileOnly: true,
strictPeerDependencies: false,
}))
const lockfile = readYamlFile<LockfileFile>(path.resolve(WANTED_LOCKFILE))
// Each project gets its own instances differentiated by peer-c version.
// Version-only suffixes — no nesting like (@pnpm.e2e/peer-c@1.0.0(@pnpm.e2e/peer-a@...)).
expect(Object.keys(lockfile.snapshots!).sort()).toStrictEqual([
'@pnpm.e2e/abc-grand-parent@1.0.0(@pnpm.e2e/peer-c@1.0.0)',
'@pnpm.e2e/abc-grand-parent@1.0.0(@pnpm.e2e/peer-c@2.0.0)',
'@pnpm.e2e/abc-parent-with-ab@1.0.0(@pnpm.e2e/peer-c@1.0.0)',
'@pnpm.e2e/abc-parent-with-ab@1.0.0(@pnpm.e2e/peer-c@2.0.0)',
'@pnpm.e2e/abc@1.0.0(@pnpm.e2e/peer-a@1.0.0)(@pnpm.e2e/peer-b@1.0.0)(@pnpm.e2e/peer-c@1.0.0)',
'@pnpm.e2e/abc@1.0.0(@pnpm.e2e/peer-a@1.0.0)(@pnpm.e2e/peer-b@1.0.0)(@pnpm.e2e/peer-c@2.0.0)',
'@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0',
'@pnpm.e2e/peer-a@1.0.0',
'@pnpm.e2e/peer-b@1.0.0',
'@pnpm.e2e/peer-c@1.0.0',
'@pnpm.e2e/peer-c@2.0.0',
'is-positive@1.0.0',
])
// Each project gets the correct abc-grand-parent instance for its peer-c version
expect(lockfile.importers?.['project-1']?.dependencies?.['@pnpm.e2e/abc-grand-parent']).toStrictEqual({
specifier: '1.0.0',
version: '1.0.0(@pnpm.e2e/peer-c@1.0.0)',
})
expect(lockfile.importers?.['project-2']?.dependencies?.['@pnpm.e2e/abc-grand-parent']).toStrictEqual({
specifier: '1.0.0',
version: '1.0.0(@pnpm.e2e/peer-c@2.0.0)',
})
})
// Covers https://github.com/pnpm/pnpm/pull/9673
test('no deadlock on circular aliased peers', async () => {
prepareEmpty()

View File

@@ -282,6 +282,7 @@ export type InstallCommandOptions = Pick<Config,
| 'dedupeInjectedDeps'
| 'dedupeDirectDeps'
| 'dedupePeerDependents'
| 'dedupePeers'
| 'deployAllFiles'
| 'depth'
| 'dev'

View File

@@ -57,6 +57,7 @@ export type InstallDepsOptions = Pick<Config,
| 'cleanupUnusedCatalogs'
| 'cliOptions'
| 'dedupePeerDependents'
| 'dedupePeers'
| 'depth'
| 'dev'
| 'enableGlobalVirtualStore'

View File

@@ -57,6 +57,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
| 'bail'
| 'configDependencies'
| 'dedupePeerDependents'
| 'dedupePeers'
| 'depth'
| 'globalPnpmfile'
| 'hoistPattern'

View File

@@ -112,6 +112,7 @@ export async function resolveDependencies (
opts: ResolveDependenciesOptions & {
defaultUpdateDepth: number
dedupePeerDependents?: boolean
dedupePeers?: boolean
dedupeDirectDeps?: boolean
dedupeInjectedDeps?: boolean
excludeLinksFromLockfile?: boolean
@@ -203,6 +204,7 @@ export async function resolveDependencies (
allPeerDepNames,
dependenciesTree,
dedupePeerDependents: opts.dedupePeerDependents,
dedupePeers: opts.dedupePeers,
dedupeInjectedDeps: opts.dedupeInjectedDeps,
lockfileDir: opts.lockfileDir,
projects: projectsToLink,

View File

@@ -87,6 +87,7 @@ export async function resolvePeers<T extends PartialResolvedPackage> (
lockfileDir: string
resolvePeersFromWorkspaceRoot?: boolean
dedupePeerDependents?: boolean
dedupePeers?: boolean
dedupeInjectedDeps?: boolean
resolvedImporters: ResolvedImporters
peersSuffixMaxLength: number
@@ -134,6 +135,7 @@ export async function resolvePeers<T extends PartialResolvedPackage> (
peersCache,
peerDependencyIssues,
purePkgs,
dedupePeers: opts.dedupePeers,
peersSuffixMaxLength: opts.peersSuffixMaxLength,
rootDir,
virtualStoreDir: opts.virtualStoreDir,
@@ -381,6 +383,7 @@ async function resolvePeersOfNode<T extends PartialResolvedPackage> (
peerDependencyIssues: Pick<PeerDependencyIssues, 'bad' | 'missing'>
peersCache: PeersCache
purePkgs: Set<PkgIdWithPatchHash> // pure packages are those that don't rely on externally resolved peers
dedupePeers?: boolean
rootDir: ProjectRootDir
lockfileDir: string
peersSuffixMaxLength: number
@@ -522,20 +525,12 @@ async function resolvePeersOfNode<T extends PartialResolvedPackage> (
const peerIds: PeerId[] = []
const pendingPeers: PendingPeer[] = []
for (const [alias, peerNodeId] of allResolvedPeers.entries()) {
if (typeof peerNodeId === 'string' && peerNodeId.startsWith('link:')) {
const linkedDir = peerNodeId.slice(5)
peerIds.push({
name: alias,
version: filenamify(linkedDir, { replacement: '+' }),
})
continue
const peerId = peerNodeIdToPeerId(alias, peerNodeId, ctx)
if (peerId != null) {
peerIds.push(peerId)
} else {
pendingPeers.push({ alias, nodeId: peerNodeId })
}
const peerDepPath = ctx.pathsByNodeId.get(peerNodeId)
if (peerDepPath) {
peerIds.push(peerDepPath)
continue
}
pendingPeers.push({ alias, nodeId: peerNodeId })
}
if (pendingPeers.length === 0) {
const peerDepGraphHash = createPeerDepGraphHash(peerIds, ctx.peersSuffixMaxLength)
@@ -575,6 +570,12 @@ async function resolvePeersOfNode<T extends PartialResolvedPackage> (
ctx.pathsByNodeIdPromises.get(pendingPeer.nodeId)?.resolve(id as DepPath)
return id
}
if (ctx.dedupePeers) {
const peerNode = ctx.dependenciesTree.get(pendingPeer.nodeId)
if (peerNode) {
return { name: peerNode.resolvedPackage.name, version: peerNode.resolvedPackage.version }
}
}
return ctx.pathsByNodeIdPromises.get(pendingPeer.nodeId)!.promise
})
),
@@ -782,6 +783,7 @@ async function resolvePeersOfChildren<T extends PartialResolvedPackage> (
virtualStoreDir: string
virtualStoreDirMaxLength: number
purePkgs: Set<PkgIdWithPatchHash>
dedupePeers?: boolean
depGraph: GenericDependenciesGraph<T>
dependenciesTree: DependenciesTree<T>
rootDir: ProjectRootDir
@@ -961,6 +963,31 @@ function getLocationFromParentNodeIds<T> (
}
}
function peerNodeIdToPeerId<T extends PartialResolvedPackage> (
alias: string,
peerNodeId: NodeId,
ctx: ResolvePeersContext & {
dedupePeers?: boolean
dependenciesTree: DependenciesTree<T>
}
): PeerId | undefined {
if (typeof peerNodeId === 'string' && peerNodeId.startsWith('link:')) {
return {
name: alias,
version: filenamify(peerNodeId.slice(5), { replacement: '+' }),
}
}
if (ctx.dedupePeers) {
// Use version-only peer identifiers instead of full dep paths.
// This eliminates nested peer suffixes like (foo@1.0.0(bar@2.0.0)).
const peerNode = ctx.dependenciesTree.get(peerNodeId)
if (peerNode) {
return { name: peerNode.resolvedPackage.name, version: peerNode.resolvedPackage.version }
}
}
return ctx.pathsByNodeId.get(peerNodeId)
}
interface ParentRefs {
[name: string]: ParentRef
}

View File

@@ -666,3 +666,255 @@ test('resolve peer dependencies with npm aliases', async () => {
'foo/2.0.0(bar/2.0.0)',
])
})
describe('dedupePeers', () => {
test('uses version-only peer suffixes without nested dep paths', async () => {
// Simulates: react@18, @emotion/react@11(peer: react), @emotion/styled@11(peer: react, @emotion/react)
// Without dedupePeers: @emotion/styled gets suffix (@emotion/react@11(react@18))(react@18) — nested dep paths
// With dedupePeers: @emotion/styled gets suffix (@emotion/react@11.0.0)(react@18.0.0) — version-only, no nesting
const reactPkg = {
name: 'react',
pkgIdWithPatchHash: 'react/18.0.0' as PkgIdWithPatchHash,
version: '18.0.0',
peerDependencies: {} as PeerDependencies,
id: '' as PkgResolutionId,
}
const emotionReactPkg = {
name: '@emotion/react',
pkgIdWithPatchHash: '@emotion/react/11.0.0' as PkgIdWithPatchHash,
version: '11.0.0',
peerDependencies: {
react: { version: '>=16' },
},
id: '' as PkgResolutionId,
}
const emotionStyledPkg = {
name: '@emotion/styled',
pkgIdWithPatchHash: '@emotion/styled/11.0.0' as PkgIdWithPatchHash,
version: '11.0.0',
peerDependencies: {
react: { version: '>=16' },
'@emotion/react': { version: '>=11' },
},
id: '' as PkgResolutionId,
}
const { dependenciesGraph } = await resolvePeers({
allPeerDepNames: new Set(['react', '@emotion/react', '@emotion/styled']),
dedupePeers: true,
projects: [
{
directNodeIdsByAlias: new Map([
['react', '>react/18.0.0>' as NodeId],
['@emotion/react', '>@emotion/react/11.0.0>' as NodeId],
['@emotion/styled', '>@emotion/styled/11.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: '',
},
],
resolvedImporters: {},
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
['>react/18.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: reactPkg,
depth: 0,
}],
['>@emotion/react/11.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: emotionReactPkg,
depth: 0,
}],
['>@emotion/styled/11.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: emotionStyledPkg,
depth: 0,
}],
]),
virtualStoreDir: '',
virtualStoreDirMaxLength: 120,
lockfileDir: '',
peersSuffixMaxLength: 1000,
})
expect(Object.keys(dependenciesGraph).sort()).toStrictEqual([
'@emotion/react/11.0.0(react@18.0.0)',
'@emotion/styled/11.0.0(@emotion/react@11.0.0)(react@18.0.0)',
'react/18.0.0',
])
})
test('transitive peers use version-only suffixes', async () => {
// A depends on B (peer: C). A has no peers itself.
// Without dedupePeers: A gets suffix (c/1.0.0) — full dep path
// With dedupePeers: A gets suffix (c@1.0.0) — version-only
const aPkg = {
name: 'a',
pkgIdWithPatchHash: 'a/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {} as PeerDependencies,
id: '' as PkgResolutionId,
}
const bPkg = {
name: 'b',
pkgIdWithPatchHash: 'b/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {
c: { version: '1.0.0' },
},
id: '' as PkgResolutionId,
}
const cPkg = {
name: 'c',
pkgIdWithPatchHash: 'c/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {} as PeerDependencies,
id: '' as PkgResolutionId,
}
const { dependenciesGraph } = await resolvePeers({
allPeerDepNames: new Set(['c']),
dedupePeers: true,
projects: [
{
directNodeIdsByAlias: new Map([
['a', '>a/1.0.0>' as NodeId],
['c', '>c/1.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: '',
},
],
resolvedImporters: {},
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
['>a/1.0.0>' as NodeId, {
children: {
b: '>a/1.0.0>b/1.0.0>' as NodeId,
},
installable: true,
resolvedPackage: aPkg,
depth: 0,
}],
['>a/1.0.0>b/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: bPkg,
depth: 1,
}],
['>c/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: cPkg,
depth: 0,
}],
]),
virtualStoreDir: '',
virtualStoreDirMaxLength: 120,
lockfileDir: '',
peersSuffixMaxLength: 1000,
})
expect(Object.keys(dependenciesGraph).sort()).toStrictEqual([
'a/1.0.0(c@1.0.0)',
'b/1.0.0(c@1.0.0)',
'c/1.0.0',
])
})
test('multi-project: different peer versions produce different instances', async () => {
// project-a has react@17, project-b has react@18
// Both have plugin@1 (peer: react)
const react17Pkg = {
name: 'react',
pkgIdWithPatchHash: 'react/17.0.0' as PkgIdWithPatchHash,
version: '17.0.0',
peerDependencies: {} as PeerDependencies,
id: '' as PkgResolutionId,
}
const react18Pkg = {
name: 'react',
pkgIdWithPatchHash: 'react/18.0.0' as PkgIdWithPatchHash,
version: '18.0.0',
peerDependencies: {} as PeerDependencies,
id: '' as PkgResolutionId,
}
const pluginPkg = {
name: 'plugin',
pkgIdWithPatchHash: 'plugin/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {
react: { version: '>=16' },
},
id: '' as PkgResolutionId,
}
const { dependenciesGraph, dependenciesByProjectId } = await resolvePeers({
allPeerDepNames: new Set(['react']),
dedupePeers: true,
projects: [
{
directNodeIdsByAlias: new Map([
['react', '>project-a>react/17.0.0>' as NodeId],
['plugin', '>project-a>plugin/1.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: 'project-a',
},
{
directNodeIdsByAlias: new Map([
['react', '>project-b>react/18.0.0>' as NodeId],
['plugin', '>project-b>plugin/1.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: 'project-b',
},
],
resolvedImporters: {},
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
['>project-a>react/17.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: react17Pkg,
depth: 0,
}],
['>project-a>plugin/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: pluginPkg,
depth: 0,
}],
['>project-b>react/18.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: react18Pkg,
depth: 0,
}],
['>project-b>plugin/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: pluginPkg,
depth: 0,
}],
]),
virtualStoreDir: '',
virtualStoreDirMaxLength: 120,
lockfileDir: '',
peersSuffixMaxLength: 1000,
})
// Plugin has two instances — one per react version
expect(Object.keys(dependenciesGraph).sort()).toStrictEqual([
'plugin/1.0.0(react@17.0.0)',
'plugin/1.0.0(react@18.0.0)',
'react/17.0.0',
'react/18.0.0',
])
// Each project gets the correct instance
expect(dependenciesByProjectId['project-a'].get('plugin')).toBe('plugin/1.0.0(react@17.0.0)')
expect(dependenciesByProjectId['project-b'].get('plugin')).toBe('plugin/1.0.0(react@18.0.0)')
})
})

View File

@@ -21,6 +21,7 @@ export const WORKSPACE_STATE_SETTING_KEYS = [
'dedupeDirectDeps',
'dedupeInjectedDeps',
'dedupePeerDependents',
'dedupePeers',
'dev',
'excludeLinksFromLockfile',
'hoistPattern',