Running an Expo SDK 57 app on Xcode 27: a field guide

★ ★ ★ ★ ★ Not rated

Before you begin

This guide was written while verifying a production-bound Expo app (expo-router, expo-notifications, expo-location, expo-sqlite, react-native-maps) in simulators for the first time. Nothing here is specific to that app. The same environment will produce the same failures for any Expo SDK 57 project built on a Mac that has moved to Xcode 27.

Environment

macOS with Xcode 27.0 and only the iOS 27 simulator runtime installed, Android emulator API 34 (Pixel 3a), Node 24, Expo SDK 57, React Native 0.86, Maestro 2.x.

Method

Development builds (npx expo run:ios, npx expo run:android) driven by Maestro flows, with screenshots and the unified log as evidence. Not Expo Go.

Read the versioned docs first. Every fix below is backed by docs.expo.dev/versions/v57.0.0. When a symptom looks like an app bug, check the environment sections (5, 7 and 8) before changing application code. Two of the eight problems here were not app bugs at all.

The app crashes at launch: "UIScene life cycle is required"

Symptom. The build succeeds, the app installs, and it dies within a few hundred milliseconds. The simulator shows the home screen. The crash report ends in ___UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption_block_invoke and the console says:

Application failed to launch: UIScene life cycle is required for apps built with this SDK.
See "Transitioning to the UIKit scene-based life cycle" in the UIKit documentation.

Cause. Apps compiled against the iOS 27 SDK (Xcode 27) must adopt the scene-based lifecycle. The Expo SDK 57 bare template does not include a scene manifest. Expo added opt-in scene support in patch release 57.0.23; a project pinned to an older patch (ours was 57.0.13) cannot enable it.

Fix. Upgrade to the latest SDK 57 patch, align the other Expo packages, and turn scene support on through expo-build-properties:

npx expo install expo@~57.0.27
npx expo install --fix
npx expo install expo-build-properties
// app.json → expo.plugins
[
  "expo-build-properties",
  { "ios": { "enableSceneSupport": true } }
]

Then regenerate the native project. A plain rebuild is not enough; the Info.plist has to be rewritten:

npx expo prebuild -p ios --clean
grep -c EXExpoAppSceneDelegate ios/<AppName>/Info.plist   # must print 1
npx expo run:ios
Expected result Info.plist contains UIApplicationSceneManifest with UISceneDelegateClassName = EXExpoAppSceneDelegate, and the app reaches React Native. The Expo docs note that SDK 58 no longer needs the property.
Do not downgrade Xcode as a workaround. The crash is enforced by the SDK the binary is built with, not by the simulator runtime, so an older runtime does not help, and App Store submissions will require the iOS 27 SDK anyway.

Metro port conflicts: 8081 belongs to someone else

Symptom. npx expo start asks "Use port 8082 instead?" and in non-interactive mode simply skips the dev server. A development build launched against the wrong Metro either shows a red box or loads a different project's bundle.

Cause. On a developer Mac, 8081 is often held by a forgotten expo run:ios from another project or by an unrelated local service. In our case both an 18-hour-old Metro from a second app and a PHP dev server were listening.

lsof -nP -iTCP:8081 -sTCP:LISTEN

Fix. Pick a free port and build the native app for it. The port is baked into the binary at build time, so --port must be given to the build command, not only to Metro:

# iOS: sets RCT_METRO_PORT for the Xcode build and starts Metro on the same port
npx expo run:ios --port 8082

# Android: passes -PreactNativeDevServerPort=8082 to Gradle
ANDROID_HOME=$HOME/Library/Android/sdk npx expo run:android --port 8082
adb reverse tcp:8082 tcp:8082
Two flags that do not mix. --port and --no-bundler are mutually exclusive in Expo CLI 57. If you want to build two platforms in parallel against one Metro, start Metro yourself with npx expo start --dev-client --port 8082; the second run:* command detects the running server for the same project and skips its own.

For Android without Expo CLI, the equivalent Gradle invocation is:

cd android && ./gradlew app:installDebug -x lint -x test \
  -PreactNativeDevServerPort=8082 -PreactNativeArchitectures=arm64-v8a

Red box: "No script URL provided"

Symptom. After a prebuild, the app launches but shows No script URL provided. Make sure the packager is running, while Metro is clearly up. The iOS unified log shows the app probing localhost:8081/status, not the port you built for.

Cause. The RCT_METRO_PORT compile-time define did not survive the fresh Pods project, so RCTBundleURLProvider fell back to 8081, found a foreign server there, and could not resolve a bundle. The Expo dev-client deep link (…://expo-development-client/?url=…) does not help a project that does not include expo-dev-client.

Fix. RCTBundleURLProvider reads the packager location from the app's NSUserDefaults, which is exactly what the in-app dev menu's "Configure Bundler" writes. Set it from the host:

SIM=<simulator UDID>
xcrun simctl terminate $SIM cz.example.app
xcrun simctl spawn $SIM defaults write cz.example.app RCT_jsLocation "localhost:8082"
xcrun simctl launch $SIM cz.example.app
This setting lives in the app container. Anything that clears app data (Maestro clearState, simctl uninstall) removes it. Re-apply it after every reset, or script the reset (see section 7).

Onboarding never shows: initialRouteName is not what you think

Symptom. A fresh install goes straight to the main tabs. The onboarding screen is reachable by URL but is never the first screen. Unit tests pass, because the gating logic itself is fine.

Cause. In Expo Router, initialRouteName (now called anchor) only matters when deep-linking into a nested route, so a back button exists. A normal cold start always resolves the URL /, which is the tabs group's index. The docs are explicit: "The anchor is only used when deep-linking to a route."

Fix. Guard the routes with Stack.Protected. A guarded initial route makes the router fall through to the first available screen, and flipping the guards moves the user automatically and clears history:

// app/_layout.tsx
<Stack screenOptions={{ headerShown: false }}>
  <Stack.Protected guard={onboarded}>
    <Stack.Screen name="(tabs)" />
  </Stack.Protected>
  <Stack.Protected guard={!onboarded}>
    <Stack.Screen name="onboarding" options={{ gestureEnabled: false }} />
  </Stack.Protected>
</Stack>

The onboarding screen must be able to flip onboarded in the root layout. Keep the context in its own module so a route file never imports _layout.tsx:

// components/OnboardingContext.ts
export const OnboardingContext = createContext<{ finish: () => void }>({ finish: () => {} });
export const useOnboarding = () => useContext(OnboardingContext);

// app/onboarding.tsx – after persisting the flag
await setFlag('onboarded', true);
onboarding.finish();           // no router.replace; the guard does the navigation
Expected result A cleared install opens onboarding; "Start" switches to the tabs; relaunch opens the tabs. Verified with Maestro on both platforms.

This also changes where a notification-permission prompt fires: if the main screens can mount before onboarding, anything they trigger (a focus effect that reschedules notifications, for instance) will ask the user before onboarding has explained why.

The ghost permission prompt on iOS

Symptom. On what looks like a clean install, the system notification prompt appears about 60 ms after launch, on the first onboarding slide, before any JavaScript has run. Tracing every call to requestPermissionsAsync and getPermissionsAsync shows nothing. An lldb breakpoint on requestAuthorizationWithOptions:completionHandler: never hits. The unified log shows SpringBoard raising a systemModalAlert for the bundle while UIKit is still creating the window.

Cause. An earlier run requested authorization and was terminated while the prompt was open (by the test runner, by simctl terminate, by a crash). iOS keeps the unanswered request and re-presents it at the next launch of the same bundle identifier. Uninstalling the app does not clear it. It is not the app asking; it is the system finishing an old conversation.

Fix. Answer the prompt once (either button), then reinstall and test again. In automation, every flow that can trigger a system prompt must also dismiss it. Add this to the testing notes so the next person does not spend an afternoon on it.

# Maestro: always resolve the dialog you caused
- tapOn: "Povolit|Allow"            # or "Nepovolovat|Don.t Allow"
How to tell the difference in two minutes. Add console.warn with new Error().stack to the permission wrapper and watch the Metro log. If the prompt appears with no trace line from this platform, it did not come from JavaScript. Remember that a shared Metro prints both platforms' logs in one stream; an Android reload triggered by your edit can look like iOS activity.

Android: sound: 'default' on a notification channel

Symptom. A red toast on Android and this line in the Metro log:

ERROR expo-notifications: Custom sound 'default' not found in native app.
Make sure the sound file (e.g. 'custom_sound.wav') is included in the expo-notifications config plugin sounds array in app config.

Cause. In expo-notifications 57.0.22+, NotificationChannelInput.sound is a custom sound file name registered through the config plugin's sounds array. The string 'default' is only a possible output value on NotificationChannel. The channel is still created and the error is logged, not thrown, so scheduling keeps working and the problem is easy to miss.

Fix. Omit sound. A channel without it uses the system default sound.

await Notifications.setNotificationChannelAsync('deadlines', {
  name: 'Vehicle deadlines',
  importance: Notifications.AndroidImportance.DEFAULT,
  // no `sound` → system default
});

Maestro pitfalls on this stack

Maestro is the quickest way to drive both simulators from one YAML file, but five of its defaults collide with Expo and iOS 27.

PitfallWhat happensDo this instead
Android launchApp grants every permissionYour permission flow silently passes because the dialog never appears.launchApp: { clearState: true, permissions: { all: unset } }
iOS clearState wipes NSUserDefaultsThe RCT_jsLocation override from section 3 disappears; the app falls back to 8081.Reset from a script: terminate, uninstall, install the .app, write the default, then launch without clearState.
Tab bar labels on iOS 27 are not text elementstapOn: "Settings" fails although the label is on screen.Tap by position: tapOn: { point: "83%,94%" }. Match screen content with a regex such as ".*[Gg]arage.*".
System dialogs are localizedA Czech simulator shows "Povolit", not "Allow".Use alternations: "Povolit|Allow", "Nepovolovat|Don.t Allow", "Povolit při používání aplikace|Allow While Using App|While using the app".
A terminated run leaves a prompt openSee section 5.Every flow that opens a dialog closes it.

A reusable iOS reset script:

#!/bin/zsh
SIM=<UDID>; APP="$1"; ID=cz.example.app
xcrun simctl terminate $SIM $ID 2>/dev/null
xcrun simctl uninstall $SIM $ID 2>/dev/null
xcrun simctl install $SIM "$APP"
xcrun simctl spawn $SIM defaults write $ID RCT_jsLocation "localhost:8082"

Maestro itself takes 20 to 30 seconds to start on first use while it checks for updates; set MAESTRO_CLI_NO_ANALYTICS=1 and do not mistake the pause for a hang.

Emulator reality: slow, noisy, and sometimes without GPS

Three environment problems masqueraded as app bugs on the Android side.

8.1 "System UI isn't responding" and "Digital Wellbeing isn't responding"

An emulator that has been running for many hours, on a host that is also compiling two native builds, throws ANR dialogs from system components. They sit on top of your app and break every assertion. Cold-restart the emulator and remove the worst offender:

adb emu kill
emulator -avd <name> -no-snapshot-load -no-boot-anim
adb shell pm disable-user --user 0 com.google.android.apps.wellbeing
adb shell settings put global hide_error_dialogs 1
Launching the emulator from a sandboxed shell. If emulator -avd reports "Unknown AVD name" although -list-avds shows it, export ANDROID_AVD_HOME=$HOME/.android/avd and ANDROID_SDK_ROOT explicitly; the sandbox can present a different HOME.

8.2 A development build cold-starts in minutes, not seconds

On the loaded emulator, ReactHost reached Running "main" three minutes after the process started, with 6-second GC pauses on the way. A 60-second extendedWaitUntil fails on a perfectly healthy app. Use 180 to 240 seconds for the first screen on Android, or run the flow against an app that is already in the foreground.

8.3 adb emu geo fix is accepted but never delivered

Our emulator answered OK to every fix and still reported last location=null for both the GPS and fused providers:

adb emu geo fix 14.4378 50.0755
adb shell dumpsys location | grep "last location"

The app correctly surfaced "no signal" and did not save anything, which verified the unavailable path but not the happy path. Treat location on Android emulators as unreliable evidence; verify saving on iOS (where simctl location set works) and on a physical Android device.

What iOS did verify for free With xcrun simctl location <UDID> set 50.0755,14.4378, the app got a fix, Apple Maps rendered, and reverse geocoding returned a street address within seconds, exercising the real expo-sqlite write path.

Checklist for the next app

  • Expo on the latest SDK 57 patch (≥ 57.0.23) and expo-build-properties with ios.enableSceneSupport: true; prebuild --clean after adding it.
  • Free Metro port chosen before the first build; --port on run:ios / run:android, plus adb reverse.
  • iOS simulator: RCT_jsLocation default written after every reset.
  • First-run routing uses Stack.Protected, not initialRouteName; onboarding reports completion through a context.
  • Every system prompt your flows trigger is answered inside the flow.
  • Android notification channels do not set sound: 'default'.
  • Maestro: permissions: { all: unset } on Android, position taps for iOS tab bars, localized regexes for system dialogs.
  • Android emulator restarted cold before a test session; Digital Wellbeing disabled; generous first-screen timeouts; location verified elsewhere.
  • Evidence kept as screenshots per step, and the handoff notes say which items were verified in a simulator versus on a device.

Companion material: the Maestro flows and reset script live in the app repository under tools/maestro/.

0.0
★ ★ ★ ★ ★
0 reviews
5 ★
0 %
4 ★
0 %
3 ★
0 %
2 ★
0 %
1 ★
0 %

Add a comment

There's nothing here.

No comments have been posted here yet.