アップグレードの前夜に、いちばん避けたいのは「上げてみて、赤い画面が出てから原因を探す」ことです。SDK 58 の公式お知らせ(expo.dev/changelog の SDK 58)を読み直したところ、React Native 側の API がまとまって削除されていました。
書かれている項目を、そのまま引用に近い形で並べます。
InteractionManagerが削除されました(代わりはrequestIdleCallback)react-nativeのルートからのTouchableの export が削除されましたModalのanimatedprop が削除されましたStatusBarのbackgroundColor・translucent・networkActivityIndicatorVisibleが削除されましたreact-native/Libraries/*からの深い import は、型エラーになります
どれも、一つひとつは小さな変更です。ただ、個人開発で長く触っているアプリほど、どこに何が残っているのかを自分の記憶だけでは数えきれません。そこで、ソースを走査して該当行を出すだけの小さなスクリプトを書き、サンプルで動かしました。
先に結論です
削除された API の使用箇所は、grep を何回か打つより、規則を一か所に書いたスクリプトで一度に出すほうが漏れにくいと感じました。理由は二つあります。StatusBar や Modal のように、props が複数行にまたがって書かれるタグは、行単位の grep では拾えません。もう一つは、import { Touchable } のように、ほかのパッケージの同名の語と区別したいものがあることです。
何を拾い、何を拾わないか
線引きを先に決めておきます。
| 対象 | 拾い方 | 備考 |
|---|---|---|
InteractionManager | 語そのもの(コメント行は除外) | import にも呼び出しにも反応します |
| 深い import | react-native/Libraries/ を含む文字列 | TypeScript の型エラーとも重なります |
Touchable | react-native からの import 波括弧の中だけ | TouchableOpacity などは拾いません |
Modal の animated | 開始タグ全体(複数行可) | props 内に => があると途中で切れます |
StatusBar の削除済み props | 開始タグ全体(複数行可) | 同上 |
expo-sqlite の libSQL | syncLibSQL・libSQLOptions・useLibSQL | 設定ファイル側は別に確認します |
File.write() | expo-file-system を import したファイルの .write( | await のない行を拾います |
拾わないものも決めました。ネイティブ側の変更(R8 の有効化、iOS のシーンベース・ライフサイクル)と、Node.js のバージョン要件は、ソースを読んでも分からないからです。これらは最後の節で、別の確認方法を書きます。
点検スクリプト
依存は Node だけです。node sdk58-removed-api-scan.mjs ./my-app のようにプロジェクトのルートを渡します。該当があれば終了コード 1 を返すので、CI に載せることもできます。
// sdk58-removed-api-scan.mjs — Expo SDK 58 で削除・変更された API の使用箇所を洗い出します
// 使い方: node sdk58-removed-api-scan.mjs <プロジェクトのルート>
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, extname, relative } from 'node:path';
const root = process.argv[2] ?? '.';
const EXT = ['.ts', '.tsx', '.js', '.jsx'];
const walk = (d) =>
readdirSync(d).flatMap((n) => {
if (n === 'node_modules' || n.startsWith('.') || n === 'ios' || n === 'android') return [];
const p = join(d, n);
return statSync(p).isDirectory() ? walk(p) : EXT.includes(extname(p)) ? [p] : [];
});
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
// 行単位の規則: [名前, 正規表現, 直し方]
const LINE_RULES = [
['InteractionManager', /\bInteractionManager\b/, 'requestIdleCallback に置き換えます'],
['react-native 深い import', /['"]react-native\/Libraries\//, 'react-native の公開 export へ。急ぐなら tsconfig の customConditions で先送りできます'],
['Touchable (ルート export)', /import\s*\{[^}]*\bTouchable\b[^}]*\}\s*from\s*['"]react-native['"]/, 'Pressable などへ置き換えます'],
['expo-sqlite libSQL', /\bsyncLibSQL\b|\blibSQLOptions\b|\buseLibSQL\b/, 'libSQL は削除されています。同期の仕組みを別に用意します'],
];
// JSX タグ単位の規則: [名前, タグ名, 属性の正規表現, 直し方]
const TAG_RULES = [
['Modal の animated', 'Modal', /\banimated\b/, 'animationType に置き換えます'],
['StatusBar の削除済み props', 'StatusBar', /\b(backgroundColor|translucent|networkActivityIndicatorVisible)\b/, '削除された props です。背景色は画面側の View に持たせます'],
];
let total = 0;
const report = (rel, line, name, fix) => {
total++;
console.log(`${rel}:${line} ${name} → ${fix}`);
};
for (const p of walk(root)) {
const text = readFileSync(p, 'utf8');
const rel = relative(root, p);
const lines = text.split('\n');
lines.forEach((l, i) => {
if (/^\s*(\/\/|\*)/.test(l)) return; // コメント行は数えません
for (const [name, re, fix] of LINE_RULES) if (re.test(l)) report(rel, i + 1, name, fix);
});
for (const [name, tag, attr, fix] of TAG_RULES) {
const tagRe = new RegExp(`<${tag}\\b[^>]*>`, 'g');
for (const m of text.matchAll(tagRe)) if (attr.test(m[0])) report(rel, lineOf(text, m.index), name, fix);
}
// expo-file-system の File.write() は SDK 58 から非同期です
if (/from\s*['"]expo-file-system['"]/.test(text)) {
lines.forEach((l, i) => {
if (/\.write\(/.test(l) && !/await\s/.test(l) && !/\.then\(/.test(l)) {
report(rel, i + 1, 'File.write() が await されていません', 'await を付けるか、従来の挙動なら writeSync() を使います');
}
});
}
}
console.log(total ? `\n${total} 件` : '該当なし');
process.exitCode = total ? 1 : 0;規則は LINE_RULES(行単位)と TAG_RULES(JSX タグ単位)に分けました。タグ単位にしたのは、<StatusBar から /> までが三行にまたがる書き方を拾うためです。あとから項目が増えたら、配列に一行足すだけで済みます。
サンプルで走らせた結果
削除対象を意図的に混ぜた二つのファイルと、何も混ぜていない空のプロジェクトで試しました。コメントの中の InteractionManager と、await を付けた File.write() は、拾わない想定のものです。
app/home.tsx:1 InteractionManager → requestIdleCallback に置き換えます
app/home.tsx:2 Touchable (ルート export) → Pressable などへ置き換えます
app/home.tsx:6 InteractionManager → requestIdleCallback に置き換えます
app/home.tsx:13 Modal の animated → animationType に置き換えます
app/home.tsx:9 StatusBar の削除済み props → 削除された props です。背景色は画面側の View に持たせます
app/store.ts:2 react-native 深い import → react-native の公開 export へ。急ぐなら tsconfig の customConditions で先送りできます
app/store.ts:7 File.write() が await されていません → await を付けるか、従来の挙動なら writeSync() を使います
7 件空のプロジェクトでは「該当なし」と表示され、終了コードは 0 でした。コメント行と await 付きの行が数えられていないことも、同じ実行で確かめています。
なお、この出力は私が用意した小さなサンプルの結果です。実際のアプリでの件数ではありません。実際のアプリで何件出るかは、手元で走らせてみないと分からないところです。
出力の読み方と、直す順番
出力を眺めて、直す順番を次のように決めました。
- 型エラーになるもの(深い import)を先に。
tsconfig.jsonのcustomConditionsに"react-native-legacy-deep-imports"を足せば先送りできますが、お知らせによると、この逃げ道は React Native 0.89 のあとに外される予定です。先送りは一回きりと考えるほうが安全です - 動作が変わるもの(
File.write()の非同期化)を次に。awaitを忘れると、書き込み完了を待たずに次の処理へ進みます。画面上は何も起きず、あとから読んだファイルが空という形で現れやすい種類の変更です - 画面の見た目に出るもの(
StatusBar・Modal)は最後に。動作確認を実機で兼ねられます
ソースを読んでも分からない変更は、別の道具で
最後に、このスクリプトが拾えない変更を三つ書き留めます。
- Node.js: Expo のツール類は 22.13 以上、24.3 以上、26 以上が必要で、奇数の 23 と 25 は対象外と書かれています。
node -vで確認します - R8: リリースビルドで
android.enableMinifyInReleaseBuilds=trueが既定になります。リフレクションを使うライブラリは ProGuard の keep ルールが必要になることがあります。止めたい場合はexpo-build-propertiesでfalseにできます - iOS のシーン・ライフサイクル: プレビルドが
SceneDelegate.swiftとInfo.plistのUIApplicationSceneManifestを生成します。AppDelegate.swiftを手で触っているプロジェクトは、公式の移行手順に沿って確認します
関連して、ほかの SDK 58 の変更の点検記録もあります。環境変数の読み込み規則はSDK 58 から .env.staging は読み込まれません。手元の .env 系ファイルを、70行ほどのスクリプトで棚卸ししますに、Stripe を使うプロジェクトのビルドはStripe を入れた Expo SDK 58 で pod install が止まる — precompiled modules を切るか、直るまで待つかの判断に書きました。依存パッケージ側の推奨がどれだけ動くかは、expo のパッチを1つ上げると、expo install の推奨が何個動くのかを数えましたで数えています。
アップグレードは、壊れてから探すより、削除された名前を先に数えておく。 それだけで、当日の迷いはかなり減ります。