From ace75ada8a1470f40fcdb12f89b28f458e31ca9c Mon Sep 17 00:00:00 2001 From: Antonis Lilis Date: Mon, 12 Oct 2026 10:07:52 +0200 Subject: [PATCH] chore(expo): adopt iOS UIScene lifecycle in the sample Make the Expo sample run under the iOS UIScene lifecycle with Expo's own EXExpoAppSceneDelegate (a Swift UIWindowSceneDelegate) via a new withIosSceneLifecycle config plugin. This is the lifecycle Apple/Expo are pushing every app toward ("Required by iOS 27"), so running the sample this way keeps the SDK exercised under it. Expo 57 ships ExpoAppSceneDelegate but does not wire it up during prebuild yet, so the plugin adds the UIApplicationSceneManifest and adjusts the generated AppDelegate to defer the window / React Native start to the scene delegate. Co-Authored-By: Claude Opus 4.8 --- samples/expo/AGENTS.md | 4 + samples/expo/app.json | 3 +- samples/expo/plugins/withIosSceneLifecycle.js | 76 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 samples/expo/plugins/withIosSceneLifecycle.js diff --git a/samples/expo/AGENTS.md b/samples/expo/AGENTS.md index fb2193c7b7..9e1130a048 100644 --- a/samples/expo/AGENTS.md +++ b/samples/expo/AGENTS.md @@ -11,3 +11,7 @@ yarn android # Build & run the Android dev client (expo run:android) ``` `yarn start` then follows the Expo CLI prompts to open on an iOS simulator, Android emulator, or a physical device. Because the SDK ships native code, plain Expo Go isn't enough — the `run:*` scripts build a dev client that includes it. + +## iOS UIScene lifecycle + +The `plugins/withIosSceneLifecycle.js` config plugin makes this app adopt the iOS **UIScene lifecycle** with Expo's own `EXExpoAppSceneDelegate` (a Swift `UIWindowSceneDelegate`). This is where Apple/Expo are pushing every app ("Required by iOS 27"), so running the sample this way keeps the SDK exercised under the lifecycle real apps are migrating to. Expo 57 ships `ExpoAppSceneDelegate` but does not wire it up during prebuild yet, so the plugin adds the `UIApplicationSceneManifest` and adjusts the generated `AppDelegate` (conform to `ExpoReactNativeFactoryProvider`, and let the scene delegate own the window / React Native start). diff --git a/samples/expo/app.json b/samples/expo/app.json index ff0424daf8..a3b1d2b7d0 100644 --- a/samples/expo/app.json +++ b/samples/expo/app.json @@ -89,7 +89,8 @@ } } ], - "expo-web-browser" + "expo-web-browser", + "./plugins/withIosSceneLifecycle" ], "extra": { "router": { diff --git a/samples/expo/plugins/withIosSceneLifecycle.js b/samples/expo/plugins/withIosSceneLifecycle.js new file mode 100644 index 0000000000..330811de49 --- /dev/null +++ b/samples/expo/plugins/withIosSceneLifecycle.js @@ -0,0 +1,76 @@ +/** + * Makes the Expo sample adopt the iOS UIScene lifecycle with Expo's own `EXExpoAppSceneDelegate` + * (a Swift `UIWindowSceneDelegate`). This is the direction Apple/Expo are pushing every app + * ("Required by iOS 27"), so running the sample this way keeps the SDK exercised under the + * lifecycle real apps are migrating to. + * + * Expo 57 ships `ExpoAppSceneDelegate` but does not yet wire it up during prebuild, so this plugin + * does it: + * 1. Adds a `UIApplicationSceneManifest` to Info.plist pointing at `EXExpoAppSceneDelegate`. + * 2. Makes the generated `AppDelegate` conform to `ExpoReactNativeFactoryProvider` and defer the + * window / React Native start to the scene delegate. + */ +const { withInfoPlist, withAppDelegate } = require('@expo/config-plugins'); + +const SCENE_DELEGATE_CLASS = 'EXExpoAppSceneDelegate'; + +function withSceneManifest(config) { + return withInfoPlist(config, cfg => { + cfg.modResults.UIApplicationSceneManifest = { + UIApplicationSupportsMultipleScenes: false, + UISceneConfigurations: { + UIWindowSceneSessionRoleApplication: [ + { + UISceneConfigurationName: 'Default Configuration', + UISceneDelegateClassName: SCENE_DELEGATE_CLASS, + }, + ], + }, + }; + return cfg; + }); +} + +function patchAppDelegate(contents) { + let out = contents; + + // Conform to ExpoReactNativeFactoryProvider so the scene delegate can retrieve the factory the + // app delegate creates in didFinishLaunching. + const classDecl = 'class AppDelegate: ExpoAppDelegate {'; + if (!out.includes(classDecl)) { + throw new Error('[withIosSceneLifecycle] AppDelegate class declaration not found; Expo template changed.'); + } + out = out.replace(classDecl, 'class AppDelegate: ExpoAppDelegate, ExpoReactNativeFactoryProvider {'); + + // Remove the app-delegate window creation + RN start; under the scene lifecycle the scene + // delegate (ExpoAppSceneDelegate) owns the window and starts React Native. + const windowBlock = /#if os\(iOS\) \|\| os\(tvOS\)[\s\S]*?factory\.startReactNative\([\s\S]*?\)\s*#endif/; + if (!windowBlock.test(out)) { + throw new Error('[withIosSceneLifecycle] window/startReactNative block not found; Expo template changed.'); + } + out = out.replace( + windowBlock, + [ + '// [withIosSceneLifecycle] Scene lifecycle: ExpoAppSceneDelegate creates the window and', + ' // starts React Native in scene(_:willConnectTo:). Do NOT start it here.', + ].join('\n'), + ); + + return out; +} + +function withSceneAppDelegate(config) { + return withAppDelegate(config, cfg => { + if (cfg.modResults.language !== 'swift') { + throw new Error('[withIosSceneLifecycle] expected a Swift AppDelegate.'); + } + cfg.modResults.contents = patchAppDelegate(cfg.modResults.contents); + return cfg; + }); +} + +module.exports = function withIosSceneLifecycle(config) { + config = withSceneManifest(config); + config = withSceneAppDelegate(config); + return config; +};