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; +};