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
SceneDelegateusing the newstartReactNative(withModuleName:in:connectionOptions:), forward links toRCTLinkingManager, addUIApplicationSceneManifesttoInfo.plist. -
RN 0.79–0.87: same shape, but you build the root view yourself from
reactNativeFactory.rootViewFactoryand handle cold-start links by hand. -
RN 0.74–0.78: keep your
RCTAppDelegatesubclass, but build the window in aSceneDelegatefrom itsrootViewFactory. -
RN below 0.74: create the root view in
SceneDelegatefrom theRCTBridgethe AppDelegate owns, withRCTRootView(bridge:moduleName:initialProperties:). -
Watch: deep links, Universal Links, push-tap routing and third-party SDKs that hook
AppDelegatecallbacks.
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:
- UIKit calls
application(_:didFinishLaunchingWithOptions:)with alaunchOptionsdictionary. If a URL or Universal Link opened the app, it's inside that dictionary. - The AppDelegate creates
UIWindow(frame: UIScreen.main.bounds), sets a root view controller and callsmakeKeyAndVisible(). - State changes arrive as
applicationDidBecomeActive,applicationWillResignActive,applicationDidEnterBackgroundandapplicationWillEnterForeground. - Warm deep links arrive in
application(_:open:options:)andapplication(_: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,
UNUserNotificationCenterdelegate, 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
}
On the JS side, three modules depend on this native plumbing:
-
Linking.getInitialURL()reads the cold-start URL out of thelaunchOptionshanded to the bridge/host. -
Linking.addEventListener('url')fires whenRCTLinkingManagerreceives a forwardedopen URLorcontinue userActivitycall. -
AppStatelistens forUIApplicationactive/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
}
}
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
}
}
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>
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:
- Build the root view and attach it to a scene-backed window.
- Turn
connectionOptionsback into alaunchOptions-shaped dictionary, soLinking.getInitialURL()keeps working. - Forward warm URLs and user activities to
RCTLinkingManager's existingapplication(...)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
}
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 })
}
}
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
RCTAppDelegateexposesautomaticallyLoadReactNativeWindow(check the header innode_modules/react-native/Libraries/AppDelegate/RCTAppDelegate.h), set it tofalsebefore callingsuper. Then use the 0.79SceneDelegateabove withappDelegate.rootViewFactory.view(...)in place offactory.rootViewFactory. - If it doesn't, the clean fix is a small
patch-packagepatch that skips the window block inRCTAppDelegate.mm, or upgrading to 0.79+. Callingsuperand 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
}
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 toRCTLinkingManager. -
Universal Links (warm): handled in
scene(_:continue:). -
Cold-start links: come from
connectionOptionsinwillConnectTo. In 0.88 only the first item is forwarded. On older versions, use thernLaunchOptionshelper. -
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
UNUserNotificationCenterDelegatestay 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:), plusconnectionOptions.shortcutItemat cold start. -
OAuth / SSO callbacks (
react-native-app-auth, Google Sign-In, Facebook Login): they usually resume throughopen URL, so forward from the scene method to the SDK as well as toRCTLinkingManager. -
Libraries reading
delegate.windoworkeyWindow(splash screens, toasts, modal hosts,react-native-navigation): setappDelegate.windowfrom the scene, or upgrade the library. -
AppStateand 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:
-
UISceneDelegateClassNamemust be$(PRODUCT_MODULE_NAME).SceneDelegatefor a Swift class. For Objective-C, use the bare class name. - Don't create React Native in both
AppDelegateandSceneDelegate. Two root views means two JS app instances. - Keep
UIApplicationSupportsMultipleScenesset tofalseuntil 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
- TN3187: Migrating to the UIKit scene-based life cycle (Apple)
- Regarding the deadline for adopting the UIScene life cycle (Apple Developer Forums)
- Mandatory UIScene lifecycle support in iOS 27 (Apple Developer Forums)
- React Native 0.88 release notes
- RN 0.88 template SceneDelegate.swift
- RN 0.88 template Info.plist
- RN 0.87 template AppDelegate.swift
- RCTReactNativeFactory PR #46298
- Airbridge: Xcode 27 SceneDelegate migration for React Native
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)