Як вирішуються конфлікти прямих залежностей
Однією з найкращих особливостей pnpm є те, що в одному проєкті певна версія пакунка завжди матиме один набір залежностей. З цього правила є один виняток — пакунки з прямими залежностями.
Прямі залежності вирішуються із залежностей, встановлених вище на графі залежностей, оскільки вони мають ту саму версію, що й їхні батьки. Це означає, що якщо foo@1.0.0 має дві прямі залежності (bar@^1 і baz@^1), то він може мати декілька різних наборів залежностей в одному проєкті.
- foo-parent-1
- bar@1.0.0
- baz@1.0.0
- foo@1.0.0
- foo-parent-2
- bar@1.0.0
- baz@1.1.0
- foo@1.0.0
У наведеному вище прикладі foo@1.0.0 встановлено для foo-parent-1 і foo-parent-2. Обидва пакунки також мають bar та baz, але вони залежать від різних версій baz. Як наслідок, foo@1.0.0 має два різних набори залежностей: один з baz@1.0.0, а інший з baz@1.1.0. Для підтримки цих варіантів використання pnpm має створити жорстке посилання foo@1.0.0 стільки разів, скільки існує різних наборів залежностей.
Зазвичай, якщо пакунок не має прямих залежностей, він привʼязується до теки node_modules жорстким посиланням поруч із символічними посиланнями його залежностей, наприклад, так:
node_modules
└── .pnpm
├── foo@1.0.0
│ └── node_modules
│ ├── foo
│ ├── qux -> ../../qux@1.0.0/node_modules/qux
│ └── plugh -> ../../plugh@1.0.0/node_modules/plugh
├── qux@1.0.0
├── plugh@1.0.0
Однак, якщо foo має прямі залежності, для нього може існувати декілька наборів залежностей, тому ми створюємо різні набори для різних резолюцій прямих залежностей:
node_modules
└── .pnpm
├── foo@1.0.0_bar@1.0.0+baz@1.0.0
│ └── node_modules
│ ├── foo
│ ├── bar -> ../../bar@1.0.0/node_modules/bar
│ ├── baz -> ../../baz@1.0.0/node_modules/baz
│ ├── qux -> ../../qux@1.0.0/node_modules/qux
│ └── plugh -> ../../plugh@1.0.0/node_modules/plugh
├── foo@1.0.0_bar@1.0.0+baz@1.1.0
│ └── node_modules
│ ├── foo
│ ├── bar -> ../../bar@1.0.0/node_modules/bar
│ ├── baz -> ../../baz@1.1.0/node_modules/baz
│ ├── qux -> ../../qux@1.0.0/node_modules/qux
│ └── plugh -> ../../plugh@1.0.0/node_modules/plugh
├── bar@1.0.0
├── baz@1.0.0
├── baz@1.1.0
├── qux@1.0.0
├── plugh@1.0.0
Ми створюємо символічні посилання або на foo, що знаходиться всередині foo@1.0.0_bar@1.0.0+baz@1.0.0, або на той, що знаходиться в foo@1.0.0_bar@1.0.0+baz@1.1.0.
Як наслідок, модуль Node.js знайде правильні прямі залежності.
Якщо пакунок не має залежностей від однорангових пакунків, але має залежності від однорангових пакунків, що розташовані вище на графі, то цей транзитивний пакунок може зʼявитися у проєкті з різними наборами залежностей. Наприклад, є пакунок a@1.0.0 з єдиною залежністю b@1.0.0. b@1.0.0 має пряму залежність c@^1. a@1.0.0 ніколи не розпізнає прямих залежностей b@1.0.0, тому він також стає залежним від залежностей b@1.0.0.
Ось як ця структура буде виглядати у node_modules. В цьому прикладі a@1.0.0 має зʼявитися двічі у node_modules проєкту — один раз за допомогою c@1.0.0, а другий — за допомогою c@1.1.0.
node_modules
└── .pnpm
├── a@1.0.0_c@1.0.0
│ └── node_modules
│ ├── a
│ └── b -> ../../b@1.0.0_c@1.0.0/node_modules/b
├── a@1.0.0_c@1.1.0
│ └── node_modules
│ ├── a
│ └── b -> ../../b@1.0.0_c@1.1.0/node_modules/b
├── b@1.0.0_c@1.0.0
│ └── node_modules
│ ├── b
│ └── c -> ../../c@1.0.0/node_modules/c
├── b@1.0.0_c@1.1.0
│ └── node_modules
│ ├── b
│ └── c -> ../../c@1.1.0/node_modules/c
├── c@1.0.0
├── c@1.1.0
Cyclic dependencies
Added in: v12.0.0-rc.5 (pnpm v12 only)
Packages may depend on each other in a cycle: a depends on b, and b — directly, or through further packages — depends back on a. When a package inside such a cycle has peer dependencies, there is no single point "higher in the graph" to resolve them from, because entering the cycle at a and entering it at b lead to different parents.
pnpm cuts every cycle at a fixed place. The packages that form a cycle are ordered by their package IDs, and the edges that close it are cut at the same point no matter where the installation walks into it. The cut edge is still a real dependency — it is resolved against an occurrence of its target taken at the project level — but the walk never travels around the cycle, so a package inside a cycle is no longer resolved once per path that reaches it. Peer dependencies of packages inside a cycle still resolve to the nearest match along that order.
Because the cut no longer depends on the path the installation takes, the lockfile is a function of the dependency graph alone. Repeated installs, reordered packages globs in pnpm-workspace.yaml, and reordered entries in package.json all produce a byte-identical lockfile. Projects with cycle-heavy dependency graphs also end up with a smaller lockfile, since packages inside a cycle no longer get a separate peer variant per path that reaches them.
Existing lockfiles keep working: --frozen-lockfile installs consume them unchanged, and installs that skip resolution leave them untouched. The first install that re-resolves — after a dependency change, for instance — re-keys the peer variants of cyclic packages once, which shows up as a one-time lockfile diff.
In pnpm v11, where the cut depends on the order the graph is walked, the same dependencies can produce different lockfiles depending on the order projects and dependencies are listed in.