diff --git a/.changeset/lucky-pears-preload.md b/.changeset/lucky-pears-preload.md new file mode 100644 index 00000000..f163cc06 --- /dev/null +++ b/.changeset/lucky-pears-preload.md @@ -0,0 +1,7 @@ +--- +'@callstack/react-native-brownfield': minor +--- + +Add `preloadBundle` to `startReactNative` on iOS. Without a preload, `startReactNative` only makes the factory, and React Native loads the JavaScript bundle when it creates the first React Native view. `startReactNative(launchOptions:preloadBundle:onBundleLoaded:)` moves that work to the app launch. On Android, `ReactNativeBrownfield.initialize` does the same operation. This shape of `startReactNative` also takes the launch options for the React Host. + +`onBundleLoaded` now runs on the main thread, and not on the JavaScript thread. A callback that you add after React Native loaded the bundle runs immediately. diff --git a/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift b/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift index 6c60a6a9..9058ffac 100644 --- a/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift +++ b/apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift @@ -135,15 +135,17 @@ struct BrownfieldAppleApp: App { init() { ReactNativeBrownfield.shared.bundle = ReactNativeBundle ReactNativeBrownfield.shared.preferEmbeddedBundleInDebug = true - ReactNativeBrownfield.shared.startReactNative { - print("React Native has been loaded") - } - #if USE_EXPO_HOST ReactNativeBrownfield.shared.ensureExpoModulesProvider() #endif - BrownfieldStore.register(initialState) + BrownfieldStore.register(initialState) + + // `preloadBundle: true` evaluates the JavaScript bundle now, thus this call is the last + // operation. + ReactNativeBrownfield.shared.startReactNative(launchOptions: nil, preloadBundle: true) { + print("React Native has been loaded") + } } var body: some Scene { diff --git a/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx b/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx index e3b5128b..070d5e35 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/objective-c.mdx @@ -44,9 +44,17 @@ A singleton that keeps an instance of `ReactNativeBrownfield` object. Starts React Native, produces an instance of React Native. You can use it to initialize React Native in your app. -| Param | Required | Type | Description | -| ---------------- | -------- | --------------- | ------------------------------------------------- | -| `onBundleLoaded` | No | `void(^)(void)` | Callback invoked after JS bundle is fully loaded. | +| Param | Required | Type | Description | +| ---------------- | -------- | --------------- | ------------------------------------------------------------------------------- | +| `launchOptions` | No | `NSDictionary` | The launch options for the React Host. Usually you get them from `AppDelegate`. | +| `preloadBundle` | No | `BOOL` | `YES` loads the JavaScript bundle now, and not with the first React Native view. | +| `onBundleLoaded` | No | `void(^)(void)` | Callback invoked on the main thread after JS bundle is fully loaded. | + +Brownfield calls `onBundleLoaded` one time, on the main thread. If React Native already loaded the bundle, the callback runs immediately. A new callback replaces the callback that waits. + +Without `preloadBundle`, React Native loads and evaluates the JavaScript bundle when it creates the first React Native view. With `preloadBundle:YES`, React Native does that work in this call. On Android, `ReactNativeBrownfield.initialize` does the same operation. See [Preload the JavaScript bundle](/docs/getting-started/ios#preload-the-javascript-bundle). + +Only the call that creates the React Host reads the launch options. Brownfield keeps the options, also if it cannot create the host now. A view that creates the host later uses them. Options that you give to `view` win over these options. **Examples:** @@ -60,6 +68,17 @@ Starts React Native, produces an instance of React Native. You can use it to ini }]; ``` +```objc +[[ReactNativeBrownfield shared] startReactNativeWithLaunchOptions:launchOptions + preloadBundle:YES + onBundleLoaded:^(void){ + NSLog(@"React Native bundle loaded"); +}]; +``` + +> [!Note] +> With Expo, `preloadBundle` can only prepare the runtime, because Expo can select the bundle after the app starts. See [Expo Integration](/docs/getting-started/expo#xcframework-present-rn-ui). + ##### `stopReactNative` Stops React Native and releases the underlying runtime. Safe to call multiple times. Call it after all React Native views are dismissed. diff --git a/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx b/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx index 62a536e6..fab20684 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx @@ -44,9 +44,17 @@ ReactNativeBrownfield.shared Starts React Native. You can use it to initialize React Native in your app. -| Param | Required | Type | Description | -| ---------------- | -------- | --------------- | ------------------------------------------------- | -| `onBundleLoaded` | No | `(() -> Void)?` | Callback invoked after JS bundle is fully loaded. | +| Param | Required | Type | Description | +| ---------------- | -------- | --------------------- | ------------------------------------------------------------------------------- | +| `launchOptions` | No | `[AnyHashable: Any]?` | The launch options for the React Host. Usually you get them from `AppDelegate`. | +| `preloadBundle` | No | `Bool` | `true` loads the JavaScript bundle now, and not with the first React Native view. | +| `onBundleLoaded` | No | `(() -> Void)?` | Callback invoked on the main thread after JS bundle is fully loaded. | + +Brownfield calls `onBundleLoaded` one time, on the main thread. If React Native already loaded the bundle, the callback runs immediately. A new callback replaces the callback that waits. + +Without `preloadBundle`, React Native loads and evaluates the JavaScript bundle when it creates the first React Native view. With `preloadBundle: true`, React Native does that work in this call. On Android, `ReactNativeBrownfield.initialize` does the same operation. See [Preload the JavaScript bundle](/docs/getting-started/ios#preload-the-javascript-bundle). + +Only the call that creates the React Host reads the launch options. Brownfield keeps the options, also if it cannot create the host now. A view that creates the host later uses them. Options that you give to `view` win over these options. **Examples:** @@ -60,6 +68,15 @@ ReactNativeBrownfield.shared.startReactNative(onBundleLoaded: { }) ``` +```swift +ReactNativeBrownfield.shared.startReactNative(launchOptions: launchOptions, preloadBundle: true) { + print("React Native bundle loaded") +} +``` + +> [!Note] +> With Expo, `preloadBundle` can only prepare the runtime, because Expo can select the bundle after the app starts. See [Expo Integration](/docs/getting-started/expo#xcframework-present-rn-ui). + ##### `stopReactNative` Stops React Native and releases the underlying runtime. Safe to call multiple times. Call it after all React Native views are dismissed. diff --git a/docs/docs/docs/getting-started/expo.mdx b/docs/docs/docs/getting-started/expo.mdx index 5d5842e8..425817bc 100644 --- a/docs/docs/docs/getting-started/expo.mdx +++ b/docs/docs/docs/getting-started/expo.mdx @@ -138,6 +138,11 @@ struct IosApp: App { } ``` +To load the JavaScript bundle at the app launch, give `preloadBundle: true` to [`startReactNative`](/docs/api-reference/react-native-brownfield/swift#startreactnative). + +> [!Note] +> The React Host keeps the first bundle that it evaluates, thus a preload waits until the bundle URL is stable. While Expo can still select a different bundle, `startReactNative` only prepares the runtime, and the first React Native screen loads the bundle. This occurs in a Debug build with `expo-dev-client`, until the user selects an app in the launcher. This also occurs with `expo-updates` in a Release build, until Expo selects the update. Brownfield keeps the launch options of the call, and the first React Native screen uses them. If you set `bundleURLOverride`, the bundle URL cannot change, and a preload always loads the bundle. + If you package the framework in **Debug** and want to run it without Metro, enable the embedded bundle explicitly before calling `startReactNative`: ```swift diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index 9e066419..7dd75b50 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -217,7 +217,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ReactNativeBrownfield.shared.bundle = ReactNativeBundle ReactNativeBrownfield.shared.startReactNative(onBundleLoaded: { print("React Native bundle loaded") - }, launchOptions: launchOptions) + }) window = UIWindow(frame: UIScreen.main.bounds) @@ -232,6 +232,23 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } ``` +### Preload the JavaScript bundle + +This `startReactNative` call only prepares the runtime. React Native creates and starts the host in `viewWithModuleName:`. Thus React Native loads and evaluates the bundle only when it creates the first React Native screen. React Native also calls `onBundleLoaded` at that time. + +To do this work at the app launch, give `preloadBundle: true` to `startReactNative`. On Android, `ReactNativeBrownfield.initialize` does the same operation. This shape of `startReactNative` also takes the launch options: + +```swift +ReactNativeBrownfield.shared.bundle = ReactNativeBundle +ReactNativeBrownfield.shared.startReactNative(launchOptions: launchOptions, preloadBundle: true) { + print("React Native bundle loaded") +} +``` + +A preload adds overhead to the caller's section, but speeds up the first React Native screen appearance. If you preload React Native in your app's launch critical section, then it impacts your app's launch time. + +For the rules of `onBundleLoaded` and of the launch options, see [`startReactNative`](/docs/api-reference/react-native-brownfield/swift#startreactnative). + ## 9. Run Your App ### Debug Configuration diff --git a/packages/react-native-brownfield/ios/BrownfieldReactHostPreloader.h b/packages/react-native-brownfield/ios/BrownfieldReactHostPreloader.h new file mode 100644 index 00000000..14dbcd28 --- /dev/null +++ b/packages/react-native-brownfield/ios/BrownfieldReactHostPreloader.h @@ -0,0 +1,30 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * Creates and starts the React Host, but does not create a view. React Native loads and evaluates + * the JavaScript bundle immediately, and not when it mounts the first React Native view. + * `viewWithModuleName:` makes the same call internally, thus a view that you create later uses the + * host that is ready. + * + * This class is in Objective-C, because Swift cannot make this call: + * `RCTReactNativeFactory.devMenuConfiguration` is nullable, but the related parameter is not + * nullable. + */ +@interface BrownfieldReactHostPreloader : NSObject + +/** + * Call this method on the main thread. If a host is already available, this method does nothing. + * + * @param reactNativeFactory An `RCTReactNativeFactory`, or a subclass, for example + * `ExpoReactNativeFactory`. The type is `id`, because this header must stay free of the React + * Native imports. + * @param launchOptions The launch options for the React Host. + */ ++ (void)preloadWithReactNativeFactory:(id)reactNativeFactory + launchOptions:(nullable NSDictionary *)launchOptions; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native-brownfield/ios/BrownfieldReactHostPreloader.m b/packages/react-native-brownfield/ios/BrownfieldReactHostPreloader.m new file mode 100644 index 00000000..6ed7ac2b --- /dev/null +++ b/packages/react-native-brownfield/ios/BrownfieldReactHostPreloader.m @@ -0,0 +1,91 @@ +#import "BrownfieldReactHostPreloader.h" + +// This file is plain Objective-C, and not Objective-C++. The umbrella header of +// React-RCTAppDelegate imports the Hermes and JSI headers if `__cplusplus` is set. This pod does +// not have the necessary search paths for those headers. Without C++, the compiler ignores those +// imports. The compiler then builds the module in the same way that Swift imports it. +// +// The name of the pod changes if the build makes it a framework. Thus you must try each possible +// name. Expo uses the same method in `RCTAppDelegateUmbrella.h`. +#if __has_include() +#import +#import +#elif __has_include() +#import +#import +#else +#import +#import +#endif + +NS_ASSUME_NONNULL_BEGIN + +// `RCTBundleConfiguration` first exists in React Native 0.84. React Native 0.83 does not declare +// the class. This forward declaration lets the file name the type with both versions. +@class RCTBundleConfiguration; + +/** + * The two shapes of `initializeReactHostWithLaunchOptions:...`. React Native 0.83 has the shape + * without `bundleConfiguration:`. React Native 0.84 added that parameter and removed the earlier + * shape. No header tells the two versions apart at compile time, thus this file declares both + * shapes and `respondsToSelector:` selects one at run time. The declarations are equal to the + * React Native declarations. The compiler then accepts the file with both versions. + */ +@protocol BrownfieldReactHostInitializing + +- (void)initializeReactHostWithLaunchOptions:(NSDictionary *__nullable)launchOptions + bundleConfiguration:(RCTBundleConfiguration *)bundleConfiguration + devMenuConfiguration:(RCTDevMenuConfiguration *)devMenuConfiguration; + +- (void)initializeReactHostWithLaunchOptions:(NSDictionary *__nullable)launchOptions + devMenuConfiguration:(RCTDevMenuConfiguration *)devMenuConfiguration; + +@end + +/** + * `RCTReactNativeFactory.bundleConfiguration` also first exists in React Native 0.84. + */ +@protocol BrownfieldBundleConfigurationProviding + +@property (nonatomic, readonly) RCTBundleConfiguration *bundleConfiguration; + +@end + +NS_ASSUME_NONNULL_END + +@implementation BrownfieldReactHostPreloader + ++ (void)preloadWithReactNativeFactory:(id)reactNativeFactory + launchOptions:(NSDictionary *_Nullable)launchOptions +{ + RCTReactNativeFactory *factory = (RCTReactNativeFactory *)reactNativeFactory; + RCTRootViewFactory *rootViewFactory = factory.rootViewFactory; + + // `initializeReactHostWithLaunchOptions:...` returns early if a view, or an earlier preload, + // created a host. Thus this method only checks the factory. + if (rootViewFactory == nil) { + return; + } + + id hostInitializer = (id)rootViewFactory; + SEL initializeWithBundleConfiguration = + @selector(initializeReactHostWithLaunchOptions:bundleConfiguration:devMenuConfiguration:); + + // Get the configurations from the factory. Do not make new configurations from the default + // values. The preloaded host is then the same as a host that a view creates later. + // `RCTReactNativeFactory.startReactNativeWithModuleName:...` sends the same configurations. The + // default value of `devMenuConfiguration` is nil. React Native then uses its own default + // configuration. + if ([hostInitializer respondsToSelector:initializeWithBundleConfiguration]) { + id configurationProvider = (id)factory; + + [hostInitializer initializeReactHostWithLaunchOptions:launchOptions + bundleConfiguration:configurationProvider.bundleConfiguration + devMenuConfiguration:factory.devMenuConfiguration]; + } else { + [hostInitializer initializeReactHostWithLaunchOptions:launchOptions + devMenuConfiguration:factory.devMenuConfiguration]; + } +} + +@end diff --git a/packages/react-native-brownfield/ios/Expo/ExpoHostRuntime.swift b/packages/react-native-brownfield/ios/Expo/ExpoHostRuntime.swift index 099b93bc..0ad3f887 100644 --- a/packages/react-native-brownfield/ios/Expo/ExpoHostRuntime.swift +++ b/packages/react-native-brownfield/ios/Expo/ExpoHostRuntime.swift @@ -11,7 +11,7 @@ internal import EXUpdates final class ExpoHostRuntime { static let shared = ExpoHostRuntime() - private let jsBundleLoadObserver = JSBundleLoadObserver() + let preloadState = ReactHostPreloadState() private var delegate = ExpoHostRuntimeDelegate() private var reactNativeFactory: RCTReactNativeFactory? private var expoDelegate: ExpoAppDelegate? @@ -35,9 +35,15 @@ final class ExpoHostRuntime { /** * Starts React Native with optional callback when bundle is loaded. * - * @param onBundleLoaded Optional callback invoked after JS bundle is fully loaded. + * @param onBundleLoaded Optional callback invoked on the main thread after JS bundle is fully loaded. */ public func startReactNative(onBundleLoaded: (() -> Void)?) { + // The callback registration is outside of the guard below. An earlier `startReactNative` call + // can already have made the factory, and the callback must still run. + if let onBundleLoaded { + preloadState.jsBundleLoadObserver.observe(onBundleLoaded: onBundleLoaded) + } + guard reactNativeFactory == nil else { return } let appDelegate = ExpoAppDelegate() @@ -50,10 +56,6 @@ final class ExpoHostRuntime { appDelegate.bindReactNativeFactory(reactNativeFactory) #endif expoDelegate = appDelegate - - if let onBundleLoaded { - jsBundleLoadObserver.observeOnce(onBundleLoaded: onBundleLoaded) - } } /** @@ -70,6 +72,7 @@ final class ExpoHostRuntime { } reactNativeFactory = nil expoDelegate = nil + preloadState.reset() } /** @@ -169,6 +172,8 @@ final class ExpoHostRuntime { let bundleURL = delegate.bundleURL() configureDevLoadingView(with: bundleURL) + let resolvedLaunchOptions = preloadState.launchOptions(overriddenBy: launchOptions) + // below: https://github.com/expo/expo/commit/2013760c46cde1404872d181a691da72fbf207a4 // has moved the recreateRootView method to ExpoReactNativeFactory #if EXPO_SDK_GTE_55 // this define comes from the Brownfield Expo config plugin @@ -176,19 +181,46 @@ final class ExpoHostRuntime { withBundleURL: bundleURL, moduleName: moduleName, initialProps: initialProps, - launchOptions: launchOptions + launchOptions: resolvedLaunchOptions ) #else return expoDelegate?.recreateRootView( withBundleURL: bundleURL, moduleName: moduleName, initialProps: initialProps, - launchOptions: launchOptions + launchOptions: resolvedLaunchOptions ) #endif } } +extension ExpoHostRuntime: ReactHostPreloading { + var reactNativeFactoryForPreload: AnyObject? { + return reactNativeFactory + } + + /** + * expo-dev-launcher finds the Metro URL only after the user selects an app in the launcher. This + * is a Debug behavior. In Release the class is in the binary, but it does nothing. Thus this + * check is also only in Debug. + * + * `ExpoHostRuntimeDelegate.isBundleURLStable` covers the other conditions. + */ + func canPreloadReactNative() -> Bool { + #if DEBUG + if NSClassFromString("EXDevLauncherController") != nil { + return false + } + #endif + + return delegate.isBundleURLStable + } + + func prepareDevLoadingView() { + configureDevLoadingView() + } +} + class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { var entryFile = ".expo/.virtual-metro-entry" var bundlePath = "main.jsbundle" @@ -242,5 +274,30 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate { return nil } } + + /** + * `true` if `bundleURL()` gives now the same result as a later resolution. This property follows + * the steps of `bundleURL()`, and it must change together with them. + * + * The override wins over every other step, thus an override makes the URL stable. + * + * expo-updates selects the launch asset while `AppController` starts. `ReactNativeViewController` + * starts `AppController`. Before this operation is complete, `launchAssetUrl()` is nil, and + * `bundleURL()` gives the embedded bundle. A host from that time keeps the embedded bundle, and + * Expo never applies the update. Thus the URL is stable only after `launchAssetUrl()` has a + * value. The class can be in the binary while the app does not use it, thus this check reads the + * state and not the class. + */ + var isBundleURLStable: Bool { + if bundleURLOverride?() != nil { + return true + } + + #if canImport(EXUpdates) && !DEBUG + return AppController.isInitialized() && AppController.sharedInstance.launchAssetUrl() != nil + #else + return true + #endif + } } #endif diff --git a/packages/react-native-brownfield/ios/JSBundleLoadObserver.swift b/packages/react-native-brownfield/ios/JSBundleLoadObserver.swift index 7c69fe21..09cec35f 100644 --- a/packages/react-native-brownfield/ios/JSBundleLoadObserver.swift +++ b/packages/react-native-brownfield/ios/JSBundleLoadObserver.swift @@ -1,38 +1,85 @@ import Foundation -internal import React +/** + * Watches the `RCTInstanceDidLoadBundle` notification, and calls the callback that waits for it. + * + * React Native sends the notification from the JavaScript thread. This class always calls the + * callback on the main thread, because the callback changes the user interface. + * + * The class starts to watch at the initialization, and not at the first callback. The class then + * knows that React Native loaded the bundle, also if nobody waited for the notification. + */ final class JSBundleLoadObserver { - private var onBundleLoaded: (() -> Void)? + private var pendingCallback: (() -> Void)? + private var didLoadBundle = false private var observerToken: NSObjectProtocol? - func observeOnce(onBundleLoaded: @escaping () -> Void) { - removeObserverIfNeeded() - self.onBundleLoaded = onBundleLoaded - + init() { observerToken = NotificationCenter.default.addObserver( forName: NSNotification.Name("RCTInstanceDidLoadBundle"), object: nil, - queue: nil + queue: .main ) { [weak self] _ in - self?.notifyAndClear() + self?.bundleDidLoad() } } deinit { - removeObserverIfNeeded() + if let observerToken { + NotificationCenter.default.removeObserver(observerToken) + } + } + + /** + * Keeps one callback, and calls it one time. A new callback replaces the callback that waits. If + * React Native already loaded the bundle, the class calls the callback in the next turn of the + * main run loop. + * + * @param onBundleLoaded The class always calls this callback on the main thread. + */ + func observe(onBundleLoaded: @escaping () -> Void) { + onMainThread { [weak self] in + self?.register(onBundleLoaded) + } + } + + /** + * Removes the callback that waits, and forgets the bundle of the earlier session. Call this + * method when you stop React Native. A callback of the earlier session must not run in the next + * session. + */ + func reset() { + onMainThread { [weak self] in + self?.pendingCallback = nil + self?.didLoadBundle = false + } } - private func notifyAndClear() { - let callback = onBundleLoaded - onBundleLoaded = nil - removeObserverIfNeeded() + // MARK: - Main thread only + + private func register(_ onBundleLoaded: @escaping () -> Void) { + guard !didLoadBundle else { + DispatchQueue.main.async(execute: onBundleLoaded) + return + } + + pendingCallback = onBundleLoaded + } + + private func bundleDidLoad() { + didLoadBundle = true + + let callback = pendingCallback + pendingCallback = nil + callback?() } - private func removeObserverIfNeeded() { - if let observerToken { - NotificationCenter.default.removeObserver(observerToken) - self.observerToken = nil + private func onMainThread(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) } } } diff --git a/packages/react-native-brownfield/ios/ReactHostPreloading.swift b/packages/react-native-brownfield/ios/ReactHostPreloading.swift new file mode 100644 index 00000000..0e3e3137 --- /dev/null +++ b/packages/react-native-brownfield/ios/ReactHostPreloading.swift @@ -0,0 +1,133 @@ +import Foundation + +/** + * The preload state that `startReactNative`, `view` and `stopReactNative` share. + */ +final class ReactHostPreloadState { + let jsBundleLoadObserver = JSBundleLoadObserver() + + private let lock = NSLock() + private var storedLaunchOptions: [AnyHashable: Any]? + + /** + * Keeps the launch options of a `startReactNative` call. + * + * `startReactNative` can move its preload work to the main thread. A view on the main thread can + * create the React Host before that. Thus this method must run at the call, and not after the + * change of thread. The view then finds the options. + */ + func storeLaunchOptions(_ launchOptions: [AnyHashable: Any]?) { + guard let launchOptions else { return } + + lock.lock() + defer { lock.unlock() } + + storedLaunchOptions = launchOptions + } + + /** + * The launch options for a call that can create the React Host. The options of the caller win + * over the options of an earlier `startReactNative` call. + */ + func launchOptions( + overriddenBy explicitLaunchOptions: [AnyHashable: Any]? = nil + ) -> [AnyHashable: Any]? { + lock.lock() + defer { lock.unlock() } + + return explicitLaunchOptions ?? storedLaunchOptions + } + + /** + * Removes the state of the session. Call this method when you stop React Native. + */ + func reset() { + jsBundleLoadObserver.reset() + + lock.lock() + defer { lock.unlock() } + + storedLaunchOptions = nil + } +} + +/** + * The preload sequence that the two host runtimes share. Only one runtime is in a build. The + * shared sequence keeps the behavior of the two runtimes equal. + */ +protocol ReactHostPreloading: AnyObject { + var preloadState: ReactHostPreloadState { get } + + /** + * The React Native factory, or nil while React Native did not start. The type is `AnyObject`, + * because `BrownfieldReactHostPreloader` also takes the factory as `id`. This file then needs no + * React Native import, and it builds with both runtimes. + */ + var reactNativeFactoryForPreload: AnyObject? { get } + + func startReactNative() + + /** + * `false` while the bundle URL can still change. The runtime must not create the host in this + * condition, because the host keeps the first bundle that it evaluates. + */ + func canPreloadReactNative() -> Bool + + func prepareDevLoadingView() +} + +extension ReactHostPreloading { + /** + * The implementation of + * `ReactNativeBrownfield.startReactNative(launchOptions:preloadBundle:onBundleLoaded:)`, which + * holds the contract. + */ + func startReactNative( + launchOptions: [AnyHashable: Any]?, + preloadBundle: Bool, + onBundleLoaded: (() -> Void)? + ) { + preloadState.storeLaunchOptions(launchOptions) + + if let onBundleLoaded { + preloadState.jsBundleLoadObserver.observe(onBundleLoaded: onBundleLoaded) + } + + guard preloadBundle else { + startReactNative() + return + } + + if Thread.isMainThread { + createReactHost() + } else { + DispatchQueue.main.async { [weak self] in + self?.createReactHost() + } + } + } + + private func createReactHost() { + startReactNative() + + guard let factory = reactNativeFactoryForPreload else { return } + + guard canPreloadReactNative() else { + NSLog( + "%@", + "ReactNativeBrownfield: startReactNative did not preload the JavaScript bundle, because " + + "the bundle URL can still change. The first React Native view loads the bundle." + ) + return + } + + // You must configure the dev loading view before React Native loads the bundle. Usually the + // `view` method does this. + prepareDevLoadingView() + + BrownfieldReactHostPreloader.preload( + withReactNativeFactory: factory, + launchOptions: preloadState.launchOptions() + ) + } +} diff --git a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift index 0c0cc054..81ea83d2 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift @@ -192,7 +192,9 @@ internal import Expo /** * Starts React Native with optional callback when bundle is loaded. * - * @param onBundleLoaded Optional callback invoked after JS bundle is fully loaded. + * @param onBundleLoaded Optional callback invoked on the main thread after the JS bundle is + * fully loaded. It runs immediately when the bundle is already loaded. A new callback replaces + * the callback that waits. */ @objc public func startReactNative(onBundleLoaded: (() -> Void)?) { #if canImport(Expo) @@ -202,6 +204,39 @@ internal import Expo #endif } + /** + * Starts React Native, and optionally loads the JavaScript bundle immediately. Without a + * preload, React Native loads the bundle when it creates the first React Native view. On + * Android, `ReactNativeBrownfield.initialize` does the same operation. + * + * @param launchOptions The launch options for the React Host. Only the call that creates the + * host reads these options. This method keeps the options. A view that creates the host later + * uses them, also if this method cannot create the host. Options that you give to `view` win + * over these options. + * @param preloadBundle `true` loads and evaluates the JavaScript bundle now. A preload adds + * work to the section of the caller, but the first React Native screen appears faster. + * @param onBundleLoaded An optional callback, with the rules of `startReactNative`. + */ + @objc public func startReactNative( + launchOptions: [AnyHashable: Any]?, + preloadBundle: Bool, + onBundleLoaded: (() -> Void)? + ) { + #if canImport(Expo) + ExpoHostRuntime.shared.startReactNative( + launchOptions: launchOptions, + preloadBundle: preloadBundle, + onBundleLoaded: onBundleLoaded + ) + #else + ReactNativeHostRuntime.shared.startReactNative( + launchOptions: launchOptions, + preloadBundle: preloadBundle, + onBundleLoaded: onBundleLoaded + ) + #endif + } + /** * Send a serialized JSON message to the React Native JS application. * The message is delivered as a `brownfieldMessage` DeviceEventEmitter event. diff --git a/packages/react-native-brownfield/ios/Vanilla/ReactNativeHostRuntime.swift b/packages/react-native-brownfield/ios/Vanilla/ReactNativeHostRuntime.swift index f2671aec..f249a435 100644 --- a/packages/react-native-brownfield/ios/Vanilla/ReactNativeHostRuntime.swift +++ b/packages/react-native-brownfield/ios/Vanilla/ReactNativeHostRuntime.swift @@ -46,7 +46,7 @@ class ReactNativeBrownfieldDelegate: RCTDefaultReactNativeFactoryDelegate { final class ReactNativeHostRuntime { public static let shared = ReactNativeHostRuntime() - private let jsBundleLoadObserver = JSBundleLoadObserver() + let preloadState = ReactHostPreloadState() private var delegate = ReactNativeBrownfieldDelegate() private func configureDevLoadingView() { @@ -133,6 +133,7 @@ final class ReactNativeHostRuntime { } reactNativeFactory = nil + preloadState.reset() } public func view( @@ -145,7 +146,7 @@ final class ReactNativeHostRuntime { return reactNativeFactory?.rootViewFactory.view( withModuleName: moduleName, initialProperties: initialProps, - launchOptions: launchOptions + launchOptions: preloadState.launchOptions(overriddenBy: launchOptions) ) } @@ -188,17 +189,38 @@ final class ReactNativeHostRuntime { /** * Starts React Native with optional callback when bundle is loaded. * - * @param onBundleLoaded Optional callback invoked after JS bundle is fully loaded. + * @param onBundleLoaded Optional callback invoked on the main thread after JS bundle is fully loaded. */ public func startReactNative(onBundleLoaded: (() -> Void)?) { + // The callback registration is outside of the guard below. An earlier `startReactNative` call + // can already have made the factory, and the callback must still run. + if let onBundleLoaded { + preloadState.jsBundleLoadObserver.observe(onBundleLoaded: onBundleLoaded) + } + guard reactNativeFactory == nil else { return } delegate.dependencyProvider = RCTAppDependencyProvider() reactNativeFactory = RCTReactNativeFactory(delegate: delegate) + } +} - if let onBundleLoaded { - jsBundleLoadObserver.observeOnce(onBundleLoaded: onBundleLoaded) - } +extension ReactNativeHostRuntime: ReactHostPreloading { + var reactNativeFactoryForPreload: AnyObject? { + return reactNativeFactory + } + + /** + * The bare React Native delegate resolves the bundle URL from the override, from Metro, or from + * the embedded bundle. No step waits for an asynchronous operation. Thus a preload uses the same + * bundle as the first view. + */ + func canPreloadReactNative() -> Bool { + return true + } + + func prepareDevLoadingView() { + configureDevLoadingView() } } #endif