👻
「自分のPCでは動くのに…」を防ぐnpmの落とし穴と対策
はじめに
ローカルでは通るのに、GitHub Actionsだけ落ちる。これ、あるあるです。
Rollup failed to resolve import "lucide-react"
原因の定番は ホイスティング と Phantom Dependencies(幽霊依存)。npmの「便利さ」の裏にある罠ってやつです。
ホイスティングとは
npmやYarnは node_modules をできるだけフラットにして、重複を減らします。これがホイスティング(hoisting)です。
ホイスティングのメリット
| メリット | 説明 |
|---|---|
| ディスク容量の節約 | 重複パッケージを1つにまとめる |
| インストール時間の短縮 | ダウンロード・展開が減る |
| 依存解決が単純化 | フラット構造で追いやすい |
例えばAとBが両方 lodash@4.17.21 に依存していれば、1つだけ入ります。
node_modules/
├── package-a/
├── package-b/
└── lodash/ # 共通化されて1つだけ
Phantom Dependencies(幽霊依存)とは
問題はここから。
Phantom Dependencies は、package.json に書いてないのに、たまたま node_modules にあるから使えてしまう依存のことです。
具体例
// components/Icon.tsx
import { Home } from "lucide-react";
package.json に lucide-react を入れ忘れていても、他の依存(例: UIライブラリ)が引いていれば、ホイスティングでルートに上がってきます。
その結果、ローカルでは動くんですよね。
node_modules/
├── some-ui-library/
│ └── (lucide-reactに依存)
└── lucide-react/ # ホイスティングでルートに配置
なぜCI/CDで落ちるのか
CI環境はローカルと条件が違います。よくあるズレはこんな感じです。
- キャッシュがない
- lockfileの状態が違う
- パッケージマネージャーが違う
- ホイスティングの順序が変わる
特にpnpmは依存関係が厳密なので、幽霊依存は素直にエラーになります。
対策
1. Biomeの noUndeclaredDependencies を使う
Biome の noUndeclaredDependencies は、package.json にないimportをちゃんと拾ってくれます。
biome.json
{
"linter": {
"rules": {
"correctness": {
"noUndeclaredDependencies": "error"
}
}
}
}
例:
components/Icon.tsx:1:1 lint/correctness/noUndeclaredDependencies ━━━━━━━━━
✖ The current dependency is not specified in your package.json.
> 1 │ import { Home } from "lucide-react";
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
2. pnpmの厳密モードを活用する
pnpmはもともと厳密ですが、さらに強くしたいなら .npmrc で調整できます。
.npmrc
# すべてのパッケージをhoistしない(最も厳密)
hoist=false
# 特定のパッケージだけhoist
hoist-pattern[]=*eslint*
hoist-pattern[]=*babel*
逆にレガシー事情で緩めたいなら:
.npmrc
# npm/Yarnに近いフラット構造にする(非推奨)
shamefully-hoist=true
3. 依存関係を明示的に入れる
結局これが一番確実です。
pnpm add lucide-react
まとめ
ホイスティングは便利ですが、幽霊依存という副作用があります。
やることはシンプルです。
- Biomeで未宣言依存を検知
- pnpmで厳密に管理
- CIではクリーンインストール
これだけで「ローカルでは動くのにCIで落ちる」はだいぶ減ります。
Discussion