Skip to content

Conversation

coado
Copy link
Contributor

@coado coado commented Oct 13, 2025

Summary:

Following the RFC, this PR adds new setBundleSource methods to ReactHost for modifying bundle URL at runtime. The first one with signature:

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:

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.

It also changes host caching in PackagerConnectionSettings to prevent storage on the device, as this leads to a problem when the bundle source is changed, for instance, from the default 10.0.2.2:8081 to 10.0.2.2:8082. Then, the new bundle source is persisted, but it fails to load the bundle from the default source after restarting the app, which is probably not a desired behavior. This can be observed using the existing option in the dev menu for changing bundle location and restarting the app. It won't try to connect to the default Metro address and will fail with the following error:

Screenshot_1760686397

On iOS it works as expected.

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.

android-setBundleSource.mov

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).

load_from_file.mov
code:

Changing debug server host:

RNTesterActivity.kt:

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)
  }
}

@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 13, 2025
@facebook-github-bot facebook-github-bot added p: Software Mansion Partner: Software Mansion Partner p: Facebook Partner: Facebook labels Oct 13, 2025
Copy link

meta-codesync bot commented Oct 15, 2025

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

…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 coado force-pushed the bundle-url-android branch from 08cd3b8 to 7713574 Compare October 17, 2025 08:03
Copy link

meta-codesync bot commented Oct 17, 2025

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

Copy link
Contributor

@cortinico cortinico left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review automatically exported from Phabricator review in Meta.

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.

3 participants