← Back to posts
Software Dev 8 min read

Expo iOS 27 crash on launch: a UIScene fix that forwards deep links

Expo SDK 54 apps built with Xcode 27 crash on launch on iOS 27. A UIScene config plugin fixes it and forwards expo-router deep links.

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 SDKWhat to do
58Nothing, scene support is the default. SDK 58 is still in beta and I haven’t run it.
57Update 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 56I 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.

Two launch sequences side by side. Without scenes, iOS 27 creates the first scene, finds no scene lifecycle and traps before the first frame. With the plugin, a scene manifest and a SceneDelegate make the window and the app draws its first frame. SDK 54 template with the plugin UIKit launches the app iOS 27 creates the first scene no manifest in Info.plist, no scene delegate to hand it to SIGTRAP no scene lifecycle adopted dies before the first frame UIKit launches the app AppDelegate builds the React Native factory, no window any more iOS 27 creates the first scene finds the manifest in Info.plist SceneDelegate makes the window on the scene, starts React Native, forwards links to Expo first frame drawn
Launching on iOS 27: the stock SDK 54 app, and with the plugin

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.

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.

How a cold-launch link reaches expo-router under scenes. The scene delegate first hands the link to the app delegate so expo-linking records it, and also puts it in the launch options, then starts React Native. The first version only did the launch options and expo-router opened on the root route. cold start from a link, app closed scene(_:willConnectTo:) the link arrives in connectionOptions, not launchOptions 1 AppDelegate open expo-linking records the URL before React Native starts 2 launchOptions[.url] what React Native's getInitialURL() reads start React Native expo-router opens the route first version: only step 2 launchOptionsonly expo-router asksexpo-linking: no URL opens on /
The cold-launch path, with the first version underneath

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/app puts FirebaseApp.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 your plugins list 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 57 enableSceneSupport has 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.

// join 78 subscribers

Enjoyed that? There are more.

Hi! I'm Jonah and I have thoughts that I share — sometimes. Sign up to receive awesome content in your inbox every week, month, when I get around to it. We don't spam. That's yuck.

Double opt-in — check your inbox (or spam folder) to confirm.