Skip to content

Conversation

@gronxb
Copy link
Contributor

@gronxb gronxb commented Jan 21, 2026

Summary

This is a follow-up to Android OTA support. Also, to support iOS reliably, we needed additional functionality.

In a typical OTA flow, when a force update occurs, we need to reload the current JSContext. In this case, ReactNativeDelegate’s bundleURL() is re-evaluated/reset without terminating the app, so even after the reload, we must be able to correctly reflect the latest bundle URL. Therefore, we need to be able to control this flow via a callback/hook.

However, with the currently provided options (bundlePath, bundle) alone:

  • it’s difficult to handle file-system-based bundle paths flexibly, and
  • more importantly, we can’t provide the updated bundleURL value at the moment reload is called, which causes OTA not to be applied at reload time.

In conclusion, the existing options don’t provide sufficient control, so we added a field that allows fully overriding the bundleURL() logic itself.

This field could be exposed under a name like overrideBundleURL(), for example.

Test plan

ios2.mov
import Brownie
import ReactBrownfield
import HotUpdater
import SwiftUI
import UIKit
import React

let initialState = BrownfieldStore(
  counter: 0,
  user: User(name: "Username")
)

/*
 Toggles testing playground for side by side brownie mode.
 Default: false
 */
let isSideBySideMode = false

@main
struct MyApp: App {
  init() {
    // Dynamic bundle URL - automatically resolves to latest hot update bundle on reload
    ReactNativeBrownfield.shared.bundleURL = {
#if DEBUG
      RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
      HotUpdater.bundleURL()
#endif
    }

    ReactNativeBrownfield.shared.startReactNative {
      print("[TesterIntegrated] onBundleLoaded")
    }

    BrownfieldStore.register(initialState)
  }

  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }

  struct ContentView: View {
    var body: some View {
      if isSideBySideMode {
        SideBySideView()
      } else {
        FullScreenView()
      }
    }
  }

  struct SideBySideView: View {
    var body: some View {
      VStack(spacing: 0) {
        NativeView()
          .frame(maxHeight: .infinity)

        Divider()

        ReactNativeView(moduleName: "ReactNative")
          .frame(maxHeight: .infinity)
      }
    }
  }

  struct FullScreenView: View {
    var body: some View {
      NavigationView {
        VStack {
          Text("React Native Brownfield App")
            .font(.title)
            .bold()
            .padding()
            .multilineTextAlignment(.center)

          CounterView()
          UserView()

          NavigationLink("Push React Native Screen") {
            ReactNativeView(moduleName: "ReactNative")
              .navigationBarHidden(true)
          }

          NavigationLink("Push UIKit Screen") {
            UIKitExampleViewControllerRepresentable()
              .navigationBarTitleDisplayMode(.inline)
          }
        }
      }.navigationViewStyle(StackNavigationViewStyle())
    }
  }

  struct CounterView: View {
    @UseStore(\BrownfieldStore.counter) var counter

    var body: some View {
      VStack {
        Text("Count: \(Int(counter))")
        Stepper(value: $counter, label: { Text("Increment") })
        
        .buttonStyle(.borderedProminent)
        .padding(.bottom)
      }
    }
  }

  struct UserView: View {
    @UseStore(\BrownfieldStore.user.name) var name

    var body: some View {
      TextField("Name", text: $name)
        .textFieldStyle(.roundedBorder)
        .padding(.horizontal)
    }
  }

  struct NativeView: View {
    @UseStore(\BrownfieldStore.counter) var counter
    @UseStore(\BrownfieldStore.user) var user

    var body: some View {
      VStack {
        Text("Native Side")
          .font(.headline)
          .padding(.top)

        Text("User: \(user.name)")
        Text("Count: \(Int(counter))")

        TextField("Name", text: $user.name)
        .textFieldStyle(.roundedBorder)
        .padding(.horizontal)

        Button("Increment") {
          $counter.set { $0 + 1 }
        }
        .buttonStyle(.borderedProminent)

        Spacer()
      }
      .frame(maxWidth: .infinity)
      .background(Color(.systemBackground))
    }
  }
}

struct UIKitExampleViewControllerRepresentable: UIViewControllerRepresentable {
  func makeUIViewController(context: Context) -> UIKitExampleViewController {
    UIKitExampleViewController()
  }

  func updateUIViewController(_ uiViewController: UIKitExampleViewController, context: Context) {}
}

@thymikee thymikee requested a review from artus9033 January 21, 2026 13:24
Copy link
Collaborator

@artus9033 artus9033 left a comment

Choose a reason for hiding this comment

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

Thank you for the contribution @gronxb ! This looks awesome, left just one comment to rename the new option, other than that LGTM.

One more question, do you have feature parity in the current state on Android? I'm wondering if we need an equivalent feature in Android, where I think we currently would need to destroy & reinitialize the whole ReactHost, right?

@gronxb
Copy link
Contributor Author

gronxb commented Jan 23, 2026

For Android, from my perspective there’s no additional feature needed anymore.

For reload behavior, we rely on the React Native API itself.

On iOS, I use:

RCTTriggerReloadCommandListeners(@"reason");

On Android, we use:

reactHost.reload("reason");

In the iOS case, RCTTriggerReloadCommandListeners re-triggers bundleURL resolution in ReactNativeDelegate, which is why the functionality introduced in this PR was necessary.

Unfortunately, reactHost.reload does not re-trigger the reactHost path, so we’re currently handling it in a different way 😂
(This applies to both brownfield and greenfield setups.)

So once this gets merged, I don’t see any remaining issues from my side!

Therefore, since this is already how things work in greenfield setups, there isn’t really anything additional we need to do for brownfield Android.

@gronxb gronxb requested a review from artus9033 January 23, 2026 02:09
@artus9033
Copy link
Collaborator

artus9033 commented Jan 28, 2026

I see, thank you for the explanation - then if there's a separate path for handling the reactHost path on Android, it all makes sense 😄 So I assume you're just overwriting the file and reloading from the same path as the 'trick' for Android, right?

Copy link
Collaborator

@artus9033 artus9033 left a comment

Choose a reason for hiding this comment

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

LGTM, thank you for this contribution @gronxb !

@gronxb
Copy link
Contributor Author

gronxb commented Jan 28, 2026

I see, thank you for the explanation - then if there's a separate path for handling the reactHost path on Android, it all makes sense 😄 So I assume you're just overwriting the file and reloading from the same path as the 'trick' for Android, right?

Based on the Android HotUpdater, i need to have at least one backup bundle, so we keep the default built-in bundle.
Because of that, i'm using a trick where we inject values into the React Native bundle loader through reflection 🥲

@artus9033
Copy link
Collaborator

Amazing, thanks for the insights!

@artus9033 artus9033 merged commit 4728945 into callstack:main Jan 28, 2026
7 checks passed
@artus9033
Copy link
Collaborator

These changes are now released as @callstack/react-native-brownfield@2.2.0 🎉

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.

2 participants