YASD-TECH
YASD TECH
# JavaScript

peerDependencies の仕組み(自動で入る版の選ばれ方・範囲外の版・pnpm 10 の不具合)

投稿日:2026/11/6

更新日:2026/11/6

ttitleImage

peerDependencies の仕組み(自動で入る版の選ばれ方・範囲外の版・pnpm 10 の不具合)

vitest を 2 系から 5 系へ上げたら、テストが起動しなくなった。原因は、vitest の peer である vite の版を pnpm が範囲を確かめずに選び、古い vite 5 が残ったことだった。
peerDependencies とは何か、パッケージマネージャーがどの版をどう入れるのかを、手元で試した結果つきで整理する。

結論

  • peerDependencies は「このパッケージは X を使うが、X は利用する側のプロジェクトと同じ1つを使う」という宣言。実行時には必ず要るが、いまは package.json に自分で書かなくてもパッケージマネージャーが自動で入れる(npm 7 以降、pnpm 8 以降)
  • 自動で入れるときは、peer の範囲に合う版を選ぶ。範囲外の版がすでにプロジェクトにあると、2つ目は入れずにそれを使う。pnpm は警告だけ出して続け、npm はエラーで止まる
  • pnpm 10 系には、ロックファイルにある版を使い回すときに peer の範囲を確かめない不具合がある(11.12.0 で修正)。依存が dependencies から peerDependencies に移る更新で、範囲外の古い版がそのまま残る
  • 警告では install が止まらないので、テストを実行するまで気づけない。pnpm を 11.12.0 以上にするか、package.json にその peer を直接書いて版を自分で決める

検証環境: Node.js 22.23.0、pnpm 10.20.0 / 11.12.0、npm 10.9.8(2026-11-06 時点)


dependencies と peerDependencies の違い

dependencies peerDependencies
意味 自分専用の部品として使う 利用する側と同じ1つを共有して使う
版が合わないとき 自分用に別の版をもう1つ入れて解決する 2つ目は入れない。警告かエラーで知らせるだけ
例 vitest 2 系 → vite react-dom → react、vitest 5 系 → vite

共有が必要な代表例は React。react-dom とアプリが別々の react を読み込むと、フックが正しく動かない。だから react-dom は react を自分の部品として抱えず、peer として「利用する側の react を使う」と宣言している。

flowchart LR
    subgraph Deps["dependencies:それぞれが自分用を持つ"]
        App1["アプリ"] --> LibA["ライブラリA"]
        App1 --> LibB["ライブラリB"]
        LibA --> X1["X 1.0(A 専用)"]
        LibB --> X2["X 2.0(B 専用)"]
    end
    subgraph Peer["peerDependencies:1つを共有する"]
        App2["アプリ"] --> React["react(1つだけ)"]
        App2 --> ReactDOM["react-dom"]
        ReactDOM -.->|peer| React
    end

書き方

vitest 5.0.5 の宣言(抜粋)。npm view vitest@5.0.5 peerDependencies peerDependenciesMeta で確かめられる。

json
{
  "name": "vitest",
  "version": "5.0.5",
  "peerDependencies": {
    "vite": "^6.4.0 || ^7.0.0 || ^8.0.0",
    "jsdom": "*",
    "@vitest/ui": "5.0.5"
  },
  "peerDependenciesMeta": {
    "vite": { "optional": false },
    "jsdom": { "optional": true },
    "@vitest/ui": { "optional": true }
  }
}
  • vite は必須の peer。無ければ vitest は動かない
  • peerDependenciesMeta で optional: true にしたものは任意の peer。DOM を使うテストを書く人だけが jsdom を入れる、という使い方

よくある思い込み:「peer はプロジェクトで入れなければならない」

半分だけ正しい。

思い込み 実際
インストールされていなければならない 正しい。vitest は実行時に vite を読み込むので、無ければ動かない
プロジェクトの package.json に書かなければならない いまは違う。必須の peer はパッケージマネージャーが自動で入れる

peer の扱いの移り変わり

パッケージマネージャー 必須の peer が無いとき 範囲外の版があるとき
npm 3〜6 入れない。利用者が自分で package.json に書く peer を無視して依存を組み立てる
npm 7 以降 範囲に合う版を自動で入れる エラーで止まる(ERESOLVE)
pnpm 8 以降 範囲に合う版を自動で入れる(autoInstallPeers の既定が true) 警告だけ出して、その版を使う

npm 7 以降でも --legacy-peer-deps を付けると npm 3〜6 と同じ扱いに戻る。npm 10.9.8 で react-dom@19.2.0 だけを書いて install すると react も入り、--legacy-peer-deps を付けると react は入らなかった。


自動で入る版の決まり方(検証)

devDependencies に vitest: 5.0.5 を書いた最小のプロジェクトで試した。

プロジェクトの状態 pnpm 10.20.0 npm 10.9.8
vite を書いていない 範囲に合う vite 8.3.6 が入る —
package.json に範囲外の vite: 5.4.21 を書いた 2つ目は入れず 5.4.21 を使う。install は成功し、警告だけ出る install がエラーで止まる
任意の peer(jsdom・happy-dom) 入らない —

pnpm の警告(install は成功する):

text
 WARN  Issues with peer dependencies found
.
└─┬ vitest 5.0.5
  ├── ✕ unmet peer vite@"^6.4.0 || ^7.0.0 || ^8.0.0": found 5.4.21
  └─┬ @vitest/mocker 5.0.5
    └── ✕ unmet peer vite@"^6.0.0 || ^7.0.0 || ^8.0.0": found 5.4.21

npm のエラー(抜粋。install は止まる):

text
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error Found: vite@5.4.21
npm error Could not resolve dependency:
npm error peer vite@"^6.4.0 || ^7.0.0 || ^8.0.0" from vitest@5.0.5

まとめると次の流れになる。

flowchart TD
    Start["peer の X を解決する"] --> Has{"プロジェクトに X がある?"}
    Has -->|ある| InRange{"X は peer の範囲に合う?"}
    InRange -->|合う| Use["その X を使う"]
    InRange -->|合わない| Keep["2つ目は入れず、その X を使う<br/>pnpm: 警告だけ / npm: エラーで止まる"]
    Has -->|無い| Required{"必須の peer?"}
    Required -->|必須| Install["範囲に合う版を自動で入れる"]
    Required -->|任意| Skip["入れない<br/>使う人が自分で入れる"]

peer は「1つを共有する」ための仕組みなので、範囲外でも2つ目は入れない。合わないことを知らせるところまでがパッケージマネージャーの仕事で、どの版にするかは利用する側が決める。


事例:vitest 2 → 5 の更新で vite 5 が残った

あるリポジトリで、Dependabot が vitest を 2.1.9 から 5.0.5 に上げた。package.json に vite は書いておらず、pnpm は 10 系に固定していた。更新後、テストが起動しなくなった。

vite の置き場所が変わっていた

vitest vite の書かれ方
2.1.9 dependencies: ^5.0.0
4.1.0 dependencies と peerDependencies の両方: ^6.0.0 || ^7.0.0 || ^8.0.0-0
5.0.0 以降 peerDependencies だけ: ^6.4.0 || ^7.0.0 || ^8.0.0

2.1.9 のころは vitest 専用の部品として vite 5.4.21 が入り、ロックファイルに記録されていた。5.0.5 では vite が peer になり、どの版を入れるかを pnpm が選ぶことになった。

再現

bash
# vitest 2.1.9 で一度 install して、ロックファイルに vite 5.4.21 を残す
echo '{"private":true,"devDependencies":{"vitest":"2.1.9"}}' > package.json
npx pnpm@10.20.0 install

# vitest だけ 5.0.5 に上げて install し直す
echo '{"private":true,"devDependencies":{"vitest":"5.0.5"}}' > package.json
npx pnpm@10.20.0 install
grep -E "^  vitest@5" pnpm-lock.yaml
pnpm 更新後のロックファイル install テストの実行
10.20.0 vitest@5.0.5(vite@5.4.21)(範囲外のまま) 成功(警告だけ) 起動前に落ちる
11.12.0 vitest@5.0.5(vite@8.3.6) 成功(警告なし) 通る

10.20.0 のときのテストの実行結果(パスは省略):

text
⎯⎯⎯⎯⎯⎯⎯ Startup Error ⎯⎯⎯⎯⎯⎯⎯⎯
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './module-runner' is not defined by "exports" in .../node_modules/vite/package.json

vitest 5 は vite/module-runner を読み込む。これは vite 6.0.0 から公開されていて、5.4.21 には無いので起動前に落ちる。

なぜ範囲外の版が選ばれたか

修正の PR(pnpm/pnpm#12932)の説明によると、pnpm は自動で入れる peer の版を決めるとき、まずロックファイルやワークスペースにすでにある版(候補)を使い回そうとする。このとき:

  • 修正前: 候補を peer の範囲と照らし合わせるのは、範囲がちょうど1つの版(@vitest/ui: "5.0.5" のような指定)のときだけだった。^6.4.0 || ... のような普通の範囲では、範囲を見ずに候補の中で一番新しい版を選んでいた。候補が 5.4.21 しか無かったので 5.4.21 が選ばれ、警告だけ出してロックファイルに記録された
  • 修正後(11.12.0): 候補のうち範囲に合う一番新しい版を選ぶ。範囲に合う候補が無ければ、範囲どおりにレジストリから選ぶ
flowchart TD
    Peer["vitest 5.0.5 の peer<br/>vite 6.4.0 以上の 6・7・8 系"] --> Cand["候補:ロックファイルにある vite 5.4.21"]
    Cand --> Ver{"pnpm の版"}
    Ver -->|"10.20.0"| Old["範囲を見ずに候補の一番新しい版を選ぶ<br/>→ 5.4.21(警告だけ出して記録)"]
    Ver -->|"11.12.0"| New["範囲に合う候補が無い<br/>→ レジストリから 8.3.6 を選ぶ"]
  • PR の説明は、複数のプロジェクトを持つワークスペースの例で書かれている。10.20.0 では、プロジェクトが1つだけでも再現した
  • 修正が入ったのは 11.12.0(リリースノートに記載あり)。10 系のリリースノート(10.34.9 まで)には、同じ修正の記載は無い

対策

方法 効果 注意
pnpm を 11.12.0 以上に上げる 範囲に合う版を選ぶようになる(再現手順で確認済み) 11.0.0 から .npmrc で読む設定が認証・レジストリなどに限られ、package.json の pnpm フィールドは読まなくなった。ほかの設定は pnpm-workspace.yaml へ移す
package.json に vite を直接書く 版を自分で決められる。pnpm の選び方に左右されない 範囲外の版を書けば、やはり警告だけで通る。vitest を上げるときは vite も一緒に上げる
strictPeerDependencies を有効にする 範囲外の peer があると install が失敗する 後述のとおり、失敗するのは最初の1回だけ
依存の更新 PR でテストを実行する 起動しないことに気づける 最後の安全網。今回もこれで気づいた

ハマりどころ

警告では何も止まらない

pnpm の unmet peer は警告なので、install もロックファイルの更新も成功する。何が選ばれたかはロックファイルで分かる。vitest@5.0.5(vite@5.4.21) のように、括弧の中に peer として使われた版が記録される。

strictPeerDependencies は最初の1回しか落ちない

pnpm 10.20.0 の再現手順で、2回目の install に --config.strict-peer-dependencies=true を付けて試した。

text
# 1回目:終了コード 1
 ERR_PNPM_PEER_DEP_ISSUES  Unmet peer dependencies
# ただし、ロックファイルは vitest@5.0.5(vite@5.4.21) に書き換わったまま残る

# 2回目:終了コード 0
Lockfile is up to date, resolution step is skipped

--frozen-lockfile を付けた install も終了コード 0 だった。ロックファイルが最新なら版の解決そのものを飛ばすので、範囲外の peer が記録されていても検査されない。

pnpm config get auto-install-peers は undefined と出る

10.20.0 で何も設定していないと undefined と表示されるが、既定で有効。実際に効いている値は、ロックファイル冒頭の settings.autoInstallPeers: true で分かる。

dependencies から peerDependencies に移る更新は要注意

古い版が「自分専用の部品」としてロックファイルに残っていて、それが peer の候補として使い回される。依存の更新でこの移動があるときは、更新後のロックファイルで peer の版(括弧の中)を確かめる。

ロックファイルが無い状態からだと再現しない

ロックファイルが無い状態から入れると vite 8.3.6 が入る(検証の表の1行目)。不具合はロックファイルにある版を使い回すときにだけ起きる。

任意の peer は自動では入らない

jsdom を使うテストにするなら、自分で入れる。pnpm のドキュメントによると、ワークスペースのどこかに範囲に合う版がすでにあれば、それが使われる。


参考

Index

  • peerDependencies の仕組み(自動で入る版の選ばれ方・範囲外の版・pnpm 10 の不具合)
  • 結論
  • dependencies と peerDependencies の違い
  • 書き方
  • よくある思い込み:「peer はプロジェクトで入れなければならない」
  • peer の扱いの移り変わり
  • 自動で入る版の決まり方(検証)
  • 事例:vitest 2 → 5 の更新で vite 5 が残った
  • vite の置き場所が変わっていた
  • 再現
  • なぜ範囲外の版が選ばれたか
  • 対策
  • ハマりどころ
  • 警告では何も止まらない
  • strictPeerDependencies は最初の1回しか落ちない
  • pnpm config get auto-install-peers は undefined と出る
  • dependencies から peerDependencies に移る更新は要注意
  • ロックファイルが無い状態からだと再現しない
  • 任意の peer は自動では入らない
  • 参考