Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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/olive-cloths-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@callstack/react-native-brownfield': minor
---

feat: add method to deallocate reactNativeFactory instance
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ struct ContentView: View {
.navigationBarHidden(true)
.clipShape(RoundedRectangle(cornerRadius: 16))
.background(Color(UIColor.systemBackground))

Button("Stop React Native") {
ReactNativeBrownfield.shared.stopReactNative()
}
.buttonStyle(PlainButtonStyle())
.padding(.top)
.foregroundColor(.red)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
Expand Down
32 changes: 16 additions & 16 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.0.0):
- BrownfieldNavigation (3.5.1):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -28,7 +28,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- Brownie (3.0.0):
- Brownie (3.5.1):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -1838,7 +1838,7 @@ PODS:
- React-RCTFBReactNativeSpec
- ReactCommon/turbomodule/core
- SocketRocket
- react-native-safe-area-context (5.6.2):
- react-native-safe-area-context (5.7.0):
- boost
- DoubleConversion
- fast_float
Expand All @@ -1856,8 +1856,8 @@ PODS:
- React-graphics
- React-ImageManager
- React-jsi
- react-native-safe-area-context/common (= 5.6.2)
- react-native-safe-area-context/fabric (= 5.6.2)
- react-native-safe-area-context/common (= 5.7.0)
- react-native-safe-area-context/fabric (= 5.7.0)
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
Expand All @@ -1868,7 +1868,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- react-native-safe-area-context/common (5.6.2):
- react-native-safe-area-context/common (5.7.0):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -1896,7 +1896,7 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- react-native-safe-area-context/fabric (5.6.2):
- react-native-safe-area-context/fabric (5.7.0):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -2378,7 +2378,7 @@ PODS:
- SocketRocket
- ReactAppDependencyProvider (0.82.1):
- ReactCodegen
- ReactBrownfield (3.0.0):
- ReactBrownfield (3.5.1):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -2494,7 +2494,7 @@ PODS:
- React-perflogger (= 0.82.1)
- React-utils (= 0.82.1)
- SocketRocket
- RNScreens (4.19.0):
- RNScreens (4.24.0):
- boost
- DoubleConversion
- fast_float
Expand All @@ -2521,10 +2521,10 @@ PODS:
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- RNScreens/common (= 4.19.0)
- RNScreens/common (= 4.24.0)
- SocketRocket
- Yoga
- RNScreens/common (4.19.0):
- RNScreens/common (4.24.0):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -2803,8 +2803,8 @@ EXTERNAL SOURCES:

SPEC CHECKSUMS:
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
BrownfieldNavigation: 12a34a451661d8f685beebab19d4ba7b43efc409
Brownie: 981350e32e072e5b55b624eb8810ba9bbc9683d9
BrownfieldNavigation: b25cd0c4b253b653743be92d141ffe475e42474d
Brownie: ac5a447e77a9d7713ebdb4e71a6083f00b4364f5
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6
FBLazyVector: 0aa6183b9afe3c31fc65b5d1eeef1f3c19b63bfa
Expand Down Expand Up @@ -2844,7 +2844,7 @@ SPEC CHECKSUMS:
React-logger: 500f2fa5697d224e63c33d913c8a4765319e19bf
React-Mapbuffer: 4c50cf6af44286015a20a5995d5321f625c93459
React-microtasksnativemodule: a84b9331106616ab1fa36de9ae555718d4bbdcf5
react-native-safe-area-context: 0a3b034bb63a5b684dd2f5fffd3c90ef6ed41ee8
react-native-safe-area-context: eda63a662750758c1fdd7e719c9f1026c8d161cb
React-NativeModulesApple: efd0906463c79d9b86197dbcf0d58358dff8c5ed
React-oscompat: 95875e81f5d4b3c7b2c888d5bd2c9d83450d8bdb
React-perflogger: 2e229bf33e42c094fd64516d89ec1187a2b79b5b
Expand Down Expand Up @@ -2875,10 +2875,10 @@ SPEC CHECKSUMS:
React-utils: f06ff240e06e2bd4b34e48f1b34cac00866e8979
React-webperformancenativemodule: b3398f8175fa96d992c071b1fa59bd6f9646b840
ReactAppDependencyProvider: a45ef34bb22dc1c9b2ac1f74167d9a28af961176
ReactBrownfield: 03a2fd2f61109c00810b8d82af6f8907095191ed
ReactBrownfield: 9d232f72e023ff2cca3bd6d4de188b2292125c83
ReactCodegen: 0bce2d209e2e802589f4c5ff76d21618200e74cb
ReactCommon: 801eff8cb9c940c04d3a89ce399c343ee3eff654
RNScreens: d6413aeb1878cdafd3c721e2c5218faf5d5d3b13
RNScreens: e902eba58a27d3ad399a495d578e8aba3ea0f490
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: 526f25666395d30c297d53154398ffd249eaf9e1

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ Starts React Native, produces an instance of React Native. You can use it to ini
}];
```

##### `stopReactNative`

Stops React Native and releases the underlying runtime. Safe to call multiple times. Call it after all React Native views are dismissed.

**Examples:**

```objc
[[ReactNativeBrownfield shared] stopReactNative];
```

##### `view`

Creates a React Native view for the specified module name.
Expand Down
10 changes: 10 additions & 0 deletions docs/docs/docs/api-reference/react-native-brownfield/swift.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ ReactNativeBrownfield.shared.startReactNative(onBundleLoaded: {
})
```

##### `stopReactNative`

Stops React Native and releases the underlying runtime. Safe to call multiple times. Call it after all React Native views are dismissed.

**Examples:**

```swift
ReactNativeBrownfield.shared.stopReactNative()
```

##### `view`

Creates a React Native view for the specified module name.
Expand Down
53 changes: 41 additions & 12 deletions packages/react-native-brownfield/ios/ExpoHostRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ final class ExpoHostRuntime {
private var reactNativeFactory: RCTReactNativeFactory?
private var expoDelegate: ExpoAppDelegate?

private var factory: RCTReactNativeFactory {
if let existingFactory = reactNativeFactory {
return existingFactory
}

delegate.dependencyProvider = RCTAppDependencyProvider()
let createdFactory = ExpoReactNativeFactory(delegate: delegate)
reactNativeFactory = createdFactory
return createdFactory
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We already create a factory instance in startReactNative and assign it as reactNativeFactory. So I believe we do not need to create another factory instance here. I know that it will only create a new instance if reactNativeFactory is nil BUT if we think about it, this instance will only be nil if startReactNative hasn't been called OR stopReactNative have been called already.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@hurali97 some duplicated code removed in my second commit, please check it again

Copy link
Copy Markdown
Member

@hurali97 hurali97 Apr 15, 2026

Choose a reason for hiding this comment

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

@marcinszalski-callstack - Why do we need to create a separate factory variable instance? Why we can't re-use the reactNativeFactory?

I think this factory change is unnecessary and you use reactNativeFactory to call in stopReactNative. Similarly, I suggest keeping the factory initialization in startReactNative.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

in the original PR, factory was responsible for making sure a valid instance is always provided, creating a new instance if necessary.
in a next commit that logic to be simplified, i.e. to get rid of the reactNativeFactory property

/**
* Starts React Native with default parameters.
*/
Expand Down Expand Up @@ -46,6 +57,24 @@ final class ExpoHostRuntime {
}
}

/**
* Stops React Native and releases the underlying factory instance.
*/
public func stopReactNative() {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in self?.stopReactNative() }
return
}

#if !EXPO_SDK_GTE_55
guard let factory = reactNativeFactory else { return }
factory.bridge?.invalidate()
#endif
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This check is here because there is no bridge instance on Expo > 55?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This API, will only work on less than 55. In that too, it may not work for new arch on SDK 54. Do we have any solution for SDK 55 and new arch on 54?


reactNativeFactory = nil
expoDelegate = nil
}

/**
* Path to JavaScript root.
* Default value: ".expo/.virtual-metro-entry"
Expand Down Expand Up @@ -125,19 +154,19 @@ final class ExpoHostRuntime {
// below: https://github.com/expo/expo/commit/2013760c46cde1404872d181a691da72fbf207a4
// has moved the recreateRootView method to ExpoReactNativeFactory
#if EXPO_SDK_GTE_55 // this define comes from the Brownfield Expo config plugin
return (reactNativeFactory as? ExpoReactNativeFactory)?.recreateRootView(
withBundleURL: bundleURL,
moduleName: moduleName,
initialProps: initialProps,
launchOptions: launchOptions
)
return (factory as? ExpoReactNativeFactory)?.recreateRootView(
withBundleURL: bundleURL,
moduleName: moduleName,
initialProps: initialProps,
launchOptions: launchOptions
)
#else
return expoDelegate?.recreateRootView(
withBundleURL: bundleURL,
moduleName: moduleName,
initialProps: initialProps,
launchOptions: launchOptions
)
return expoDelegate?.recreateRootView(
withBundleURL: bundleURL,
moduleName: moduleName,
initialProps: initialProps,
launchOptions: launchOptions
)
#endif
}
}
Expand Down
11 changes: 11 additions & 0 deletions packages/react-native-brownfield/ios/ReactNativeBrownfield.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ internal import Expo
#endif
}

/**
* Stops React Native.
*/
@objc public func stopReactNative() {
#if canImport(Expo)
ExpoHostRuntime.shared.stopReactNative()
#else
ReactNativeHostRuntime.shared.stopReactNative()
#endif
}

@objc public func view(
moduleName: String,
initialProps: [AnyHashable: Any]?,
Expand Down
38 changes: 31 additions & 7 deletions packages/react-native-brownfield/ios/ReactNativeHostRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ final class ReactNativeHostRuntime {
delegate.bundlePath = bundlePath
}
}

/**
* Bundle instance to lookup the JavaScript bundle.
* Default value: Bundle.main
Expand All @@ -68,6 +69,7 @@ final class ReactNativeHostRuntime {
delegate.bundle = bundle
}
}

/**
* Dynamic bundle URL provider called on every bundle load.
* When set, this overrides the default bundleURL() behavior in the delegate.
Expand All @@ -79,17 +81,23 @@ final class ReactNativeHostRuntime {
delegate.bundleURLOverride = bundleURLOverride
}
}

/**
* React Native factory instance created when starting React Native.
* Default value: nil
*/
private var reactNativeFactory: RCTReactNativeFactory? = nil
/**
* Root view factory used to create React Native views.
*/
lazy private var rootViewFactory: RCTRootViewFactory? = {
return reactNativeFactory?.rootViewFactory
}()

private var factory: RCTReactNativeFactory {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

likewise

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

same here - please, @hurali97 , check my second commit

if let existingFactory = reactNativeFactory {
return existingFactory
}

delegate.dependencyProvider = RCTAppDependencyProvider()
let createdFactory = RCTReactNativeFactory(delegate: delegate)
reactNativeFactory = createdFactory
return createdFactory
}

/**
* Starts React Native with default parameters.
Expand All @@ -98,12 +106,28 @@ final class ReactNativeHostRuntime {
startReactNative(onBundleLoaded: nil)
}

/**
* Stops React Native and releases the underlying factory instance.
*/
public func stopReactNative() {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in self?.stopReactNative() }
return
}

guard let factory = reactNativeFactory else { return }

factory.bridge?.invalidate()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I believe we can skip this invocation as we do not support old arch anymore? And if I recall correctly on newer versions of RN, this factory.bridge should always be nil?

Copy link
Copy Markdown
Collaborator

@artus9033 artus9033 Apr 13, 2026

Choose a reason for hiding this comment

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

I think we still do - we have a separate sourceset on Android.

Not all APIs support it (e.g. postMessage needs the New Arch).

I'd leave it as-is, unless you object?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I recall we did a few PRs to remove the old arch support, when react-native decided to drop it. For instance, here - we previously provided a way for old arch but that was removed.

As part of this - we dropped support for old arch on Android - #148

I see that we still have a few places with old arch, we can plan removing those now.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Alright, I also recall this. Let's have this change dropped then + we'll follow up with decoupling of OA remainders later


reactNativeFactory = nil
}
Comment on lines +101 to +108
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Did you accidentally remove the call to bridge.invalidate?

Copy link
Copy Markdown
Contributor Author

@marcinszalski-callstack marcinszalski-callstack Apr 16, 2026

Choose a reason for hiding this comment

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

yes, I guess that was a consequence of this topic: #301 (comment)

should it be restored then?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ah no, they should not be restored. All good.

One thing, can you also remove this from ExpoHostRuntime? That should be the only thing left with this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ok, will be done in a next commit


public func view(
moduleName: String,
initialProps: [AnyHashable: Any]?,
launchOptions: [AnyHashable: Any]? = nil
) -> UIView? {
rootViewFactory?.view(
factory.rootViewFactory.view(
withModuleName: moduleName,
initialProperties: initialProps,
launchOptions: launchOptions
Expand Down
Loading