ピアの解決方法

pnpmの最も優れた機能の1つは、プロジェクトが、特定バージョンの依存パッケージを常に1セットの依存関係として持つことです。 There is one exception from this rule, though - packages with peer dependencies.

ピア依存関係は、依存関係グラフの上位にインストールされている依存関係として解決されます。同じバージョンの依存パッケージを親として共有するからです。つまり、foo@1.0.0に2つのピア依存関係があるとき (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は2つの依存関係のセットを持つことになります。片方のセットには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がピア依存関係を持つ場合、おそらく依存関係のセットは複数になります。pnpmはピア依存関係の解決結果に対する、それぞれの依存関係のセットを作成します。

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

pnpmはfoo@1.0.0_bar@1.0.0+baz@1.0.0とfoo@1.0.0_bar@1.0.0+baz@1.1.0のそれぞれに、fooのシンボリックリンクを作成します。結果として、Node.jsのモジュールリゾルバは正しいピア依存関係を発見できるようになります。

ピア依存関係を持たないパッケージでも、そのパッケージの依存関係が持つピア依存関係は、依存関係グラフの上位で解決されることになります。すると、推移的依存関係となるパッケージが、プロジェクトの複数の依存関係のセットとして表れることになります。例えば、パッケージa@1.0.0が1つの依存関係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に2回登場しなければなりません。1つはc@1.0.0を解決するため、もう1つは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.