From 8980a997797b118e22fece9bb9bbdad49bf7b67a Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 27 Aug 2026 18:54:37 -0700 Subject: [PATCH 1/2] Adopt the UIScene lifecycle on iOS iOS 26 deprecates the UIApplicationDelegate window and lifecycle callbacks once an app ships a scene manifest, and Xcode 27 is expected to remove the UIDesignRequiresCompatibility opt-out we currently rely on. Move window creation, foreground/background transitions, deep links and home-screen quick actions onto a new SceneDelegate, and leave AppDelegate owning only app-wide concerns. React Native 0.79 has no scene-aware entry points, so the scene rebuilds the launch options RCTLinkingManager.getInitialURL reads from the scene connection options. Without that, a cold-launch deep link would resolve to null. Also replaces applicationIconBadgeNumber, deprecated since iOS 17. --- ios/edge.xcodeproj/project.pbxproj | 4 + ios/edge/AppDelegate.swift | 110 +++++--------------- ios/edge/Info.plist | 17 +++ ios/edge/SceneDelegate.swift | 160 +++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 83 deletions(-) create mode 100644 ios/edge/SceneDelegate.swift diff --git a/ios/edge.xcodeproj/project.pbxproj b/ios/edge.xcodeproj/project.pbxproj index 72954219091..c33f10cde43 100644 --- a/ios/edge.xcodeproj/project.pbxproj +++ b/ios/edge.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 3D5BD9CF2A4D27F300590088 /* custom.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 3D5BD9AD2A4D277B00590088 /* custom.ttf */; }; 3D88EE2D2E3C3BE80086BA9D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D88EE2C2E3C3BE80086BA9D /* AppDelegate.swift */; }; 3D88EE312E3D670F0086BA9D /* Sentry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D88EE302E3D670F0086BA9D /* Sentry.swift */; }; + 3DA1F0012E7A11B000C0FFEE /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA1F0022E7A11B000C0FFEE /* SceneDelegate.swift */; }; 3DB299A62BCEF2A600D867B0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3DB299A52BCEF2A600D867B0 /* PrivacyInfo.xcprivacy */; }; 812B284944A10A722B22763D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08ADAD14914ABC9FD7D54456 /* ExpoModulesProvider.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; @@ -80,6 +81,7 @@ 3D5BD9BC2A4D277B00590088 /* Quicksand-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Quicksand-Medium.ttf"; path = "../android/app/src/main/assets/fonts/Quicksand-Medium.ttf"; sourceTree = ""; }; 3D88EE2C2E3C3BE80086BA9D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = edge/AppDelegate.swift; sourceTree = ""; }; 3D88EE302E3D670F0086BA9D /* Sentry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sentry.swift; sourceTree = ""; }; + 3DA1F0022E7A11B000C0FFEE /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SceneDelegate.swift; path = edge/SceneDelegate.swift; sourceTree = ""; }; 3DB299A52BCEF2A600D867B0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = edge/PrivacyInfo.xcprivacy; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-edge.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-edge.release.xcconfig"; path = "Target Support Files/Pods-edge/Pods-edge.release.xcconfig"; sourceTree = ""; }; 5B7EB9410499542E8C5724F5 /* Pods-edge-edgeTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-edge-edgeTests.debug.xcconfig"; path = "Target Support Files/Pods-edge-edgeTests/Pods-edge-edgeTests.debug.xcconfig"; sourceTree = ""; }; @@ -108,6 +110,7 @@ isa = PBXGroup; children = ( 3D88EE2C2E3C3BE80086BA9D /* AppDelegate.swift */, + 3DA1F0022E7A11B000C0FFEE /* SceneDelegate.swift */, 3D5BD98B2A4D245500590088 /* edge.entitlements */, 3D5BD9892A4CF04C00590088 /* GoogleService-Info.plist */, 13B07FB51A68108700A75B9A /* Images.xcassets */, @@ -464,6 +467,7 @@ 3D88EE312E3D670F0086BA9D /* Sentry.swift in Sources */, 3D5BD9882A4CEFC700590088 /* Base58.swift in Sources */, 3D88EE2D2E3C3BE80086BA9D /* AppDelegate.swift in Sources */, + 3DA1F0012E7A11B000C0FFEE /* SceneDelegate.swift in Sources */, 3D5BD9862A4CEFB900590088 /* EdgeCore.swift in Sources */, 812B284944A10A722B22763D /* ExpoModulesProvider.swift in Sources */, 04DBAACE71E94A3B9BCAF10A /* EdgeAttestation.swift in Sources */, diff --git a/ios/edge/AppDelegate.swift b/ios/edge/AppDelegate.swift index 5e72ff52b2e..2406e3ea47d 100644 --- a/ios/edge/AppDelegate.swift +++ b/ios/edge/AppDelegate.swift @@ -1,4 +1,3 @@ -import ExpoQuickActions import Firebase import FirebaseMessaging import RNBootSplash @@ -10,41 +9,22 @@ import UserNotifications @main class AppDelegate: UIResponder, UIApplicationDelegate { + /// The scene owns the window, but third-party code still reaches for it here. + /// `@react-native-firebase/messaging` reads `application.delegate.window` on + /// `UIApplicationDidFinishLaunchingNotification`, which raises + /// `doesNotRecognizeSelector` if the delegate has no `window` property at all, + /// so `SceneDelegate` mirrors its window into this. var window: UIWindow? - var securityView: UIView? var reactNativeDelegate: ReactNativeDelegate? var reactNativeFactory: RCTReactNativeFactory? - /** - * Handles deep links. - * From https://reactnative.dev/docs/0.79/linking?ios-language=swift#enabling-deep-links - */ - func application( - _ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] - ) -> Bool { - return RCTLinkingManager.application(app, open: url, options: options) - } - - /** - * Handles deep links. - * From https://reactnative.dev/docs/0.79/linking?ios-language=swift#enabling-deep-links - */ - func application( - _ application: UIApplication, - continue userActivity: NSUserActivity, - restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void - ) -> Bool { - return RCTLinkingManager.application( - application, - continue: userActivity, - restorationHandler: restorationHandler - ) - } - /** * Handles app start-up. * React Native template code. + * + * The window and everything tied to its lifecycle lives in `SceneDelegate`, + * since this app adopts the `UIScene` lifecycle. */ func application( _ application: UIApplication, @@ -57,14 +37,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Client-side background fetch interval: application.setMinimumBackgroundFetchInterval(60 * 60 * 12) - // Capture a home-screen quick action on a cold launch so - // expo-quick-actions can report it as the initial action once JS loads. - // This delegate is not an ExpoAppDelegate, so the library's own - // subscriber never runs. Edge addition. - if let shortcutItem = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem { - ExpoQuickActions.initialAction = shortcutItem - } - // React Native template code: let delegate = ReactNativeDelegate() let factory = RCTReactNativeFactory(delegate: delegate) @@ -73,30 +45,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate { reactNativeDelegate = delegate reactNativeFactory = factory - window = UIWindow(frame: UIScreen.main.bounds) - - factory.startReactNative( - withModuleName: "edge", - in: window, - launchOptions: launchOptions - ) - return true } /** - * Handles home-screen quick action taps while the app is running. - * Mirrors ExpoQuickActionsAppDelegate, which never runs because this - * delegate is not an ExpoAppDelegate. Edge addition. + * Points every new scene at our `SceneDelegate`. + * The scene manifest in Info.plist names the same configuration. */ func application( _ application: UIApplication, - performActionFor shortcutItem: UIApplicationShortcutItem, - completionHandler: @escaping (Bool) -> Void - ) { - NotificationCenter.default.post( - name: Notification.Name("onQuickAction"), object: shortcutItem) - completionHandler(true) + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + return UISceneConfiguration( + name: "Default Configuration", + sessionRole: connectingSceneSession.role + ) } /** @@ -130,7 +94,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { print("Background notification: \(message)") DispatchQueue.main.async { - application.applicationIconBadgeNumber = problemUsers.count + setBadgeCount(problemUsers.count) let content = UNMutableNotificationContent() content.title = "Urgent Security Issue" @@ -147,36 +111,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } +} - /** - * Hides the app when we go into the background. - * Edge addition. - */ - func applicationDidEnterBackground(_ application: UIApplication) { - guard - let storyboard = UIStoryboard(name: "LaunchScreen", bundle: nil) as UIStoryboard?, - let launchScreen = storyboard.instantiateInitialViewController(), - let launchView = launchScreen.view, - let window = self.window - else { - return - } - - launchView.frame = window.bounds - window.addSubview(launchView) - window.makeKeyAndVisible() - self.securityView = launchView - } - - /** - * Shows the app when we come into the foreground. - * Edge addition. - */ - func applicationWillEnterForeground(_ application: UIApplication) { - if let view = securityView { - view.removeFromSuperview() - securityView = nil - } +/// Sets the home-screen badge. +/// `UIApplication.applicationIconBadgeNumber` is deprecated since iOS 17, +/// but our deployment target still includes iOS 15. +private func setBadgeCount(_ count: Int) { + if #available(iOS 16.0, *) { + UNUserNotificationCenter.current().setBadgeCount(count) + } else { + UIApplication.shared.applicationIconBadgeNumber = count } } diff --git a/ios/edge/Info.plist b/ios/edge/Info.plist index 7f52713a1e4..e88f50c7ae8 100644 --- a/ios/edge/Info.plist +++ b/ios/edge/Info.plist @@ -152,6 +152,23 @@ Quicksand-SemiBold.ttf Quicksand-Bold.ttf + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + UIBackgroundModes fetch diff --git a/ios/edge/SceneDelegate.swift b/ios/edge/SceneDelegate.swift new file mode 100644 index 00000000000..abf20ec4f85 --- /dev/null +++ b/ios/edge/SceneDelegate.swift @@ -0,0 +1,160 @@ +import ExpoQuickActions +import React +import UIKit + +/** + * Owns the app's single window and its lifecycle. + * + * iOS 26 deprecates the `UIApplicationDelegate` window and lifecycle callbacks + * for apps that adopt the `UIScene` lifecycle, and Xcode 27 is expected to drop + * the `UIDesignRequiresCompatibility` opt-out entirely, so window creation, + * foreground/background transitions, deep links and quick actions all live here + * instead of on `AppDelegate`. + */ +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + var securityView: UIView? + + /** + * Handles app start-up for this scene. + * + * React Native is created once by `AppDelegate`, but the root view is mounted + * into the window this scene owns. + */ + 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 + } + + // Capture a home-screen quick action on a cold launch so + // expo-quick-actions can report it as the initial action once JS loads. + // Under the scene lifecycle this arrives in the connection options rather + // than in the application launch options. Edge addition. + if let shortcutItem = connectionOptions.shortcutItem { + ExpoQuickActions.initialAction = shortcutItem + } + + let window = UIWindow(windowScene: windowScene) + self.window = window + appDelegate.window = window + + factory.startReactNative( + withModuleName: "edge", + in: window, + launchOptions: Self.launchOptions(from: connectionOptions) + ) + } + + /** + * Handles deep links while the app is running. + * The scene lifecycle replaces `application(_:open:options:)`. + */ + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + for context in URLContexts { + RCTLinkingManager.application( + UIApplication.shared, + open: context.url, + options: [:] + ) + } + } + + /** + * Handles universal links. + * The scene lifecycle replaces + * `application(_:continue:restorationHandler:)`. + */ + func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { + RCTLinkingManager.application( + UIApplication.shared, + continue: userActivity, + restorationHandler: { _ in } + ) + } + + /** + * Handles home-screen quick action taps while the app is running. + * Mirrors ExpoQuickActionsAppDelegate, which never runs because our + * app delegate is not an ExpoAppDelegate. Edge addition. + */ + func windowScene( + _ windowScene: UIWindowScene, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void + ) { + NotificationCenter.default.post( + name: Notification.Name("onQuickAction"), object: shortcutItem) + completionHandler(true) + } + + /** + * Hides the app when we go into the background. + * Edge addition. + */ + func sceneDidEnterBackground(_ scene: UIScene) { + guard + let storyboard = UIStoryboard(name: "LaunchScreen", bundle: nil) as UIStoryboard?, + let launchScreen = storyboard.instantiateInitialViewController(), + let launchView = launchScreen.view, + let window = self.window + else { + return + } + + launchView.frame = window.bounds + window.addSubview(launchView) + window.makeKeyAndVisible() + self.securityView = launchView + } + + /** + * Shows the app when we come into the foreground. + * Edge addition. + */ + func sceneWillEnterForeground(_ scene: UIScene) { + if let view = securityView { + view.removeFromSuperview() + securityView = nil + } + } + + /** + * Rebuilds the launch options React Native expects from the scene's + * connection options. + * + * `RCTLinkingManager.getInitialURL` reads the cold-launch URL out of the + * bridge's launch options, but iOS does not put deep-link keys into + * `didFinishLaunchingWithOptions` once the scene lifecycle is adopted, so + * without this the first `Linking.getInitialURL()` after a cold launch + * would always resolve to null. + */ + private static func launchOptions( + from connectionOptions: UIScene.ConnectionOptions + ) -> [AnyHashable: Any] { + if let url = connectionOptions.urlContexts.first?.url { + return [UIApplication.LaunchOptionsKey.url: url] + } + + let webActivity = connectionOptions.userActivities.first { userActivity in + userActivity.activityType == NSUserActivityTypeBrowsingWeb + } + if let webActivity = webActivity { + return [ + UIApplication.LaunchOptionsKey.userActivityDictionary: [ + UIApplication.LaunchOptionsKey.userActivityType: webActivity.activityType, + "UIApplicationLaunchOptionsUserActivityKey": webActivity + ] + ] + } + + return [:] + } +} From c05e922b0c00a6481e012a4b07d0833d11dd57fd Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Thu, 27 Aug 2026 19:39:52 -0700 Subject: [PATCH 2/2] Document the remaining iOS 26 migration work Records what the UIScene adoption covers, the two React Native 0.79 workarounds it needed, how it was verified on the simulator, and the surfaces that still have to be normalized before Xcode 27 removes the UIDesignRequiresCompatibility opt-out. --- CHANGELOG.md | 1 + src/docs/ios-26-scene-lifecycle-migration.md | 124 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/docs/ios-26-scene-lifecycle-migration.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 68120eca1f7..e867c7b0d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - added: Sign Message option in the wallet list menu for Bitcoin-family wallets, letting users prove self-hosted wallet ownership to exchanges by signing an exchange-provided message. - added: `edge://buy` and `edge://sell` deep links (and their `https://deep.edge.app` equivalents) that open the buy/sell flow, optionally pinning a provider and payment method to the top of the quote options for that visit. - added: Provider priority in the buy/sell options for affiliated accounts, configured through the info server promo card data. +- changed: Adopt the iOS UIScene lifecycle, removing the deprecated app-delegate window and lifecycle APIs ahead of Xcode 27. - changed: Target Android 16 (API level 36), which Google Play requires for app updates submitted after Aug 30, 2026. Predictive back is opted out of for now, since React Native 0.79 cannot handle it, so the back button behaves exactly as it did before. - changed: Sign MoonPay buy/sell widget URLs and bind them to the customer's IP via the info server, for MoonPay's on-ramp IP-matching security upgrade. - changed: Style the entire "Already have an account? Sign in" line in the getting-started USP carousel with the tertiary link color, not just "Sign in". diff --git a/src/docs/ios-26-scene-lifecycle-migration.md b/src/docs/ios-26-scene-lifecycle-migration.md new file mode 100644 index 00000000000..80788ffc7e8 --- /dev/null +++ b/src/docs/ios-26-scene-lifecycle-migration.md @@ -0,0 +1,124 @@ +# iOS 26 migration: UIScene lifecycle, and what is left before Xcode 27 + +Status: the UIScene lifecycle adoption described in "What shipped" is done. +Everything under "What is still owed" is not. + +## Why this exists + +Building Edge with the iOS 26 SDK turns on Apple's Liquid Glass design system for +every standard UIKit control. That conflicts with Edge's own styling, so +`ios/edge/Info.plist` currently sets `UIDesignRequiresCompatibility` to opt out. +Apple has said that flag goes away in the next major Xcode, so the opt-out stops +working as soon as the build boxes move to Xcode 27 (expected around September +2026). The work below has to land before that move, and it cannot wait on the +React Native 0.86 upgrade, which is tracked separately and is not ready. + +## What shipped + +`AppDelegate` no longer owns a window. `ios/edge/SceneDelegate.swift` adopts +`UIWindowSceneDelegate`, `Info.plist` declares a single-scene +`UIApplicationSceneManifest`, and `AppDelegate` vends the scene configuration. +This removes the three deprecated call sites the audit flagged: +`UIWindow(frame: UIScreen.main.bounds)`, `applicationDidEnterBackground`, and +`applicationWillEnterForeground`. It also replaces `applicationIconBadgeNumber` +(deprecated in iOS 17) with `UNUserNotificationCenter.setBadgeCount`, guarded for +the iOS 15.6 deployment target. + +Two things about React Native 0.79 made this more than a file split, and both are +worth knowing before anyone touches this code again: + +1. **React Native has no scene-aware linking entry points.** `RCTLinkingManager` + only exposes the `UIApplicationDelegate` forms, and `getInitialURL` reads the + cold-launch URL out of `bridge.launchOptions`. iOS stops putting deep-link keys + into `didFinishLaunchingWithOptions` the moment a scene manifest exists, so the + scene rebuilds those launch options from `UIScene.ConnectionOptions` before + starting React Native. Without that step, `Linking.getInitialURL()` resolves to + null on every cold-launch deep link, and nothing crashes to tell you. +2. **`@react-native-firebase/messaging` reads `application.delegate.window`.** Its + `UIApplicationDidFinishLaunchingNotification` observer walks + `delegate.window.rootViewController.view` looking for an `RCTRootView`. A + delegate with no `window` property at all raises `doesNotRecognizeSelector` and + the app aborts on launch. `AppDelegate` therefore keeps a `window` property that + the scene mirrors its own window into. + +Both are React Native 0.79 and Firebase facts, not Edge choices, so re-check them +after the 0.86 upgrade rather than assuming the shims are still needed. + +### How it was verified + +Driven on an iOS 18.6 simulator against a debug build, with temporary logging in +the scene callbacks that was removed before commit: + +- Cold launch reaches the wallet list, and a Buy quote resolves, so window + creation and the React Native start path work under the scene. +- Backgrounding logs `sceneDidEnterBackground`, and the next foreground observes + the LaunchScreen privacy overlay present and removes it, so both halves of the + privacy screen survived the move. +- A cold launch from `edge://...` arrives with `UIApplicationLaunchOptionsURLKey` + populated, and the app surfaces its own "Unknown deep link format" banner, which + proves the URL travelled from the scene through `getInitialURL` into JS. +- A warm `edge://...` reaches `scene(_:openURLContexts:)`. +- `setBadgeCount` puts the requested count on the home-screen icon once badge + authorization is granted. +- Home-screen quick actions reach `windowScene(_:performActionFor:)` while the app + runs, and `connectionOptions.shortcutItem` on a cold launch, both ending in Safari + on the action's URL. The item replayed through those entry points is the real one + `QuickActionsManager` registers, taken back out of `UIApplication.shortcutItems`, + because SpringBoard's icon context menu cannot be opened from the test harness. +- A universal link reaches JS through both the cold path + (`launchOptions(from:)` rebuilding the `userActivityDictionary`, which the app + answers with "Parsing link...") and the warm path (`scene(_:continue:)`, answered + with "No wallets exist that support this link."). Those two are driven from a + synthesized `NSUserActivity` rather than a real link, for the reason below. +- The Release configuration compiles. + +### Universal links are not associated today + +`edge.app` serves a 404 HTML page at `/.well-known/apple-app-site-association`, and +Apple's CDN has no entry for the domain. The app ships +`com.apple.developer.associated-domains: applinks:edge.app`, but with no association +file iOS never hands the app a browsing-web activity, so both universal-link paths +are unreachable in production regardless of this change. That is worth fixing on its +own, and until it is, the scene code above cannot be exercised by a real link. + +## What is still owed before Xcode 27 + +### 1. Remove `UIDesignRequiresCompatibility` + +This is the item with the hard deadline, and it is a visual-QA problem rather than +a code problem. Edge draws almost everything itself in React Native, so the +exposure is limited to the places where UIKit still renders: + +| Surface | Where | Risk | +|---|---|---| +| Status bar | `src/components/services/StatusBarManager.tsx` | Contrast against the themed header | +| Blur backgrounds | `src/components/common/BlurBackground.tsx`, `QrModal`, `LoginScene` (rn-id-blurview) | Liquid Glass changes what a `UIBlurEffect` looks like | +| Native modals and alerts | `react-native-screens` presentation, system alerts | New material behind sheets | +| Tab bar and safe-area math | `src/components/themed/MenuTabs.tsx`, `useSafeAreaInsets` callers | Inset changes shift the custom tab bar | +| Keyboard accessory | Amount-entry scenes | New keyboard chrome height | + +The work is: flip the flag locally, walk those surfaces on an iOS 26 simulator, +and fix what breaks. Doing it behind the flag first is safe, because the flag is +still honored on Xcode 26 build boxes, so the fixes can land ahead of the removal +and the flag can be deleted in a small follow-up. + +### 2. Navigation stack + +React Navigation 6 with `react-native-screens` 4.16. The v7 and v8 line is where +the iOS 26 presentation work lands upstream. This is the piece most entangled with +the React Native 0.86 upgrade, so sequence it after that lands rather than doing it +twice. + +### 3. Re-check the two shims after React Native 0.86 + +The launch-options rebuild and the `AppDelegate.window` mirror both exist to work +around versions of React Native and Firebase we happen to be on. Newer React Native +adds its own scene handling, and keeping a hand-rolled copy alongside it is how you +get two competing deep-link paths. + +## Deadline + +Practical deadline is the Xcode 27 move, around September 2026, not an iOS 27 +launch date. Treat it as a Q3 2026 completion target. Item 1 is the only one that +strictly must land by then. Items 2 and 3 are cleanup that gets cheaper if it +follows the React Native 0.86 upgrade instead of racing it.