Skip to content

Commit 1c120ea

Browse files
author
Dev Patel
committed
feat: support shared JS runtime for same-origin sandbox instances (Android only)
1 parent ccd4d10 commit 1c120ea

4 files changed

Lines changed: 230 additions & 163 deletions

File tree

packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxJSIInstaller.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,13 @@ object SandboxJSIInstaller {
4444
*/
4545
@JvmStatic
4646
external fun nativeDestroy(stateHandle: Long)
47+
48+
/**
49+
* Installs the error handler into the JS runtime. Must be called on the
50+
* JS thread after the bundle has loaded (when ErrorUtils is available).
51+
*
52+
* @param stateHandle Handle returned by nativeInstall
53+
*/
54+
@JvmStatic
55+
external fun nativeInstallErrorHandler(stateHandle: Long)
4756
}

packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt

Lines changed: 89 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -33,29 +33,21 @@ class SandboxReactNativeDelegate(
3333
) {
3434
companion object {
3535
private const val TAG = "SandboxRNDelegate"
36+
37+
private val sharedHosts = mutableMapOf<String, SharedReactHost>()
38+
39+
private data class SharedReactHost(
40+
val reactHost: ReactHostImpl,
41+
val sandboxContext: Context,
42+
var refCount: Int,
43+
)
3644
}
3745

38-
var origin: String = ""
39-
set(value) {
40-
if (field == value) return
41-
if (field.isNotEmpty()) {
42-
SandboxRegistry.unregister(field)
43-
}
44-
field = value
45-
if (value.isNotEmpty()) {
46-
SandboxRegistry.register(value, this, allowedOrigins)
47-
}
48-
}
46+
@JvmField var origin: String = ""
4947

5048
var jsBundleSource: String = ""
5149
var allowedTurboModules: Set<String> = emptySet()
5250
var allowedOrigins: Set<String> = emptySet()
53-
set(value) {
54-
field = value
55-
if (origin.isNotEmpty()) {
56-
SandboxRegistry.register(origin, this, value)
57-
}
58-
}
5951

6052
@JvmField var hasOnMessageHandler: Boolean = false
6153

@@ -66,6 +58,7 @@ class SandboxReactNativeDelegate(
6658
private var reactSurface: ReactSurface? = null
6759
private var jsiStateHandle: Long = 0
6860
private var sandboxReactContext: ReactContext? = null
61+
private var ownsReactHost = false
6962

7063
@OptIn(UnstableReactNativeAPI::class)
7164
fun loadReactNativeView(
@@ -79,49 +72,75 @@ class SandboxReactNativeDelegate(
7972

8073
val capturedBundleSource = jsBundleSource
8174
val capturedAllowedModules = allowedTurboModules
82-
val sandboxId = System.identityHashCode(this).toString(16)
83-
val sandboxContext = SandboxContextWrapper(context, sandboxId)
8475

8576
try {
86-
val packages: List<ReactPackage> =
87-
listOf(
88-
FilteredReactPackage(MainReactPackage(), capturedAllowedModules),
89-
)
90-
91-
val bundleLoader = createBundleLoader(capturedBundleSource) ?: return null
92-
93-
val tmmDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder()
94-
95-
val bindingsInstaller = SandboxBindingsInstaller.create(this)
96-
97-
val hostDelegate =
98-
DefaultReactHostDelegate(
99-
jsMainModulePath = capturedBundleSource,
100-
jsBundleLoader = bundleLoader,
101-
reactPackages = packages,
102-
jsRuntimeFactory = HermesInstance(),
103-
turboModuleManagerDelegateBuilder = tmmDelegateBuilder,
104-
bindingsInstaller = bindingsInstaller,
105-
)
106-
107-
val componentFactory = ComponentFactory()
108-
DefaultComponentsRegistry.register(componentFactory)
109-
110-
val host =
111-
ReactHostImpl(
112-
sandboxContext,
113-
hostDelegate,
114-
componentFactory,
115-
true,
116-
true,
117-
)
77+
val shared = if (origin.isNotEmpty()) sharedHosts[origin] else null
78+
79+
val host: ReactHostImpl
80+
val sandboxContext: Context
81+
82+
if (shared != null) {
83+
host = shared.reactHost
84+
sandboxContext = shared.sandboxContext
85+
shared.refCount++
86+
ownsReactHost = false
87+
Log.d(TAG, "Reusing shared ReactHost for origin '$origin' (refCount=${shared.refCount})")
88+
} else {
89+
val sandboxId = System.identityHashCode(this).toString(16)
90+
sandboxContext = SandboxContextWrapper(context, sandboxId)
91+
92+
val packages: List<ReactPackage> =
93+
listOf(
94+
FilteredReactPackage(MainReactPackage(), capturedAllowedModules),
95+
)
96+
97+
val bundleLoader = createBundleLoader(capturedBundleSource) ?: return null
98+
99+
val tmmDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder()
100+
101+
val bindingsInstaller = SandboxBindingsInstaller.create(this)
102+
103+
val hostDelegate =
104+
DefaultReactHostDelegate(
105+
jsMainModulePath = capturedBundleSource,
106+
jsBundleLoader = bundleLoader,
107+
reactPackages = packages,
108+
jsRuntimeFactory = HermesInstance(),
109+
turboModuleManagerDelegateBuilder = tmmDelegateBuilder,
110+
bindingsInstaller = bindingsInstaller,
111+
)
112+
113+
val componentFactory = ComponentFactory()
114+
DefaultComponentsRegistry.register(componentFactory)
115+
116+
host =
117+
ReactHostImpl(
118+
sandboxContext,
119+
hostDelegate,
120+
componentFactory,
121+
true,
122+
true,
123+
)
124+
125+
ownsReactHost = true
126+
127+
if (origin.isNotEmpty()) {
128+
sharedHosts[origin] = SharedReactHost(host, sandboxContext, refCount = 1)
129+
Log.d(TAG, "Created shared ReactHost for origin '$origin'")
130+
}
131+
}
118132

119133
reactHost = host
120134

121135
host.addReactInstanceEventListener(
122136
object : ReactInstanceEventListener {
123137
override fun onReactContextInitialized(reactContext: ReactContext) {
124138
sandboxReactContext = reactContext
139+
if (jsiStateHandle != 0L) {
140+
reactContext.runOnJSQueueThread {
141+
SandboxJSIInstaller.nativeInstallErrorHandler(jsiStateHandle)
142+
}
143+
}
125144
}
126145
},
127146
)
@@ -240,7 +259,8 @@ class SandboxReactNativeDelegate(
240259
return false
241260
}
242261

243-
return routeMessage(messageJson, targetOrigin)
262+
// Routing handled entirely in C++ SandboxRegistry (see SandboxJSIInstaller.cpp)
263+
return false
244264
}
245265

246266
@Suppress("unused")
@@ -261,28 +281,6 @@ class SandboxReactNativeDelegate(
261281
}
262282
}
263283

264-
fun routeMessage(
265-
message: String,
266-
targetId: String,
267-
): Boolean {
268-
val target = SandboxRegistry.find(targetId)
269-
Log.d(TAG, "routeMessage from '$origin' to '$targetId': target found=${target != null}")
270-
if (target == null) return false
271-
272-
if (!SandboxRegistry.isPermittedFrom(origin, targetId)) {
273-
Log.w(TAG, "routeMessage DENIED: '$origin' -> '$targetId'")
274-
sandboxView?.emitOnError(
275-
"AccessDeniedError",
276-
"Access denied: Sandbox '$origin' is not permitted to send messages to '$targetId'",
277-
)
278-
return false
279-
}
280-
281-
Log.d(TAG, "routeMessage PERMITTED: '$origin' -> '$targetId', delivering...")
282-
target.postMessage(message)
283-
return true
284-
}
285-
286284
private fun getActivity(): Activity? {
287285
var ctx = context
288286
while (ctx is android.content.ContextWrapper) {
@@ -305,17 +303,28 @@ class SandboxReactNativeDelegate(
305303
}
306304
reactSurface = null
307305

308-
reactHost?.let {
309-
it.onHostDestroy()
310-
it.destroy("sandbox cleanup", null)
306+
val host = reactHost
307+
if (host != null) {
308+
if (origin.isNotEmpty()) {
309+
val shared = sharedHosts[origin]
310+
if (shared != null && shared.reactHost === host) {
311+
shared.refCount--
312+
if (shared.refCount <= 0) {
313+
sharedHosts.remove(origin)
314+
host.onHostDestroy()
315+
host.destroy("sandbox cleanup", null)
316+
}
317+
}
318+
} else if (ownsReactHost) {
319+
host.onHostDestroy()
320+
host.destroy("sandbox cleanup", null)
321+
}
311322
}
312323
reactHost = null
324+
ownsReactHost = false
313325
}
314326

315327
fun destroy() {
316-
if (origin.isNotEmpty()) {
317-
SandboxRegistry.unregister(origin)
318-
}
319328
cleanup()
320329
}
321330

packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxRegistry.kt

Lines changed: 0 additions & 72 deletions
This file was deleted.

0 commit comments

Comments
 (0)