From 94f4065170aa5ada8772deb39b25a69cf8b1545b Mon Sep 17 00:00:00 2001 From: Boris Yankov Date: Thu, 20 Aug 2026 01:25:32 +0300 Subject: [PATCH 1/2] feat(ios): add preloadReactNative `startReactNative` only builds the factory - React Native creates and starts the host inside `viewWithModuleName:`, so no JavaScript ran until the first React Native screen was opened. `preloadReactNative` calls `RCTRootViewFactory.initializeReactHostWithLaunchOptions:...` through a plain Objective-C shim, so the bundle is loaded and evaluated at app launch and `onBundleLoaded` fires there. This is the iOS counterpart of Android's `ReactNativeBrownfield.initialize`. The shim is plain Objective-C because Swift cannot make the call: `RCTReactNativeFactory.devMenuConfiguration` is nullable while the matching parameter is not. It declares both `initializeReactHost` shapes and selects at run time, because no header separates RN 0.83 from 0.84. On Expo the preload fails closed until the launch asset is known, so a preload before `AppController` initializes cannot pin the embedded bundle over an update. `bundleURLOverride` is respected, and expo-dev-launcher is vetoed only in Debug. When the preload cannot load the bundle it logs, and the first React Native view loads the bundle instead. `onBundleLoaded` is delivered on the main thread, callbacks are appended instead of replacing each other, a callback fires immediately when the bundle is already loaded, and pending callbacks are cleared in `stopReactNative`. Launch options given to `preloadReactNative` are kept for a host that a view creates later, covering both the guard-skip and off-main race paths. --- .changeset/lucky-pears-preload.md | 7 + .../react-native-brownfield/objective-c.mdx | 35 ++++- .../react-native-brownfield/swift.mdx | 34 ++++- docs/docs/docs/getting-started/expo.mdx | 5 + docs/docs/docs/getting-started/ios.mdx | 21 ++- .../ios/BrownfieldReactHostPreloader.h | 30 +++++ .../ios/BrownfieldReactHostPreloader.m | 91 +++++++++++++ .../ios/Expo/ExpoHostRuntime.swift | 73 ++++++++-- .../ios/JSBundleLoadObserver.swift | 85 +++++++++--- .../ios/ReactHostPreloading.swift | 125 ++++++++++++++++++ .../ios/ReactNativeBrownfield.swift | 43 +++++- .../ios/Vanilla/ReactNativeHostRuntime.swift | 34 ++++- 12 files changed, 547 insertions(+), 36 deletions(-) create mode 100644 .changeset/lucky-pears-preload.md create mode 100644 packages/react-native-brownfield/ios/BrownfieldReactHostPreloader.h create mode 100644 packages/react-native-brownfield/ios/BrownfieldReactHostPreloader.m create mode 100644 packages/react-native-brownfield/ios/ReactHostPreloading.swift 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 From c567c5e05b8d87a232a43a349c1314c3085cb482 Mon Sep 17 00:00:00 2001 From: Boris Yankov Date: Sun, 23 Aug 2026 23:50:09 +0300 Subject: [PATCH 2/2] refactor(ios): move preload into startReactNative Review feedback on #450. `preloadReactNative` becomes the `preloadBundle` parameter of `startReactNative`, which also takes the launch options. The iOS API is then as small as the Android API, where `initialize` does the same operation. The parameter has no default value, thus a preload is always opt-in, and an app that changes nothing keeps its behavior. `JSBundleLoadObserver` keeps one callback again. A new callback replaces the callback that waits. Only one method registers a callback now, thus the list of callbacks is not necessary. The callback continues to run on the main thread, and it runs immediately if React Native already loaded the bundle. The Apple App preloads React Native. The other configuration is before that call, because the call evaluates the JavaScript bundle. `BrownfieldReactHostPreloader.m` marks `launchOptions` as nullable, in the same way as the header. Claude-Session: https://claude.ai/code/session_012ny7AHVpsLhDSX6qBpjGZ7 --- .changeset/lucky-pears-preload.md | 4 +- .../BrownfieldAppleApp.swift | 12 +-- .../react-native-brownfield/objective-c.mdx | 42 ++++------ .../react-native-brownfield/swift.mdx | 39 +++------- docs/docs/docs/getting-started/expo.mdx | 4 +- docs/docs/docs/getting-started/ios.mdx | 12 ++- .../ios/BrownfieldReactHostPreloader.m | 2 +- .../ios/Expo/ExpoHostRuntime.swift | 4 +- .../ios/JSBundleLoadObserver.swift | 22 +++--- .../ios/ReactHostPreloading.swift | 22 ++++-- .../ios/ReactNativeBrownfield.swift | 76 +++++++++---------- .../ios/Vanilla/ReactNativeHostRuntime.swift | 4 +- 12 files changed, 107 insertions(+), 136 deletions(-) diff --git a/.changeset/lucky-pears-preload.md b/.changeset/lucky-pears-preload.md index 62f6e57f..f163cc06 100644 --- a/.changeset/lucky-pears-preload.md +++ b/.changeset/lucky-pears-preload.md @@ -2,6 +2,6 @@ '@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. +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. `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. +`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 7b2a92e9..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,11 +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 on the main thread 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). -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. +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:** @@ -62,36 +68,16 @@ Brownfield calls `onBundleLoaded` one time, on the main thread. If React Native }]; ``` -##### `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){ +[[ReactNativeBrownfield shared] startReactNativeWithLaunchOptions:launchOptions + preloadBundle:YES + 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). +> 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` 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 ea8eebcf..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,11 +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 on the main thread 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). -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. +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:** @@ -62,35 +68,14 @@ 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) { +ReactNativeBrownfield.shared.startReactNative(launchOptions: launchOptions, preloadBundle: true) { 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). +> 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` diff --git a/docs/docs/docs/getting-started/expo.mdx b/docs/docs/docs/getting-started/expo.mdx index 8d808b8b..425817bc 100644 --- a/docs/docs/docs/getting-started/expo.mdx +++ b/docs/docs/docs/getting-started/expo.mdx @@ -138,10 +138,10 @@ struct IosApp: App { } ``` -To load the JavaScript bundle at the app launch, use [`preloadReactNative`](/docs/api-reference/react-native-brownfield/swift#preloadreactnative). +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 `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. +> 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`: diff --git a/docs/docs/docs/getting-started/ios.mdx b/docs/docs/docs/getting-started/ios.mdx index e969fddc..7dd75b50 100644 --- a/docs/docs/docs/getting-started/ios.mdx +++ b/docs/docs/docs/getting-started/ios.mdx @@ -232,24 +232,22 @@ 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. +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, call `preloadReactNative`. On Android, `ReactNativeBrownfield.initialize` does the same operation: +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.preloadReactNative(launchOptions: launchOptions) { +ReactNativeBrownfield.shared.startReactNative(launchOptions: launchOptions, preloadBundle: true) { 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. +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 [`preloadReactNative`](/docs/api-reference/react-native-brownfield/swift#preloadreactnative). +For the rules of `onBundleLoaded` and of the launch options, see [`startReactNative`](/docs/api-reference/react-native-brownfield/swift#startreactnative). ## 9. Run Your App diff --git a/packages/react-native-brownfield/ios/BrownfieldReactHostPreloader.m b/packages/react-native-brownfield/ios/BrownfieldReactHostPreloader.m index b52875f4..6ed7ac2b 100644 --- a/packages/react-native-brownfield/ios/BrownfieldReactHostPreloader.m +++ b/packages/react-native-brownfield/ios/BrownfieldReactHostPreloader.m @@ -56,7 +56,7 @@ @protocol BrownfieldBundleConfigurationProviding @implementation BrownfieldReactHostPreloader + (void)preloadWithReactNativeFactory:(id)reactNativeFactory - launchOptions:(NSDictionary *)launchOptions + launchOptions:(NSDictionary *_Nullable)launchOptions { RCTReactNativeFactory *factory = (RCTReactNativeFactory *)reactNativeFactory; RCTRootViewFactory *rootViewFactory = factory.rootViewFactory; diff --git a/packages/react-native-brownfield/ios/Expo/ExpoHostRuntime.swift b/packages/react-native-brownfield/ios/Expo/ExpoHostRuntime.swift index dea0fe5d..0ad3f887 100644 --- a/packages/react-native-brownfield/ios/Expo/ExpoHostRuntime.swift +++ b/packages/react-native-brownfield/ios/Expo/ExpoHostRuntime.swift @@ -38,8 +38,8 @@ final class ExpoHostRuntime { * @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. + // 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) } diff --git a/packages/react-native-brownfield/ios/JSBundleLoadObserver.swift b/packages/react-native-brownfield/ios/JSBundleLoadObserver.swift index 3cf169d4..09cec35f 100644 --- a/packages/react-native-brownfield/ios/JSBundleLoadObserver.swift +++ b/packages/react-native-brownfield/ios/JSBundleLoadObserver.swift @@ -1,16 +1,16 @@ import Foundation /** - * Watches the `RCTInstanceDidLoadBundle` notification, and calls the callbacks that wait for it. + * 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 - * callbacks on the main thread, because the callbacks change the user interface. + * 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 pendingCallbacks: [() -> Void] = [] + private var pendingCallback: (() -> Void)? private var didLoadBundle = false private var observerToken: NSObjectProtocol? @@ -31,7 +31,7 @@ final class JSBundleLoadObserver { } /** - * Adds a callback. The class keeps all of the callbacks that wait, and calls each one time. If + * 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. * @@ -44,13 +44,13 @@ final class JSBundleLoadObserver { } /** - * Removes the callbacks that wait, and forgets the bundle of the earlier session. Call this + * 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?.pendingCallbacks.removeAll() + self?.pendingCallback = nil self?.didLoadBundle = false } } @@ -63,18 +63,16 @@ final class JSBundleLoadObserver { return } - pendingCallbacks.append(onBundleLoaded) + pendingCallback = onBundleLoaded } private func bundleDidLoad() { didLoadBundle = true - let callbacks = pendingCallbacks - pendingCallbacks.removeAll() + let callback = pendingCallback + pendingCallback = nil - for callback in callbacks { - callback() - } + callback?() } private func onMainThread(_ work: @escaping () -> Void) { diff --git a/packages/react-native-brownfield/ios/ReactHostPreloading.swift b/packages/react-native-brownfield/ios/ReactHostPreloading.swift index c0164769..0e3e3137 100644 --- a/packages/react-native-brownfield/ios/ReactHostPreloading.swift +++ b/packages/react-native-brownfield/ios/ReactHostPreloading.swift @@ -1,7 +1,7 @@ import Foundation /** - * The preload state that `preloadReactNative`, `view` and `stopReactNative` share. + * The preload state that `startReactNative`, `view` and `stopReactNative` share. */ final class ReactHostPreloadState { let jsBundleLoadObserver = JSBundleLoadObserver() @@ -10,9 +10,9 @@ final class ReactHostPreloadState { private var storedLaunchOptions: [AnyHashable: Any]? /** - * Keeps the launch options of a `preloadReactNative` call. + * Keeps the launch options of a `startReactNative` call. * - * `preloadReactNative` can move its work to the main thread. A view on the main thread can + * `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. */ @@ -27,7 +27,7 @@ final class ReactHostPreloadState { /** * 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. + * over the options of an earlier `startReactNative` call. */ func launchOptions( overriddenBy explicitLaunchOptions: [AnyHashable: Any]? = nil @@ -78,10 +78,13 @@ protocol ReactHostPreloading: AnyObject { extension ReactHostPreloading { /** - * The implementation of `ReactNativeBrownfield.preloadReactNative`, which holds the contract. + * The implementation of + * `ReactNativeBrownfield.startReactNative(launchOptions:preloadBundle:onBundleLoaded:)`, which + * holds the contract. */ - func preloadReactNative( + func startReactNative( launchOptions: [AnyHashable: Any]?, + preloadBundle: Bool, onBundleLoaded: (() -> Void)? ) { preloadState.storeLaunchOptions(launchOptions) @@ -90,6 +93,11 @@ extension ReactHostPreloading { preloadState.jsBundleLoadObserver.observe(onBundleLoaded: onBundleLoaded) } + guard preloadBundle else { + startReactNative() + return + } + if Thread.isMainThread { createReactHost() } else { @@ -107,7 +115,7 @@ extension ReactHostPreloading { guard canPreloadReactNative() else { NSLog( "%@", - "ReactNativeBrownfield: preloadReactNative did not load the JavaScript bundle, because " + "ReactNativeBrownfield: startReactNative did not preload the JavaScript bundle, because " + "the bundle URL can still change. The first React Native view loads the bundle." ) return diff --git a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift index addd35b7..81ea83d2 100644 --- a/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift +++ b/packages/react-native-brownfield/ios/ReactNativeBrownfield.swift @@ -96,45 +96,6 @@ 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. */ @@ -232,8 +193,8 @@ internal import Expo * Starts React Native with optional callback when bundle is 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. + * 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) @@ -243,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 5d7fba34..f249a435 100644 --- a/packages/react-native-brownfield/ios/Vanilla/ReactNativeHostRuntime.swift +++ b/packages/react-native-brownfield/ios/Vanilla/ReactNativeHostRuntime.swift @@ -192,8 +192,8 @@ final class ReactNativeHostRuntime { * @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. + // 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) }