From 606f53e78f2328c5dea30fb43187d95ccc4c9bfa Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Tue, 24 Mar 2026 13:51:17 +0100 Subject: [PATCH] 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) --- .changeset/dedupe-peers-option.md | 10 + config/reader/src/Config.ts | 1 + config/reader/src/configFileKey.ts | 1 + config/reader/src/index.ts | 1 + config/reader/src/types.ts | 1 + installing/commands/src/install.ts | 1 + installing/commands/src/installDeps.ts | 1 + installing/commands/src/recursive.ts | 1 + .../src/getPeerDependencyIssues.ts | 1 + .../src/install/extendInstallOptions.ts | 2 + .../deps-installer/src/install/index.ts | 4 + .../test/install/peerDependencies.ts | 101 +++++++ installing/deps-resolver/src/index.ts | 2 + installing/deps-resolver/src/resolvePeers.ts | 53 +++- installing/deps-resolver/test/resolvePeers.ts | 252 ++++++++++++++++++ .../src/getOutdatedLockfileSetting.ts | 6 + lockfile/types/src/index.ts | 1 + workspace/state/src/types.ts | 1 + 18 files changed, 427 insertions(+), 13 deletions(-) create mode 100644 .changeset/dedupe-peers-option.md diff --git a/.changeset/dedupe-peers-option.md b/.changeset/dedupe-peers-option.md new file mode 100644 index 0000000000..9ba214ce3a --- /dev/null +++ b/.changeset/dedupe-peers-option.md @@ -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). diff --git a/config/reader/src/Config.ts b/config/reader/src/Config.ts index 256bbf20b5..7201942530 100644 --- a/config/reader/src/Config.ts +++ b/config/reader/src/Config.ts @@ -186,6 +186,7 @@ export interface Config extends AuthInfo, OptionsFromRootManifest { legacyDirFiltering?: boolean allowBuilds?: Record dedupePeerDependents?: boolean + dedupePeers?: boolean patchesDir?: string ignoreWorkspaceCycles?: boolean disallowWorkspaceCycles?: boolean diff --git a/config/reader/src/configFileKey.ts b/config/reader/src/configFileKey.ts index 74853d7b64..a934b44536 100644 --- a/config/reader/src/configFileKey.ts +++ b/config/reader/src/configFileKey.ts @@ -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', diff --git a/config/reader/src/index.ts b/config/reader/src/index.ts index 3388bfc699..7e098a7c45 100644 --- a/config/reader/src/index.ts +++ b/config/reader/src/index.ts @@ -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, diff --git a/config/reader/src/types.ts b/config/reader/src/types.ts index 82c1a02b26..ae1c6232c8 100644 --- a/config/reader/src/types.ts +++ b/config/reader/src/types.ts @@ -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], diff --git a/installing/commands/src/install.ts b/installing/commands/src/install.ts index 9c97820c85..c463151a41 100644 --- a/installing/commands/src/install.ts +++ b/installing/commands/src/install.ts @@ -275,6 +275,7 @@ export type InstallCommandOptions = Pick & Pick { resolveSymlinksInInjectedDirs: false, dedupeDirectDeps: true, dedupePeerDependents: true, + dedupePeers: false, resolvePeersFromWorkspaceRoot: true, extendNodePath: true, ignoreWorkspaceCycles: false, diff --git a/installing/deps-installer/src/install/index.ts b/installing/deps-installer/src/install/index.ts index 43fc136dc5..7b4212cff6 100644 --- a/installing/deps-installer/src/install/index.ts +++ b/installing/deps-installer/src/install/index.ts @@ -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, diff --git a/installing/deps-installer/test/install/peerDependencies.ts b/installing/deps-installer/test/install/peerDependencies.ts index f9804bfe9a..56d21e5aa4 100644 --- a/installing/deps-installer/test/install/peerDependencies.ts +++ b/installing/deps-installer/test/install/peerDependencies.ts @@ -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(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() diff --git a/installing/deps-resolver/src/index.ts b/installing/deps-resolver/src/index.ts index 739aaef3e3..fe8022b103 100644 --- a/installing/deps-resolver/src/index.ts +++ b/installing/deps-resolver/src/index.ts @@ -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, diff --git a/installing/deps-resolver/src/resolvePeers.ts b/installing/deps-resolver/src/resolvePeers.ts index 182ef73c17..2d3736e323 100644 --- a/installing/deps-resolver/src/resolvePeers.ts +++ b/installing/deps-resolver/src/resolvePeers.ts @@ -88,6 +88,7 @@ export async function resolvePeers ( lockfileDir: string resolvePeersFromWorkspaceRoot?: boolean dedupePeerDependents?: boolean + dedupePeers?: boolean dedupeInjectedDeps?: boolean resolvedImporters: ResolvedImporters peersSuffixMaxLength: number @@ -136,6 +137,7 @@ export async function resolvePeers ( peersCache, peerDependencyIssues, purePkgs, + dedupePeers: opts.dedupePeers, peersSuffixMaxLength: opts.peersSuffixMaxLength, rootDir, virtualStoreDir: opts.virtualStoreDir, @@ -384,6 +386,7 @@ async function resolvePeersOfNode ( peerDependencyIssues: Pick peersCache: PeersCache purePkgs: Set // 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 ( 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 ( 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 ( virtualStoreDir: string virtualStoreDirMaxLength: number purePkgs: Set + dedupePeers?: boolean depGraph: GenericDependenciesGraph dependenciesTree: DependenciesTree rootDir: ProjectRootDir @@ -964,6 +966,31 @@ function getLocationFromParentNodeIds ( } } +function peerNodeIdToPeerId ( + alias: string, + peerNodeId: NodeId, + ctx: ResolvePeersContext & { + dedupePeers?: boolean + dependenciesTree: DependenciesTree + } +): 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 } diff --git a/installing/deps-resolver/test/resolvePeers.ts b/installing/deps-resolver/test/resolvePeers.ts index 032ed8d86d..fbcba3a813 100644 --- a/installing/deps-resolver/test/resolvePeers.ts +++ b/installing/deps-resolver/test/resolvePeers.ts @@ -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>([ + ['>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>([ + ['>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>([ + ['>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)') + }) +}) diff --git a/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts b/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts index ac7a0cf88b..318d0d317f 100644 --- a/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts +++ b/lockfile/settings-checker/src/getOutdatedLockfileSetting.ts @@ -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 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' } diff --git a/lockfile/types/src/index.ts b/lockfile/types/src/index.ts index f19bbe6929..6965cdfd7e 100644 --- a/lockfile/types/src/index.ts +++ b/lockfile/types/src/index.ts @@ -8,6 +8,7 @@ import type { SpecifierAndResolution } from './lockfileFileTypes.js' export interface LockfileSettings { autoInstallPeers?: boolean + dedupePeers?: boolean excludeLinksFromLockfile?: boolean peersSuffixMaxLength?: number injectWorkspacePackages?: boolean diff --git a/workspace/state/src/types.ts b/workspace/state/src/types.ts index 4b6d08f7ae..910f4c4b12 100644 --- a/workspace/state/src/types.ts +++ b/workspace/state/src/types.ts @@ -21,6 +21,7 @@ export const WORKSPACE_STATE_SETTING_KEYS = [ 'dedupeDirectDeps', 'dedupeInjectedDeps', 'dedupePeerDependents', + 'dedupePeers', 'dev', 'excludeLinksFromLockfile', 'hoistPattern',