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?

| `fallbackResource` | `String?` | `nil` | Path to bundle fallback resource. |
| `bundlePath` | `String` | `main.jsbundle` | Path to bundle fallback resource. |
| `bundle` | `Bundle` | `Bundle.main` | Bundle instance to lookup the JavaScript bundle resource. |
| `bundleURL` | `(() -> URL?)?` | `nil` | Dynamic bundle URL provider called on every bundle load. When set, overrides default behavior. |
Copy link
Collaborator

Choose a reason for hiding this comment

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

[minor] I would suggest we name it bundleURLOverride, this way we will be explicit about which field takes precedence

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done !

@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
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