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)
This commit is contained in:
Zoltan Kochan
2026-03-24 13:51:17 +01:00
committed by GitHub
parent 833955341d
commit 606f53e78f
18 changed files with 427 additions and 13 deletions

View File

@@ -0,0 +1,10 @@
---
"@pnpm/installing.deps-resolver": minor
"@pnpm/installing.deps-installer": minor
"@pnpm/lockfile.types": minor
"@pnpm/lockfile.settings-checker": minor
"@pnpm/config.reader": 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

@@ -186,6 +186,7 @@ export interface Config extends AuthInfo, OptionsFromRootManifest {
legacyDirFiltering?: boolean
allowBuilds?: Record<string, boolean | string>
dedupePeerDependents?: boolean
dedupePeers?: boolean
patchesDir?: string
ignoreWorkspaceCycles?: boolean
disallowWorkspaceCycles?: boolean

View File

@@ -63,6 +63,7 @@ export const excludedPnpmKeys = [
'merge-git-branch-lockfiles-branch-pattern',
'deploy-all-files',
'dedupe-peer-dependents',
'dedupe-peers',
'dedupe-direct-deps',
'dedupe-injected-deps',
'dev',

View File

@@ -151,6 +151,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 pnpmTypes = {
'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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -470,6 +470,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,
@@ -494,6 +495,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,
@@ -506,6 +508,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,
@@ -1267,6 +1270,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

@@ -2059,6 +2059,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 = readYamlFileSync<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

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

View File

@@ -88,6 +88,7 @@ export async function resolvePeers<T extends PartialResolvedPackage> (
lockfileDir: string
resolvePeersFromWorkspaceRoot?: boolean
dedupePeerDependents?: boolean
dedupePeers?: boolean
dedupeInjectedDeps?: boolean
resolvedImporters: ResolvedImporters
peersSuffixMaxLength: number
@@ -136,6 +137,7 @@ export async function resolvePeers<T extends PartialResolvedPackage> (
peersCache,
peerDependencyIssues,
purePkgs,
dedupePeers: opts.dedupePeers,
peersSuffixMaxLength: opts.peersSuffixMaxLength,
rootDir,
virtualStoreDir: opts.virtualStoreDir,
@@ -384,6 +386,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
@@ -525,20 +528,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)
@@ -578,6 +573,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
})
),
@@ -785,6 +786,7 @@ async function resolvePeersOfChildren<T extends PartialResolvedPackage> (
virtualStoreDir: string
virtualStoreDirMaxLength: number
purePkgs: Set<PkgIdWithPatchHash>
dedupePeers?: boolean
depGraph: GenericDependenciesGraph<T>
dependenciesTree: DependenciesTree<T>
rootDir: ProjectRootDir
@@ -964,6 +966,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

@@ -673,3 +673,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,
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)')
})
})

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, string>
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 @@ import type { SpecifierAndResolution } from './lockfileFileTypes.js'
export interface LockfileSettings {
autoInstallPeers?: boolean
dedupePeers?: boolean
excludeLinksFromLockfile?: boolean
peersSuffixMaxLength?: number
injectWorkspacePackages?: boolean

View File

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