Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-papayas-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@callstack/brownfield-navigation': patch
---

fix: allow unregistering the navigation delegate
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,21 @@ class MainActivity : AppCompatActivity(), BrownfieldNavigationDelegate {
ReactNativeHostManager.onConfigurationChanged(application, newConfig)
}

override fun onResume() {
super.onResume()
// Own Brownfield navigation only while this activity is foregrounded.
BrownfieldNavigationManager.setDelegate(this)
}

override fun onPause() {
// Release ownership before another host can become the active delegate.
BrownfieldNavigationManager.clearDelegate()
super.onPause()
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(null)
enableEdgeToEdge()
BrownfieldNavigationManager.setDelegate(this)

if (savedInstanceState == null) {
ReactNativeHostManager.initialize(application) {
Expand Down
44 changes: 39 additions & 5 deletions apps/AppleApp/Brownfield Apple App/BrownfieldAppleApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import BrownfieldNavigation

class AppDelegate: NSObject, UIApplicationDelegate {
var window: UIWindow?
private let navigationDelegate = RNNavigationDelegate()

func registerNavigationDelegate() {
BrownfieldNavigationManager.shared.setDelegate(
navigationDelegate: navigationDelegate
)
}

func clearNavigationDelegate() {
BrownfieldNavigationManager.shared.clearDelegate()
}

func application(
_ application: UIApplication,
Expand Down Expand Up @@ -83,10 +94,6 @@ struct BrownfieldAppleApp: App {
print("React Native has been loaded")
}

BrownfieldNavigationManager.shared.setDelegate(
navigationDelegate: RNNavigationDelegate()
)

#if USE_EXPO_HOST
ReactNativeBrownfield.shared.ensureExpoModulesProvider()
#endif
Expand All @@ -96,7 +103,34 @@ struct BrownfieldAppleApp: App {

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

private struct RootContentView: View {
@Environment(\.scenePhase) private var scenePhase

let appDelegate: AppDelegate

var body: some View {
ContentView()
.onAppear {
syncNavigationDelegate(for: scenePhase)
}
.onChange(of: scenePhase) { newPhase in
syncNavigationDelegate(for: newPhase)
}
}

private func syncNavigationDelegate(for phase: ScenePhase) {
switch phase {
case .active:
appDelegate.registerNavigationDelegate()
case .inactive, .background:
appDelegate.clearNavigationDelegate()
@unknown default:
appDelegate.clearNavigationDelegate()
}
}
}
12 changes: 6 additions & 6 deletions apps/RNApp/ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
PODS:
- boost (1.84.0)
- BrownfieldNavigation (3.6.0):
- BrownfieldNavigation (3.7.0):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -28,7 +28,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- Brownie (3.6.0):
- Brownie (3.7.0):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -2461,7 +2461,7 @@ PODS:
- SocketRocket
- ReactAppDependencyProvider (0.85.0):
- ReactCodegen
- ReactBrownfield (3.6.0):
- ReactBrownfield (3.7.0):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -2898,8 +2898,8 @@ EXTERNAL SOURCES:

SPEC CHECKSUMS:
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
BrownfieldNavigation: 0a4abcd0295639640d0222ac5c47ab63d94983c8
Brownie: c75e781646955724c3b385e1a53704cc06491bf0
BrownfieldNavigation: 2a110b2734c33e3a695e28117ff4515c9bb0a035
Brownie: b30acefef59a97b9d84353b4e010af58f09dc900
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6
FBLazyVector: dfb9ab6ee2eac316f7869edf6ec27b9e872329f0
Expand Down Expand Up @@ -2974,7 +2974,7 @@ SPEC CHECKSUMS:
React-utils: f2dc3878565c3cc54bdf7f65a106efaf93f189a6
React-webperformancenativemodule: 214e42892a044b865f73ad4f88cac6979c27aa76
ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2
ReactBrownfield: 9e36bd174c53254c7a283a6305a4b26589e75f97
ReactBrownfield: 0420c061dccf3a41c495fd2fecc22a6faed5d7fd
ReactCodegen: 6ddd8f44847646a047320a22f5ddb10b27a515c9
ReactCommon: 6a42764f1136fb9ac210e05e88a0733a00ee23d3
RNScreens: e902eba58a27d3ad399a495d578e8aba3ea0f490
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Native Integration

After codegen, implement the generated delegate interface in your host app and register it before JavaScript uses the module.
After codegen, implement the generated delegate interface in your host app and register it before JavaScript uses the module. Clear that delegate when the host stops owning Brownfield navigation so another native owner can safely register.

On both platforms, the native manager API is intentionally explicit: use `setDelegate(...)` to claim ownership, `clearDelegate()` to release it, and rely on `getDelegate()` failing fast if no active host has registered yet.

Do not treat the delegate as an app-lifetime singleton unless your app truly has only one native owner for Brownfield navigation. The intended contract is ownership-based: register when a host becomes responsible for Brownfield navigation, then clear when that responsibility moves elsewhere or the host becomes inactive.

## Pre-Requisite:

Expand Down Expand Up @@ -31,21 +35,26 @@ class MainActivity : AppCompatActivity(), BrownfieldNavigationDelegate {
}
```

### 2) Register the delegate during startup
### 2) Register and clear the delegate with host lifecycle

Register before any React Native screen can call `BrownfieldNavigation.*`:
Register before any React Native screen can call `BrownfieldNavigation.*`, and clear the delegate when this host is no longer the active navigation owner:

```kotlin
import android.os.Bundle
import com.callstack.nativebrownfieldnavigation.BrownfieldNavigationManager

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
override fun onResume() {
super.onResume()
BrownfieldNavigationManager.setDelegate(this)
// Initialize React Native host
}

override fun onPause() {
BrownfieldNavigationManager.clearDelegate()
super.onPause()
}
```

`BrownfieldNavigationManager.getDelegate()` still fails fast when no delegate is registered, so missing registration remains a host lifecycle bug rather than a silent no-op.

## iOS

### 1) Implement `BrownfieldNavigationDelegate`
Expand Down Expand Up @@ -74,30 +83,83 @@ public final class RNNavigationDelegate: BrownfieldNavigationDelegate {
}
```

### 2) Register the delegate at app startup
### 2) Register and clear the delegate with host lifecycle

If your app can hand Brownfield navigation between multiple native owners, register in the object that currently owns navigation and clear it during the matching teardown phase.

In SwiftUI apps, prefer scene-driven lifecycle hooks such as `scenePhase`. In UIKit scene-based apps, use the matching `UISceneDelegate` callbacks. `UIApplicationDelegate` launch setup is still useful, but it is not always the right place to track foreground navigation ownership.

```swift
import BrownfieldNavigation
import SwiftUI

@main
struct BrownfieldAppleApp: App {
init() {
final class AppDelegate: NSObject, UIApplicationDelegate {
private let navigationDelegate = RNNavigationDelegate()

func registerNavigationDelegate() {
BrownfieldNavigationManager.shared.setDelegate(
navigationDelegate: RNNavigationDelegate()
navigationDelegate: navigationDelegate
)
}

func clearNavigationDelegate() {
BrownfieldNavigationManager.shared.clearDelegate()
}
}

@main
struct BrownfieldAppleApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

var body: some Scene {
WindowGroup {
RootContentView(appDelegate: appDelegate)
}
}
}

private struct RootContentView: View {
@Environment(\.scenePhase) private var scenePhase

let appDelegate: AppDelegate

var body: some View {
ContentView()
.onAppear {
syncNavigationDelegate(for: scenePhase)
}
.onChange(of: scenePhase) { newPhase in
syncNavigationDelegate(for: newPhase)
}
}

private func syncNavigationDelegate(for phase: ScenePhase) {
switch phase {
case .active:
appDelegate.registerNavigationDelegate()
case .inactive, .background:
appDelegate.clearNavigationDelegate()
@unknown default:
appDelegate.clearNavigationDelegate()
}
}
}
```

`BrownfieldNavigationManager.shared.getDelegate()` still fails fast when no delegate is registered, so lifecycle ownership bugs surface immediately during native integration.

## Lifecycle Requirements

- Register delegate before rendering JS that might call the module.
- Clear delegate when that host stops owning Brownfield navigation; do not keep stale delegates registered for the full app lifetime by default.
- Keep navigation on main/UI thread.
- Re-register delegate if your host object is recreated.
- Treat missing delegate as a startup bug: runtime calls require a registered delegate.
- Re-register delegate if your host object is recreated or regains focus.
- Treat missing delegate as a lifecycle bug: runtime calls require a registered delegate.

## Troubleshooting

- **Method added in TS but not visible natively**: rerun codegen and rebuild.
- **Calls crash on app launch**: verify delegate registration happens before RN route rendering.
- **Calls crash after switching hosts**: confirm the previous host clears the delegate and the active host re-registers it before RN can navigate.
- **SwiftUI app never re-registers the delegate**: move ownership to `scenePhase` or `UISceneDelegate` instead of relying on `UIApplicationDelegate` active/resign callbacks.
- **Wrong screen opens**: check native delegate method wiring and params mapping.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ object BrownfieldNavigationManager {
this.navigationDelegate = navigationDelegate
}

fun clearDelegate() {
navigationDelegate = null
}

fun getDelegate(): BrownfieldNavigationDelegate {
return navigationDelegate
?: throw IllegalStateException("BrownfieldNavigation delegate is not set.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ public class BrownfieldNavigationManager: NSObject {
public func setDelegate(navigationDelegate: BrownfieldNavigationDelegate) {
self.navigationDelegate = navigationDelegate
}

public func clearDelegate() {
navigationDelegate = nil
Comment thread
hurali97 marked this conversation as resolved.
}

@objc public func getDelegate() -> BrownfieldNavigationDelegate {
guard let delegate = navigationDelegate else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,31 @@
- Route each generated method to intended native destination
- Map params directly (for example through `Intent` extras)

## Register delegate at startup
## Register delegate with lifecycle ownership

- Call:
- `BrownfieldNavigationManager.setDelegate(...)`
- Register in startup flow (for example `onCreate`)
- `BrownfieldNavigationManager.clearDelegate()` is part of the public API and should be called when this host stops owning navigation
- Register in the lifecycle phase where this host becomes active (for example `onResume`)
- Clear in the matching release phase (for example `onPause`)
- Ensure registration happens before RN calls
- Do not keep a backgrounded or inactive `Activity` registered for the full app lifetime unless it is truly the only Brownfield navigation owner
- `BrownfieldNavigationManager.getDelegate()` still throws if no delegate is registered

## Minimal pattern

```kotlin
class MainActivity : AppCompatActivity(), BrownfieldNavigationDelegate {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
override fun onResume() {
super.onResume()
BrownfieldNavigationManager.setDelegate(this)
}

override fun onPause() {
BrownfieldNavigationManager.clearDelegate()
super.onPause()
}

override fun openNativeProfile(userId: String) {
val intent = Intent(this, ProfileActivity::class.java).apply {
putExtra("userId", userId)
Expand Down
Loading
Loading