React Native teams face two separate problems this autumn, and one is much more urgent than the other.
- iOS 27 can stop your app launching. Apps built with the iOS 27 SDK must use UIKit’s scene-based life cycle, and no stable React Native release supports it yet. This affects every React Native iOS app from its first iOS 27 build, iPhone Duo or not.
- iPhone Duo exposes layout shortcuts. A React Native app resizes correctly on the foldable, but common patterns break, and nothing in React Native can tell your JavaScript where the fold is.
This post takes them in that order. Status is as of the date at the top; the linked issues move quickly.
Part 1: the iOS 27 launch failure
Apple’s iOS 27 release notes: “Apps built with the latest SDK must adopt the scene-based life cycle or they fail to launch.” An unmodified Expo SDK 56 app, built with Xcode 27, stops at launch with:
Application failed to launch: UIScene life cycle is required for apps built
with this SDK. See Technote TN3187 for more information on migration.
That report, in expo/expo#46664, lists react-native: 0.85.3 in its environment.
| Stack | Status | What it means for you |
|---|---|---|
| UIKit / SwiftUI (native) | You migrate | Adopt a scene manifest and a window scene delegate. SwiftUI apps using the App protocol already use scenes.TN3187 |
| Flutter | Shipped in 3.41 | Default since 3.41; unmodified AppDelegates migrate automatically on build. Custom AppDelegates, and older Flutter versions, migrate by hand.Flutter migration guide |
| React Native | 0.88 release candidate | Scene life cycle support, including linking, is in the 0.88 release candidates — 0.88.0-rc.1 is current — and in no stable release. 0.88 stable is scheduled for 12 October. On 0.87 or earlier, add a SceneDelegate to your iOS project yourself.Commit · 0.88 on npm · Release schedule · Template PR for 0.87 |
| Expo | Shipped, opt-in on 57 | SDK 58 beta uses scenes by default. On SDK 57 the runtime shipped too, but you opt in: set ios.enableSceneSupport in expo-build-properties and run expo 57.0.23 or newer. SDK 56 still fails to launch under Xcode 27.SDK 58 beta · enableSceneSupport · SDK 57 runtime · Original issue |
Where React Native and Expo stand
- React Native core: scene life cycle support landed on 11 August. The change adds a SceneDelegate path, keeps the AppDelegate path for compatibility, and updates linking, window resolution and the HelloWorld template. It ships in the 0.88 release candidates — 0.88.0-rc.1 is current. It is not in any stable release — 0.87.1, the latest stable, doesn’t include it, and 0.88 stable is scheduled for 12 October 2026.
- Community template for 0.87: a pull request adopting the scene life cycle so 0.87 apps launch with Xcode 27 (template#251) is open against
0.87-stable. - Expo: this is now solved on SDK 57 and 58. The scene runtime shipped on the
sdk-57branch, and you opt in withios.enableSceneSupportinexpo-build-propertiesonexpo57.0.23 or newer; SDK 58 beta uses scenes by default. Full details, including why the original backport was closed, in Expo SDK 58 beta, iOS 27 scenes and iPhone Duo.
If you are on Expo, stop here and go to the Expo post — your fix is a config flag, not a code change. For bare React Native there are two routes. Wait for 0.88 to go stable on 12 October and adopt its template changes, which is the cleaner long-term path because core now handles linking and window lookup for scenes. Or, if you need to ship an iOS 27 build before then, carry the migration yourself on 0.87 or earlier.
On 0.87 or earlier: add a scene delegate yourself
The community template PR shows the shape of the change for the current Swift template. It touches three files.
Info.plist gains a scene manifest pointing at a new SceneDelegate:
<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>
AppDelegate.swift stops creating the window. It keeps the launch options for the scene to use, and returns a scene configuration:
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var reactNativeDelegate: ReactNativeDelegate?
var reactNativeFactory: RCTReactNativeFactory?
var launchOptions: [UIApplication.LaunchOptionsKey: Any]?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
self.launchOptions = launchOptions
return true
}
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
let configuration = UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
configuration.delegateClass = SceneDelegate.self
return configuration
}
}
SceneDelegate.swift (new) creates the window from the scene and starts React Native there:
import UIKit
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var reactNativeDelegate: ReactNativeDelegate?
var reactNativeFactory: RCTReactNativeFactory?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let appDelegate = UIApplication.shared.delegate as? AppDelegate
let rnDelegate = appDelegate?.reactNativeDelegate ?? ReactNativeDelegate()
rnDelegate.dependencyProvider = RCTAppDependencyProvider()
let factory = appDelegate?.reactNativeFactory ?? RCTReactNativeFactory(delegate: rnDelegate)
reactNativeDelegate = rnDelegate
reactNativeFactory = factory
appDelegate?.reactNativeDelegate = rnDelegate
appDelegate?.reactNativeFactory = factory
let window = UIWindow(windowScene: windowScene)
factory.startReactNative(
withModuleName: "YourAppName",
in: window,
launchOptions: appDelegate?.launchOptions
)
self.window = window
appDelegate?.window = window
}
}
Keep appDelegate?.window = window — it isn’t decoration. React Native has shipped code that reads the app delegate’s window: #53602 was a crash from exactly that under a scene delegate. The fix was merged, but native libraries can make the same assumption.
What this template change does not cover, and you must test:
- Deep links and universal links. With a scene delegate in place, UIKit delivers URLs to the scene, not the app delegate. A commenter on the Expo issue reports this breaking React Native’s initial-URL handling. The 0.88 core change updates linking for scenes; this 0.87 template diff doesn’t. Test a cold-start deep link, a warm deep link and a universal link before shipping.
- Every native SDK that hooks app-delegate events — push notifications, analytics, attribution, crash reporting. Check each vendor’s iOS 27 notes.
- Your own app-delegate customisations. The template diff assumes an unmodified template. Most production app delegates aren’t.
Expo
For a managed or prebuild Expo app, the migration belongs in Expo’s generated iOS project rather than in your code — and as of 20 September 2026 you do not have to do it yourself at all. Set ios.enableSceneSupport in expo-build-properties, run expo 57.0.23 or newer, and SDK 57 builds with Xcode 27. SDK 58 beta needs no flag. The community config plugins that circulated in the issue thread are no longer the answer, and one of them is still unpublished on npm; the Expo SDK 58 beta post has the flag, the version floor and the reasoning.
One Xcode 27 tooling change also hit Expo. npx expo run:ios failed because Xcode 27 manages simulators through Device Hub, and Expo CLI looked for Simulator.app (#46882). A fix has been merged in the CLI.
Part 2: iPhone Duo layout
The good news first. When iPhone Duo opens, closes or rotates, UIKit resizes your app’s root view, and React Native lays out again with the new size. Flexbox layouts adapt. useWindowDimensions() reports the new size. Safe-area insets from UIKit reach JavaScript through libraries such as react-native-safe-area-context.
The problems are shortcuts that assumed the screen never changes shape while the app runs.
Trap 1: a width captured once
// Breaks on iPhone Duo — evaluated once, when the module loads
import { Dimensions, StyleSheet } from 'react-native';
const { width } = Dimensions.get('window');
const styles = StyleSheet.create({
card: { width: width * 0.9 },
});
Anything derived from that value — a StyleSheet entry, a “responsive” percentage, a column count — stays at the closed-device width after the device unfolds. Read the size during render instead:
import { useWindowDimensions, View } from 'react-native';
function Card({ children }: { children: React.ReactNode }) {
const { width } = useWindowDimensions();
return <View style={{ width: width * 0.9 }}>{children}</View>;
}
Trap 2: orientation locks
Apple is explicit: iPhone Duo’s inner display doesn’t honor your supported interface orientations. A React Native app that locks to portrait — through Info.plist or an orientation-locking library — can’t rely on that lock on the inner display. Layouts that only ever handled one tall, narrow shape are where this shows.
Branch on available space, not orientation or device type. A single width breakpoint handles the outer display, the inner display in either orientation, and Split View:
const { width } = useWindowDimensions();
const columns = width >= 600 ? 2 : 1;
Device-type checks answer the wrong question. Apple’s guidance is to avoid assumptions about display size based on idiom, because iPhone Duo is still an iPhone with a very different amount of space.
Trap 3: symmetric safe-area maths
On iPhone Duo, toolbars and tab bars can sit along one side. Apple’s guidance: safe areas are often asymmetric, and code should handle each side independently.
The same bug is common in React Native:
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const insets = useSafeAreaInsets();
// Wrong on iPhone Duo — assumes both sides match
<View style={{ paddingHorizontal: insets.left }} />
// Right — each side on its own
<View style={{ paddingLeft: insets.left, paddingRight: insets.right }} />
Trap 4: tab bars drawn in JavaScript
iPhone Duo moves tab bars and toolbars to the side of the outer display, and of the inner display in landscape — for the system’s own bars. Apple’s sample code notes that content from bars you place yourself won’t be considered.
This is our analysis, and it matters for most React Native apps. A tab bar rendered by a JavaScript navigation library is a row of ordinary views, not a system tab bar, so iOS has no reason to move it. Expect it to stay a horizontal strip at the bottom of a wide display, where Apple’s own apps put controls on the side. Tab implementations backed by native UIKit tab bar controllers are the ones positioned to pick up the system behaviour. Treat the switch as a design decision to test in Device Hub — which you can do now, in the Xcode 27.1 beta — not an assumption.
Part 3: making the fold visible to JavaScript
iOS 27.1 adds reserved regions: areas of the screen your UI should avoid. On iPhone Duo these are the camera regions and the fold.
Apple’s design guidelines say to keep important elements clear of the centre when the system doesn’t move them automatically, and to prefer an even number of columns in grids so content divides cleanly. React Native has no API for any of this, and none has been announced. A React Native app can be correct on iPhone Duo — resizing and respecting insets — while having no idea the fold exists.
If your product needs to know — a video call that puts faces on either side, a reading app that paginates across halves, a game — you need a native module.
Here is the JavaScript side. It uses only long-established React Native APIs, so it works today and returns an empty array on any device without a fold:
import { useEffect, useState } from 'react';
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
export type FoldRect = { x: number; y: number; width: number; height: number };
const native = Platform.OS === 'ios' ? NativeModules.DuoFoldModule : undefined;
const emitter = native ? new NativeEventEmitter(native) : undefined;
/** Active fold regions, in points. Empty when flat or on a device without a fold. */
export function useFoldState(): FoldRect[] {
const [folds, setFolds] = useState<FoldRect[]>([]);
useEffect(() => {
if (!emitter) return;
const sub = emitter.addListener('foldStateDidChange', (e: { folds: FoldRect[] }) => setFolds(e.folds));
return () => sub.remove();
}, []);
return folds;
}
The native side reads the fold with the API Apple showed in the “Strike a pose” Tech Talk, view.reservedRegions(kind: .division), and forwards the frames:
// NOT COMPILED: requires the iOS 27.1 SDK in Xcode 27.1, which is not yet
// released. API names are from Apple's Tech Talk sample code.
@objc(DuoFoldModule)
final class DuoFoldModule: RCTEventEmitter {
override func supportedEvents() -> [String]! { ["foldStateDidChange"] }
override static func requiresMainQueueSetup() -> Bool { true }
/// Call on layout changes of the root React Native view.
func publishFolds(for rootView: UIView) {
let frames = rootView.reservedRegions(kind: .division).map(\.frame)
sendEvent(withName: "foldStateDidChange", body: [
"folds": frames.map { ["x": $0.minX, "y": $0.minY, "width": $0.width, "height": $0.height] }
])
}
}
Three design notes for whoever builds this for real:
- By default only active regions are returned. Apple’s talk says the fold’s division region is active only when someone has folded the device, and has zero width when flat. An empty array therefore means “no fold right now,” which is the value your layout wants.
- Use the hinge for effects, never for layout. UIKit’s
UIHingeInteractionreports the hinge as closed, partially open or fully open, plus a continuous angle. Apple positions hinge data for interactions and effects; layout should come from regions and size. Apple hasn’t published code forUIHingeInteractionyet, so we don’t show any. - Don’t ship this before you can test it. Until the Xcode 27.1 simulator exists, this is a design, not a module.
A plan, in order
- Now: build with Xcode 27 on a branch. Choose your scene life cycle route — React Native 0.88 when stable, a scene delegate of your own on 0.87, or Expo’s release — and test deep links, notifications and every native SDK.
- Now: search the codebase for
Dimensions.get,paddingHorizontal: insets, orientation locks and device-type checks. Replace them withuseWindowDimensions(), per-side insets and width breakpoints. - Now: decide whether your tab bar should become a native one.
- Now, in the Xcode 27.1 beta: run the app in Device Hub on the iPhone Duo simulator and go through every pose and Split View. Widgets and share extensions are the exception — the simulator will not run them.
- Only if your product needs it: build the fold bridge.
Our rough planning estimate for an established app, not a quote: steps 1 and 2 are typically days to a couple of weeks, depending on how customised the iOS start-up code is. Moving a JavaScript tab bar to native tabs, or building the fold bridge, is a separate, larger piece of work worth scoping on its own.
For a per-issue reference of what breaks on iPhone Duo, with diagrams for each case, see the React Native guide on iphoneduosupport.com. For the iOS 27 changes beyond the scene life cycle, see iOS 27 for developers.