生成されたプロジェクトにリスト表示を足したくて、FlashList を追加しました。手癖で npm install @shopify/flash-list と打ちました。ビルドは通り、リストも動きました。数日後に npx expo-doctor を回したら、そこだけ赤くなっていました。
入っていたのは 2.3.2 で、Expo が想定していたのは 2.0.2 でした。マイナーが 3 つ離れています。この差はエラーにならず、警告も出ません。検査を明示的に回した日にだけ見えます。
気になったのは「Expo は何を根拠に 2.0.2 と言っているのか」でした。調べてみると、その根拠は expo パッケージ本体の中に入っていて、しかもパッチ更新のたびに書き換わっていました。
expo install が参照している台帳の場所
npx expo install <パッケージ> は npm の latest を取ってきません。インストール済みの expo パッケージに同梱された bundledNativeModules.json を読み、そこに書かれたバージョンを入れます。
# 台帳の実体はここにあります
cat node_modules/expo/bundledNativeModules.json | head -20
2026-09-02 時点の expo@57.0.19 では、この台帳に 123 エントリが載っていました。内訳は Expo 自前のモジュール(expo-* と @expo/*)が 87 件、それ以外のサードパーティが 36 件です。
サードパーティ側には Sentry、Stripe、FlashList、Skia、Lottie、react-native-maps、react-native-webview、AsyncStorage といった、実務でまず入れるものが並んでいます。つまり expo install は Expo 製モジュールだけを面倒見ているのではなく、主要なコミュニティ製ライブラリについても「この SDK ではこの版で検証しました」という表明を持っています。
npm install はこの表明を一切見ません。同じ名前のパッケージを、レジストリの最新版で入れます。ここが最初の分岐点です。
パッチ2つ分、4日間で 30 エントリが動いていました
台帳が SDK ごとに固定されているなら話は簡単です。実際は違いました。expo のパッチ版ごとに中身が書き換わります。
npm レジストリから複数のパッチ版の tarball を取得し、台帳だけを取り出して差分を数える小さなスクリプトを書きました。
// drift.mjs — 2つの expo パッチ版の台帳を突き合わせ、動いたエントリ数を出す
import { readFileSync } from "node:fs";
const read = (v) =>
JSON.parse(readFileSync(`x${v}/package/bundledNativeModules.json`, "utf8"));
const [from, to] = process.argv.slice(2);
const a = read(from), b = read(to);
const moved = Object.keys(a).filter((k) => k in b && a[k] !== b[k]);
const pinned = Object.entries(b).filter(([, r]) => /^\d/.test(r)); // ~ も ^ も付かない厳密固定
console.log(`台帳のエントリ数: ${Object.keys(b).length}`);
console.log(`${from} → ${to} で動いたエントリ: ${moved.length}`);
console.log(`厳密固定(範囲指定なし): ${pinned.length} 件`);
実行結果です。
$ node drift.mjs 57.0.17 57.0.19
台帳のエントリ数: 123
57.0.17 → 57.0.19 で動いたエントリ: 30
厳密固定(範囲指定なし): 22 件
57.0.17 の公開は 2026-08-26(UTC。JST では 8月27日)、57.0.19 は 2026-09-01 です。4日ほどの間隔で、123 件のうち 30 件の推奨バージョンが動いていました。
範囲を広げるとさらに大きくなります。7月22日公開の 57.0.8 と 57.0.17 を比べると、動いたのは 54 件でした。同じ SDK 57 の中の話です。
参考までに、expo の 57 系パッチがどの間隔で出ているかも取っておきました。
| バージョン | 公開日(UTC) |
| 57.0.14 | 2026-08-17 |
| 57.0.15 | 2026-08-20 |
| 57.0.16 | 2026-08-24 |
| 57.0.17 | 2026-08-26 |
| 57.0.18 | 2026-08-28 |
| 57.0.19 | 2026-09-01 |
3〜4日おきです。package.json に "expo": "~57.0.19" と書いてある以上、チルダは 57.0.x のパッチを許します。lock ファイルを作り直した日によって、expo install の答えが変わるということになります。
動いていたのは Expo 自前のモジュールだけでした
ここが予想と違った部分です。「パッチを上げれば台帳も新しくなるのだから、サードパーティも追随するだろう」と考えていました。
57.0.17 → 57.0.19 で動いた 30 件を並べてみると、全て expo-* か @expo/* でした。expo-updates が ~57.0.18 から ~57.0.20 へ、expo-widgets が ~57.0.13 から ~57.0.16 へ、といった具合です。
一方、サードパーティ側は 1 件も動いていません。react-native は 0.86.3、react は 19.2.3 のまま。この 2 つにはチルダもキャレットも付いていません。厳密固定です。
| 種別 | 台帳での書かれ方 | パッチ更新での動き |
| Expo 自前モジュール | ~57.0.15 のようなレンジ | 数日おきに動く |
| react / react-native | 0.86.3 の厳密固定 | SDK メジャー版の間は動かない |
| 主要サードパーティ 22 件 | 厳密固定 | SDK メジャー版の間は動かない |
つまり Sentry や AsyncStorage の推奨版は、expo をいくらパッチ更新しても新しくなりません。次のメジャー SDK を待つ以外にありません。「とりあえず expo を上げておけば周辺も揃う」という期待は、Expo 製モジュールにしか当てはまらないということです。
この非対称は、依存を更新するときの判断を変えます。私は個人でアプリを出していて、依存の追随は基本的に「上げられるものは上げる」で進めてきました。ただ Android 版の壁紙アプリで画像ライブラリを単体で上げたとき、そのライブラリ自体は正常なのに、ビルドツール側との組み合わせで古い端末だけが起動時に落ちる、という形で足を掬われたことがあります。壊れたのはライブラリではなく組み合わせでした。個人開発では、その組み合わせを自分で検証する時間がいちばん先に足りなくなります。台帳が固定している 22 件は、まさにその「組み合わせ」を Expo 側が抱え込んでいる部分だと理解しています。
npm install が台帳から外す幅を測る
では、手癖の npm install はどれくらい外すのでしょうか。台帳の値と npm の latest を並べてみました(いずれも 2026-09-02 実測)。
| パッケージ | 台帳(expo@57.0.19) | npm latest | ずれ |
| @sentry/react-native | ~7.11.0 | 8.24.0 | メジャー 1 |
| @react-native-async-storage/async-storage | 2.2.0 | 3.1.1 | メジャー 1 |
| react-native-webview | 13.16.1 | 14.0.1 | メジャー 1 |
| @shopify/react-native-skia | 2.6.2 | 2.11.2 | マイナー 5 |
| @shopify/flash-list | 2.0.2 | 2.3.2 | マイナー 3 |
| react-native-maps | 1.27.2 | 1.29.0 | マイナー 2 |
| lottie-react-native | ~7.3.8 | 7.5.0 | マイナー 2 |
| react-native-reanimated | 4.5.1 | 4.6.0 | マイナー 1 |
| react-native-svg | 15.15.4 | 15.15.5 | パッチ 1 |
Sentry、AsyncStorage、WebView はメジャーが 1 つ飛びます。ネイティブモジュールのメジャー更新は、大抵の場合ビルド設定か初期化コードのどちらかに変更を要求します。npm install は、その要求を黙って持ち込みます。
AI にコードを生成させる場面では、これが起きやすいと感じています。「FlashList を使ってリストを作って」と頼めば、生成された手順にはたいてい npm install @shopify/flash-list が並びます。npx expo install に読み替えてくれるかどうかは、その時の生成結果次第です。生成物を受け取る側が、この差を知っているかどうかで結果が変わります。
生成直後のプロジェクトに置く 1 本の検査
npx expo install --check を毎回打てば済む話ではあります。ただ実際には、打つのを忘れた週に限って依存が増えています。CI で落ちる形にしておくほうが確実だと考えて、package.json と台帳を突き合わせるスクリプトを置くことにしました。
// scripts/check-bundled.mjs
// package.json の依存を、インストール済み expo が持つ台帳と突き合わせる。
// 台帳に載っていない依存(zustand など)は Expo の検証対象外なので黙って飛ばす。
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const ledger = require("expo/bundledNativeModules.json");
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
const strip = (r) => r.replace(/^[\^~]/, "");
const rows = [];
for (const [name, range] of Object.entries(deps)) {
const want = ledger[name];
if (!want) continue; // 台帳外は判定しない
if (range === want) continue; // 完全一致
const pinned = !/^[\^~]/.test(want); // 台帳が厳密固定かどうか
const same = strip(range) === strip(want);
if (pinned && !same) rows.push([name, range, want, "台帳は厳密固定です"]);
else if (!pinned && !same) rows.push([name, range, want, "台帳のレンジから外れています"]);
}
if (rows.length === 0) {
console.log("台帳と食い違う依存はありません");
} else {
console.log(`台帳と食い違う依存: ${rows.length} 件`);
for (const [n, got, want, why] of rows) {
console.log(` ${n}\n package.json: ${got}\n 台帳: ${want} (${why})`);
}
process.exitCode = 1;
}
expo・expo-image・react-native は台帳どおり、FlashList と Sentry だけを latest で入れた package.json に対して回した結果です。
$ node scripts/check-bundled.mjs
台帳と食い違う依存: 2 件
@shopify/flash-list
package.json: ^2.3.2
台帳: 2.0.2 (台帳は厳密固定です)
@sentry/react-native
package.json: ^8.24.0
台帳: ~7.11.0 (台帳のレンジから外れています)
意図的に外している依存もあるはずなので、無条件に落とすと運用が回りません。除外リストを添えて、外す理由を書き残す形にしています。
// 意図して台帳から外している依存は、理由つきで明示する
const ALLOW = {
"@sentry/react-native": "v8 の Session Replay が必要。SDK 58 で台帳が追いつくまでの暫定",
};
// 判定ループの中に一行足すだけです
if (ALLOW[name]) continue;
理由を書く欄を用意しておくと、半年後の自分が「なぜここだけ外れているのか」を思い出せます。外していること自体より、外した理由が残っていないことのほうが後で困ります。
台帳から外れていた依存をどう扱うか
検査が赤くなった後の判断は、3 つに分かれると考えています。どれを選ぶかで、その後の作業量が変わります。
そのまま台帳に揃える場合
いちばん軽い選択です。npx expo install <パッケージ> を打ち直せば、台帳の版に落ちます。私はこの場合は迷わず揃えます。latest でなければ困る理由が思いつかないなら、揃えておくほうが後の SDK 更新が楽になります。
注意点が 1 つあります。ダウングレードになるケースでは、node_modules と lock ファイルの整合が崩れて解決が失敗することがあります。npm install を一度挟むか、lock を作り直してから再試行するのが確実な回避策です。
意図的に外したままにする場合
台帳の版では欲しい機能が入っていない、という理由は普通にあります。Sentry のように機能追加のペースが速いものは、台帳が追いつくのを待てないことがあります。
この場合は前掲の除外リストに理由を書いて、期限の目安(次のメジャー SDK まで、など)も添えます。外していること自体は問題ではありません。理由が残っていないことが問題になります。
判断を保留する場合
すぐに決められないなら、検査を落とさない形で警告だけ出しておきます。エラーとして扱う対象を、厳密固定のエントリだけに絞る運用です。
// 厳密固定のずれだけを CI のエラーにし、レンジのずれは警告にとどめる
const fatal = rows.filter(([, , want]) => !/^[\^~]/.test(want));
if (fatal.length > 0) process.exitCode = 1;
react-native や Skia のような厳密固定は、ずれると本番のビルドで問題が表面化しやすい層です。そこだけを止めれば、判断が終わっていない依存があっても運用は回ります。全部を止めると検査ごと無効化されがちなので、私はこの絞り方を推奨します。
手元と CI で結果が割れる条件
台帳が expo 本体に同梱されている以上、手元と CI で expo のパッチ版が違えば、expo install --check の結果も違います。
割れる条件はほぼ 2 つです。
package.json が "expo": "~57.0.x" のレンジ指定で、lock ファイルを共有していない(もしくは CI で npm install して lock を書き換えている)
- CI のキャッシュに古い
node_modules が残っていて、expo だけ前のパッチ版のまま動いている
どちらも「lock が壊れている」ように見える症状を出します。実際に壊れているのは lock ではなく、参照している台帳の版です。切り分けは 1 行で済みます。
# 手元と CI の両方で打って、同じ数字が出るかを見る
node -p "require('expo/package.json').version"
ここが揃っていないなら、依存側をいじる前に expo の版を揃えます。この確認を先に入れるようになってから、lock ファイルを疑って時間を溶かすことがなくなりました。関連する検査の組み立て方は、生成ループが緑を返した後に回している受け入れ検査にもまとめています。
台帳を読むと SDK 更新の重さが先に分かる
副次的な使い道として、台帳の差分は「次の SDK に上げるとき何が動くか」の予告にもなります。
expo@58 が出たら、まず新旧の台帳だけを取り出して drift.mjs にかけます。厳密固定の 22 件のうち何件が動いたかで、その SDK 更新がネイティブ側にどれだけ触るかの見当がつきます。Expo 製モジュールだけが動いているなら軽い更新です。Sentry や AsyncStorage のメジャーが動いているなら、初期化コードとビルド設定を見る時間を確保しておく必要があります。
リリースノートを読むより先に数字が出るので、私はこの順序を気に入っています。何が変わったかの説明を読む前に、どれくらい変わったかを知れるからです。
expo prebuild を挟む構成では、SDK を上げるときにネイティブ側の手入れが消える問題も併発します。そちらはprebuild で消えるネイティブ変更の洗い出しに分けて書いています。
次にやること
いま手元にある Expo プロジェクトで、次の 1 行を打ってみてください。
node -p "Object.keys(require('expo/bundledNativeModules.json')).length"
123 という数字が出たら、その 123 件は Expo が組み合わせを検証した範囲です。package.json に並んでいる依存のうち何件がこの中に入っていて、そのうち何件が台帳どおりの版なのか。数えてみると、npm install で足したものがどこにいるかがはっきりします。
私自身、FlashList のマイナー 3 つ分のずれに気づくまで、expo install と npm install を同じものとして扱っていました。違いは名前ではなく、参照している台帳の有無にあります。