Skip to content

Conversation

j-piasecki
Copy link

@j-piasecki j-piasecki commented Sep 2, 2025

Proposes a new API that would allow frameworks to customize the bundle source.

View the rendered RFC

@j-piasecki j-piasecki marked this pull request as ready for review September 2, 2025 08:20
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
coado added a commit to coado/react-native that referenced this pull request Oct 17, 2025
…dle URL at runtime (facebook#54139)

Summary:
Following the [RFC](react-native-community/discussions-and-proposals#933), this PR adds new `setBundleSource` methods to `ReactHost` for modifying bundle URL at runtime. The first one with signature:

```Kotlin
public fun setBundleSource(debugServerHost: String, moduleName: String, queryBuilder: (Map<String, String>) -> Map<String, String> = { it })
```

takes debugServerHost (set in packager connection settings), moduleName (set in DevSupportManager's jsAppBundleName), and queryBuilder (set in packager connection settings). Before updating settings, the packager connection is closed to reset the packager client, which will be newly created during reload with updated configuration.


The second one for loading bundle from the file takes single `filePath` argument:

```Kotlin
public fun setBundleSource(filePath: String)
```

It sets `customBundleFilePath` in `DevSupportManager` which has priority over other methods of loading the bundle in `jsBundleLoader` and reloads `ReactHost`.


## Changelog:

[ANDROID][ADDED] - added new `setBundleSource` method to `ReactHost` for changing bundle URL at runtime.


Test Plan:
Started with running two Metro instances on ports `8081` and `8082` (first with white background, second with blue). Created a native button that toggles `debugServerHost` port and invokes  `setBundleSource`.

https://github.com/user-attachments/assets/7afe2cbc-6fef-44bc-930c-e9f9c4edd2bd

For setting bundle file path, generated JS bundle with different background comparing to the one serving by Metro. Moved file to the `app/files` directory in android emulator and configured native button to invoke a `setBundleSource(filePath)`. 

https://github.com/user-attachments/assets/5e59d7b7-c6ae-475c-94e3-50d4ac69cf24




<details>

<summary>code:</summary>

Changing debug server host:

`RNTesterActivity.kt`:

```Kotlin
package com.facebook.react.uiapp

import android.content.res.Configuration
import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.FrameLayout
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.facebook.common.logging.FLog
import com.facebook.react.FBRNTesterEndToEndHelper
import com.facebook.react.ReactActivity
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import java.io.FileDescriptor
import java.io.PrintWriter

internal class RNTesterActivity : ReactActivity() {
  private var activePort = "8081"

  class RNTesterActivityDelegate(val activity: ReactActivity, mainComponentName: String) :
      DefaultReactActivityDelegate(activity, mainComponentName, fabricEnabled) {
    private val PARAM_ROUTE = "route"
    private lateinit var initialProps: Bundle

    override fun onCreate(savedInstanceState: Bundle?) {
      // Get remote param before calling super which uses it
      val bundle = activity.intent?.extras

      if (bundle != null && bundle.containsKey(PARAM_ROUTE)) {
        val routeUri = "rntester://example/${bundle.getString(PARAM_ROUTE)}Example"
        initialProps = Bundle().apply { putString("exampleFromAppetizeParams", routeUri) }
      }
      FBRNTesterEndToEndHelper.onCreate(activity.application)
      super.onCreate(savedInstanceState)
    }

    override fun getLaunchOptions() =
        if (this::initialProps.isInitialized) initialProps else Bundle()
  }

  private fun getButtonText(): String {
    return "Port: $activePort"
  }

  private fun setupPortButton(onClick: () -> Unit) {
    val portButton = Button(this).apply {
      text = getButtonText()
      setBackgroundColor(Color.rgb(0, 123, 255)) // Blue background
      setTextColor(Color.WHITE)
      setPadding(32, 16, 32, 16)
      textSize = 16f
      elevation = 8f
    }

    // Get the root view and add button to it
    val rootView = this.findViewById<FrameLayout>(android.R.id.content)
    val layoutParams = FrameLayout.LayoutParams(
      FrameLayout.LayoutParams.WRAP_CONTENT,
      FrameLayout.LayoutParams.WRAP_CONTENT
    ).apply {
      gravity = android.view.Gravity.TOP or android.view.Gravity.CENTER_HORIZONTAL
      topMargin = 200 // 50dp from top
    }

    rootView.addView(portButton, layoutParams)

    portButton.setOnClickListener {
      onClick()
      portButton.text = getButtonText()
    }
  }

  // set background color so it will show below transparent system bars on forced edge-to-edge
  private fun maybeUpdateBackgroundColor() {
    val isDarkMode =
        resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
            Configuration.UI_MODE_NIGHT_YES

    val color =
        if (isDarkMode) {
          Color.rgb(11, 6, 0)
        } else {
          Color.rgb(243, 248, 255)
        }

    window?.setBackgroundDrawable(color.toDrawable())
  }

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    fullyDrawnReporter.addReporter()
    maybeUpdateBackgroundColor()

   reactDelegate?.reactHost?.let {
     setupPortButton {
       activePort = if (activePort == "8081") "8082" else "8081"
       reactHost.setBundleSource("10.0.2.2:$activePort", "js/RNTesterApp.android")
       // reactHost.setBundleSource("/data/user/0/com.facebook.react.uiapp/files/android.bundle")
     }
   }

    // register insets listener to update margins on the ReactRootView to avoid overlap w/ system
    // bars
    reactDelegate?.reactRootView?.let { rootView ->
      val insetsType: Int =
          WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()

      val windowInsetsListener = { view: View, windowInsets: WindowInsetsCompat ->
        val insets = windowInsets.getInsets(insetsType)

        (view.layoutParams as FrameLayout.LayoutParams).apply {
          setMargins(insets.left, insets.top, insets.right, insets.bottom)
        }

        WindowInsetsCompat.CONSUMED
      }
      ViewCompat.setOnApplyWindowInsetsListener(rootView, windowInsetsListener)
    }
  }

  override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)

    // update background color on UI mode change
    maybeUpdateBackgroundColor()
  }

  override fun createReactActivityDelegate() = RNTesterActivityDelegate(this, mainComponentName)

  override fun getMainComponentName() = "RNTesterApp"

  override fun dump(
      prefix: String,
      fd: FileDescriptor?,
      writer: PrintWriter,
      args: Array<String>?,
  ) {
    FBRNTesterEndToEndHelper.maybeDump(prefix, writer, args)
  }
}

```

</detail>

Differential Revision: D84713639

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 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants