Files
pnpm/installing/deps-resolver/test/resolvePeers.ts
Zoltan Kochan 606f53e78f feat: add dedupePeers option to reduce peer dependency duplication (#11071)
* feat: add `dedupePeers` option to reduce peer dependency duplication

When enabled, this option applies two optimizations to peer dependency resolution:

1. Version-only peer suffixes: Uses name@version instead of full dep paths
   (including nested peer suffixes) when building peer identity hashes.
   This eliminates deeply nested suffixes like (foo@1.0.0(bar@2.0.0)).

2. Transitive peer pruning: Only directly declared peer dependencies are
   included in a package's suffix. Transitive peers from children are not
   propagated upward, preventing combinatorial explosion while maintaining
   correct node_modules layout.

The option is scoped per-project: each workspace project defines a peer
resolution environment, and all packages within that project's tree share
that environment. Projects with different peer versions correctly produce
different instances.

Closes #11070

* fix: pass dedupePeers to getOutdatedLockfileSetting and use spread for lockfile write

The frozen install path (used by approve-builds) calls getOutdatedLockfileSetting
but was missing the dedupePeers parameter. This caused a false LOCKFILE_CONFIG_MISMATCH
error because the lockfile had the key written (as undefined/null via YAML serialization)
while the check function received undefined for the config value.

Fix: pass dedupePeers to the settings check call, and use spread syntax to only write
the dedupePeers key to lockfile settings when it's truthy (avoiding undefined keys).

* fix: write dedupePeers to lockfile like other settings

Write the value directly instead of spread syntax, and use the same
!= null guard pattern as autoInstallPeers in the settings checker.

* test: add integration test for dedupePeers in peerDependencies.ts

* fix: only write dedupePeers to lockfile when enabled

When dedupePeers is false (default), don't write it to lockfile settings.
This avoids adding a new key to every lockfile.

* test: simplify dedupePeers test assertions

* test: check exact snapshot keys in dedupePeers integration test

* test: add workspace test for dedupePeers with different peer versions

* fix: keep transitive peers in suffix with version-only IDs

Instead of pruning transitive peers entirely (which prevented per-project
differentiation), keep them but use version-only identifiers. This way:

- Packages like abc-grand-parent still get a peer suffix when different
  projects provide different peer versions (correct per-project isolation)
- But the suffixes use name@version instead of full dep paths, eliminating
  the nested parentheses that cause combinatorial explosion

* refactor: extract peerNodeIdToPeerId helper in resolvePeers

* refactor: simplify peerNodeIdToPeerId return

* fix: pin peer-a dist tag in dedupePeers tests for CI stability

* fix: address review comments

- Register dedupe-peers in config schema, types, and defaults so
  .npmrc/pnpm-workspace.yaml settings are parsed correctly
- Use Boolean() comparison in settings checker so enabling dedupePeers
  on a pre-existing lockfile triggers re-resolution
- Fix changeset text and test names: transitive peers are still
  propagated, just with version-only IDs (no nested dep paths)
2026-03-24 13:51:17 +01:00

928 lines
28 KiB
TypeScript

/// <reference path="../../../__typings__/index.d.ts" />
import type {
PeerDependencyIssuesByProjects,
PkgIdWithPatchHash,
PkgResolutionId,
ProjectRootDir,
} from '@pnpm/types'
import type { NodeId } from '../lib/nextNodeId.js'
import type { DependenciesTreeNode, PeerDependencies } from '../lib/resolveDependencies.js'
import { type PartialResolvedPackage, resolvePeers } from '../lib/resolvePeers.js'
test('resolve peer dependencies of cyclic dependencies', async () => {
const fooPkg = {
name: 'foo',
pkgIdWithPatchHash: 'foo/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {
qar: { version: '1.0.0' },
zoo: { version: '1.0.0' },
},
id: '' as PkgResolutionId,
}
const barPkg = {
name: 'bar',
pkgIdWithPatchHash: 'bar/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {
foo: { version: '1.0.0' },
zoo: { version: '1.0.0' },
},
id: '' as PkgResolutionId,
}
const { dependenciesGraph } = await resolvePeers({
allPeerDepNames: new Set(['foo', 'bar', 'qar', 'zoo']),
projects: [
{
directNodeIdsByAlias: new Map([
['foo', '>foo/1.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: '',
},
],
resolvedImporters: {},
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
['>foo/1.0.0>' as NodeId, {
children: {
bar: '>foo/1.0.0>bar/1.0.0>' as NodeId,
},
installable: true,
resolvedPackage: fooPkg,
depth: 0,
}],
['>foo/1.0.0>bar/1.0.0>' as NodeId, {
children: {
qar: '>foo/1.0.0>bar/1.0.0>qar/1.0.0>' as NodeId,
},
installable: true,
resolvedPackage: barPkg,
depth: 1,
}],
['>foo/1.0.0>bar/1.0.0>qar/1.0.0>' as NodeId, {
children: {
zoo: '>foo/1.0.0>bar/1.0.0>qar/1.0.0>zoo/1.0.0>' as NodeId,
},
installable: true,
resolvedPackage: {
name: 'qar',
pkgIdWithPatchHash: 'qar/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {
foo: { version: '1.0.0' },
bar: { version: '1.0.0' },
},
id: '' as PkgResolutionId,
},
depth: 2,
}],
['>foo/1.0.0>bar/1.0.0>qar/1.0.0>zoo/1.0.0>' as NodeId, {
children: {
foo: '>foo/1.0.0>bar/1.0.0>qar/1.0.0>zoo/1.0.0>foo/1.0.0>' as NodeId,
bar: '>foo/1.0.0>bar/1.0.0>qar/1.0.0>zoo/1.0.0>bar/1.0.0>' as NodeId,
},
installable: true,
resolvedPackage: {
name: 'zoo',
pkgIdWithPatchHash: 'zoo/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {
qar: { version: '1.0.0' },
},
id: '' as PkgResolutionId,
},
depth: 3,
}],
['>foo/1.0.0>bar/1.0.0>qar/1.0.0>zoo/1.0.0>foo/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: fooPkg,
depth: 4,
}],
['>foo/1.0.0>bar/1.0.0>qar/1.0.0>zoo/1.0.0>bar/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: barPkg,
depth: 4,
}],
]),
virtualStoreDir: '',
lockfileDir: '',
virtualStoreDirMaxLength: 120,
peersSuffixMaxLength: 1000,
workspaceProjectIds: new Set(),
})
expect(Object.keys(dependenciesGraph)).toStrictEqual([
'foo/1.0.0',
'bar/1.0.0(foo/1.0.0)',
'qar/1.0.0(bar/1.0.0(foo/1.0.0))(foo/1.0.0)',
'zoo/1.0.0(qar/1.0.0(bar/1.0.0(foo/1.0.0))(foo/1.0.0))',
'foo/1.0.0(qar/1.0.0(bar/1.0.0(foo/1.0.0))(foo/1.0.0))(zoo/1.0.0(qar/1.0.0(bar/1.0.0(foo/1.0.0))(foo/1.0.0)))',
'bar/1.0.0(foo/1.0.0)(zoo/1.0.0(qar/1.0.0(bar/1.0.0(foo/1.0.0))(foo/1.0.0)))',
])
})
test('when a package is referenced twice in the dependencies graph and one of the times it cannot resolve its peers, still try to resolve it in the other occurrence', async () => {
const fooPkg = {
name: 'foo',
pkgIdWithPatchHash: 'foo/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {
qar: { version: '1.0.0' },
},
id: '' as PkgResolutionId,
}
const barPkg = {
name: 'bar',
pkgIdWithPatchHash: 'bar/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {} as PeerDependencies,
id: '' as PkgResolutionId,
}
const zooPkg = {
name: 'zoo',
pkgIdWithPatchHash: 'zoo/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {} as PeerDependencies,
id: '' as PkgResolutionId,
}
const { dependenciesGraph } = await resolvePeers({
allPeerDepNames: new Set(['foo', 'bar', 'qar', 'zoo']),
projects: [
{
directNodeIdsByAlias: new Map([
['zoo', '>zoo/1.0.0>' as NodeId],
['bar', '>bar/1.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: '',
},
],
resolvedImporters: {},
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
['>zoo/1.0.0>' as NodeId, {
children: {
foo: '>zoo/1.0.0>foo/1.0.0>' as NodeId,
},
installable: true,
resolvedPackage: zooPkg,
depth: 0,
}],
['>zoo/1.0.0>foo/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: fooPkg,
depth: 1,
}],
['>bar/1.0.0>' as NodeId, {
children: {
zoo: '>bar/1.0.0>zoo/1.0.0>' as NodeId,
qar: '>bar/1.0.0>qar/1.0.0>' as NodeId,
},
installable: true,
resolvedPackage: barPkg,
depth: 0,
}],
['>bar/1.0.0>zoo/1.0.0>' as NodeId, {
children: {
foo: '>bar/1.0.0>zoo/1.0.0>foo/1.0.0>' as NodeId,
},
installable: true,
resolvedPackage: zooPkg,
depth: 1,
}],
['>bar/1.0.0>zoo/1.0.0>foo/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: fooPkg,
depth: 2,
}],
['>bar/1.0.0>qar/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: {
name: 'qar',
pkgIdWithPatchHash: 'qar/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {},
id: '' as PkgResolutionId,
},
depth: 1,
}],
]),
virtualStoreDir: '',
virtualStoreDirMaxLength: 120,
lockfileDir: '',
peersSuffixMaxLength: 1000,
workspaceProjectIds: new Set(),
})
expect(Object.keys(dependenciesGraph).sort()).toStrictEqual([
'bar/1.0.0',
'foo/1.0.0',
'foo/1.0.0(qar/1.0.0)',
'qar/1.0.0',
'zoo/1.0.0',
'zoo/1.0.0(qar/1.0.0)',
])
})
describe('peer dependency issues', () => {
let peerDependencyIssuesByProjects: PeerDependencyIssuesByProjects
beforeAll(async () => {
const fooPkg = {
name: 'foo',
pkgIdWithPatchHash: 'foo/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {
peer: { version: '1' },
},
id: '' as PkgResolutionId,
}
const fooWithOptionalPeer = {
name: 'foo',
pkgIdWithPatchHash: 'foo/2.0.0' as PkgIdWithPatchHash,
version: '2.0.0',
peerDependencies: {
peer: { version: '1', optional: true },
},
id: '' as PkgResolutionId,
}
const barPkg = {
name: 'bar',
pkgIdWithPatchHash: 'bar/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {
peer: { version: '2' },
},
id: '' as PkgResolutionId,
}
const barWithOptionalPeer = {
name: 'bar',
pkgIdWithPatchHash: 'bar/2.0.0' as PkgIdWithPatchHash,
version: '2.0.0',
peerDependencies: {
peer: { version: '2', optional: true },
},
id: '' as PkgResolutionId,
}
const qarPkg = {
name: 'qar',
pkgIdWithPatchHash: 'qar/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {
peer: { version: '^2.2.0' },
},
id: '' as PkgResolutionId,
}
peerDependencyIssuesByProjects = (await resolvePeers({
allPeerDepNames: new Set(),
projects: [
{
directNodeIdsByAlias: new Map([
['foo', '>project1>foo/1.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: 'project1' as PkgResolutionId,
},
{
directNodeIdsByAlias: new Map([
['bar', '>project2>bar/1.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: 'project2' as PkgResolutionId,
},
{
directNodeIdsByAlias: new Map([
['foo', '>project3>foo/1.0.0>' as NodeId],
['bar', '>project3>bar/1.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: 'project3' as PkgResolutionId,
},
{
directNodeIdsByAlias: new Map([
['bar', '>project4>bar/1.0.0>' as NodeId],
['qar', '>project4>qar/1.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: 'project4' as PkgResolutionId,
},
{
directNodeIdsByAlias: new Map([
['foo', '>project5>foo/1.0.0>' as NodeId],
['bar', '>project5>bar/2.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: 'project5' as PkgResolutionId,
},
{
directNodeIdsByAlias: new Map([
['foo', '>project6>foo/2.0.0>' as NodeId],
['bar', '>project6>bar/2.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: 'project6' as PkgResolutionId,
},
],
resolvedImporters: {},
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
['>project1>foo/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: fooPkg,
depth: 0,
}],
['>project2>bar/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: barPkg,
depth: 0,
}],
['>project3>foo/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: fooPkg,
depth: 0,
}],
['>project3>bar/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: barPkg,
depth: 0,
}],
['>project4>bar/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: barPkg,
depth: 0,
}],
['>project4>qar/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: qarPkg,
depth: 0,
}],
['>project5>foo/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: fooPkg,
depth: 0,
}],
['>project5>bar/2.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: barWithOptionalPeer,
depth: 0,
}],
['>project6>foo/2.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: fooWithOptionalPeer,
depth: 0,
}],
['>project6>bar/2.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: barWithOptionalPeer,
depth: 0,
}],
]),
virtualStoreDir: '',
virtualStoreDirMaxLength: 120,
lockfileDir: '',
peersSuffixMaxLength: 1000,
workspaceProjectIds: new Set(),
})).peerDependencyIssuesByProjects
})
it('should find peer dependency conflicts', () => {
expect(peerDependencyIssuesByProjects['project3'].conflicts).toStrictEqual(['peer'])
})
it('should find peer dependency conflicts when the peer is an optional peer of one of the dependencies', () => {
expect(peerDependencyIssuesByProjects['project5'].conflicts).toStrictEqual(['peer'])
})
it('should ignore conflicts between missing optional peer dependencies', () => {
expect(peerDependencyIssuesByProjects['project6'].conflicts).toStrictEqual([])
})
it('should pick the single wanted peer dependency range', () => {
expect(peerDependencyIssuesByProjects['project1'].intersections)
.toStrictEqual({ peer: '1' })
expect(peerDependencyIssuesByProjects['project2'].intersections)
.toStrictEqual({ peer: '2' })
})
it('should return the intersection of two compatible ranges', () => {
expect(peerDependencyIssuesByProjects['project4'].intersections)
.toStrictEqual({ peer: '>=2.2.0 <3.0.0-0' })
})
})
describe('unmet peer dependency issues', () => {
let peerDependencyIssuesByProjects: PeerDependencyIssuesByProjects
beforeAll(async () => {
peerDependencyIssuesByProjects = (await resolvePeers({
allPeerDepNames: new Set(),
projects: [
{
directNodeIdsByAlias: new Map([
['foo', '>project1>foo/1.0.0>' as NodeId],
['peer1', '>project1>peer1/1.0.0-rc.0>' as NodeId],
['peer2', '>project1>peer2/1.1.0-rc.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: 'project1' as PkgResolutionId,
},
],
resolvedImporters: {},
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
['>project1>foo/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: {
name: 'foo',
version: '1.0.0',
pkgIdWithPatchHash: 'foo/1.0.0' as PkgIdWithPatchHash,
peerDependencies: {
peer1: { version: '*' },
peer2: { version: '>=1' },
},
id: '' as PkgResolutionId,
},
depth: 0,
}],
['>project1>peer1/1.0.0-rc.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: {
name: 'peer1',
version: '1.0.0-rc.0',
pkgIdWithPatchHash: 'peer/1.0.0-rc.0' as PkgIdWithPatchHash,
peerDependencies: {},
id: '' as PkgResolutionId,
},
depth: 0,
}],
['>project1>peer2/1.1.0-rc.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: {
name: 'peer2',
version: '1.1.0-rc.0',
pkgIdWithPatchHash: 'peer/1.1.0-rc.0' as PkgIdWithPatchHash,
peerDependencies: {},
id: '' as PkgResolutionId,
},
depth: 0,
}],
]),
virtualStoreDir: '',
virtualStoreDirMaxLength: 120,
lockfileDir: '',
peersSuffixMaxLength: 1000,
workspaceProjectIds: new Set(),
})).peerDependencyIssuesByProjects
})
it('should not warn when the found package has prerelease version and the wanted range is *', () => {
expect(peerDependencyIssuesByProjects).not.toHaveProperty(['project1', 'bad', 'peer1'])
})
it('should not warn when the found package is a prerelease version but satisfies the range', () => {
expect(peerDependencyIssuesByProjects).not.toHaveProperty(['project1', 'bad', 'peer2'])
})
})
describe('unmet peer dependency issue resolved from subdependency', () => {
let peerDependencyIssuesByProjects: PeerDependencyIssuesByProjects
beforeAll(async () => {
peerDependencyIssuesByProjects = (await resolvePeers({
allPeerDepNames: new Set(['dep']),
projects: [
{
directNodeIdsByAlias: new Map([
['foo', '>project>foo/1.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: 'project' as PkgResolutionId,
},
],
resolvedImporters: {},
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
['>project>foo/1.0.0>' as NodeId, {
children: {
dep: '>project>foo/1.0.0>dep/1.0.0>' as NodeId,
bar: '>project>foo/1.0.0>bar/1.0.0>' as NodeId,
},
installable: true,
resolvedPackage: {
name: 'foo',
pkgIdWithPatchHash: 'foo/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {},
id: '' as PkgResolutionId,
},
depth: 0,
}],
['>project>foo/1.0.0>dep/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: {
name: 'dep',
pkgIdWithPatchHash: 'dep/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {},
id: '' as PkgResolutionId,
},
depth: 1,
}],
['>project>foo/1.0.0>bar/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: {
name: 'bar',
pkgIdWithPatchHash: 'bar/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {
dep: { version: '10' },
},
id: '' as PkgResolutionId,
},
depth: 1,
}],
]),
virtualStoreDir: '',
virtualStoreDirMaxLength: 120,
lockfileDir: '',
peersSuffixMaxLength: 1000,
workspaceProjectIds: new Set(),
})).peerDependencyIssuesByProjects
})
it('should return from where the bad peer dependency is resolved', () => {
expect(peerDependencyIssuesByProjects.project.bad.dep[0].resolvedFrom).toStrictEqual([{ name: 'foo', version: '1.0.0' }])
})
})
test('resolve peer dependencies with npm aliases', async () => {
const fooPkg = {
name: 'foo',
pkgIdWithPatchHash: 'foo/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {
bar: { version: '1.0.0' },
},
id: '' as PkgResolutionId,
}
const fooAliasPkg = {
name: 'foo',
pkgIdWithPatchHash: 'foo/2.0.0' as PkgIdWithPatchHash,
version: '2.0.0',
peerDependencies: {
bar: { version: '2.0.0' },
},
id: '' as PkgResolutionId,
}
const barPkg = {
name: 'bar',
pkgIdWithPatchHash: 'bar/1.0.0' as PkgIdWithPatchHash,
version: '1.0.0',
peerDependencies: {},
id: '' as PkgResolutionId,
}
const barAliasPkg = {
name: 'bar',
pkgIdWithPatchHash: 'bar/2.0.0' as PkgIdWithPatchHash,
version: '2.0.0',
peerDependencies: {},
id: '' as PkgResolutionId,
}
const { dependenciesGraph } = await resolvePeers({
allPeerDepNames: new Set(['bar']),
projects: [
{
directNodeIdsByAlias: new Map([
['foo', '>foo/1.0.0>' as NodeId],
['bar', '>bar/1.0.0>' as NodeId],
['foo-next', '>foo/2.0.0>' as NodeId],
['bar-next', '>bar/2.0.0>' as NodeId],
]),
topParents: [],
rootDir: '' as ProjectRootDir,
id: '' as PkgResolutionId,
},
],
resolvedImporters: {},
dependenciesTree: new Map<NodeId, DependenciesTreeNode<PartialResolvedPackage>>([
['>foo/1.0.0>' as NodeId, {
children: {
bar: '>foo/1.0.0>bar/1.0.0>' as NodeId,
},
installable: true,
resolvedPackage: fooPkg,
depth: 0,
}],
['>foo/1.0.0>bar/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: barPkg,
depth: 1,
}],
['>foo/2.0.0>' as NodeId, {
children: {
bar: '>foo/2.0.0>bar/2.0.0>' as NodeId,
},
installable: true,
resolvedPackage: fooAliasPkg,
depth: 0,
}],
['>foo/2.0.0>bar/2.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: barAliasPkg,
depth: 1,
}],
['>bar/1.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: barPkg,
depth: 0,
}],
['>bar/2.0.0>' as NodeId, {
children: {},
installable: true,
resolvedPackage: barAliasPkg,
depth: 0,
}],
]),
virtualStoreDir: '',
virtualStoreDirMaxLength: 120,
lockfileDir: '',
peersSuffixMaxLength: 1000,
workspaceProjectIds: new Set(),
})
expect(Object.keys(dependenciesGraph).sort()).toStrictEqual([
'bar/1.0.0',
'bar/2.0.0',
'foo/1.0.0(bar/1.0.0)',
'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,
workspaceProjectIds: new Set(),
})
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,
workspaceProjectIds: new Set(),
})
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,
workspaceProjectIds: new Set(),
})
// 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)')
})
})