| name | apple-expo-crash |
| description | Diagnose Expo / React Native iOS crashes that only reproduce on a real device or in TestFlight (sim launches fine, device SIGABRTs). Use when the user shares a TestFlight crash log, mentions an Apple TestFlight feedback bundle, or reports their Expo app dies on launch in production but works in `expo run:ios` or simulator. Provides a deterministic triage flow that surfaces the actual JS exception instead of guessing. |
| metadata | {"author":"igor-ducca","version":"1.0.0"} |
apple-expo-crash
Triage flow for Expo / React Native iOS apps that launch fine in the simulator and dev mode but crash on real devices and in TestFlight, especially when the native crash log shows:
ErrorRecovery.crash() / ErrorRecovery.tryRelaunchFromCache
StartupProcedure.throwException
EXC_CRASH (SIGABRT)
That stack means the JS bundle threw during startup and expo-updates ate the error inside its anti-bricking recovery loop. The native log alone is useless — every crash from this codepath looks identical regardless of root cause. You must surface the underlying JS exception before guessing at fixes.
Iron rule
No fixes before the JS exception text is in front of you. Every step below either reveals the actual error or rules out a class of root causes. Do not propose a remedy from resources/known-root-causes.md until you have a concrete signal — silent fixes ship the same bug under a different number.
Phase 1 — Read the artifacts the user gave you
If the user dropped a TestFlight feedback bundle (typically ~/Downloads/testflight_feedback*/):
- Read
feedback.json — confirms cfBundleVersion, osVersion, deviceModel. Note iOS beta builds (23F..., Release Type: Beta).
- Read
crashlog.crash — confirm the signature is the ErrorRecovery.crash path. If it's a different stack (e.g. straight EXC_BAD_ACCESS in a third-party framework) this skill does not apply — escalate to a normal native iOS crash flow instead.
If the signature matches, the native log will not tell you the root cause. Stop reading it and move on.
Phase 2 — Prove the bundle builds before chasing anything else
Before any device-side investigation, confirm the JS bundle actually bundles cleanly with the user's current node_modules:
cd <project-root>
bunx expo export:embed --eager --platform ios --dev false \
--bundle-output /tmp/helix-test.jsbundle \
--assets-dest /tmp/helix-test-assets
If this fails (e.g. Metro Invariant Violation: Unexpectedly escaped traversal, missing module errors), you are debugging a build-toolchain bug, not a runtime bug. See resources/known-root-causes.md § "Bundle won't bundle". Common cause for bun monorepos: linker = "isolated" is the default in bun@1.3+; switch to hoisted via bunfig.toml.
If bundling succeeds, proceed to Phase 3.
Phase 3 — Run expo-doctor and align dep versions
bunx expo-doctor
A passing doctor is necessary but not sufficient. EAS does not gate builds on doctor failure — many crashing apps have shipped with doctor warnings. If doctor reports version mismatches or duplicate copies of native modules, fix them with:
bunx expo install --fix
Then wipe node_modules and reinstall to drop stale duplicate copies left in bun's cache:
rm -rf node_modules apps/*/node_modules packages/*/node_modules bun.lock
bun install
bun pm trust --all
bunx expo prebuild --clean
Re-run expo-doctor and expo export:embed. Both must succeed before continuing. If they pass and the device still crashes, dep drift was masking but not causing the crash — keep going.
Phase 4 — Install the diagnostic harness (the actual unblocker)
Two changes that, together, force the real JS error to leak out where you can see it:
4a. Install a global JS error handler at the very top of the bundle
Replace package.json's "main": "expo-router/entry" with "main": "./index.js" and create apps/<app>/index.js from resources/diagnostic-index.js. That file:
- Installs
ErrorUtils.setGlobalHandler before expo-router/entry is required, so any throw during module-graph evaluation or first render is captured.
- Wraps
require("expo-router/entry") in a try/catch.
- Logs every captured error via
console.error and console.warn so it lands in iOS native device logs (visible via Console.app on Mac when device is attached).
- Stashes the last error on
globalThis.__HELIX_LAST_ERROR__ for runtime inspection.
4b. Disable expo-updates anti-bricking for diagnostic builds
In app.json, under expo.updates, set:
{
"updates": {
"url": "https://u.expo.dev/<your-project-id>",
"disableAntiBrickingMeasures": true
}
}
This stops the SIGABRT recovery loop from masking errors. After the diagnostic build proves stable on device, the user can flip this back to false (or remove the key) for production. The harness file is cheap to keep — leave it in.
Run prebuild + bundle to verify both changes landed:
bunx expo prebuild --clean --platform ios
grep -i "DisableAntiBricking" ios/<App>/Supporting/Expo.plist
Ship a build (eas build -p ios --profile production) and have the user reproduce the crash with iPhone plugged into Mac and Console.app filtering on the app process. The actual JS error message will appear in the log stream.
Phase 5 — Match the JS error against known root causes
With the JS exception in hand, see resources/known-root-causes.md. The catalog covers:
Invalid environment variables (@t3-oss/env-core or zod schema throw)
Cannot read property 'X' of undefined from expo-router internals
Native module is null for autolinked modules
- New-architecture / TurboModule registration failures
- iOS-26-only framework loads (
FoundationModels, glass effect, etc.)
- Reanimated worklet capture failures (sim runs, device dies)
For each entry the catalog lists: the exact error string to grep for, the underlying cause, the minimum reproduction steps, and the precise fix.
Phase 6 — Verify the fix mechanically, not by hope
After applying a candidate fix, prove it worked without rebuilding the app on device. For env-var and inlining issues specifically:
bunx expo export:embed --eager --platform ios --dev false \
--bundle-output /tmp/check.jsbundle \
--assets-dest /tmp/check-assets
grep -c "<expected-secret-or-url-fragment>" /tmp/check.jsbundle
If the value the user expects is in the bundle, the fix is real. If not, the fix is not yet correct — iterate.
For non-bundle fixes (deps, native config), at minimum re-run expo-doctor, expo export:embed, and any prebuild step to confirm a clean state, then ship a TestFlight build.
Things that look like answers but are not
- "Reinstall node_modules" — necessary cleanup, but never the root cause on its own. Don't claim victory after
bun install.
- "Add
environment: 'production' to eas.json" — needed if EAS env vars exist in a named environment, but alone it does not inline values into the bundle if the JS code uses runtimeEnv: process.env (whole-object pattern). Both the eas.json wire and the env-core call site must be correct.
- "Update Expo SDK" — almost never the fix for this crash class. Don't recommend a major SDK bump as a debugging step.
- "It's the simulator vs. arm64e thing" — the device is
arm64e, the app is arm64, this is normal and supported. Don't blame architectures.
- "Disable updates" — only valid as a temporary diagnostic, not a fix. Re-enable before release.
When to escalate
- The JS exception text shows code from a library you cannot read or modify (proprietary native module). Escalate to that library's maintainer with the exact log lines.
- After 3 hypothesis cycles (each ending with a bundle that contains the proof of the fix), the device still crashes. Stop. Likely the issue is genuinely device- or OS-version-specific (e.g. iOS beta regression). Capture the iOS version + device model + Hermes version and file an issue with Expo.
Completion
Output a structured report:
DIAGNOSIS
Symptom: <user-visible behavior>
JS error: <exact text from device log>
Root cause: <one-sentence claim>
Fix: <file:line reference and what changed>
Mechanical proof: <command + grep output showing fix worked>
Status: DONE | DONE_WITH_CONCERNS | BLOCKED
Do not declare DONE until the mechanical proof step (Phase 6) passes. The user has been burned before by fixes that looked right but didn't work. They will trust a grep over an opinion.