Skip to content

Commit 8033667

Browse files
coadofacebook-github-bot
authored andcommitted
iOS: Add new RCTCustomBundleConfiguration for modifying bundle URL (#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
1 parent fcb51b1 commit 8033667

File tree

4 files changed

+192
-11
lines changed

4 files changed

+192
-11
lines changed

packages/react-native/React/Base/RCTBundleManager.h

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,66 @@
99

1010
@class RCTBridge;
1111

12-
typedef NSURL * (^RCTBridgelessBundleURLGetter)(void);
13-
typedef void (^RCTBridgelessBundleURLSetter)(NSURL *bundleURL);
12+
typedef NSURL *_Nullable (^RCTBridgelessBundleURLGetter)(void);
13+
typedef void (^RCTBridgelessBundleURLSetter)(NSURL *_Nullable bundleURL);
14+
typedef NSMutableArray<NSURLQueryItem *> *_Nullable (^RCTPackagerOptionsUpdater)(
15+
NSMutableArray<NSURLQueryItem *> *_Nullable options);
16+
17+
/**
18+
* Configuration class for setting up custom bundle locations
19+
*/
20+
@interface RCTBundleConfiguration : NSObject
21+
22+
/**
23+
* The URL of the bundle to load from the file system
24+
*/
25+
@property (nonatomic, readonly, nullable) NSURL *bundleFilePath;
26+
27+
/**
28+
* The server scheme (e.g. http or https) to use when loading from the packager
29+
*/
30+
@property (nonatomic, readonly, nullable) NSString *packagerServerScheme;
31+
32+
/**
33+
* The server host (e.g. localhost) to use when loading from the packager
34+
*/
35+
@property (nonatomic, readonly, nullable) NSString *packagerServerHost;
36+
37+
/**
38+
* A block that modifies the packager options when loading from the packager
39+
*/
40+
@property (nonatomic, copy, nullable) RCTPackagerOptionsUpdater packagerOptionsUpdater;
41+
42+
/**
43+
* The relative path to the bundle.
44+
*/
45+
@property (nonatomic, readonly, nullable) NSString *bundlePath;
46+
47+
- (nonnull instancetype)initWithBundleFilePath:(nullable NSURL *)bundleFilePath;
48+
49+
- (nonnull instancetype)initWithPackagerServerScheme:(nullable NSString *)packagerServerScheme
50+
packagerServerHost:(nullable NSString *)packagerServerHost
51+
bundlePath:(nullable NSString *)bundlePath;
52+
53+
- (nullable NSURL *)getBundleURL:(NSURL *_Nullable (^_Nullable)(void))fallbackURLProvider;
54+
55+
- (nullable NSString *)getPackagerServerScheme;
56+
57+
- (nullable NSString *)getPackagerServerHost;
58+
59+
@end
1460

1561
/**
1662
* A class that allows NativeModules/TurboModules to read/write the bundleURL, with or without the bridge.
1763
*/
1864
@interface RCTBundleManager : NSObject
1965
#ifndef RCT_REMOVE_LEGACY_ARCH
20-
- (void)setBridge:(RCTBridge *)bridge;
66+
- (void)setBridge:(nullable RCTBridge *)bridge;
2167
#endif // RCT_REMOVE_LEGACY_ARCH
22-
- (void)setBridgelessBundleURLGetter:(RCTBridgelessBundleURLGetter)getter
23-
andSetter:(RCTBridgelessBundleURLSetter)setter
24-
andDefaultGetter:(RCTBridgelessBundleURLGetter)defaultGetter;
68+
- (void)setBridgelessBundleURLGetter:(nullable RCTBridgelessBundleURLGetter)getter
69+
andSetter:(nullable RCTBridgelessBundleURLSetter)setter
70+
andDefaultGetter:(nullable RCTBridgelessBundleURLGetter)defaultGetter;
2571
- (void)resetBundleURL;
26-
@property NSURL *bundleURL;
72+
@property (nonatomic, nullable) NSURL *bundleURL;
73+
@property (nonatomic, nullable) RCTBundleConfiguration *bundleConfig;
2774
@end

packages/react-native/React/Base/RCTBundleManager.m

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,94 @@
66
*/
77

88
#import "RCTBundleManager.h"
9+
#import <React/RCTBundleURLProvider.h>
910
#import "RCTAssert.h"
1011
#import "RCTBridge+Private.h"
1112
#import "RCTBridge.h"
13+
#import "RCTLog.h"
14+
15+
@implementation RCTBundleConfiguration
16+
17+
- (instancetype)initWithBundleFilePath:(NSURL *)bundleFilePath
18+
{
19+
return [self initWithBundleFilePath:bundleFilePath packagerServerScheme:nil packagerServerHost:nil bundlePath:nil];
20+
}
21+
22+
- (instancetype)initWithPackagerServerScheme:(NSString *)packagerServerScheme
23+
packagerServerHost:(NSString *)packagerServerHost
24+
bundlePath:(NSString *)bundlePath
25+
{
26+
return [self initWithBundleFilePath:nil
27+
packagerServerScheme:packagerServerScheme
28+
packagerServerHost:packagerServerHost
29+
bundlePath:bundlePath];
30+
}
31+
32+
- (instancetype)initWithBundleFilePath:(NSURL *)bundleFilePath
33+
packagerServerScheme:(NSString *)packagerServerScheme
34+
packagerServerHost:(NSString *)packagerServerHost
35+
bundlePath:(NSString *)bundlePath
36+
{
37+
if (self = [super init]) {
38+
_bundleFilePath = bundleFilePath;
39+
_packagerServerScheme = packagerServerScheme;
40+
_packagerServerHost = packagerServerHost;
41+
_bundlePath = bundlePath;
42+
_packagerOptionsUpdater = ^NSMutableArray<NSURLQueryItem *> *(NSMutableArray<NSURLQueryItem *> *options)
43+
{
44+
return options;
45+
};
46+
}
47+
48+
return self;
49+
}
50+
51+
- (NSString *)getPackagerServerScheme
52+
{
53+
if (!_packagerServerScheme) {
54+
return [[RCTBundleURLProvider sharedSettings] packagerScheme];
55+
}
56+
57+
return _packagerServerScheme;
58+
}
59+
60+
- (NSString *)getPackagerServerHost
61+
{
62+
if (!_packagerServerHost) {
63+
return [[RCTBundleURLProvider sharedSettings] packagerServerHostPort];
64+
}
65+
66+
return _packagerServerHost;
67+
}
68+
69+
- (NSURL *)getBundleURL:(NSURL * (^)(void))fallbackURLProvider
70+
{
71+
if (_packagerServerScheme && _packagerServerHost) {
72+
NSArray<NSURLQueryItem *> *jsBundleURLQuery =
73+
[[RCTBundleURLProvider sharedSettings] createJSBundleURLQuery:_packagerServerHost
74+
packagerScheme:_packagerServerScheme];
75+
76+
NSArray<NSURLQueryItem *> *updatedBundleURLQuery = _packagerOptionsUpdater((NSMutableArray *)jsBundleURLQuery);
77+
NSString *path = [NSString stringWithFormat:@"/%@.bundle", _bundlePath];
78+
return [[RCTBundleURLProvider class] resourceURLForResourcePath:path
79+
packagerHost:_packagerServerHost
80+
scheme:_packagerServerScheme
81+
queryItems:updatedBundleURLQuery];
82+
}
83+
84+
if (_bundleFilePath) {
85+
if (!_bundleFilePath.fileURL) {
86+
RCTLogError(@"Bundle file path must be a file URL");
87+
return nil;
88+
}
89+
90+
return _bundleFilePath;
91+
}
92+
93+
return fallbackURLProvider();
94+
}
95+
96+
@end
1297

1398
@implementation RCTBundleManager {
1499
#ifndef RCT_REMOVE_LEGACY_ARCH
@@ -18,6 +103,7 @@ @implementation RCTBundleManager {
18103
RCTBridgelessBundleURLSetter _bridgelessBundleURLSetter;
19104
RCTBridgelessBundleURLGetter _bridgelessBundleURLDefaultGetter;
20105
}
106+
@synthesize bundleConfig;
21107

22108
#ifndef RCT_REMOVE_LEGACY_ARCH
23109
- (void)setBridge:(RCTBridge *)bridge

packages/react-native/React/Base/RCTBundleURLProvider.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,19 @@ NS_ASSUME_NONNULL_BEGIN
9797
resourceExtension:(NSString *)extension
9898
offlineBundle:(NSBundle *)offlineBundle;
9999

100+
- (NSArray<NSURLQueryItem *> *)createJSBundleURLQuery:(NSString *)packagerHost
101+
packagerScheme:(NSString *__nullable)scheme;
102+
103+
+ (NSArray<NSURLQueryItem *> *)createJSBundleURLQuery:(NSString *)packagerHost
104+
packagerScheme:(NSString *__nullable)scheme
105+
enableDev:(BOOL)enableDev
106+
enableMinification:(BOOL)enableMinification
107+
inlineSourceMap:(BOOL)inlineSourceMap
108+
modulesOnly:(BOOL)modulesOnly
109+
runModule:(BOOL)runModule
110+
additionalOptions:
111+
(NSDictionary<NSString *, NSString *> *__nullable)additionalOptions;
112+
100113
/**
101114
* The IP address or hostname of the packager.
102115
*/

packages/react-native/React/Base/RCTBundleURLProvider.mm

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,44 @@ + (NSURL *__nullable)jsBundleURLForBundleRoot:(NSString *)bundleRoot
313313
additionalOptions:(NSDictionary<NSString *, NSString *> *__nullable)additionalOptions
314314
{
315315
NSString *path = [NSString stringWithFormat:@"/%@.bundle", bundleRoot];
316+
NSArray<NSURLQueryItem *> *queryItems = [self createJSBundleURLQuery:packagerHost
317+
packagerScheme:scheme
318+
enableDev:enableDev
319+
enableMinification:enableMinification
320+
inlineSourceMap:inlineSourceMap
321+
modulesOnly:modulesOnly
322+
runModule:runModule
323+
additionalOptions:additionalOptions];
324+
325+
return [[self class] resourceURLForResourcePath:path
326+
packagerHost:packagerHost
327+
scheme:scheme
328+
queryItems:[queryItems copy]];
329+
}
330+
331+
- (NSArray<NSURLQueryItem *> *)createJSBundleURLQuery:(NSString *)packagerHost
332+
packagerScheme:(NSString *__nullable)scheme
333+
{
334+
return [[self class] createJSBundleURLQuery:packagerHost
335+
packagerScheme:scheme
336+
enableDev:[self enableDev]
337+
enableMinification:[self enableMinification]
338+
inlineSourceMap:[self inlineSourceMap]
339+
modulesOnly:NO
340+
runModule:YES
341+
additionalOptions:nil];
342+
}
343+
344+
+ (NSArray<NSURLQueryItem *> *)createJSBundleURLQuery:(NSString *)packagerHost
345+
packagerScheme:(NSString *__nullable)scheme
346+
enableDev:(BOOL)enableDev
347+
enableMinification:(BOOL)enableMinification
348+
inlineSourceMap:(BOOL)inlineSourceMap
349+
modulesOnly:(BOOL)modulesOnly
350+
runModule:(BOOL)runModule
351+
additionalOptions:
352+
(NSDictionary<NSString *, NSString *> *__nullable)additionalOptions
353+
{
316354
BOOL lazy = enableDev;
317355
NSMutableArray<NSURLQueryItem *> *queryItems = [[NSMutableArray alloc] initWithArray:@[
318356
[[NSURLQueryItem alloc] initWithName:@"platform" value:RCTPlatformName],
@@ -345,10 +383,7 @@ + (NSURL *__nullable)jsBundleURLForBundleRoot:(NSString *)bundleRoot
345383
}
346384
}
347385

348-
return [[self class] resourceURLForResourcePath:path
349-
packagerHost:packagerHost
350-
scheme:scheme
351-
queryItems:[queryItems copy]];
386+
return queryItems;
352387
}
353388

354389
+ (NSURL *)resourceURLForResourcePath:(NSString *)path

0 commit comments

Comments
 (0)