Ten green flows said nothing.
On 20 September 2026 I updated OldMate on my phone from build 46 to build 48, tapped the icon, and it died before it drew a frame. My end-to-end suite was green. It runs on an iOS 26.5 simulator, and iOS 26.5 doesn’t enforce the rule that killed the app.
If your Expo app crashes on launch on iOS 27, it is probably the same thing. Here is the cause and the config plugin that fixed it on Expo SDK 54. The part that cost me the most time was getting expo-router deep links through once the app has scenes.
The short version
Apple now requires the UIKit scene lifecycle in any app built with the iOS 27 SDK, and says an app without it fails to launch. Expo’s SDK 54 template makes its window the old way, so an SDK 54 app built with Xcode 27 won’t launch on iOS 27. What to do depends on your SDK, as of 7 October 2026:
| Your Expo SDK | What to do |
|---|---|
| 58 | Nothing, scene support is the default. SDK 58 is still in beta and I haven’t run it. |
| 57 | Update to expo 57.0.23 or newer and expo-build-properties 57.0.20 or newer, then set ios.enableSceneSupport to true (Expo’s note). |
| 54 to 56 | I couldn’t find a first-party fix (the SDK 55 and 56 expo-build-properties have no scene option). Use the plugin below. I’ve only run it on SDK 54. |
On SDK 58, delete the plugin: prebuild generates its own SceneDelegate.swift, and this plugin throws on that template to remind you. Expo’s scene lifecycle guide covers the official routes, and its troubleshooting says cold links that fail on 58 need the latest SDK 58 expo.
The rest of this post is about the last row. I tested on expo 54.0.37, React Native 0.81.5 and expo-router 6.0.24. You only hit this when you build with Xcode 27: build 46 (Xcode 26) ran; build 48, made with Xcode 27 like 47 before it, crashed on open. Expo said on 15 September that its latest EAS Build image still shipped Xcode 26.6. I build locally with eas build --local, so my builds used the Mac’s Xcode.
What the crash looks like
The top of the crashed thread from my phone’s crash report (iOS 27.0), trimmed:
EXC_BREAKPOINT (SIGTRAP), crashed thread: com.apple.main-thread
UIKitCore ___UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption_block_invoke+700
libdispatch.dylib _dispatch_client_callout+16
libdispatch.dylib _dispatch_once_callout+32
UIKitCore -[UIApplication workspace:didCreateScene:withTransitionContext:completion:]+324
…
_UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption is the whole story. iOS 27 creates the app’s first scene, finds no scene lifecycle to hand it to, and traps before the first frame. It is a native trap, so a JS error tracker never hears about it. Under the iOS 26 SDK the same omission was only a console warning, and the trap needs an app built with the iOS 27 SDK running on iOS 27, so my iOS 26.5 simulators had nothing to say.
Run from Xcode, the console says it outright (expo/expo#46664):
Application failed to launch: UIScene life cycle is required for apps built with this SDK.
I reproduced it on an iOS 27 simulator with the code on main, fixed it there, and build 49 launched on my phone.
The fix: a config plugin that adds a SceneDelegate
SDK 54’s generated AppDelegate.swift makes the window and starts React Native inside didFinishLaunchingWithOptions. Under scenes the window has to belong to a UIWindowScene, so two things change. Info.plist gets a UIApplicationSceneManifest naming a SceneDelegate class, and the app delegate stops making the window. The scene delegate makes one and starts React Native in it.
Add only the manifest and the crash turns into a black screen, which is easy to mistake for success (an Expo issue walks through it). The plugin does both halves by editing text at prebuild. It appends the SceneDelegate to AppDelegate.swift rather than adding a file, so the Xcode project file needs no edits, and it throws if the SDK 54 window block isn’t where it expects, so a template change fails prebuild instead of shipping a black screen.
Save this as plugins/with-scene-lifecycle.js and add "./plugins/with-scene-lifecycle" to the plugins array in app.json. It is OldMate’s file minus a long header comment, with the marker renamed, a couple of comments and the error text reworded, and the check for my own earlier version dropped.
const { withAppDelegate, withInfoPlist } = require('expo/config-plugins');
const MARKER = '// scene lifecycle (with-scene-lifecycle.js)';
// The exact block Expo SDK 54's AppDelegate.swift template uses to make the window.
const WINDOW_BLOCK = `#if os(iOS) || os(tvOS)
window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "main",
in: window,
launchOptions: launchOptions)
#endif
`;
const SCENE_DELEGATE = `
${MARKER}
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard
let windowScene = scene as? UIWindowScene,
let appDelegate = UIApplication.shared.delegate as? AppDelegate,
let factory = appDelegate.reactNativeFactory
else { return }
// A cold launch from a link arrives here, not in the app delegate's launch
// options. Before React Native starts, it goes through the app delegate's
// handlers, where expo-linking records the initial URL expo-router opens
// on, and into the launch options React Native's initial URL is read from.
// Nothing is listening yet, so nothing hears it twice.
var launchOptions: [UIApplication.LaunchOptionsKey: Any] = [:]
if let url = connectionOptions.urlContexts.first?.url {
launchOptions[.url] = url
_ = appDelegate.application(UIApplication.shared, open: url, options: [:])
} else if let activity = connectionOptions.userActivities.first(where: {
$0.activityType == NSUserActivityTypeBrowsingWeb
}), let url = activity.webpageURL {
launchOptions[.url] = url
_ = appDelegate.application(
UIApplication.shared,
continue: activity,
restorationHandler: { _ in })
}
let window = UIWindow(windowScene: windowScene)
self.window = window
// Libraries still look for the window on the app delegate.
appDelegate.window = window
factory.startReactNative(
withModuleName: "main",
in: window,
launchOptions: launchOptions.isEmpty ? nil : launchOptions)
}
// Handed to the app delegate's own handlers rather than straight to
// RCTLinkingManager. Those run Expo's app-delegate subscribers (expo-linking
// among them) as well as React Native's linking. Going straight to
// RCTLinkingManager opened the app and navigated nowhere.
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
for context in URLContexts {
_ = appDelegate.application(UIApplication.shared, open: context.url, options: [:])
}
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { return }
_ = appDelegate.application(
UIApplication.shared,
continue: userActivity,
restorationHandler: { _ in })
}
}
`;
function patchAppDelegate(contents) {
if (contents.includes(MARKER)) return contents;
if (!contents.includes(WINDOW_BLOCK)) {
throw new Error(
'with-scene-lifecycle: the window block in AppDelegate.swift was not found. ' +
'If another plugin has edited that block, this one has to match its output; ' +
'if this Expo SDK adopts UIScene itself, remove this plugin.',
);
}
return (
contents.replace(
WINDOW_BLOCK,
' // The window is made by SceneDelegate, below.\n',
) + SCENE_DELEGATE
);
}
module.exports = function withSceneLifecycle(config) {
config = withInfoPlist(config, (mod) => {
mod.modResults.UIApplicationSceneManifest = {
UIApplicationSupportsMultipleScenes: false,
UISceneConfigurations: {
UIWindowSceneSessionRoleApplication: [
{
UISceneConfigurationName: 'Default Configuration',
UISceneDelegateClassName: '$(PRODUCT_MODULE_NAME).SceneDelegate',
},
],
},
};
return mod;
});
return withAppDelegate(config, (mod) => {
if (mod.modResults.language !== 'swift') {
throw new Error('with-scene-lifecycle expects a Swift AppDelegate.');
}
mod.modResults.contents = patchAppDelegate(mod.modResults.contents);
return mod;
});
};
module.exports.patchAppDelegate = patchAppDelegate;
Then delete ios/ and prebuild again (npx expo prebuild --platform ios --clean), and do the same whenever you edit the plugin: the patch is skipped when its marker is already in AppDelegate.swift, and EAS skips prebuild when it finds an ios/ directory, so a stale one quietly builds the old app.
Why expo-router links go nowhere under scenes
Once an app has a scene, UIKit stops calling the app delegate’s application(_:open:options:) and application(_:continue:restorationHandler:). It calls the scene delegate instead, and when a link cold-starts the app, the URL arrives in connectionOptions, not in the app delegate’s launch options. OldMate’s sign-up confirmation and password reset both arrive as links, so a build that drops them is broken in a way no launch check shows.
I got this wrong twice.
Warm links go through the app delegate’s handlers. My first draft sent them straight to RCTLinkingManager. The app came to the front, expo-router went nowhere, and a Maestro flow that navigates with openLink failed. Sending the URL through the app delegate’s application(_:open:options:) fixed it. That handler also runs Expo’s app-delegate subscribers, which going straight to RCTLinkingManager skips. I can’t point to the line in expo-router that needed them, so all I proved is red to green.
Cold links need the URL put back in two places. The first version put it into the launch options and nothing else. That feeds React Native’s Linking.getInitialURL(), but on iOS expo-router 6.0.24 asks expo-linking for the initial URL instead. expo-linking 8.0.12 only has one once its app delegate subscriber has seen application(_:open:options:) or a universal link. By my reading of the source, nothing called it, so expo-router fell back to /. The fix is to make that call before React Native starts and keep the launch options entry too. Nothing is listening that early, so nothing hears the link twice. A unit test pins that order, but I haven’t yet watched a cold link land on this version, so run the check below on your own app.
I didn’t catch this one by watching a link fail: the cold-link check I ran that day missed it. A review pass three days later read the plugin against the expo-linking and expo-router source, and the fix landed on 24 September. Expo’s own scene delegate had a cold-start bug of the same family (expo/expo#47628).
To check a cold link yourself, terminate the app and open a URL from the command line:
xcrun simctl terminate booted com.example.myapp
xcrun simctl openurl booted "myscheme:///a-route-only-expo-router-handles"
iOS asks whether to open the link, so tap Open. Pick a route that only expo-router handles and that doesn’t redirect. My first check used the password-reset link, which my root layout also reads through React Native’s Linking.getInitialURL(), so it can land even with the bug. Another of my test links was behind sign-in and told me nothing.
expo run:ios fails on Xcode 27: build with xcodebuild and simctl
Xcode 27 replaced Simulator.app with Device Hub. The Expo CLI that ships with SDK 54 asks AppleScript for Simulator.app’s id before it compiles anything, so after prebuild and pod install have both succeeded, expo run:ios stops with:
CommandError: Can't determine id of Simulator app; the Simulator is most likely not installed on this machine. Run `sudo xcode-select -s /Applications/Xcode.app`
expo start --ios does the same. Expo fixed the lookup in the SDK 56 CLI, and the ports for SDK 55 and SDK 54 were still open when I checked.
You don’t need the CLI. The simulator is a CoreSimulator device, and xcodebuild and simctl drive it directly. This is OldMate’s build script, stripped down. Boot an iOS 27 simulator first (xcrun simctl list devices available, then xcrun simctl boot), and start Metro with npx expo start in another terminal.
export LANG=en_US.UTF-8 # CocoaPods fails without a UTF-8 locale
npx expo prebuild --platform ios --clean # only if ios/ is missing or stale
UDID=$(xcrun simctl list devices booted | grep -oE '[0-9A-F]{8}-([0-9A-F]{4}-){3}[0-9A-F]{12}' | head -n 1)
# MyApp = the project name: the .xcworkspace and the app folder under ios/
xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp \
-configuration Debug -sdk iphonesimulator \
-destination "id=$UDID" -derivedDataPath ios/build build
xcrun simctl install "$UDID" ios/build/Build/Products/Debug-iphonesimulator/MyApp.app
xcrun simctl launch "$UDID" com.example.myapp
The locale line matters in shells started by an agent or a launchd job, which often have none. Without it CocoaPods prints a UTF-8 warning, then fails inside prebuild with Unicode Normalization not appropriate for ASCII-8BIT (Encoding::CompatibilityError), which is easy to miss in prebuild’s output.
Gotchas
- A green run on iOS 26 says nothing about iOS 27. I now launch every TestFlight candidate on an iOS 27 simulator first. Maestro couldn’t clear iOS 27’s system sheets for me, so the full suite still runs on 26.5, and on 27 I could only check launch, smoke and sign-up. I wrote up more cases of green tests over broken code separately.
- I only ran an app on SDK 54. The patch applies to the SDK 55, 56 and 57 templates and the result parses as Swift, but that is all I checked. I use a custom URL scheme, so the universal-link branch is unrun too.
- Other plugins that edit
AppDelegate.swift. The patch needs the stock SDK 54 window block word for word.@react-native-firebase/appputsFirebaseApp.configure()inside that block, and it writes the file in a dangerous mod, which Expo runs before ordinary mods like this one, so the order of yourpluginslist doesn’t help: prebuild throws. I reproduced that by running Firebase’s AppDelegate edit on the SDK 54 template first, and I haven’t written the looser match it would need. Expo’s SDK 57enableSceneSupporthas the same limitation (Expo’s note). - It forwards links, nothing else. Under scenes UIKit also stops calling the app delegate’s active and background callbacks and its quick-action handler. Expo’s SDK 58 scene delegate forwards those; this plugin doesn’t. If a module you use hooks them through an app-delegate subscriber (quick actions are the likely one), check it.
- expo-dev-client is untested. OldMate doesn’t use it. The SDK 54 dev launcher source stops with a fatal error if there’s no key window during
didFinishLaunching, and this plugin makes the window later, so expect trouble. The SDK 54 plugin in the description of expo/expo#50250 keeps window creation in the app delegate for that reason, so look there first if you use dev-client.