mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-07 08:47:11 -04:00
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:
10
.changeset/dedupe-peers-option.md
Normal file
10
.changeset/dedupe-peers-option.md
Normal 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).
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './lockfileFileTypes.js'
|
||||
|
||||
export interface LockfileSettings {
|
||||
autoInstallPeers?: boolean
|
||||
dedupePeers?: boolean
|
||||
excludeLinksFromLockfile?: boolean
|
||||
peersSuffixMaxLength?: number
|
||||
injectWorkspacePackages?: boolean
|
||||
|
||||
@@ -11,6 +11,7 @@ export type ListMissingPeersOptions = Partial<GetContextOptions>
|
||||
& Pick<InstallOptions, 'hooks'
|
||||
| 'catalogs'
|
||||
| 'dedupePeerDependents'
|
||||
| 'dedupePeers'
|
||||
| 'ignoreCompatibilityDb'
|
||||
| 'linkWorkspacePackagesDepth'
|
||||
| 'nodeVersion'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -282,6 +282,7 @@ export type InstallCommandOptions = Pick<Config,
|
||||
| 'dedupeInjectedDeps'
|
||||
| 'dedupeDirectDeps'
|
||||
| 'dedupePeerDependents'
|
||||
| 'dedupePeers'
|
||||
| 'deployAllFiles'
|
||||
| 'depth'
|
||||
| 'dev'
|
||||
|
||||
@@ -57,6 +57,7 @@ export type InstallDepsOptions = Pick<Config,
|
||||
| 'cleanupUnusedCatalogs'
|
||||
| 'cliOptions'
|
||||
| 'dedupePeerDependents'
|
||||
| 'dedupePeers'
|
||||
| 'depth'
|
||||
| 'dev'
|
||||
| 'enableGlobalVirtualStore'
|
||||
|
||||
@@ -57,6 +57,7 @@ export type RecursiveOptions = CreateStoreControllerOptions & Pick<Config,
|
||||
| 'bail'
|
||||
| 'configDependencies'
|
||||
| 'dedupePeerDependents'
|
||||
| 'dedupePeers'
|
||||
| 'depth'
|
||||
| 'globalPnpmfile'
|
||||
| 'hoistPattern'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ export const WORKSPACE_STATE_SETTING_KEYS = [
|
||||
'dedupeDirectDeps',
|
||||
'dedupeInjectedDeps',
|
||||
'dedupePeerDependents',
|
||||
'dedupePeers',
|
||||
'dev',
|
||||
'excludeLinksFromLockfile',
|
||||
'hoistPattern',
|
||||
|
||||
Reference in New Issue
Block a user