ブログ一覧

pnpm monorepo で共有パッケージを複数バージョンの peer に対応させる(injected dependencies)

pnpm の workspace で、複数のアプリが共有パッケージ(内製プラグインや UI ライブラリなど)を workspace:* で参照する構成はよくあります。ところがその共有パッケージが reactastro のようなフレームワークを peer dependency に持ち、各アプリが異なるメジャーバージョンを使っていると、ビルドが壊れます。

本記事では、その原因と、pnpm の injected dependencies による解決方法をまとめます。

前提: 本記事は pnpm 10 系(v10.33 で確認)を想定しています。dependenciesMeta.injected は現行の pnpm でサポートされている機能です。

症状

共有パッケージ @myorg/shared-plugin が peer に some-framework を持ち、アプリ A は some-framework@2、アプリ B は some-framework@1 を使っているとします。すると片方のビルドで「フレームワークの内部モジュールが解決できない」エラーが出ます。

[vite]: Rollup failed to resolve import "virtual:some-framework/internal"
  from ".../some-framework@2.0.0/.../internal.mjs"

アプリ B(v1)のビルドなのに、共有パッケージが v2 の内部モジュールを引き込んでいるのがポイントです。

なぜ壊れるのか

workspace:* の依存は、pnpm がシンボリックリンクでつなぎます。共有パッケージの実体はモノレポ内に1 つしかありません。

そのため共有パッケージの peer(some-framework)も1 つのバージョンに解決され、全消費者で共有されます。アプリ A(v2)とアプリ B(v1)が同じ共有パッケージをリンクすると、pnpm は peer をどちらか一方(多くは高い方)に解決し、もう片方のアプリが壊れます。

解決策:injected: true

pnpm には、まさにこのための機能があります。消費側package.jsondependenciesMeta.<pkg>.injectedtrue にします(公式ドキュメント: dependenciesMeta.injected)。

// apps/app-b/package.json
{
  "dependencies": {
    "@myorg/shared-plugin": "workspace:*"
  },
  "dependenciesMeta": {
    "@myorg/shared-plugin": { "injected": true }
  }
}

injected: true にすると、pnpm は共有パッケージをシンボリックリンクではなく各アプリへハードコピーし、そのアプリの依存グラフで peer を解決します。

  • アプリ A のコピー → some-framework@2 で解決
  • アプリ B のコピー → some-framework@1 で解決

共有パッケージの実体は 1 つ(単一のソース)のまま、各アプリが独立したバージョンで解決できます。修正は共有パッケージ 1 箇所に入れれば全アプリへ伝播します。

共有パッケージ側の peer は、全アプリのバージョンを含むよう広めのレンジにしておきます。

// packages/shared-plugin/package.json
{
  "peerDependencies": {
    "some-framework": ">=1"
  }
}

3 つの落とし穴

① 消費者は全員 injected に揃える

一部のアプリだけ injected、残りを素の workspace:*(シンボリックリンク)にすると、シンボリックリンク側が peer レンジ内の最も高いバージョンに引っ張られて壊れます。共有パッケージを使うアプリは全員 injected にしてください。

② src を編集したら pnpm install し直す

injected はコピーなので、共有パッケージのソースを編集しても各アプリのコピーには自動反映されません。pnpm install で再同期が必要です。CI は毎回クリーンインストールなので問題になりませんが、「ローカルで共有パッケージを直したのに反映されない」ときはこれが原因です。

共有パッケージを頻繁に編集するなら、prepare スクリプトで自動ビルドさせるか、`pnpm-sync-dependencies-meta-injected`(watch mode 対応の自動同期ツール)を使うと手間が減ります。

③ injected が吸収するのは「解決」だけ。API 互換は別

injected は「どのバージョンに解決するか」を分けるだけで、API の互換性は保証しません。共有パッケージのソースが v2 専用の API を使い出すと、v1 のアプリのコピーはビルドで壊れます。共有パッケージは使用中の全バージョン共通の API に収めておく必要があります。裏を返すと、アプリのメジャーは揃えて運用し、混在はあくまで移行途中の一時状態と捉えるのが安全です。

まとめ

  • workspace:* の共有パッケージは peer を単一解決するため、消費側が異なるメジャーだと壊れる。
  • 消費側で dependenciesMeta.<pkg>.injected: true にすると、各アプリへハードコピーされアプリごとに独立して peer を解決する。単一ソースのまま、バージョン混在に強くなる。
  • 「全消費者を injected に揃える」「src 編集後は再 install」「API 互換は別」の 3 点に注意。

参考リンク