DEV Community

Cover image for iOS SceneDelegate and React Native: what changed and how to fix it
Ahsan Ali
Ahsan Ali

Posted on

iOS SceneDelegate and React Native: what changed and how to fix it

TL;DR

Any UIKit app built with the iOS 27 SDK (Xcode 27) that hasn't adopted the UIScene lifecycle won't launch. React Native apps are UIKit apps, so every RN app is affected, whatever the RN version.

  • Deadline: the day you build with Xcode 27. Binaries built with Xcode 26 keep launching on iOS 27; Apple says it strives for binary compatibility but won't guarantee zero issues.
  • RN 0.88+: move startup into a SceneDelegate using the new startReactNative(withModuleName:in:connectionOptions:), forward links to RCTLinkingManager, add UIApplicationSceneManifest to Info.plist.
  • RN 0.79–0.87: same shape, but you build the root view yourself from reactNativeFactory.rootViewFactory and handle cold-start links by hand.
  • RN 0.74–0.78: keep your RCTAppDelegate subclass, but build the window in a SceneDelegate from its rootViewFactory.
  • RN below 0.74: create the root view in SceneDelegate from the RCTBridge the AppDelegate owns, with RCTRootView(bridge:moduleName:initialProperties:).
  • Watch: deep links, Universal Links, push-tap routing and third-party SDKs that hook AppDelegate callbacks.

Before: one app, one window, one AppDelegate

Until iOS 13, the UIApplicationDelegate (your AppDelegate) owned everything: process launch, the single UIWindow, foreground/background state, deep links and push.

A typical flow:

  1. UIKit calls application(_:didFinishLaunchingWithOptions:) with a launchOptions dictionary. If a URL or Universal Link opened the app, it's inside that dictionary.
  2. The AppDelegate creates UIWindow(frame: UIScreen.main.bounds), sets a root view controller and calls makeKeyAndVisible().
  3. State changes arrive as applicationDidBecomeActive, applicationWillResignActive, applicationDidEnterBackground and applicationWillEnterForeground.
  4. Warm deep links arrive in application(_:open:options:) and application(_:continue:restorationHandler:).

This model assumes the app is its window. Any code can reach the UI through UIApplication.shared.delegate.window or keyWindow, and lots of libraries do.

Now: AppDelegate for the process, SceneDelegate for the UI

A scene (UIWindowScene) is one instance of your app's UI. iOS 13 split responsibilities in two:

  • AppDelegate keeps process-level work: SDK init, push token registration, UNUserNotificationCenter delegate, background tasks, and choosing a scene configuration.
  • SceneDelegate (UIWindowSceneDelegate) owns a window and its lifecycle: create the window, go foreground/background, receive URLs and user activities.

The key behaviour change: once UIApplicationSceneManifest is in Info.plist, UIKit stops calling the AppDelegate's UI-lifecycle and deep-link methods and calls the scene equivalents instead.

AppDelegate (old) SceneDelegate (new)
Create window in didFinishLaunchingWithOptions scene(_:willConnectTo:options:), using UIWindow(windowScene:)
launchOptions[.url] (cold-start link) connectionOptions.urlContexts
launchOptions[.userActivityDictionary] (cold-start Universal Link) connectionOptions.userActivities
launchOptions[.shortcutItem] connectionOptions.shortcutItem
application(_:open:options:) scene(_:openURLContexts:)
application(_:continue:restorationHandler:) scene(_:continue:)
application(_:performActionFor:completionHandler:) windowScene(_:performActionFor:completionHandler:)
applicationDidBecomeActive / WillResignActive sceneDidBecomeActive / sceneWillResignActive
applicationDidEnterBackground / WillEnterForeground sceneDidEnterBackground / sceneWillEnterForeground

Unchanged on AppDelegate: didFinishLaunchingWithOptions (minus window work), didRegisterForRemoteNotificationsWithDeviceToken, notification delegate methods, background fetch and supportedInterfaceOrientationsFor.

Why Apple changed it, and when it bites

Apple's UI no longer fits "one app = one full-screen window". iPad multi-window, Stage Manager, resizable windows, external displays, Mac Catalyst and visionOS all need an app to show several independent UIs, each with its own state. A scene gives each window its own lifecycle and restoration state, which a single AppDelegate can't express. Apple has been deprecating the window-centric APIs (keyWindow, UIScreen.main-sized windows) in the same direction.

Apple rolled this out gradually (TN3187):

Release What happens to apps without UIScene
iOS 13 (2019) Scenes introduced; optional
iOS 18.4 Console log: the process doesn't adopt the UIScene lifecycle and this will become an assert
iOS 26 Log hardened: UIScene lifecycle will soon be required
iOS 27 SDK (Xcode 27) Assert at launch. The app doesn't launch

Two details from Apple engineers on the developer forums:

  • The assert is only evaluated when you build with the new SDK. An Xcode 26 build still runs on iOS 27, though Apple doesn't guarantee zero issues (thread).
  • Only adopting the scene lifecycle is required. Supporting multiple scenes is encouraged, not mandatory.

In practice, App Store Connect will eventually require the latest SDK for submissions, so "stay on Xcode 26" only buys time.

How React Native booted on iOS before 0.88

Every RN version up to 0.87 starts React Native from didFinishLaunchingWithOptions and creates the window itself from UIScreen.main.bounds. Only the wrapper changed over time:

RN version Entry point What it does at launch
≤ 0.70 Hand-written AppDelegate Creates RCTBridge(delegate:launchOptions:), then RCTRootView(bridge:moduleName:initialProperties:), puts it in a UIViewController, creates the window
0.71–0.73 Subclass of RCTAppDelegate Same work hidden inside the superclass's didFinishLaunching; you set moduleName and initialProps
0.74–0.78 Subclass of RCTAppDelegate Root view creation moves into RCTRootViewFactory (self.rootViewFactory), which also handles bridgeless/New Architecture
0.79–0.87 RCTReactNativeFactory + RCTDefaultReactNativeFactoryDelegate factory.startReactNative(withModuleName:in:launchOptions:) on a window built from UIScreen.main.bounds
0.88 SceneDelegate + RCTReactNativeFactory startReactNative(withModuleName:in:connectionOptions:) inside scene(_:willConnectTo:options:)

The 0.87 template shows the pattern every older version shares: the window and React Native both live on the AppDelegate.

// RN 0.87 template AppDelegate (abridged)
func application(_ application: UIApplication,
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
  let delegate = ReactNativeDelegate()
  let factory = RCTReactNativeFactory(delegate: delegate)
  delegate.dependencyProvider = RCTAppDependencyProvider()
  reactNativeDelegate = delegate
  reactNativeFactory = factory

  window = UIWindow(frame: UIScreen.main.bounds)
  factory.startReactNative(withModuleName: "HelloWorld", in: window, launchOptions: launchOptions)
  return true
}
Enter fullscreen mode Exit fullscreen mode

On the JS side, three modules depend on this native plumbing:

  • Linking.getInitialURL() reads the cold-start URL out of the launchOptions handed to the bridge/host.
  • Linking.addEventListener('url') fires when RCTLinkingManager receives a forwarded open URL or continue userActivity call.
  • AppState listens for UIApplication active/background notifications.

What breaks in a React Native app

If you do nothing and build with Xcode 27, the app asserts on launch. Adding only the scene manifest without moving code gives you subtler bugs:

Symptom Cause
Crash on launch (Xcode 27) No UIApplicationSceneManifest
Blank or white screen Manifest present, but the window is still created in AppDelegate. A scene-based app needs a window attached to a UIWindowScene
Linking.getInitialURL() returns null The cold-start URL moved from launchOptions to connectionOptions
Warm deep links and Universal Links do nothing application(_:open:) and continue userActivity on AppDelegate are no longer called
Home-screen quick actions ignored performActionFor moved to the window scene
Libraries can't find the window They read UIApplication.shared.delegate.window, which is now nil
Attribution/analytics SDKs lose deep-link events Their AppDelegate hooks or swizzles stop firing (Branch, AppsFlyer, Adjust, Airbridge, Firebase Dynamic Links-style SDKs)
Layout surprises on iPad The window becomes resizable; Dimensions and useWindowDimensions now change at runtime

Attribution vendors are already publishing scene migration guides for this. Airbridge's guide, for example, lists a blank screen and missing deep-link events as the two headline symptoms.

Fix A: upgrade to React Native 0.88

RN 0.88 adds scene support as an additive change: RCTReactNativeFactory gains startReactNativeWithModuleName:inWindow:connectionOptions: and a variant with initialProperties:. The old AppDelegate path still works under Xcode 26, so you can upgrade first and migrate second (RN 0.88 release notes).

1. Add SceneDelegate.swift. This is the 0.88 template file; replace HelloWorld with your module name.

import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
import UIKit

class SceneDelegate: RCTDefaultReactNativeFactoryDelegate, UIWindowSceneDelegate {
  var window: UIWindow?
  var reactNativeFactory: RCTReactNativeFactory?

  func scene(_ scene: UIScene,
             willConnectTo session: UISceneSession,
             options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = scene as? UIWindowScene else { return }

    dependencyProvider = RCTAppDependencyProvider()
    reactNativeFactory = RCTReactNativeFactory(delegate: self)
    window = UIWindow(windowScene: windowScene)

    #if DEBUG
    reactNativeFactory?.devMenuConfiguration = RCTDevMenuConfiguration(
      devMenuEnabled: true, shakeGestureEnabled: true, keyboardShortcutsEnabled: true)
    #endif

    reactNativeFactory?.startReactNative(
      withModuleName: "HelloWorld",
      in: window,
      connectionOptions: connectionOptions)
  }

  func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    RCTLinkingManager.scene(scene, openURLContexts: URLContexts)
  }

  func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    RCTLinkingManager.scene(scene, continue: userActivity)
  }

  override func bundleURL() -> URL? {
    #if DEBUG
    RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
    #else
    Bundle.main.url(forResource: "main", withExtension: "jsbundle")
    #endif
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Slim down AppDelegate. Remove the window, the factory and the RN start call. Keep Firebase, push registration and other process-level setup.

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
  func application(_ application: UIApplication,
                   didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
    // FirebaseApp.configure(), push setup, SDK init...
    return true
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Add the scene manifest to Info.plist. Keep UIApplicationSupportsMultipleScenes set to false: 0.88 supports a single scene only.

<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>
Enter fullscreen mode Exit fullscreen mode

4. Move any custom AppDelegate code for links, quick actions or lifecycle into the matching scene method (mapping table above).

Limits in 0.88: only one scene, and at cold start RN forwards only the first URL context or user activity from connectionOptions. Expo apps get this through Expo SDK 58.

Fix B: stay on an older React Native version

You can adopt scenes on any RN version. React Native only needs a root UIView, and UIKit doesn't care who creates it. Without 0.88's helpers, your SceneDelegate does three jobs by hand:

  1. Build the root view and attach it to a scene-backed window.
  2. Turn connectionOptions back into a launchOptions-shaped dictionary, so Linking.getInitialURL() keeps working.
  3. Forward warm URLs and user activities to RCTLinkingManager's existing application(...) class methods.

Add the same Info.plist manifest as in Fix A, then pick the variant for your version.

Shared helper (all versions)

// Rebuilds the dictionary RCTLinkingManager reads for getInitialURL()
func rnLaunchOptions(from options: UIScene.ConnectionOptions) -> [UIApplication.LaunchOptionsKey: Any] {
  var launch: [UIApplication.LaunchOptionsKey: Any] = [:]
  if let url = options.urlContexts.first?.url {
    launch[.url] = url
  }
  if let activity = options.userActivities.first {
    launch[.userActivityDictionary] = [
      "UIApplicationLaunchOptionsUserActivityKey": activity,
      "UIApplicationLaunchOptionsUserActivityTypeKey": activity.activityType,
    ]
  }
  return launch
}
Enter fullscreen mode Exit fullscreen mode

RN 0.79–0.87 (RCTReactNativeFactory)

Keep creating the factory in AppDelegate, but delete the window and startReactNative lines. Keep a window property on AppDelegate too, because libraries still read delegate.window.

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 }

    let rootView = factory.rootViewFactory.view(
      withModuleName: "HelloWorld",
      initialProperties: nil,
      launchOptions: rnLaunchOptions(from: connectionOptions))

    let vc = UIViewController()
    vc.view = rootView
    let window = UIWindow(windowScene: windowScene)
    window.rootViewController = vc
    window.makeKeyAndVisible()
    self.window = window
    appDelegate.window = window // for libraries that read delegate.window
  }

  func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
    guard let url = URLContexts.first?.url else { return }
    RCTLinkingManager.application(UIApplication.shared, open: url, options: [:])
  }

  func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    RCTLinkingManager.application(UIApplication.shared, continue: userActivity,
                                  restorationHandler: { _ in })
  }
}
Enter fullscreen mode Exit fullscreen mode

RN 0.74–0.78 (RCTAppDelegate subclass)

The superclass's didFinishLaunching builds rootViewFactory and then also creates a screen-sized window. You want the first part, not the second.

  • If your RCTAppDelegate exposes automaticallyLoadReactNativeWindow (check the header in node_modules/react-native/Libraries/AppDelegate/RCTAppDelegate.h), set it to false before calling super. Then use the 0.79 SceneDelegate above with appDelegate.rootViewFactory.view(...) in place of factory.rootViewFactory.
  • If it doesn't, the clean fix is a small patch-package patch that skips the window block in RCTAppDelegate.mm, or upgrading to 0.79+. Calling super and then making a second root view starts the JS app twice.

RN 0.70 and earlier, or 0.71–0.73 on the old architecture (RCTBridge)

Stop subclassing RCTAppDelegate (on 0.71–0.73) and use a plain UIApplicationDelegate. Create the bridge inside the scene, so it receives the cold-start link:

class SceneDelegate: UIResponder, UIWindowSceneDelegate, RCTBridgeDelegate {
  var window: UIWindow?
  var bridge: RCTBridge?

  func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
             options connectionOptions: UIScene.ConnectionOptions) {
    guard let windowScene = scene as? UIWindowScene else { return }
    bridge = RCTBridge(delegate: self, launchOptions: rnLaunchOptions(from: connectionOptions))
    let rootView = RCTRootView(bridge: bridge!, moduleName: "HelloWorld", initialProperties: nil)

    let vc = UIViewController()
    vc.view = rootView
    let window = UIWindow(windowScene: windowScene)
    window.rootViewController = vc
    window.makeKeyAndVisible()
    self.window = window
  }

  func sourceURL(for bridge: RCTBridge) -> URL? {
    #if DEBUG
    return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
    #else
    return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
    #endif
  }

  // openURLContexts / continue userActivity: same forwarding as the 0.79 variant
}
Enter fullscreen mode Exit fullscreen mode

If a native SDK needs the bridge during didFinishLaunching, keep creating it there. Then, once JS has loaded, forward the scene's cold-start URL with RCTLinkingManager.application(_:open:options:), so it arrives as a Linking url event instead of through getInitialURL().

Expo

Expo SDK 58 ships RN 0.88 with this built in. On older SDKs, write a config plugin that applies the changes above at prebuild. Editing ios/ by hand gets wiped on the next prebuild.

Be realistic about very old versions

Xcode 27 is a new toolchain. RN versions several releases behind may hit unrelated build failures with it (C++ headers, Folly, Hermes builds). Scene adoption is necessary but may not be enough. Below roughly 0.76, budget for an upgrade anyway.

Deep links, push and third-party SDKs

This is where most migrations actually break. Audit everything that touches AppDelegate:

  • Custom URL schemes (warm): handled in scene(_:openURLContexts:) and forwarded to RCTLinkingManager.
  • Universal Links (warm): handled in scene(_:continue:).
  • Cold-start links: come from connectionOptions in willConnectTo. In 0.88 only the first item is forwarded. On older versions, use the rnLaunchOptions helper.
  • Attribution SDKs (Branch, AppsFlyer, Adjust, Airbridge, your own): upgrade to a scene-aware version and call their scene APIs from SceneDelegate. Check that their swizzling doesn't still assume AppDelegate.
  • Push notifications: token registration and UNUserNotificationCenterDelegate stay on AppDelegate. A notification tap that opens a deep link still reaches JS through your push library.
  • Home-screen quick actions: move to windowScene(_:performActionFor:completionHandler:), plus connectionOptions.shortcutItem at cold start.
  • OAuth / SSO callbacks (react-native-app-auth, Google Sign-In, Facebook Login): they usually resume through open URL, so forward from the scene method to the SDK as well as to RCTLinkingManager.
  • Libraries reading delegate.window or keyWindow (splash screens, toasts, modal hosts, react-native-navigation): set appDelegate.window from the scene, or upgrade the library.
  • AppState and analytics session timing: verify foreground/background events still fire as expected.

Testing checklist and gotchas

Test on a release build, on a real device, with the app killed between cold-start cases.

Case How to trigger Expect
Normal launch Tap icon No blank screen; no UIScene warning in the Xcode console
Cold custom-scheme link xcrun simctl openurl booted myapp://path with the app killed getInitialURL() returns the URL
Warm custom-scheme link Same command, app in background Linking url event fires once
Cold Universal Link Tap a link in Notes or Messages, app killed Correct screen opens
Warm Universal Link Same, app in background Correct screen opens
Push tap (killed / background) Send a test push Routing works; token still registers
Quick action Long-press icon Action handled
Background → foreground Home, then reopen AppState changes; no JS reload
iPad resize / Stage Manager Resize window Layout responds to useWindowDimensions
Dev menu Shake (debug) Menu opens

Gotchas:

  • UISceneDelegateClassName must be $(PRODUCT_MODULE_NAME).SceneDelegate for a Swift class. For Objective-C, use the bare class name.
  • Don't create React Native in both AppDelegate and SceneDelegate. Two root views means two JS app instances.
  • Keep UIApplicationSupportsMultipleScenes set to false until RN supports multiple scenes; a second scene would need its own RN surface.
  • Remove leftover window = UIWindow(frame: UIScreen.main.bounds) lines. A window with no scene never shows.
  • Watch the Xcode console for the UIScene log message. If it's gone, UIKit sees you as scene-based.

Sources

The older-version code in Fix B isn't from an official template. It's built on public RN APIs (RCTRootViewFactory, RCTRootView, RCTLinkingManager), so check it against the headers in your installed RN version.

Top comments (0)