Boolean Build

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:

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.