From b0abdcd1a2e886e0f862b166e1ae41d093ee910a Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Fri, 24 Oct 2025 00:42:04 -0700 Subject: [PATCH] Android: Add `setBundleSource` method to `ReactHost` for changing bundle URL at runtime (#54139) Summary: Following the [RFC](https://github.com/react-native-community/discussions-and-proposals/pull/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) -> Map = { 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
code: 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(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?, ) { FBRNTesterEndToEndHelper.maybeDump(prefix, writer, args) } } ``` Differential Revision: D84713639 Pulled By: coado --- .../ReactAndroid/api/ReactAndroid.api | 12 +++++++ .../main/java/com/facebook/react/ReactHost.kt | 17 +++++++++ .../react/devsupport/DevServerHelper.kt | 6 +++- .../react/devsupport/DevSupportManagerBase.kt | 8 ++++- .../interfaces/DevSupportManager.kt | 4 +++ .../PackagerConnectionSettings.kt | 32 +++++++++-------- .../facebook/react/runtime/ReactHostImpl.kt | 36 +++++++++++++++++++ 7 files changed, 98 insertions(+), 17 deletions(-) diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 0dbbd9dbd047bd..5f8595ff671e8b 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -239,6 +239,9 @@ public abstract interface class com/facebook/react/ReactHost { public abstract fun reload (Ljava/lang/String;)Lcom/facebook/react/interfaces/TaskInterface; public abstract fun removeBeforeDestroyListener (Lkotlin/jvm/functions/Function0;)V public abstract fun removeReactInstanceEventListener (Lcom/facebook/react/ReactInstanceEventListener;)V + public fun setBundleSource (Ljava/lang/String;)V + public fun setBundleSource (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V + public static synthetic fun setBundleSource$default (Lcom/facebook/react/ReactHost;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public fun setDevMenuConfiguration (Lcom/facebook/react/devsupport/DevMenuConfiguration;)V public abstract fun start ()Lcom/facebook/react/interfaces/TaskInterface; } @@ -1943,6 +1946,7 @@ public abstract class com/facebook/react/devsupport/DevSupportManagerBase : com/ public fun downloadBundleResourceFromUrlSync (Ljava/lang/String;Ljava/io/File;)Ljava/io/File; public final fun fetchSplitBundleAndCreateBundleLoader (Ljava/lang/String;Lcom/facebook/react/devsupport/DevSupportManagerBase$CallbackWithBundleLoader;)V protected final fun getApplicationContext ()Landroid/content/Context; + public fun getBundleFilePath ()Ljava/lang/String; public fun getCurrentActivity ()Landroid/app/Activity; public final fun getCurrentReactContext ()Lcom/facebook/react/bridge/ReactContext; public final fun getDevLoadingViewManager ()Lcom/facebook/react/devsupport/interfaces/DevLoadingViewManager; @@ -1977,11 +1981,13 @@ public abstract class com/facebook/react/devsupport/DevSupportManagerBase : com/ public fun reloadJSFromServer (Ljava/lang/String;Lcom/facebook/react/devsupport/interfaces/BundleLoadCallback;)V public fun reloadSettings ()V public fun setAdditionalOptionForPackager (Ljava/lang/String;Ljava/lang/String;)V + public fun setBundleFilePath (Ljava/lang/String;)V public final fun setDevLoadingViewManager (Lcom/facebook/react/devsupport/interfaces/DevLoadingViewManager;)V public fun setDevMenuEnabled (Z)V public final fun setDevSupportEnabled (Z)V public fun setFpsDebugEnabled (Z)V public fun setHotModuleReplacementEnabled (Z)V + public final fun setJsAppBundleName (Ljava/lang/String;)V public fun setKeyboardShortcutsEnabled (Z)V public final fun setLastErrorCookie (I)V public final fun setLastErrorStack ([Lcom/facebook/react/devsupport/interfaces/StackFrame;)V @@ -2151,6 +2157,7 @@ public abstract interface class com/facebook/react/devsupport/interfaces/DevSupp public abstract fun createSurfaceDelegate (Ljava/lang/String;)Lcom/facebook/react/common/SurfaceDelegate; public abstract fun destroyRootView (Landroid/view/View;)V public abstract fun downloadBundleResourceFromUrlSync (Ljava/lang/String;Ljava/io/File;)Ljava/io/File; + public fun getBundleFilePath ()Ljava/lang/String; public abstract fun getCurrentActivity ()Landroid/app/Activity; public abstract fun getCurrentReactContext ()Lcom/facebook/react/bridge/ReactContext; public fun getDevMenuEnabled ()Z @@ -2180,6 +2187,7 @@ public abstract interface class com/facebook/react/devsupport/interfaces/DevSupp public abstract fun reloadJSFromServer (Ljava/lang/String;Lcom/facebook/react/devsupport/interfaces/BundleLoadCallback;)V public abstract fun reloadSettings ()V public abstract fun setAdditionalOptionForPackager (Ljava/lang/String;Ljava/lang/String;)V + public fun setBundleFilePath (Ljava/lang/String;)V public fun setDevMenuEnabled (Z)V public abstract fun setDevSupportEnabled (Z)V public abstract fun setFpsDebugEnabled (Z)V @@ -3021,6 +3029,8 @@ public class com/facebook/react/packagerconnection/PackagerConnectionSettings { public fun resetDebugServerHost ()V public final fun setAdditionalOptionForPackager (Ljava/lang/String;Ljava/lang/String;)V public fun setDebugServerHost (Ljava/lang/String;)V + public final fun setPackagerOptionsUpdater (Lkotlin/jvm/functions/Function1;)V + public final fun updatePackagerOptions (Ljava/util/Map;)Ljava/util/Map; } public final class com/facebook/react/packagerconnection/ReconnectingWebSocket : okhttp3/WebSocketListener { @@ -3099,6 +3109,8 @@ public final class com/facebook/react/runtime/ReactHostImpl : com/facebook/react public fun reload (Ljava/lang/String;)Lcom/facebook/react/interfaces/TaskInterface; public fun removeBeforeDestroyListener (Lkotlin/jvm/functions/Function0;)V public fun removeReactInstanceEventListener (Lcom/facebook/react/ReactInstanceEventListener;)V + public fun setBundleSource (Ljava/lang/String;)V + public fun setBundleSource (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V public fun setDevMenuConfiguration (Lcom/facebook/react/devsupport/DevMenuConfiguration;)V public fun start ()Lcom/facebook/react/interfaces/TaskInterface; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactHost.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactHost.kt index 583bc2aed37d52..c0912c4945276a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactHost.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactHost.kt @@ -193,4 +193,21 @@ public interface ReactHost { /** Set the DevMenu configuration. */ public fun setDevMenuConfiguration(config: DevMenuConfiguration): Unit = Unit + + /** Sets the source of the bundle to be loaded from the file system. */ + public fun setBundleSource(filePath: String): Unit = Unit + + /** + * Sets the source of the bundle to be loaded from the packager server and updates the packager + * connection. + * + * @param debugServerHost host and port of the server, for example "localhost:8081" + * @param moduleName the module name to load, for example "js/RNTesterApp.android" + * @param queryMapper a function that takes current packager options and returns updated options + */ + public fun setBundleSource( + debugServerHost: String, + moduleName: String, + queryMapper: (Map) -> Map = { it }, + ): Unit = Unit } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.kt index d7777c3b8f7d06..cfbceaac4daeeb 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevServerHelper.kt @@ -280,7 +280,11 @@ public open class DevServerHelper( ): String { val dev = devMode val additionalOptionsBuilder = StringBuilder() - for ((key, value) in packagerConnectionSettings.additionalOptionsForPackager) { + val packagerOptions = + packagerConnectionSettings.updatePackagerOptions( + packagerConnectionSettings.additionalOptionsForPackager + ) + for ((key, value) in packagerOptions) { if (value.isEmpty()) { continue } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt index 95d9307d6c3efc..40cdf48cb33ac4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt @@ -85,7 +85,7 @@ import java.util.Locale public abstract class DevSupportManagerBase( protected val applicationContext: Context, public val reactInstanceDevHelper: ReactInstanceDevHelper, - @get:JvmName("getJSAppBundleName") public val jsAppBundleName: String?, + @get:JvmName("getJSAppBundleName") public var jsAppBundleName: String?, enableOnCreate: Boolean, public override val redBoxHandler: RedBoxHandler?, private val devBundleDownloadListener: DevBundleDownloadListener?, @@ -148,6 +148,12 @@ public abstract class DevSupportManagerBase( field = value } + override var bundleFilePath: String? = null + get() = field + set(value) { + field = value + } + override val sourceMapUrl: String get() = jsAppBundleName?.let { devServerHelper.getSourceMapUrl(it) } ?: "" diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/interfaces/DevSupportManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/interfaces/DevSupportManager.kt index fef972a17205c5..f48122aeb9e302 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/interfaces/DevSupportManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/interfaces/DevSupportManager.kt @@ -48,6 +48,10 @@ public interface DevSupportManager : JSExceptionHandler { get() = true set(value) = Unit + public var bundleFilePath: String? + get() = null + set(value) = Unit + public var devSupportEnabled: Boolean public fun showNewJavaError(message: String?, e: Throwable) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/PackagerConnectionSettings.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/PackagerConnectionSettings.kt index 6ac6731e07376c..06405c0ab13f29 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/PackagerConnectionSettings.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/PackagerConnectionSettings.kt @@ -4,30 +4,24 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - -@file:Suppress("DEPRECATION") // PreferenceManager should be migrated to androidx - package com.facebook.react.packagerconnection import android.content.Context -import android.content.SharedPreferences -import android.preference.PreferenceManager import com.facebook.common.logging.FLog import com.facebook.react.modules.systeminfo.AndroidInfoHelpers public open class PackagerConnectionSettings(private val appContext: Context) { - private val preferences: SharedPreferences = - PreferenceManager.getDefaultSharedPreferences(appContext) public val packageName: String = appContext.packageName private val _additionalOptionsForPackager: MutableMap = mutableMapOf() + private var _packagerOptionsUpdater: (Map) -> Map = { it } + private var cachedHost: String? = null public open var debugServerHost: String get() { - // Check host setting first. If empty try to detect emulator type and use default + // Check cached host first. If empty try to detect emulator type and use default // hostname for those - val hostFromSettings = preferences.getString(PREFS_DEBUG_SERVER_HOST_KEY, null) - if (!hostFromSettings.isNullOrEmpty()) { - return hostFromSettings + cachedHost?.let { + return it } val host = AndroidInfoHelpers.getServerHost(appContext) if (host == AndroidInfoHelpers.DEVICE_LOCALHOST) { @@ -36,20 +30,29 @@ public open class PackagerConnectionSettings(private val appContext: Context) { "You seem to be running on device. Run '${AndroidInfoHelpers.getAdbReverseTcpCommand(appContext)}' to forward the debug server's port to the device.", ) } + + cachedHost = host return host } set(host) { if (host.isEmpty()) { - preferences.edit().remove(PREFS_DEBUG_SERVER_HOST_KEY).apply() + cachedHost = null } else { - preferences.edit().putString(PREFS_DEBUG_SERVER_HOST_KEY, host).apply() + cachedHost = host } } public open fun resetDebugServerHost() { - preferences.edit().remove(PREFS_DEBUG_SERVER_HOST_KEY).apply() + cachedHost = null } + public fun setPackagerOptionsUpdater(queryMapper: (Map) -> Map) { + _packagerOptionsUpdater = queryMapper + } + + public fun updatePackagerOptions(options: Map): Map = + _packagerOptionsUpdater(options) + public fun setAdditionalOptionForPackager(key: String, value: String) { _additionalOptionsForPackager[key] = value } @@ -59,6 +62,5 @@ public open class PackagerConnectionSettings(private val appContext: Context) { private companion object { private val TAG = PackagerConnectionSettings::class.java.simpleName - private const val PREFS_DEBUG_SERVER_HOST_KEY = "debug_http_host" } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.kt index 954cfb15fc3414..473fa23babb102 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.kt @@ -74,6 +74,9 @@ import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import kotlin.Unit import kotlin.concurrent.Volatile +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch /** * A ReactHost is an object that manages a single [ReactInstance]. A ReactHost can be constructed @@ -650,6 +653,29 @@ public class ReactHostImpl( } } + @ThreadConfined(value = ThreadConfined.UI) + override fun setBundleSource(filePath: String) { + devSupportManager.bundleFilePath = filePath + reload("Change bundle source") + } + + @ThreadConfined(value = ThreadConfined.UI) + override fun setBundleSource( + debugServerHost: String, + moduleName: String, + queryMapper: (Map) -> Map, + ) { + CoroutineScope(Dispatchers.Default).launch { + (devSupportManager as DevSupportManagerBase).devServerHelper.closePackagerConnection() + devSupportManager.devSettings.packagerConnectionSettings.let { it -> + it.setPackagerOptionsUpdater(queryMapper) + it.debugServerHost = debugServerHost + } + devSupportManager.jsAppBundleName = moduleName + reload("Changed bundle source") + } + } + @ThreadConfined(ThreadConfined.UI) override fun onConfigurationChanged(context: Context) { val currentReactContext = this.currentReactContext @@ -1064,6 +1090,16 @@ public class ReactHostImpl( get() { stateTracker.enterState("getJSBundleLoader()") + if (devSupportManager.bundleFilePath != null) { + return try { + Task.forResult( + JSBundleLoader.createFileLoader(checkNotNull(devSupportManager.bundleFilePath)) + ) + } catch (e: Exception) { + Task.forError(e) + } + } + if (useDevSupport && allowPackagerServerAccess) { return isMetroRunning.onSuccessTask( { task ->