Your app crashes on launch in iOS 27 because it never adopted UIScene
This one is nasty because nothing in your code is wrong. The app builds, archives and uploads cleanly, then dies instantly on a modern device with a stack trace that never touches your JavaScript.
Exception Type: EXC_BREAKPOINT (SIGTRAP)
Triggered by Thread: 0
0 UIKitCore ___UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption_block_invoke + 700
1 libdispatch.dylib _dispatch_client_callout + 16
2 UIKitCore -[UIApplication workspace:didCreateScene:withTransitionContext:completion:] + 324
3 UIKitCore -[UIApplicationSceneClientAgent scene:didInitializeWithEvent:completion:] + 288
What is actually happening
Apps compiled against the iOS 27 SDK are required to adopt the UIScene life
cycle. If the app has no UIApplicationSceneManifest, UIKit hits this check while the
first scene is being created and stops the process with brk #0. There is no
exception to catch and no JS to hook — UIApplicationMain never gets far enough.
The check is gated on the SDK the binary was built with, not the OS version and not your React Native version. That is why the same source can ship for a year and then fail the day you update Xcode.
Why your TestFlight build looked fine
Two reasons, and both are worth checking before you rewrite anything:
- It was compiled with an older SDK. A CI image pinned to Xcode 26 produces a binary without the trap, even though it runs on iOS 27.
- You tested on iOS 26. Simulators installed from an older Xcode do not reproduce it.
Also check the build number in any crash report you are handed. A report saying
Version 1.1 (6) says nothing about the build 8 binary you just uploaded — we lost an
afternoon to exactly that.
The fix, in three parts
1. Declare a scene manifest
In Info.plist:
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key><false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
2. Stop creating the window in the AppDelegate
The default Expo template does this in didFinishLaunching:
window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "main",
in: window,
launchOptions: launchOptions)
That has to move into the scene delegate — otherwise you get two windows and a blank app.
// AppDelegate.swift
public var launchOptions: [UIApplication.LaunchOptionsKey: Any]?
func startReactNative(in window: UIWindow, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
self.window = window
reactNativeFactory?.startReactNative(
withModuleName: "main",
in: window,
launchOptions: launchOptions)
}
3. Add the SceneDelegate
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
else { return }
// Scene cold starts deliver deep links through connectionOptions, not launchOptions.
var launchOptions: [UIApplication.LaunchOptionsKey: Any] = appDelegate.launchOptions ?? [:]
if let url = connectionOptions.urlContexts.first?.url {
launchOptions[.url] = url
}
let window = UIWindow(windowScene: windowScene)
self.window = window
appDelegate.startReactNative(in: window, launchOptions: launchOptions)
}
}
Do it as an Expo config plugin
If you edit ios/ by hand, the next expo prebuild deletes your work.
Put the three changes in a plugin and register it in app.json:
// plugins/withSceneLifecycle.js
const { withInfoPlist, withAppDelegate } = require('@expo/config-plugins');
module.exports = function withSceneLifecycle(config) {
config = withInfoPlist(config, (cfg) => {
cfg.modResults.UIApplicationSceneManifest = {
UIApplicationSupportsMultipleScenes: false,
UISceneConfigurations: {
UIWindowSceneSessionRoleApplication: [{
UISceneConfigurationName: 'Default Configuration',
UISceneDelegateClassName: '$(PRODUCT_MODULE_NAME).SceneDelegate',
}],
},
};
return cfg;
});
return withAppDelegate(config, (cfg) => {
let src = cfg.modResults.contents;
// replace the window block, append SceneDelegate
// (full source in the repo linked at the bottom)
cfg.modResults.contents = src;
return cfg;
});
};
Make the string replacements assert. If the Expo template changes, you want a build failure, not a silently unpatched app.
Verify the artifact, not the source
The only proof that matters is the binary you uploaded:
APP=build/YourApp.xcarchive/Products/Applications/YourApp.app
/usr/libexec/PlistBuddy -c "Print :DTSDKName" "$APP/Info.plist"
/usr/libexec/PlistBuddy -c "Print :UIApplicationSceneManifest" "$APP/Info.plist"
/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$APP/Info.plist"
You want an iphoneos27.* SDK and a scene manifest and the build
number you think you shipped. Any two out of three is how this bug gets shipped twice.
Get new posts
One email when a new post ships. No spam.