diff --git a/.changeset/lucky-pears-preload.md b/.changeset/lucky-pears-preload.md new file mode 100644 index 00000000..62f6e57f --- /dev/null +++ b/.changeset/lucky-pears-preload.md @@ -0,0 +1,7 @@ +--- +'@callstack/react-native-brownfield': minor +--- + +Add `preloadReactNative` on iOS. `startReactNative` only makes the factory, and React Native loads the JavaScript bundle when it creates the first React Native view. `preloadReactNative` moves that work to the app launch. On Android, `ReactNativeBrownfield.initialize` does the same operation. + +`onBundleLoaded` now runs on the main thread, and not on the JavaScript thread. `startReactNative` and `preloadReactNative` can each have a callback, because a new callback joins the callbacks that wait and does not replace them. A callback that you add after React Native loaded the bundle runs immediately. 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..7b2a92e9 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 @@ -46,7 +46,9 @@ Starts React Native, produces an instance of React Native. You can use it to ini | Param | Required | Type | Description | | ---------------- | -------- | --------------- | ------------------------------------------------- | -| `onBundleLoaded` | No | `void(^)(void)` | Callback invoked after JS bundle is fully loaded. | +| `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. The callback joins the callbacks that already wait, and does not replace them. **Examples:** @@ -60,6 +62,37 @@ Starts React Native, produces an instance of React Native. You can use it to ini }]; ``` +##### `preloadReactNative` + +Starts React Native and loads the JavaScript bundle immediately. If you do not call this method, React Native loads the bundle when it creates the first React Native view. On Android, `ReactNativeBrownfield.initialize` does the same operation. See [Preload the JavaScript bundle](/docs/getting-started/ios#preload-the-javascript-bundle). + +You can call this method more than one time. You can also call it together with `startReactNative`. + +| Param | Required | Type | Description | +| ---------------- | -------- | --------------- | ------------------------------------------------------------------------------- | +| `launchOptions` | No | `NSDictionary` | The launch options for the React Host. Usually you get them from `AppDelegate`. | +| `onBundleLoaded` | No | `void(^)(void)` | Callback invoked on the main thread after JS bundle is fully loaded. | + +`onBundleLoaded` follows the rules of [`startReactNative`](#startreactnative). Thus `startReactNative` and `preloadReactNative` can each have a callback. + +Only the call that creates the React Host reads the launch options. Brownfield keeps the options of this method, 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:** + +```objc +[[ReactNativeBrownfield shared] preloadReactNative]; +``` + +```objc +[[ReactNativeBrownfield shared] preloadReactNativeWithLaunchOptions:launchOptions + onBundleLoaded:^(void){ + NSLog(@"React Native bundle loaded"); +}]; +``` + +> [!Note] +> With Expo, this method 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..ea8eebcf 100644 --- a/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx +++ b/docs/docs/docs/api-reference/react-native-brownfield/swift.mdx @@ -46,7 +46,9 @@ 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. | +| `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. The callback joins the callbacks that already wait, and does not replace them. **Examples:** @@ -60,6 +62,36 @@ ReactNativeBrownfield.shared.startReactNative(onBundleLoaded: { }) ``` +##### `preloadReactNative` + +Starts React Native and loads the JavaScript bundle immediately. If you do not call this method, React Native loads the bundle when it creates the first React Native view. On Android, `ReactNativeBrownfield.initialize` does the same operation. See [Preload the JavaScript bundle](/docs/getting-started/ios#preload-the-javascript-bundle). + +You can call this method more than one time. You can also call it together with `startReactNative`. + +| Param | Required | Type | Description | +| ---------------- | -------- | --------------------- | ------------------------------------------------------------------------------- | +| `launchOptions` | No | `[AnyHashable: Any]?` | The launch options for the React Host. Usually you get them from `AppDelegate`. | +| `onBundleLoaded` | No | `(() -> Void)?` | Callback invoked on the main thread after JS bundle is fully loaded. | + +`onBundleLoaded` follows the rules of [`startReactNative`](#startreactnative). Thus `startReactNative` and `preloadReactNative` can each have a callback. + +Only the call that creates the React Host reads the launch options. Brownfield keeps the options of this method, 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:** + +```swift +ReactNativeBrownfield.shared.preloadReactNative() +``` + +```swift +ReactNativeBrownfield.shared.preloadReactNative(launchOptions: nil) { + print("React Native bundle loaded") +} +``` + +> [!Note] +> With Expo, this method 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..8d808b8b 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, use [`preloadReactNative`](/docs/api-reference/react-native-brownfield/swift#preloadreactnative). + +> [!Note] +> The React Host keeps the first bundle that it evaluates, thus `preloadReactNative` waits until the bundle URL is stable. While Expo can still select a different bundle, `preloadReactNative` 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 `preloadReactNative` 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..e969fddc 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,25 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } ``` +`startReactNative` does not take the launch options. Give them to `preloadReactNative`, which is in the next section. + +### Preload the JavaScript bundle + +`startReactNative` 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, call `preloadReactNative`. On Android, `ReactNativeBrownfield.initialize` does the same operation: + +```swift +ReactNativeBrownfield.shared.bundle = ReactNativeBundle +ReactNativeBrownfield.shared.preloadReactNative(launchOptions: launchOptions) { + print("React Native bundle loaded") +} +``` + +`preloadReactNative` 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 [`preloadReactNative`](/docs/api-reference/react-native-brownfield/swift#preloadreactnative). + ## 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..b52875f4 --- /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 *)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..dea0fe5d 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. A `preloadReactNative` 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..3cf169d4 100644 --- a/packages/react-native-brownfield/ios/JSBundleLoadObserver.swift +++ b/packages/react-native-brownfield/ios/JSBundleLoadObserver.swift @@ -1,38 +1,87 @@ import Foundation -internal import React +/** + * Watches the `RCTInstanceDidLoadBundle` notification, and calls the callbacks that wait for it. + * + * React Native sends the notification from the JavaScript thread. This class always calls the + * callbacks on the main thread, because the callbacks change 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 pendingCallbacks: [() -> 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) + } + } + + /** + * Adds a callback. The class keeps all of the callbacks that wait, and calls each one time. 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) + } } - private func notifyAndClear() { - let callback = onBundleLoaded - onBundleLoaded = nil - removeObserverIfNeeded() - callback?() + /** + * Removes the callbacks that wait, 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?.pendingCallbacks.removeAll() + self?.didLoadBundle = false + } } - private func removeObserverIfNeeded() { - if let observerToken { - NotificationCenter.default.removeObserver(observerToken) - self.observerToken = nil + // MARK: - Main thread only + + private func register(_ onBundleLoaded: @escaping () -> Void) { + guard !didLoadBundle else { + DispatchQueue.main.async(execute: onBundleLoaded) + return + } + + pendingCallbacks.append(onBundleLoaded) + } + + private func bundleDidLoad() { + didLoadBundle = true + + let callbacks = pendingCallbacks + pendingCallbacks.removeAll() + + for callback in callbacks { + callback() + } + } + + 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..c0164769 --- /dev/null +++ b/packages/react-native-brownfield/ios/ReactHostPreloading.swift @@ -0,0 +1,125 @@ +import Foundation + +/** + * The preload state that `preloadReactNative`, `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 `preloadReactNative` call. + * + * `preloadReactNative` can move its 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 `preloadReactNative` 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.preloadReactNative`, which holds the contract. + */ + func preloadReactNative( + launchOptions: [AnyHashable: Any]?, + onBundleLoaded: (() -> Void)? + ) { + preloadState.storeLaunchOptions(launchOptions) + + if let onBundleLoaded { + preloadState.jsBundleLoadObserver.observe(onBundleLoaded: onBundleLoaded) + } + + 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: preloadReactNative did not load 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..addd35b7 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift @@ -96,6 +96,45 @@ internal import Expo #endif } + /** + * Starts React Native and loads the JavaScript bundle immediately. + */ + @objc public func preloadReactNative() { + preloadReactNative(launchOptions: nil, onBundleLoaded: nil) + } + + /** + * Starts React Native and loads the JavaScript bundle immediately. If you do not call this + * method, React Native loads the bundle when it creates the first React Native view. On Android, + * `ReactNativeBrownfield.initialize` does the same operation. + * + * You can call this method more than one time. You can also call it together with + * `startReactNative`, which continues to load the bundle only when it is necessary. + * + * @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 onBundleLoaded An optional callback, with the rules of `startReactNative`. Thus + * `startReactNative` and `preloadReactNative` can each have a callback. + */ + @objc public func preloadReactNative( + launchOptions: [AnyHashable: Any]?, + onBundleLoaded: (() -> Void)? + ) { + #if canImport(Expo) + ExpoHostRuntime.shared.preloadReactNative( + launchOptions: launchOptions, + onBundleLoaded: onBundleLoaded + ) + #else + ReactNativeHostRuntime.shared.preloadReactNative( + launchOptions: launchOptions, + onBundleLoaded: onBundleLoaded + ) + #endif + } + /** * Stops React Native. */ @@ -192,7 +231,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. It joins the callbacks + * that already wait, and does not replace them. */ @objc public func startReactNative(onBundleLoaded: (() -> Void)?) { #if canImport(Expo) diff --git a/packages/react-native-brownfield/ios/Vanilla/ReactNativeHostRuntime.swift b/packages/react-native-brownfield/ios/Vanilla/ReactNativeHostRuntime.swift index f2671aec..5d7fba34 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. A `preloadReactNative` 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