Skip to content

Conversation

coado
Copy link
Contributor

@coado coado commented Oct 1, 2025

Summary:

Following the RFC, this PR introduces a new RCTCustomBundleConfiguration interface for modifying the bundle URL and exposes a new API for setting its instance in the RCTReactNativeFactory. The configuration object includes:

  • bundleFilePath - the URL of the bundle to load from the file system,
  • packagerServerScheme - the server scheme (e.g. http or https) to use when loading from the packager,
  • packagerServerHost - the server host (e.g. localhost) to use when loading from the packager.

It associates RCTPackagerConnection (previously singleton) with the RCTDevSettings instance, which has access to the RCTBundleManager, which contains the specified configuration object. The connection is now established in the RCTDevSettings initialize method, called after the bundle manager is set by invoking the new startWithBundleManager method on the RCTPackagerConnection.

The RCTCustomBundleConfiguration allows only for either bundleFilePath or (packagerServerScheme, packagerServerHost) to be set by defining appropriate initializers.

The logic for creating bundle URL query items is extracted to a separate createJSBundleURLQuery method and is used by RCTBundleManager to set the configured packagerServerHost and packagerServerScheme. If the configuration is not defined, the getBundleURL method returns the result of the passed fallbackURLProvider.

The bundleFilePath should be created with [NSURL fileURLWithPath:<path>], as otherwise the HMR client is created and fails ungracefully. The check is added in the getBundle method to log the error beforehand:

Simulator Screenshot - iPhone 16 Pro - 2025-10-15 at 17 09 58

When the bundleFilePath is set in the RCTCustomBundleConfiguration the Connect to Metro... message shouldn't be suggested.

Changelog:

[IOS][ADDED] - Add new RCTCustomBundleConfiguration for modifying bundle URL on RCTReactNativeFactory.

Test Plan:

Tested changing packagerServerHost from the AppDelegate by re-creating the React Native instance with updated RCTCustomBundleConfiguration. I've run two Metro instances, each serving a different JS bundle (changed background) on 8081 and 8082 ports. The native Restart RN:<current port> button on top of the screen toggles between ports (used in bundle configuration) and re-creates connections. Tested with RCT_DEV set to true and false.

port-toggle-recording.mov

For setting bundle source from a file, I've generated bundle with a blue background and created a custom bundle configuration using initWithBundleFilePath. I've run the app without starting Metro:

Simulator Screenshot - iPhone 16 Pro - 2025-10-15 at 17 06 21
code:

AppDelegate.mm

#import "AppDelegate.h"

#import <UserNotifications/UserNotifications.h>

#import <React/RCTBundleManager.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTDefines.h>
#import <React/RCTLinkingManager.h>
#import <ReactCommon/RCTSampleTurboModule.h>
#import <ReactCommon/RCTTurboModuleManager.h>

#import <React/RCTPushNotificationManager.h>

#import <NativeCxxModuleExample/NativeCxxModuleExample.h>
#ifndef RN_DISABLE_OSS_PLUGIN_HEADER
#import <RNTMyNativeViewComponentView.h>
#endif

#if __has_include(<ReactAppDependencyProvider/RCTAppDependencyProvider.h>)
#define USE_OSS_CODEGEN 1
#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>
#else
#define USE_OSS_CODEGEN 0
#endif

static NSString *kBundlePath = @"js/RNTesterApp.ios";

@interface AppDelegate () <UNUserNotificationCenterDelegate>
@end

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  self.launchOptions = launchOptions;
  self.port = @"8081";
  
#if USE_OSS_CODEGEN
  self.dependencyProvider = [RCTAppDependencyProvider new];
#endif

  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  
  [self startReactNative];

  [[UNUserNotificationCenter currentNotificationCenter] setDelegate:self];

  return YES;
}

- (void)startReactNative
{
  self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self];
  
  NSString *packagerServerHost = [NSString stringWithFormat:@"localhost:%@", self.port];
  
  RCTCustomBundleConfiguration *customBundleConfiguration =
      [[RCTCustomBundleConfiguration alloc] initWithPackagerServerScheme:@"http" packagerServerHost:packagerServerHost bundlePath:kBundlePath];

  self.reactNativeFactory.customBundleConfiguration = customBundleConfiguration;
  
  [self.reactNativeFactory startReactNativeWithModuleName:@"RNTesterApp"
                                                 inWindow:self.window
                                        initialProperties:[self prepareInitialProps]
                                            launchOptions:self.launchOptions];
  
  [self createTopButton];
}

- (void)createTopButton
{
  NSString *title = [NSString stringWithFormat:@"Restart RN:%@", self.port];
  
  self.topButton = [UIButton buttonWithType:UIButtonTypeSystem];
  [self.topButton setTitle:title forState:UIControlStateNormal];
  [self.topButton setBackgroundColor:[UIColor colorWithRed:0.0 green:0.5 blue:1.0 alpha:1]];
  [self.topButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];

  CGFloat buttonWidth = 120;
  CGFloat buttonHeight = 44;
  CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;

  self.topButton.frame = CGRectMake((screenWidth - buttonWidth) / 2, 50, buttonWidth, buttonHeight);
  self.topButton.layer.cornerRadius = 8;
  [self.topButton addTarget:self action:@selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside];
  [self.window addSubview:self.topButton];
  [self.window bringSubviewToFront:self.topButton];
}

- (void)togglePort
{
  self.port = [self.port  isEqual: @"8081"] ? @"8082" : @"8081";
}

- (void)buttonTapped:(UIButton *)sender
{
  self.reactNativeFactory = nil;
  [self togglePort];
  [self startReactNative];
}

- (NSDictionary *)prepareInitialProps
{
  NSMutableDictionary *initProps = [NSMutableDictionary new];

  NSString *_routeUri = [[NSUserDefaults standardUserDefaults] stringForKey:@"route"];
  if (_routeUri) {
    initProps[@"exampleFromAppetizeParams"] = [NSString stringWithFormat:@"rntester://example/%@Example", _routeUri];
  }

  return initProps;
}

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:kBundlePath];
}

- (BOOL)application:(UIApplication *)app
            openURL:(NSURL *)url
            options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options
{
  return [RCTLinkingManager application:app openURL:url options:options];
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
                                                      jsInvoker:(std::shared_ptr<facebook::react::CallInvoker>)jsInvoker
{
  if (name == facebook::react::NativeCxxModuleExample::kModuleName) {
    return std::make_shared<facebook::react::NativeCxxModuleExample>(jsInvoker);
  }

  return [super getTurboModule:name jsInvoker:jsInvoker];
}

// Required for the remoteNotificationsRegistered event.
- (void)application:(__unused UIApplication *)application
    didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
  [RCTPushNotificationManager didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}

// Required for the remoteNotificationRegistrationError event.
- (void)application:(__unused UIApplication *)application
    didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
  [RCTPushNotificationManager didFailToRegisterForRemoteNotificationsWithError:error];
}

#pragma mark - UNUserNotificationCenterDelegate

// Required for the remoteNotificationReceived and localNotificationReceived events
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
       willPresentNotification:(UNNotification *)notification
         withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler
{
  [RCTPushNotificationManager didReceiveNotification:notification];
  completionHandler(UNNotificationPresentationOptionNone);
}

// Required for the remoteNotificationReceived and localNotificationReceived events
// Called when a notification is tapped from background. (Foreground notification will not be shown per
// the presentation option selected above).
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
    didReceiveNotificationResponse:(UNNotificationResponse *)response
             withCompletionHandler:(void (^)(void))completionHandler
{
  UNNotification *notification = response.notification;

  // This condition will be true if tapping the notification launched the app.
  if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) {
    // This can be retrieved with getInitialNotification.
    [RCTPushNotificationManager setInitialNotification:notification];
  }

  [RCTPushNotificationManager didReceiveNotification:notification];
  completionHandler();
}

#pragma mark - New Arch Enabled settings

- (BOOL)bridgelessEnabled
{
  return YES;
}

#pragma mark - RCTComponentViewFactoryComponentProvider

#ifndef RN_DISABLE_OSS_PLUGIN_HEADER
- (nonnull NSDictionary<NSString *, Class<RCTComponentViewProtocol>> *)thirdPartyFabricComponents
{
  NSMutableDictionary *dict = [super thirdPartyFabricComponents].mutableCopy;
  if (!dict[@"RNTMyNativeView"]) {
    dict[@"RNTMyNativeView"] = NSClassFromString(@"RNTMyNativeViewComponentView");
  }
  if (!dict[@"SampleNativeComponent"]) {
    dict[@"SampleNativeComponent"] = NSClassFromString(@"RCTSampleNativeComponentComponentView");
  }
  return dict;
}
#endif

- (NSURL *)bundleURL
{
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:kBundlePath];
}

@end

AppDelegate.h

#import <RCTDefaultReactNativeFactoryDelegate.h>
#import <RCTReactNativeFactory.h>
#import <UIKit/UIKit.h>

@interface AppDelegate : RCTDefaultReactNativeFactoryDelegate <UIApplicationDelegate>

@property (nonatomic, strong, nonnull) UIWindow *window;
@property (nonatomic, strong, nonnull) RCTReactNativeFactory *reactNativeFactory;
@property (nonatomic, strong, nullable) UIButton *topButton;
@property (nonatomic, strong) NSDictionary *launchOptions;
@property (nonatomic, assign) NSString *port;

@end

@meta-cla meta-cla bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 1, 2025
@facebook-github-bot facebook-github-bot added p: Software Mansion Partner: Software Mansion Partner p: Facebook Partner: Facebook labels Oct 1, 2025
Copy link

meta-codesync bot commented Oct 7, 2025

@coado has imported this pull request. If you are a Meta employee, you can view this in D84058022.

coado added a commit to coado/react-native that referenced this pull request Oct 15, 2025
…acebook#54006)

Summary:
Following the [RFC](react-native-community/discussions-and-proposals#933), this PR introduces a new `RCTCustomBundleConfiguration` interface for modifying the bundle URL and exposes a new API for setting its instance in the `RCTReactNativeFactory`. The configuration object includes:

- bundleFilePath - the URL of the bundle to load from the file system,
- packagerServerScheme - the server scheme (e.g. http or https) to use when loading from the packager,
- packagerServerHost - the server host (e.g. localhost) to use when loading from the packager.

It associates `RCTPackagerConnection` (previously singleton) with the `RCTDevSettings` instance, which has access to the `RCTBundleManager`, which contains the specified configuration object. The connection is now established in the `RCTDevSettings initialize`  method, called after the bundle manager is set by invoking the new `startWithBundleManager` method on the `RCTPackagerConnection`.

The `RCTCustomBundleConfiguration` allows only for either `bundleFilePath` or `(packagerServerScheme, packagerServerHost)` to be set by defining appropriate initializers. 

The logic for creating bundle URL query items is extracted to a separate `createJSBundleURLQuery` method and is used by `RCTBundleManager` to set the configured `packagerServerHost` and `packagerServerScheme`. If the configuration is not defined, the `getBundleURL` method returns the result of the passed `fallbackURLProvider`.

The `bundleFilePath` should be created as `[NSURL fileURLWithPath:<path>]`, as otherwise the HMR client is created and fails ungracefully. The check is added in the `getBundle` method to log the error beforehand.  

When the `bundleFilePath` is set in the `RCTCustomBundleConfiguration` the `Connect to Metro...` message shouldn't be suggested.

## Changelog:

[IOS][ADDED] - Add new `RCTCustomBundleConfiguration` for modifying bundle URL on `RCTReactNativeFactory`.


Test Plan:
Tested changing `packagerServerHost` from the `AppDelegate` by re-creating the React Native instance with updated `RCTCustomBundleConfiguration`. I've run two Metro instances, each serving a different JS bundle (changed background) on `8081` and `8082` ports. The native `Restart RN:<current port>` button on top of the screen toggles between ports (used in bundle configuration) and re-creates connections. Tested with `RCT_DEV` set to true and false.

https://github.com/user-attachments/assets/fd57068b-869c-4f45-93be-09d33f691cea



<details>

<summary>code:</summary>

`AppDelegate.mm`

```objc
#import "AppDelegate.h"

#import <UserNotifications/UserNotifications.h>

#import <React/RCTBundleManager.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTDefines.h>
#import <React/RCTLinkingManager.h>
#import <ReactCommon/RCTSampleTurboModule.h>
#import <ReactCommon/RCTTurboModuleManager.h>

#import <React/RCTPushNotificationManager.h>

#import <NativeCxxModuleExample/NativeCxxModuleExample.h>
#ifndef RN_DISABLE_OSS_PLUGIN_HEADER
#import <RNTMyNativeViewComponentView.h>
#endif

#if __has_include(<ReactAppDependencyProvider/RCTAppDependencyProvider.h>)
#define USE_OSS_CODEGEN 1
#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>
#else
#define USE_OSS_CODEGEN 0
#endif

static NSString *kBundlePath = @"js/RNTesterApp.ios";

interface AppDelegate () <UNUserNotificationCenterDelegate>
end

implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  self.launchOptions = launchOptions;
  self.port = @"8081";
  
#if USE_OSS_CODEGEN
  self.dependencyProvider = [RCTAppDependencyProvider new];
#endif

  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  
  [self startReactNative];

  [[UNUserNotificationCenter currentNotificationCenter] setDelegate:self];

  return YES;
}

- (void)startReactNative
{
  self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self];
  
  NSString *packagerServerHost = [NSString stringWithFormat:@"localhost:%@", self.port];
  
  RCTCustomBundleConfiguration *customBundleConfiguration =
      [[RCTCustomBundleConfiguration alloc] initWithPackagerServerScheme:@"http" packagerServerHost:packagerServerHost];

  self.reactNativeFactory.customBundleConfiguration = customBundleConfiguration;
  
  [self.reactNativeFactory startReactNativeWithModuleName:@"RNTesterApp"
                                                 inWindow:self.window
                                        initialProperties:[self prepareInitialProps]
                                            launchOptions:self.launchOptions];
  
  [self createTopButton];
}

- (void)createTopButton
{
  NSString *title = [NSString stringWithFormat:@"Restart RN:%@", self.port];
  
  self.topButton = [UIButton buttonWithType:UIButtonTypeSystem];
  [self.topButton setTitle:title forState:UIControlStateNormal];
  [self.topButton setBackgroundColor:[UIColor colorWithRed:0.0 green:0.5 blue:1.0 alpha:1]];
  [self.topButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];

  CGFloat buttonWidth = 120;
  CGFloat buttonHeight = 44;
  CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;

  self.topButton.frame = CGRectMake((screenWidth - buttonWidth) / 2, 50, buttonWidth, buttonHeight);
  self.topButton.layer.cornerRadius = 8;
  [self.topButton addTarget:self action:selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside];
  [self.window addSubview:self.topButton];
  [self.window bringSubviewToFront:self.topButton];
}

- (void)togglePort
{
  self.port = [self.port  isEqual: @"8081"] ? @"8082" : @"8081";
}

- (void)buttonTapped:(UIButton *)sender
{
  self.reactNativeFactory = nil;
  [self togglePort];
  [self startReactNative];
}

- (NSDictionary *)prepareInitialProps
{
  NSMutableDictionary *initProps = [NSMutableDictionary new];

  NSString *_routeUri = [[NSUserDefaults standardUserDefaults] stringForKey:@"route"];
  if (_routeUri) {
    initProps[@"exampleFromAppetizeParams"] = [NSString stringWithFormat:@"rntester://example/%Example", _routeUri];
  }

  return initProps;
}

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:kBundlePath];
}

- (BOOL)application:(UIApplication *)app
            openURL:(NSURL *)url
            options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options
{
  return [RCTLinkingManager application:app openURL:url options:options];
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
                                                      jsInvoker:(std::shared_ptr<facebook::react::CallInvoker>)jsInvoker
{
  if (name == facebook::react::NativeCxxModuleExample::kModuleName) {
    return std::make_shared<facebook::react::NativeCxxModuleExample>(jsInvoker);
  }

  return [super getTurboModule:name jsInvoker:jsInvoker];
}

// Required for the remoteNotificationsRegistered event.
- (void)application:(__unused UIApplication *)application
    didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
  [RCTPushNotificationManager didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}

// Required for the remoteNotificationRegistrationError event.
- (void)application:(__unused UIApplication *)application
    didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
  [RCTPushNotificationManager didFailToRegisterForRemoteNotificationsWithError:error];
}

#pragma mark - UNUserNotificationCenterDelegate

// Required for the remoteNotificationReceived and localNotificationReceived events
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
       willPresentNotification:(UNNotification *)notification
         withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler
{
  [RCTPushNotificationManager didReceiveNotification:notification];
  completionHandler(UNNotificationPresentationOptionNone);
}

// Required for the remoteNotificationReceived and localNotificationReceived events
// Called when a notification is tapped from background. (Foreground notification will not be shown per
// the presentation option selected above).
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
    didReceiveNotificationResponse:(UNNotificationResponse *)response
             withCompletionHandler:(void (^)(void))completionHandler
{
  UNNotification *notification = response.notification;

  // This condition will be true if tapping the notification launched the app.
  if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) {
    // This can be retrieved with getInitialNotification.
    [RCTPushNotificationManager setInitialNotification:notification];
  }

  [RCTPushNotificationManager didReceiveNotification:notification];
  completionHandler();
}

#pragma mark - New Arch Enabled settings

- (BOOL)bridgelessEnabled
{
  return YES;
}

#pragma mark - RCTComponentViewFactoryComponentProvider

#ifndef RN_DISABLE_OSS_PLUGIN_HEADER
- (nonnull NSDictionary<NSString *, Class<RCTComponentViewProtocol>> *)thirdPartyFabricComponents
{
  NSMutableDictionary *dict = [super thirdPartyFabricComponents].mutableCopy;
  if (!dict[@"RNTMyNativeView"]) {
    dict[@"RNTMyNativeView"] = NSClassFromString(@"RNTMyNativeViewComponentView");
  }
  if (!dict[@"SampleNativeComponent"]) {
    dict[@"SampleNativeComponent"] = NSClassFromString(@"RCTSampleNativeComponentComponentView");
  }
  return dict;
}
#endif

- (NSURL *)bundleURL
{
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:kBundlePath];
}

end
```

`AppDelegate.h`

```objc
#import <RCTDefaultReactNativeFactoryDelegate.h>
#import <RCTReactNativeFactory.h>
#import <UIKit/UIKit.h>

interface AppDelegate : RCTDefaultReactNativeFactoryDelegate <UIApplicationDelegate>

property (nonatomic, strong, nonnull) UIWindow *window;
property (nonatomic, strong, nonnull) RCTReactNativeFactory *reactNativeFactory;
property (nonatomic, strong, nullable) UIButton *topButton;
property (nonatomic, strong) NSDictionary *launchOptions;
property (nonatomic, assign) NSString *port;

end
```
</details>

Differential Revision: D84058022

Pulled By: coado
Copy link

meta-codesync bot commented Oct 15, 2025

@coado has exported this pull request. If you are a Meta employee, you can view the originating Diff in D84058022.

coado added a commit to coado/react-native that referenced this pull request Oct 17, 2025
…acebook#54006)

Summary:
Following the [RFC](react-native-community/discussions-and-proposals#933), this PR introduces a new `RCTCustomBundleConfiguration` interface for modifying the bundle URL and exposes a new API for setting its instance in the `RCTReactNativeFactory`. The configuration object includes:

- bundleFilePath - the URL of the bundle to load from the file system,
- packagerServerScheme - the server scheme (e.g. http or https) to use when loading from the packager,
- packagerServerHost - the server host (e.g. localhost) to use when loading from the packager.

It associates `RCTPackagerConnection` (previously singleton) with the `RCTDevSettings` instance, which has access to the `RCTBundleManager`, which contains the specified configuration object. The connection is now established in the `RCTDevSettings initialize`  method, called after the bundle manager is set by invoking the new `startWithBundleManager` method on the `RCTPackagerConnection`.

The `RCTCustomBundleConfiguration` allows only for either `bundleFilePath` or `(packagerServerScheme, packagerServerHost)` to be set by defining appropriate initializers. 

The logic for creating bundle URL query items is extracted to a separate `createJSBundleURLQuery` method and is used by `RCTBundleManager` to set the configured `packagerServerHost` and `packagerServerScheme`. If the configuration is not defined, the `getBundleURL` method returns the result of the passed `fallbackURLProvider`.

The `bundleFilePath` should be created as `[NSURL fileURLWithPath:<path>]`, as otherwise the HMR client is created and fails ungracefully. The check is added in the `getBundle` method to log the error beforehand.  

When the `bundleFilePath` is set in the `RCTCustomBundleConfiguration` the `Connect to Metro...` message shouldn't be suggested.

## Changelog:

[IOS][ADDED] - Add new `RCTCustomBundleConfiguration` for modifying bundle URL on `RCTReactNativeFactory`.


Test Plan:
Tested changing `packagerServerHost` from the `AppDelegate` by re-creating the React Native instance with updated `RCTCustomBundleConfiguration`. I've run two Metro instances, each serving a different JS bundle (changed background) on `8081` and `8082` ports. The native `Restart RN:<current port>` button on top of the screen toggles between ports (used in bundle configuration) and re-creates connections. Tested with `RCT_DEV` set to true and false.

https://github.com/user-attachments/assets/fd57068b-869c-4f45-93be-09d33f691cea



<details>

<summary>code:</summary>

`AppDelegate.mm`

```objc
#import "AppDelegate.h"

#import <UserNotifications/UserNotifications.h>

#import <React/RCTBundleManager.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTDefines.h>
#import <React/RCTLinkingManager.h>
#import <ReactCommon/RCTSampleTurboModule.h>
#import <ReactCommon/RCTTurboModuleManager.h>

#import <React/RCTPushNotificationManager.h>

#import <NativeCxxModuleExample/NativeCxxModuleExample.h>
#ifndef RN_DISABLE_OSS_PLUGIN_HEADER
#import <RNTMyNativeViewComponentView.h>
#endif

#if __has_include(<ReactAppDependencyProvider/RCTAppDependencyProvider.h>)
#define USE_OSS_CODEGEN 1
#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>
#else
#define USE_OSS_CODEGEN 0
#endif

static NSString *kBundlePath = @"js/RNTesterApp.ios";

interface AppDelegate () <UNUserNotificationCenterDelegate>
end

implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  self.launchOptions = launchOptions;
  self.port = @"8081";
  
#if USE_OSS_CODEGEN
  self.dependencyProvider = [RCTAppDependencyProvider new];
#endif

  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  
  [self startReactNative];

  [[UNUserNotificationCenter currentNotificationCenter] setDelegate:self];

  return YES;
}

- (void)startReactNative
{
  self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self];
  
  NSString *packagerServerHost = [NSString stringWithFormat:@"localhost:%@", self.port];
  
  RCTCustomBundleConfiguration *customBundleConfiguration =
      [[RCTCustomBundleConfiguration alloc] initWithPackagerServerScheme:@"http" packagerServerHost:packagerServerHost];

  self.reactNativeFactory.customBundleConfiguration = customBundleConfiguration;
  
  [self.reactNativeFactory startReactNativeWithModuleName:@"RNTesterApp"
                                                 inWindow:self.window
                                        initialProperties:[self prepareInitialProps]
                                            launchOptions:self.launchOptions];
  
  [self createTopButton];
}

- (void)createTopButton
{
  NSString *title = [NSString stringWithFormat:@"Restart RN:%@", self.port];
  
  self.topButton = [UIButton buttonWithType:UIButtonTypeSystem];
  [self.topButton setTitle:title forState:UIControlStateNormal];
  [self.topButton setBackgroundColor:[UIColor colorWithRed:0.0 green:0.5 blue:1.0 alpha:1]];
  [self.topButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];

  CGFloat buttonWidth = 120;
  CGFloat buttonHeight = 44;
  CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;

  self.topButton.frame = CGRectMake((screenWidth - buttonWidth) / 2, 50, buttonWidth, buttonHeight);
  self.topButton.layer.cornerRadius = 8;
  [self.topButton addTarget:self action:selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside];
  [self.window addSubview:self.topButton];
  [self.window bringSubviewToFront:self.topButton];
}

- (void)togglePort
{
  self.port = [self.port  isEqual: @"8081"] ? @"8082" : @"8081";
}

- (void)buttonTapped:(UIButton *)sender
{
  self.reactNativeFactory = nil;
  [self togglePort];
  [self startReactNative];
}

- (NSDictionary *)prepareInitialProps
{
  NSMutableDictionary *initProps = [NSMutableDictionary new];

  NSString *_routeUri = [[NSUserDefaults standardUserDefaults] stringForKey:@"route"];
  if (_routeUri) {
    initProps[@"exampleFromAppetizeParams"] = [NSString stringWithFormat:@"rntester://example/%Example", _routeUri];
  }

  return initProps;
}

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:kBundlePath];
}

- (BOOL)application:(UIApplication *)app
            openURL:(NSURL *)url
            options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options
{
  return [RCTLinkingManager application:app openURL:url options:options];
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
                                                      jsInvoker:(std::shared_ptr<facebook::react::CallInvoker>)jsInvoker
{
  if (name == facebook::react::NativeCxxModuleExample::kModuleName) {
    return std::make_shared<facebook::react::NativeCxxModuleExample>(jsInvoker);
  }

  return [super getTurboModule:name jsInvoker:jsInvoker];
}

// Required for the remoteNotificationsRegistered event.
- (void)application:(__unused UIApplication *)application
    didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
  [RCTPushNotificationManager didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}

// Required for the remoteNotificationRegistrationError event.
- (void)application:(__unused UIApplication *)application
    didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
  [RCTPushNotificationManager didFailToRegisterForRemoteNotificationsWithError:error];
}

#pragma mark - UNUserNotificationCenterDelegate

// Required for the remoteNotificationReceived and localNotificationReceived events
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
       willPresentNotification:(UNNotification *)notification
         withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler
{
  [RCTPushNotificationManager didReceiveNotification:notification];
  completionHandler(UNNotificationPresentationOptionNone);
}

// Required for the remoteNotificationReceived and localNotificationReceived events
// Called when a notification is tapped from background. (Foreground notification will not be shown per
// the presentation option selected above).
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
    didReceiveNotificationResponse:(UNNotificationResponse *)response
             withCompletionHandler:(void (^)(void))completionHandler
{
  UNNotification *notification = response.notification;

  // This condition will be true if tapping the notification launched the app.
  if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) {
    // This can be retrieved with getInitialNotification.
    [RCTPushNotificationManager setInitialNotification:notification];
  }

  [RCTPushNotificationManager didReceiveNotification:notification];
  completionHandler();
}

#pragma mark - New Arch Enabled settings

- (BOOL)bridgelessEnabled
{
  return YES;
}

#pragma mark - RCTComponentViewFactoryComponentProvider

#ifndef RN_DISABLE_OSS_PLUGIN_HEADER
- (nonnull NSDictionary<NSString *, Class<RCTComponentViewProtocol>> *)thirdPartyFabricComponents
{
  NSMutableDictionary *dict = [super thirdPartyFabricComponents].mutableCopy;
  if (!dict[@"RNTMyNativeView"]) {
    dict[@"RNTMyNativeView"] = NSClassFromString(@"RNTMyNativeViewComponentView");
  }
  if (!dict[@"SampleNativeComponent"]) {
    dict[@"SampleNativeComponent"] = NSClassFromString(@"RCTSampleNativeComponentComponentView");
  }
  return dict;
}
#endif

- (NSURL *)bundleURL
{
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:kBundlePath];
}

end
```

`AppDelegate.h`

```objc
#import <RCTDefaultReactNativeFactoryDelegate.h>
#import <RCTReactNativeFactory.h>
#import <UIKit/UIKit.h>

interface AppDelegate : RCTDefaultReactNativeFactoryDelegate <UIApplicationDelegate>

property (nonatomic, strong, nonnull) UIWindow *window;
property (nonatomic, strong, nonnull) RCTReactNativeFactory *reactNativeFactory;
property (nonatomic, strong, nullable) UIButton *topButton;
property (nonatomic, strong) NSDictionary *launchOptions;
property (nonatomic, assign) NSString *port;

end
```
</details>

Differential Revision: D84058022

Pulled By: coado
coado added a commit to coado/react-native that referenced this pull request Oct 17, 2025
…acebook#54006)

Summary:
Following the [RFC](react-native-community/discussions-and-proposals#933), this PR introduces a new `RCTCustomBundleConfiguration` interface for modifying the bundle URL and exposes a new API for setting its instance in the `RCTReactNativeFactory`. The configuration object includes:

- bundleFilePath - the URL of the bundle to load from the file system,
- packagerServerScheme - the server scheme (e.g. http or https) to use when loading from the packager,
- packagerServerHost - the server host (e.g. localhost) to use when loading from the packager.

It associates `RCTPackagerConnection` (previously singleton) with the `RCTDevSettings` instance, which has access to the `RCTBundleManager`, which contains the specified configuration object. The connection is now established in the `RCTDevSettings initialize`  method, called after the bundle manager is set by invoking the new `startWithBundleManager` method on the `RCTPackagerConnection`.

The `RCTCustomBundleConfiguration` allows only for either `bundleFilePath` or `(packagerServerScheme, packagerServerHost)` to be set by defining appropriate initializers. 

The logic for creating bundle URL query items is extracted to a separate `createJSBundleURLQuery` method and is used by `RCTBundleManager` to set the configured `packagerServerHost` and `packagerServerScheme`. If the configuration is not defined, the `getBundleURL` method returns the result of the passed `fallbackURLProvider`.

The `bundleFilePath` should be created as `[NSURL fileURLWithPath:<path>]`, as otherwise the HMR client is created and fails ungracefully. The check is added in the `getBundle` method to log the error beforehand.  

When the `bundleFilePath` is set in the `RCTCustomBundleConfiguration` the `Connect to Metro...` message shouldn't be suggested.

## Changelog:

[IOS][ADDED] - Add new `RCTCustomBundleConfiguration` for modifying bundle URL on `RCTReactNativeFactory`.


Test Plan:
Tested changing `packagerServerHost` from the `AppDelegate` by re-creating the React Native instance with updated `RCTCustomBundleConfiguration`. I've run two Metro instances, each serving a different JS bundle (changed background) on `8081` and `8082` ports. The native `Restart RN:<current port>` button on top of the screen toggles between ports (used in bundle configuration) and re-creates connections. Tested with `RCT_DEV` set to true and false.

https://github.com/user-attachments/assets/fd57068b-869c-4f45-93be-09d33f691cea



<details>

<summary>code:</summary>

`AppDelegate.mm`

```objc
#import "AppDelegate.h"

#import <UserNotifications/UserNotifications.h>

#import <React/RCTBundleManager.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTDefines.h>
#import <React/RCTLinkingManager.h>
#import <ReactCommon/RCTSampleTurboModule.h>
#import <ReactCommon/RCTTurboModuleManager.h>

#import <React/RCTPushNotificationManager.h>

#import <NativeCxxModuleExample/NativeCxxModuleExample.h>
#ifndef RN_DISABLE_OSS_PLUGIN_HEADER
#import <RNTMyNativeViewComponentView.h>
#endif

#if __has_include(<ReactAppDependencyProvider/RCTAppDependencyProvider.h>)
#define USE_OSS_CODEGEN 1
#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>
#else
#define USE_OSS_CODEGEN 0
#endif

static NSString *kBundlePath = @"js/RNTesterApp.ios";

interface AppDelegate () <UNUserNotificationCenterDelegate>
end

implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  self.launchOptions = launchOptions;
  self.port = @"8081";
  
#if USE_OSS_CODEGEN
  self.dependencyProvider = [RCTAppDependencyProvider new];
#endif

  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  
  [self startReactNative];

  [[UNUserNotificationCenter currentNotificationCenter] setDelegate:self];

  return YES;
}

- (void)startReactNative
{
  self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self];
  
  NSString *packagerServerHost = [NSString stringWithFormat:@"localhost:%@", self.port];
  
  RCTCustomBundleConfiguration *customBundleConfiguration =
      [[RCTCustomBundleConfiguration alloc] initWithPackagerServerScheme:@"http" packagerServerHost:packagerServerHost];

  self.reactNativeFactory.customBundleConfiguration = customBundleConfiguration;
  
  [self.reactNativeFactory startReactNativeWithModuleName:@"RNTesterApp"
                                                 inWindow:self.window
                                        initialProperties:[self prepareInitialProps]
                                            launchOptions:self.launchOptions];
  
  [self createTopButton];
}

- (void)createTopButton
{
  NSString *title = [NSString stringWithFormat:@"Restart RN:%@", self.port];
  
  self.topButton = [UIButton buttonWithType:UIButtonTypeSystem];
  [self.topButton setTitle:title forState:UIControlStateNormal];
  [self.topButton setBackgroundColor:[UIColor colorWithRed:0.0 green:0.5 blue:1.0 alpha:1]];
  [self.topButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];

  CGFloat buttonWidth = 120;
  CGFloat buttonHeight = 44;
  CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;

  self.topButton.frame = CGRectMake((screenWidth - buttonWidth) / 2, 50, buttonWidth, buttonHeight);
  self.topButton.layer.cornerRadius = 8;
  [self.topButton addTarget:self action:selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside];
  [self.window addSubview:self.topButton];
  [self.window bringSubviewToFront:self.topButton];
}

- (void)togglePort
{
  self.port = [self.port  isEqual: @"8081"] ? @"8082" : @"8081";
}

- (void)buttonTapped:(UIButton *)sender
{
  self.reactNativeFactory = nil;
  [self togglePort];
  [self startReactNative];
}

- (NSDictionary *)prepareInitialProps
{
  NSMutableDictionary *initProps = [NSMutableDictionary new];

  NSString *_routeUri = [[NSUserDefaults standardUserDefaults] stringForKey:@"route"];
  if (_routeUri) {
    initProps[@"exampleFromAppetizeParams"] = [NSString stringWithFormat:@"rntester://example/%Example", _routeUri];
  }

  return initProps;
}

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:kBundlePath];
}

- (BOOL)application:(UIApplication *)app
            openURL:(NSURL *)url
            options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options
{
  return [RCTLinkingManager application:app openURL:url options:options];
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
                                                      jsInvoker:(std::shared_ptr<facebook::react::CallInvoker>)jsInvoker
{
  if (name == facebook::react::NativeCxxModuleExample::kModuleName) {
    return std::make_shared<facebook::react::NativeCxxModuleExample>(jsInvoker);
  }

  return [super getTurboModule:name jsInvoker:jsInvoker];
}

// Required for the remoteNotificationsRegistered event.
- (void)application:(__unused UIApplication *)application
    didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
  [RCTPushNotificationManager didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}

// Required for the remoteNotificationRegistrationError event.
- (void)application:(__unused UIApplication *)application
    didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
  [RCTPushNotificationManager didFailToRegisterForRemoteNotificationsWithError:error];
}

#pragma mark - UNUserNotificationCenterDelegate

// Required for the remoteNotificationReceived and localNotificationReceived events
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
       willPresentNotification:(UNNotification *)notification
         withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler
{
  [RCTPushNotificationManager didReceiveNotification:notification];
  completionHandler(UNNotificationPresentationOptionNone);
}

// Required for the remoteNotificationReceived and localNotificationReceived events
// Called when a notification is tapped from background. (Foreground notification will not be shown per
// the presentation option selected above).
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
    didReceiveNotificationResponse:(UNNotificationResponse *)response
             withCompletionHandler:(void (^)(void))completionHandler
{
  UNNotification *notification = response.notification;

  // This condition will be true if tapping the notification launched the app.
  if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) {
    // This can be retrieved with getInitialNotification.
    [RCTPushNotificationManager setInitialNotification:notification];
  }

  [RCTPushNotificationManager didReceiveNotification:notification];
  completionHandler();
}

#pragma mark - New Arch Enabled settings

- (BOOL)bridgelessEnabled
{
  return YES;
}

#pragma mark - RCTComponentViewFactoryComponentProvider

#ifndef RN_DISABLE_OSS_PLUGIN_HEADER
- (nonnull NSDictionary<NSString *, Class<RCTComponentViewProtocol>> *)thirdPartyFabricComponents
{
  NSMutableDictionary *dict = [super thirdPartyFabricComponents].mutableCopy;
  if (!dict[@"RNTMyNativeView"]) {
    dict[@"RNTMyNativeView"] = NSClassFromString(@"RNTMyNativeViewComponentView");
  }
  if (!dict[@"SampleNativeComponent"]) {
    dict[@"SampleNativeComponent"] = NSClassFromString(@"RCTSampleNativeComponentComponentView");
  }
  return dict;
}
#endif

- (NSURL *)bundleURL
{
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:kBundlePath];
}

end
```

`AppDelegate.h`

```objc
#import <RCTDefaultReactNativeFactoryDelegate.h>
#import <RCTReactNativeFactory.h>
#import <UIKit/UIKit.h>

interface AppDelegate : RCTDefaultReactNativeFactoryDelegate <UIApplicationDelegate>

property (nonatomic, strong, nonnull) UIWindow *window;
property (nonatomic, strong, nonnull) RCTReactNativeFactory *reactNativeFactory;
property (nonatomic, strong, nullable) UIButton *topButton;
property (nonatomic, strong) NSDictionary *launchOptions;
property (nonatomic, assign) NSString *port;

end
```
</details>

Differential Revision: D84058022

Pulled By: coado
@coado coado force-pushed the bundle-url-ios branch 2 times, most recently from c904e49 to 8da150a Compare October 20, 2025 13:23
coado added a commit to coado/react-native that referenced this pull request Oct 20, 2025
…acebook#54006)

Summary:
Following the [RFC](react-native-community/discussions-and-proposals#933), this PR introduces a new `RCTCustomBundleConfiguration` interface for modifying the bundle URL and exposes a new API for setting its instance in the `RCTReactNativeFactory`. The configuration object includes:

- bundleFilePath - the URL of the bundle to load from the file system,
- packagerServerScheme - the server scheme (e.g. http or https) to use when loading from the packager,
- packagerServerHost - the server host (e.g. localhost) to use when loading from the packager.

It associates `RCTPackagerConnection` (previously singleton) with the `RCTDevSettings` instance, which has access to the `RCTBundleManager`, which contains the specified configuration object. The connection is now established in the `RCTDevSettings initialize`  method, called after the bundle manager is set by invoking the new `startWithBundleManager` method on the `RCTPackagerConnection`.

The `RCTCustomBundleConfiguration` allows only for either `bundleFilePath` or `(packagerServerScheme, packagerServerHost)` to be set by defining appropriate initializers. 

The logic for creating bundle URL query items is extracted to a separate `createJSBundleURLQuery` method and is used by `RCTBundleManager` to set the configured `packagerServerHost` and `packagerServerScheme`. If the configuration is not defined, the `getBundleURL` method returns the result of the passed `fallbackURLProvider`.

The `bundleFilePath` should be created as `[NSURL fileURLWithPath:<path>]`, as otherwise the HMR client is created and fails ungracefully. The check is added in the `getBundle` method to log the error beforehand.  

When the `bundleFilePath` is set in the `RCTCustomBundleConfiguration` the `Connect to Metro...` message shouldn't be suggested.

## Changelog:

[IOS][ADDED] - Add new `RCTCustomBundleConfiguration` for modifying bundle URL on `RCTReactNativeFactory`.


Test Plan:
Tested changing `packagerServerHost` from the `AppDelegate` by re-creating the React Native instance with updated `RCTCustomBundleConfiguration`. I've run two Metro instances, each serving a different JS bundle (changed background) on `8081` and `8082` ports. The native `Restart RN:<current port>` button on top of the screen toggles between ports (used in bundle configuration) and re-creates connections. Tested with `RCT_DEV` set to true and false.

https://github.com/user-attachments/assets/fd57068b-869c-4f45-93be-09d33f691cea



<details>

<summary>code:</summary>

`AppDelegate.mm`

```objc
#import "AppDelegate.h"

#import <UserNotifications/UserNotifications.h>

#import <React/RCTBundleManager.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTDefines.h>
#import <React/RCTLinkingManager.h>
#import <ReactCommon/RCTSampleTurboModule.h>
#import <ReactCommon/RCTTurboModuleManager.h>

#import <React/RCTPushNotificationManager.h>

#import <NativeCxxModuleExample/NativeCxxModuleExample.h>
#ifndef RN_DISABLE_OSS_PLUGIN_HEADER
#import <RNTMyNativeViewComponentView.h>
#endif

#if __has_include(<ReactAppDependencyProvider/RCTAppDependencyProvider.h>)
#define USE_OSS_CODEGEN 1
#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>
#else
#define USE_OSS_CODEGEN 0
#endif

static NSString *kBundlePath = @"js/RNTesterApp.ios";

interface AppDelegate () <UNUserNotificationCenterDelegate>
end

implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  self.launchOptions = launchOptions;
  self.port = @"8081";
  
#if USE_OSS_CODEGEN
  self.dependencyProvider = [RCTAppDependencyProvider new];
#endif

  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  
  [self startReactNative];

  [[UNUserNotificationCenter currentNotificationCenter] setDelegate:self];

  return YES;
}

- (void)startReactNative
{
  self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self];
  
  NSString *packagerServerHost = [NSString stringWithFormat:@"localhost:%@", self.port];
  
  RCTCustomBundleConfiguration *customBundleConfiguration =
      [[RCTCustomBundleConfiguration alloc] initWithPackagerServerScheme:@"http" packagerServerHost:packagerServerHost];

  self.reactNativeFactory.customBundleConfiguration = customBundleConfiguration;
  
  [self.reactNativeFactory startReactNativeWithModuleName:@"RNTesterApp"
                                                 inWindow:self.window
                                        initialProperties:[self prepareInitialProps]
                                            launchOptions:self.launchOptions];
  
  [self createTopButton];
}

- (void)createTopButton
{
  NSString *title = [NSString stringWithFormat:@"Restart RN:%@", self.port];
  
  self.topButton = [UIButton buttonWithType:UIButtonTypeSystem];
  [self.topButton setTitle:title forState:UIControlStateNormal];
  [self.topButton setBackgroundColor:[UIColor colorWithRed:0.0 green:0.5 blue:1.0 alpha:1]];
  [self.topButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];

  CGFloat buttonWidth = 120;
  CGFloat buttonHeight = 44;
  CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;

  self.topButton.frame = CGRectMake((screenWidth - buttonWidth) / 2, 50, buttonWidth, buttonHeight);
  self.topButton.layer.cornerRadius = 8;
  [self.topButton addTarget:self action:selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside];
  [self.window addSubview:self.topButton];
  [self.window bringSubviewToFront:self.topButton];
}

- (void)togglePort
{
  self.port = [self.port  isEqual: @"8081"] ? @"8082" : @"8081";
}

- (void)buttonTapped:(UIButton *)sender
{
  self.reactNativeFactory = nil;
  [self togglePort];
  [self startReactNative];
}

- (NSDictionary *)prepareInitialProps
{
  NSMutableDictionary *initProps = [NSMutableDictionary new];

  NSString *_routeUri = [[NSUserDefaults standardUserDefaults] stringForKey:@"route"];
  if (_routeUri) {
    initProps[@"exampleFromAppetizeParams"] = [NSString stringWithFormat:@"rntester://example/%Example", _routeUri];
  }

  return initProps;
}

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:kBundlePath];
}

- (BOOL)application:(UIApplication *)app
            openURL:(NSURL *)url
            options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options
{
  return [RCTLinkingManager application:app openURL:url options:options];
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
                                                      jsInvoker:(std::shared_ptr<facebook::react::CallInvoker>)jsInvoker
{
  if (name == facebook::react::NativeCxxModuleExample::kModuleName) {
    return std::make_shared<facebook::react::NativeCxxModuleExample>(jsInvoker);
  }

  return [super getTurboModule:name jsInvoker:jsInvoker];
}

// Required for the remoteNotificationsRegistered event.
- (void)application:(__unused UIApplication *)application
    didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
  [RCTPushNotificationManager didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}

// Required for the remoteNotificationRegistrationError event.
- (void)application:(__unused UIApplication *)application
    didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
  [RCTPushNotificationManager didFailToRegisterForRemoteNotificationsWithError:error];
}

#pragma mark - UNUserNotificationCenterDelegate

// Required for the remoteNotificationReceived and localNotificationReceived events
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
       willPresentNotification:(UNNotification *)notification
         withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler
{
  [RCTPushNotificationManager didReceiveNotification:notification];
  completionHandler(UNNotificationPresentationOptionNone);
}

// Required for the remoteNotificationReceived and localNotificationReceived events
// Called when a notification is tapped from background. (Foreground notification will not be shown per
// the presentation option selected above).
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
    didReceiveNotificationResponse:(UNNotificationResponse *)response
             withCompletionHandler:(void (^)(void))completionHandler
{
  UNNotification *notification = response.notification;

  // This condition will be true if tapping the notification launched the app.
  if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) {
    // This can be retrieved with getInitialNotification.
    [RCTPushNotificationManager setInitialNotification:notification];
  }

  [RCTPushNotificationManager didReceiveNotification:notification];
  completionHandler();
}

#pragma mark - New Arch Enabled settings

- (BOOL)bridgelessEnabled
{
  return YES;
}

#pragma mark - RCTComponentViewFactoryComponentProvider

#ifndef RN_DISABLE_OSS_PLUGIN_HEADER
- (nonnull NSDictionary<NSString *, Class<RCTComponentViewProtocol>> *)thirdPartyFabricComponents
{
  NSMutableDictionary *dict = [super thirdPartyFabricComponents].mutableCopy;
  if (!dict[@"RNTMyNativeView"]) {
    dict[@"RNTMyNativeView"] = NSClassFromString(@"RNTMyNativeViewComponentView");
  }
  if (!dict[@"SampleNativeComponent"]) {
    dict[@"SampleNativeComponent"] = NSClassFromString(@"RCTSampleNativeComponentComponentView");
  }
  return dict;
}
#endif

- (NSURL *)bundleURL
{
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:kBundlePath];
}

end
```

`AppDelegate.h`

```objc
#import <RCTDefaultReactNativeFactoryDelegate.h>
#import <RCTReactNativeFactory.h>
#import <UIKit/UIKit.h>

interface AppDelegate : RCTDefaultReactNativeFactoryDelegate <UIApplicationDelegate>

property (nonatomic, strong, nonnull) UIWindow *window;
property (nonatomic, strong, nonnull) RCTReactNativeFactory *reactNativeFactory;
property (nonatomic, strong, nullable) UIButton *topButton;
property (nonatomic, strong) NSDictionary *launchOptions;
property (nonatomic, assign) NSString *port;

end
```
</details>

Differential Revision: D84058022

Pulled By: coado
@coado coado marked this pull request as ready for review October 20, 2025 13:59
…acebook#54006)

Summary:
Following the [RFC](react-native-community/discussions-and-proposals#933), this PR introduces a new `RCTCustomBundleConfiguration` interface for modifying the bundle URL and exposes a new API for setting its instance in the `RCTReactNativeFactory`. The configuration object includes:

- bundleFilePath - the URL of the bundle to load from the file system,
- packagerServerScheme - the server scheme (e.g. http or https) to use when loading from the packager,
- packagerServerHost - the server host (e.g. localhost) to use when loading from the packager.

It associates `RCTPackagerConnection` (previously singleton) with the `RCTDevSettings` instance, which has access to the `RCTBundleManager`, which contains the specified configuration object. The connection is now established in the `RCTDevSettings initialize`  method, called after the bundle manager is set by invoking the new `startWithBundleManager` method on the `RCTPackagerConnection`.

The `RCTCustomBundleConfiguration` allows only for either `bundleFilePath` or `(packagerServerScheme, packagerServerHost)` to be set by defining appropriate initializers. 

The logic for creating bundle URL query items is extracted to a separate `createJSBundleURLQuery` method and is used by `RCTBundleManager` to set the configured `packagerServerHost` and `packagerServerScheme`. If the configuration is not defined, the `getBundleURL` method returns the result of the passed `fallbackURLProvider`.

The `bundleFilePath` should be created with `[NSURL fileURLWithPath:<path>]`, as otherwise the HMR client is created and fails ungracefully. The check is added in the `getBundle` method to log the error beforehand:

<img width="306" height="822" alt="Simulator Screenshot - iPhone 16 Pro - 2025-10-15 at 17 09 58" src="https://github.com/user-attachments/assets/869eed16-c5d8-4204-81d7-bd9cd42b2223" />

When the `bundleFilePath` is set in the `RCTCustomBundleConfiguration` the `Connect to Metro...` message shouldn't be suggested.

## Changelog:

[IOS][ADDED] - Add new `RCTCustomBundleConfiguration` for modifying bundle URL on `RCTReactNativeFactory`.


Test Plan:
Tested changing `packagerServerHost` from the `AppDelegate` by re-creating the React Native instance with updated `RCTCustomBundleConfiguration`. I've run two Metro instances, each serving a different JS bundle (changed background) on `8081` and `8082` ports. The native `Restart RN:<current port>` button on top of the screen toggles between ports (used in bundle configuration) and re-creates connections. Tested with `RCT_DEV` set to true and false.

https://github.com/user-attachments/assets/fd57068b-869c-4f45-93be-09d33f691cea


For setting bundle source from a file, I've generated bundle with a blue background and created a custom bundle configuration using `initWithBundleFilePath`. I've run the app without starting Metro:

<img width="306" height="822" alt="Simulator Screenshot - iPhone 16 Pro - 2025-10-15 at 17 06 21" src="https://github.com/user-attachments/assets/8283f202-0150-4e93-a4a9-d2b6ea6f1c37" />


<details>

<summary>code:</summary>

`AppDelegate.mm`

```objc
#import "AppDelegate.h"

#import <UserNotifications/UserNotifications.h>

#import <React/RCTBundleManager.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTDefines.h>
#import <React/RCTLinkingManager.h>
#import <ReactCommon/RCTSampleTurboModule.h>
#import <ReactCommon/RCTTurboModuleManager.h>

#import <React/RCTPushNotificationManager.h>

#import <NativeCxxModuleExample/NativeCxxModuleExample.h>
#ifndef RN_DISABLE_OSS_PLUGIN_HEADER
#import <RNTMyNativeViewComponentView.h>
#endif

#if __has_include(<ReactAppDependencyProvider/RCTAppDependencyProvider.h>)
#define USE_OSS_CODEGEN 1
#import <ReactAppDependencyProvider/RCTAppDependencyProvider.h>
#else
#define USE_OSS_CODEGEN 0
#endif

static NSString *kBundlePath = @"js/RNTesterApp.ios";

interface AppDelegate () <UNUserNotificationCenterDelegate>
end

implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  self.launchOptions = launchOptions;
  self.port = @"8081";
  
#if USE_OSS_CODEGEN
  self.dependencyProvider = [RCTAppDependencyProvider new];
#endif

  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  
  [self startReactNative];

  [[UNUserNotificationCenter currentNotificationCenter] setDelegate:self];

  return YES;
}

- (void)startReactNative
{
  self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self];
  
  NSString *packagerServerHost = [NSString stringWithFormat:@"localhost:%@", self.port];
  
  RCTCustomBundleConfiguration *customBundleConfiguration =
      [[RCTCustomBundleConfiguration alloc] initWithPackagerServerScheme:@"http" packagerServerHost:packagerServerHost bundlePath:kBundlePath];

  self.reactNativeFactory.customBundleConfiguration = customBundleConfiguration;
  
  [self.reactNativeFactory startReactNativeWithModuleName:@"RNTesterApp"
                                                 inWindow:self.window
                                        initialProperties:[self prepareInitialProps]
                                            launchOptions:self.launchOptions];
  
  [self createTopButton];
}

- (void)createTopButton
{
  NSString *title = [NSString stringWithFormat:@"Restart RN:%@", self.port];
  
  self.topButton = [UIButton buttonWithType:UIButtonTypeSystem];
  [self.topButton setTitle:title forState:UIControlStateNormal];
  [self.topButton setBackgroundColor:[UIColor colorWithRed:0.0 green:0.5 blue:1.0 alpha:1]];
  [self.topButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];

  CGFloat buttonWidth = 120;
  CGFloat buttonHeight = 44;
  CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;

  self.topButton.frame = CGRectMake((screenWidth - buttonWidth) / 2, 50, buttonWidth, buttonHeight);
  self.topButton.layer.cornerRadius = 8;
  [self.topButton addTarget:self action:selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside];
  [self.window addSubview:self.topButton];
  [self.window bringSubviewToFront:self.topButton];
}

- (void)togglePort
{
  self.port = [self.port  isEqual: @"8081"] ? @"8082" : @"8081";
}

- (void)buttonTapped:(UIButton *)sender
{
  self.reactNativeFactory = nil;
  [self togglePort];
  [self startReactNative];
}

- (NSDictionary *)prepareInitialProps
{
  NSMutableDictionary *initProps = [NSMutableDictionary new];

  NSString *_routeUri = [[NSUserDefaults standardUserDefaults] stringForKey:@"route"];
  if (_routeUri) {
    initProps[@"exampleFromAppetizeParams"] = [NSString stringWithFormat:@"rntester://example/%Example", _routeUri];
  }

  return initProps;
}

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:kBundlePath];
}

- (BOOL)application:(UIApplication *)app
            openURL:(NSURL *)url
            options:(NSDictionary<UIApplicationOpenURLOptionsKey, id> *)options
{
  return [RCTLinkingManager application:app openURL:url options:options];
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const std::string &)name
                                                      jsInvoker:(std::shared_ptr<facebook::react::CallInvoker>)jsInvoker
{
  if (name == facebook::react::NativeCxxModuleExample::kModuleName) {
    return std::make_shared<facebook::react::NativeCxxModuleExample>(jsInvoker);
  }

  return [super getTurboModule:name jsInvoker:jsInvoker];
}

// Required for the remoteNotificationsRegistered event.
- (void)application:(__unused UIApplication *)application
    didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
  [RCTPushNotificationManager didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}

// Required for the remoteNotificationRegistrationError event.
- (void)application:(__unused UIApplication *)application
    didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
  [RCTPushNotificationManager didFailToRegisterForRemoteNotificationsWithError:error];
}

#pragma mark - UNUserNotificationCenterDelegate

// Required for the remoteNotificationReceived and localNotificationReceived events
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
       willPresentNotification:(UNNotification *)notification
         withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler
{
  [RCTPushNotificationManager didReceiveNotification:notification];
  completionHandler(UNNotificationPresentationOptionNone);
}

// Required for the remoteNotificationReceived and localNotificationReceived events
// Called when a notification is tapped from background. (Foreground notification will not be shown per
// the presentation option selected above).
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
    didReceiveNotificationResponse:(UNNotificationResponse *)response
             withCompletionHandler:(void (^)(void))completionHandler
{
  UNNotification *notification = response.notification;

  // This condition will be true if tapping the notification launched the app.
  if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) {
    // This can be retrieved with getInitialNotification.
    [RCTPushNotificationManager setInitialNotification:notification];
  }

  [RCTPushNotificationManager didReceiveNotification:notification];
  completionHandler();
}

#pragma mark - New Arch Enabled settings

- (BOOL)bridgelessEnabled
{
  return YES;
}

#pragma mark - RCTComponentViewFactoryComponentProvider

#ifndef RN_DISABLE_OSS_PLUGIN_HEADER
- (nonnull NSDictionary<NSString *, Class<RCTComponentViewProtocol>> *)thirdPartyFabricComponents
{
  NSMutableDictionary *dict = [super thirdPartyFabricComponents].mutableCopy;
  if (!dict[@"RNTMyNativeView"]) {
    dict[@"RNTMyNativeView"] = NSClassFromString(@"RNTMyNativeViewComponentView");
  }
  if (!dict[@"SampleNativeComponent"]) {
    dict[@"SampleNativeComponent"] = NSClassFromString(@"RCTSampleNativeComponentComponentView");
  }
  return dict;
}
#endif

- (NSURL *)bundleURL
{
  return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:kBundlePath];
}

end
```

`AppDelegate.h`

```objc
#import <RCTDefaultReactNativeFactoryDelegate.h>
#import <RCTReactNativeFactory.h>
#import <UIKit/UIKit.h>

interface AppDelegate : RCTDefaultReactNativeFactoryDelegate <UIApplicationDelegate>

property (nonatomic, strong, nonnull) UIWindow *window;
property (nonatomic, strong, nonnull) RCTReactNativeFactory *reactNativeFactory;
property (nonatomic, strong, nullable) UIButton *topButton;
property (nonatomic, strong) NSDictionary *launchOptions;
property (nonatomic, assign) NSString *port;

end
```
</details>

Differential Revision: D84058022

Pulled By: coado
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. fb-exported meta-exported p: Facebook Partner: Facebook p: Software Mansion Partner: Software Mansion Partner

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants