The last thing I want the night before an upgrade is to bump the version, meet a red screen, and only then start hunting for the cause. Rereading the SDK 58 announcement on expo.dev/changelog, I noticed that a cluster of React Native APIs had been removed.
Here is what it lists, close to the original wording:
InteractionManageris removed (userequestIdleCallbackinstead)- The
Touchableexport from thereact-nativeroot is removed - The
animatedprop onModalis removed StatusBarlosesbackgroundColor,translucent, andnetworkActivityIndicatorVisible- Deep imports from
react-native/Libraries/*become type errors
Each change is small. But in my own app, which I've maintained for years as an indie developer, I cannot count from memory where each of these still hides. So I wrote a short script that walks the source and prints the matching lines, then ran it against a sample project.
The short version
For removed APIs, one script that keeps all the rules in one place misses less than a handful of grep commands. There are two reasons. Tags such as StatusBar and Modal often spread their props over several lines, which a line-based grep cannot see. And some names, like import { Touchable }, need to be told apart from similar words elsewhere.
What it catches, and what it does not
I drew the line first.
| Target | How it is matched | Note |
|---|---|---|
InteractionManager | The word itself (comment lines skipped) | Reacts to imports and calls alike |
| Deep imports | Any string containing react-native/Libraries/ | Overlaps with TypeScript errors |
Touchable | Only inside the braces of an import from react-native | Does not match TouchableOpacity and friends |
animated on Modal | The whole opening tag (multi-line is fine) | A => inside a prop cuts the match short |
Removed StatusBar props | The whole opening tag (multi-line is fine) | Same caveat |
libSQL in expo-sqlite | syncLibSQL, libSQLOptions, useLibSQL | Config files need a separate look |
File.write() | A .write( in a file that imports expo-file-system | Reports lines without await |
I also decided what it leaves alone. Native-side changes (R8 being enabled, the iOS scene-based life cycle) and the Node.js version requirement cannot be read from source. The last section covers how to check those another way.
The audit script
It depends only on Node. Run node sdk58-removed-api-scan.mjs ./my-app with your project root. It exits with code 1 when anything matches, so it also fits into 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;The rules live in two arrays: LINE_RULES for line-based checks and TAG_RULES for JSX tags. I made the tag rules work on whole tags so that a <StatusBar that runs over three lines before its /> still gets caught. When a new item appears, you add one line to an array.
Running it on a sample
I made two files with removed APIs mixed in on purpose, plus an empty project with nothing in it. The InteractionManager inside a comment and the File.write() that already has await are meant to be ignored.
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 件The hint text on the right is in Japanese because the script was written for my own use; translate the strings if you share it with a team. The empty project printed "該当なし" (no matches) and exited with 0. The same run confirmed that the comment line and the awaited line were not counted.
Keep in mind that this is the result on a small sample I prepared, not a count from a real app. How many hits your own app produces is something only a run on your machine can tell you.
How I read the output, and the order I fix things
I settle the order like this:
- Type errors first (deep imports). Adding
"react-native-legacy-deep-imports"tocustomConditionsintsconfig.jsondefers the problem, but the announcement says that escape hatch is set to be removed after React Native 0.89. I treat the deferral as a one-time loan - Behavior changes next (
File.write()becoming async). Forget theawaitand the next step starts before the write finishes. Nothing looks wrong on screen; it shows up later as a file that reads back empty - Visual changes last (
StatusBar,Modal), since checking them on a real device doubles as the visual test
Changes the source cannot reveal
Three things this script cannot see, written down so they are not forgotten:
- Node.js: Expo tooling needs 22.13+, 24.3+, or 26+, and the odd-numbered 23 and 25 are unsupported. Check with
node -v - R8: Release builds now default to
android.enableMinifyInReleaseBuilds=true. Libraries that rely on reflection may need ProGuard keep rules. To opt out, set it tofalsethroughexpo-build-properties - iOS scene life cycle: Prebuild generates
SceneDelegate.swiftand aUIApplicationSceneManifestentry inInfo.plist. If you editAppDelegate.swiftby hand, follow the official migration steps
I have also written up checks for other SDK 58 changes. The environment-variable rules are in Expo SDK 58 No Longer Loads .env.staging. A 70-Line Script to Audit Your .env Files, the build trouble for projects using Stripe is in Expo SDK 58 With Stripe: pod install Stops, and Whether to Turn Off Precompiled Modules or Wait, and how many dependency recommendations move after one bump is counted in I counted how many versions expo install starts recommending after one patch bump.
Count the removed names before you upgrade, not after something breaks. It takes much of the guesswork out of upgrade day.