From c42d541c5f7f44db607392efc7ffd05593cfbeea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Kelmanson?= Date: Mon, 15 Jun 2026 11:26:11 -0300 Subject: [PATCH 1/5] fix(android): actually download remote http(s) jsBundleSource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loading a sandbox from a remote URL never worked on Android: - `createBundleLoader` mapped an http(s) source to `JSBundleLoader.createFileLoader(url)`, but that loader calls `loadScriptFromFile`, which treats its argument as a local path — a remote URL is never downloaded. - Even with a working loader, the sandbox ReactHost was created with developer support enabled, so in dev builds the runtime ignored the loader and fetched the bundle from Metro using `jsMainModulePath`, turning `http://host/x.bundle` into `http://localhost:8081/http://host/x.bundle.bundle` (a 404). Fix: - Prefetch the remote bundle to a cache file off the main thread (avoids NetworkOnMainThreadException) and load it via `createCachedBundleFromNetworkLoader`, preserving the source URL for stack traces. Returns null (existing failure path) on download error. - Disable developer support for remote (http/https) sources only, so the runtime uses our loader instead of Metro. Local asset/name sources keep dev support and Fast Refresh. Verified on a device: host loads the sandbox bundle from a remote HTTP URL in a debug build, renders, and exchanges messages both ways. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rnsandbox/SandboxReactNativeDelegate.kt | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt index 6b0ce06..5cecf95 100644 --- a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt @@ -27,6 +27,9 @@ import com.facebook.react.runtime.ReactHostImpl import com.facebook.react.runtime.hermes.HermesInstance import com.facebook.react.shell.MainReactPackage import com.facebook.react.uimanager.ViewManager +import java.io.File +import java.net.HttpURLConnection +import java.net.URL class SandboxReactNativeDelegate( private val context: Context, @@ -34,6 +37,10 @@ class SandboxReactNativeDelegate( companion object { private const val TAG = "SandboxRNDelegate" + private const val REMOTE_BUNDLE_CONNECT_TIMEOUT_MS = 10_000 + private const val REMOTE_BUNDLE_READ_TIMEOUT_MS = 15_000 + private const val REMOTE_BUNDLE_DOWNLOAD_TIMEOUT_MS = 20_000L + private val sharedHosts = mutableMapOf() private val registeredSubstitutionPackages = mutableListOf() private val registeredHostPackages = mutableListOf() @@ -166,13 +173,20 @@ class SandboxReactNativeDelegate( val componentFactory = ComponentFactory() DefaultComponentsRegistry.register(componentFactory) + // For a remote (http/https) bundle, disable developer support on this + // ReactHost. With dev support enabled the runtime ignores jsBundleLoader + // and fetches the bundle from the Metro dev server using jsMainModulePath, + // turning the URL into http://localhost:8081/.bundle (a 404). Local + // sources ("index"/asset names) keep dev support so Fast Refresh works. + val isRemoteBundle = capturedBundleSource.startsWith("http://") || + capturedBundleSource.startsWith("https://") host = ReactHostImpl( sandboxContext, hostDelegate, componentFactory, - true, - true, + !isRemoteBundle, + !isRemoteBundle, ) ownsReactHost = true @@ -259,7 +273,43 @@ class SandboxReactNativeDelegate( if (bundleSource.isEmpty()) return null return when { bundleSource.startsWith("http://") || bundleSource.startsWith("https://") -> { - JSBundleLoader.createFileLoader(bundleSource) + // JSBundleLoader.createFileLoader(url) does not download anything: + // loadScriptFromFile treats the argument as a local path, so a remote + // URL never resolves. Prefetch the bundle to a cache file (off the main + // thread to avoid NetworkOnMainThreadException) and hand it to the + // network loader, which keeps the original sourceURL for stack traces. + val cacheFile = File( + context.cacheDir, + "sandbox-remote-${bundleSource.hashCode()}.bundle", + ) + var downloadError: Exception? = null + val worker = Thread { + try { + val connection = URL(bundleSource).openConnection() as HttpURLConnection + connection.connectTimeout = REMOTE_BUNDLE_CONNECT_TIMEOUT_MS + connection.readTimeout = REMOTE_BUNDLE_READ_TIMEOUT_MS + try { + connection.inputStream.use { input -> + cacheFile.outputStream().use { output -> input.copyTo(output) } + } + } finally { + connection.disconnect() + } + } catch (e: Exception) { + downloadError = e + } + } + worker.start() + worker.join(REMOTE_BUNDLE_DOWNLOAD_TIMEOUT_MS) + if (downloadError != null || !cacheFile.exists() || cacheFile.length() == 0L) { + Log.e(TAG, "Failed to download remote bundle '$bundleSource'", downloadError) + return null + } + Log.d(TAG, "Downloaded remote bundle '$bundleSource' (${cacheFile.length()} bytes)") + JSBundleLoader.createCachedBundleFromNetworkLoader( + bundleSource, + cacheFile.absolutePath, + ) } else -> { From 206be599696afc42a684bac855ce3e59fa6c98a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Kelmanson?= Date: Mon, 15 Jun 2026 22:56:13 -0300 Subject: [PATCH 2/5] perf(android): cache remote bundle by URL, skip re-download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remote (http/https) loader downloaded the bundle on every sandbox VM creation, so each cold start re-fetched the full bundle and failed when offline. Treat a remote URL as immutable: if the cached file for that exact URL already exists, reuse it and skip the network. Repeated loads (cold starts) become instant and work offline; a partial/failed download is deleted so a later load retries cleanly. To publish an update, change the URL (version segment or `?v=` query) — a new URL misses the cache and is fetched once. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rnsandbox/SandboxReactNativeDelegate.kt | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt index 5cecf95..996d302 100644 --- a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt @@ -278,34 +278,47 @@ class SandboxReactNativeDelegate( // URL never resolves. Prefetch the bundle to a cache file (off the main // thread to avoid NetworkOnMainThreadException) and hand it to the // network loader, which keeps the original sourceURL for stack traces. + // + // The cache file is keyed by the URL, and a remote URL is treated as + // immutable: if it was already downloaded, reuse the cached copy and + // skip the network entirely. This makes repeated loads (e.g. every cold + // start) instant and offline-capable. To ship an update, change the URL + // (a version segment or `?v=` query) — a new URL misses the cache and is + // fetched once. Serving changing content at a fixed URL won't update. val cacheFile = File( context.cacheDir, "sandbox-remote-${bundleSource.hashCode()}.bundle", ) - var downloadError: Exception? = null - val worker = Thread { - try { - val connection = URL(bundleSource).openConnection() as HttpURLConnection - connection.connectTimeout = REMOTE_BUNDLE_CONNECT_TIMEOUT_MS - connection.readTimeout = REMOTE_BUNDLE_READ_TIMEOUT_MS + if (cacheFile.exists() && cacheFile.length() > 0L) { + Log.d(TAG, "Reusing cached bundle '$bundleSource' (${cacheFile.length()} bytes)") + } else { + var downloadError: Exception? = null + val worker = Thread { try { - connection.inputStream.use { input -> - cacheFile.outputStream().use { output -> input.copyTo(output) } + val connection = URL(bundleSource).openConnection() as HttpURLConnection + connection.connectTimeout = REMOTE_BUNDLE_CONNECT_TIMEOUT_MS + connection.readTimeout = REMOTE_BUNDLE_READ_TIMEOUT_MS + try { + connection.inputStream.use { input -> + cacheFile.outputStream().use { output -> input.copyTo(output) } + } + } finally { + connection.disconnect() } - } finally { - connection.disconnect() + } catch (e: Exception) { + downloadError = e } - } catch (e: Exception) { - downloadError = e } + worker.start() + worker.join(REMOTE_BUNDLE_DOWNLOAD_TIMEOUT_MS) + if (downloadError != null || !cacheFile.exists() || cacheFile.length() == 0L) { + // Drop a partial file so a later load can retry cleanly. + cacheFile.delete() + Log.e(TAG, "Failed to download remote bundle '$bundleSource'", downloadError) + return null + } + Log.d(TAG, "Downloaded remote bundle '$bundleSource' (${cacheFile.length()} bytes)") } - worker.start() - worker.join(REMOTE_BUNDLE_DOWNLOAD_TIMEOUT_MS) - if (downloadError != null || !cacheFile.exists() || cacheFile.length() == 0L) { - Log.e(TAG, "Failed to download remote bundle '$bundleSource'", downloadError) - return null - } - Log.d(TAG, "Downloaded remote bundle '$bundleSource' (${cacheFile.length()} bytes)") JSBundleLoader.createCachedBundleFromNetworkLoader( bundleSource, cacheFile.absolutePath, From ab1c14f8a4ba6595555a27340a337119cb257eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Kelmanson?= Date: Tue, 16 Jun 2026 00:42:23 -0300 Subject: [PATCH 3/5] fix(android): load cached remote bundle synchronously for RN 0.85 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createCachedBundleFromNetworkLoader loads the cached file with loadSynchronously=false. On RN 0.85 bridgeless this executes the bundle before the TurboModule JSI bindings are installed on the runtime, so the bundle's InitializeCore hits TurboModuleRegistry.getEnforcing('PlatformConstants') with no proxy registered and the sandbox VM aborts. Because this happens before the sandbox error handler is installed, it escalates to a native fatal and takes the host process down with it. Load the already-downloaded cache file synchronously via JSBundleLoader.createFileLoader(path, sourceURL, true) — mirroring how the built-in asset loader runs the main bundle — so the runtime is ready before the bundle runs. The original URL is kept as sourceURL for stack traces. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rnsandbox/SandboxReactNativeDelegate.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt index 996d302..2a8cc16 100644 --- a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt @@ -319,9 +319,19 @@ class SandboxReactNativeDelegate( } Log.d(TAG, "Downloaded remote bundle '$bundleSource' (${cacheFile.length()} bytes)") } - JSBundleLoader.createCachedBundleFromNetworkLoader( - bundleSource, + // Load the cached bundle synchronously (loadSynchronously = true). + // createCachedBundleFromNetworkLoader() loads with loadSynchronously + // = false, which on RN 0.85 bridgeless executes the script before the + // TurboModule JSI bindings are installed on the runtime — the bundle's + // InitializeCore then hits TurboModuleRegistry.getEnforcing('Platform- + // Constants') with no proxy registered and the VM aborts. A synchronous + // load mirrors how the built-in asset loader (createAssetLoader(.., true)) + // runs the main bundle, which sequences correctly. We keep the original + // URL as sourceURL so stack traces still point at the remote bundle. + JSBundleLoader.createFileLoader( cacheFile.absolutePath, + bundleSource, + true, ) } From 44bd3b410f3d57ac242d43c0a712c59a46cfdd4c Mon Sep 17 00:00:00 2001 From: Aliaksandr Babrykovich Date: Sat, 25 Jul 2026 10:20:48 +0200 Subject: [PATCH 4/5] refactor: extract downloadRemoteBundle and simplify comments - Extract remote bundle download logic into a private downloadRemoteBundle() function for clarity - Shorten the two inline comments in createBundleLoader to 2 lines each Co-Authored-By: Claude Sonnet 4.6 --- .../rnsandbox/SandboxReactNativeDelegate.kt | 100 +++++++----------- 1 file changed, 40 insertions(+), 60 deletions(-) diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt index 2a8cc16..d44078c 100644 --- a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt @@ -273,66 +273,12 @@ class SandboxReactNativeDelegate( if (bundleSource.isEmpty()) return null return when { bundleSource.startsWith("http://") || bundleSource.startsWith("https://") -> { - // JSBundleLoader.createFileLoader(url) does not download anything: - // loadScriptFromFile treats the argument as a local path, so a remote - // URL never resolves. Prefetch the bundle to a cache file (off the main - // thread to avoid NetworkOnMainThreadException) and hand it to the - // network loader, which keeps the original sourceURL for stack traces. - // - // The cache file is keyed by the URL, and a remote URL is treated as - // immutable: if it was already downloaded, reuse the cached copy and - // skip the network entirely. This makes repeated loads (e.g. every cold - // start) instant and offline-capable. To ship an update, change the URL - // (a version segment or `?v=` query) — a new URL misses the cache and is - // fetched once. Serving changing content at a fixed URL won't update. - val cacheFile = File( - context.cacheDir, - "sandbox-remote-${bundleSource.hashCode()}.bundle", - ) - if (cacheFile.exists() && cacheFile.length() > 0L) { - Log.d(TAG, "Reusing cached bundle '$bundleSource' (${cacheFile.length()} bytes)") - } else { - var downloadError: Exception? = null - val worker = Thread { - try { - val connection = URL(bundleSource).openConnection() as HttpURLConnection - connection.connectTimeout = REMOTE_BUNDLE_CONNECT_TIMEOUT_MS - connection.readTimeout = REMOTE_BUNDLE_READ_TIMEOUT_MS - try { - connection.inputStream.use { input -> - cacheFile.outputStream().use { output -> input.copyTo(output) } - } - } finally { - connection.disconnect() - } - } catch (e: Exception) { - downloadError = e - } - } - worker.start() - worker.join(REMOTE_BUNDLE_DOWNLOAD_TIMEOUT_MS) - if (downloadError != null || !cacheFile.exists() || cacheFile.length() == 0L) { - // Drop a partial file so a later load can retry cleanly. - cacheFile.delete() - Log.e(TAG, "Failed to download remote bundle '$bundleSource'", downloadError) - return null - } - Log.d(TAG, "Downloaded remote bundle '$bundleSource' (${cacheFile.length()} bytes)") - } - // Load the cached bundle synchronously (loadSynchronously = true). - // createCachedBundleFromNetworkLoader() loads with loadSynchronously - // = false, which on RN 0.85 bridgeless executes the script before the - // TurboModule JSI bindings are installed on the runtime — the bundle's - // InitializeCore then hits TurboModuleRegistry.getEnforcing('Platform- - // Constants') with no proxy registered and the VM aborts. A synchronous - // load mirrors how the built-in asset loader (createAssetLoader(.., true)) - // runs the main bundle, which sequences correctly. We keep the original - // URL as sourceURL so stack traces still point at the remote bundle. - JSBundleLoader.createFileLoader( - cacheFile.absolutePath, - bundleSource, - true, - ) + // createFileLoader(url) treats its argument as a local path and never + // downloads. Prefetch to a cache file on a worker thread instead. + val cacheFile = downloadRemoteBundle(bundleSource) ?: return null + // Load synchronously: on RN 0.85 bridgeless an async load executes the + // bundle before TurboModule JSI bindings are installed, causing a fatal. + JSBundleLoader.createFileLoader(cacheFile.absolutePath, bundleSource, true) } else -> { @@ -341,6 +287,40 @@ class SandboxReactNativeDelegate( } } + private fun downloadRemoteBundle(bundleSource: String): File? { + val cacheFile = File(context.cacheDir, "sandbox-remote-${bundleSource.hashCode()}.bundle") + if (cacheFile.exists() && cacheFile.length() > 0L) { + Log.d(TAG, "Reusing cached bundle '$bundleSource' (${cacheFile.length()} bytes)") + return cacheFile + } + var downloadError: Exception? = null + val worker = Thread { + try { + val connection = URL(bundleSource).openConnection() as HttpURLConnection + connection.connectTimeout = REMOTE_BUNDLE_CONNECT_TIMEOUT_MS + connection.readTimeout = REMOTE_BUNDLE_READ_TIMEOUT_MS + try { + connection.inputStream.use { input -> + cacheFile.outputStream().use { output -> input.copyTo(output) } + } + } finally { + connection.disconnect() + } + } catch (e: Exception) { + downloadError = e + } + } + worker.start() + worker.join(REMOTE_BUNDLE_DOWNLOAD_TIMEOUT_MS) + if (downloadError != null || !cacheFile.exists() || cacheFile.length() == 0L) { + cacheFile.delete() + Log.e(TAG, "Failed to download remote bundle '$bundleSource'", downloadError) + return null + } + Log.d(TAG, "Downloaded remote bundle '$bundleSource' (${cacheFile.length()} bytes)") + return cacheFile + } + fun onJSIBindingsInstalled(stateHandle: Long) { jsiStateHandle = stateHandle } From 1b80aa9df7b46bff36c71cf146dcbb9e191141b2 Mon Sep 17 00:00:00 2001 From: Aliaksandr Babrykovich Date: Sat, 25 Jul 2026 10:56:31 +0200 Subject: [PATCH 5/5] fix: apply ktlint multiline-expression-wrapping formatting --- .../rnsandbox/SandboxReactNativeDelegate.kt | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt index d44078c..4727614 100644 --- a/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt +++ b/packages/react-native-sandbox/android/src/main/java/io/callstack/rnsandbox/SandboxReactNativeDelegate.kt @@ -178,8 +178,9 @@ class SandboxReactNativeDelegate( // and fetches the bundle from the Metro dev server using jsMainModulePath, // turning the URL into http://localhost:8081/.bundle (a 404). Local // sources ("index"/asset names) keep dev support so Fast Refresh works. - val isRemoteBundle = capturedBundleSource.startsWith("http://") || - capturedBundleSource.startsWith("https://") + val isRemoteBundle = + capturedBundleSource.startsWith("http://") || + capturedBundleSource.startsWith("https://") host = ReactHostImpl( sandboxContext, @@ -294,22 +295,23 @@ class SandboxReactNativeDelegate( return cacheFile } var downloadError: Exception? = null - val worker = Thread { - try { - val connection = URL(bundleSource).openConnection() as HttpURLConnection - connection.connectTimeout = REMOTE_BUNDLE_CONNECT_TIMEOUT_MS - connection.readTimeout = REMOTE_BUNDLE_READ_TIMEOUT_MS + val worker = + Thread { try { - connection.inputStream.use { input -> - cacheFile.outputStream().use { output -> input.copyTo(output) } + val connection = URL(bundleSource).openConnection() as HttpURLConnection + connection.connectTimeout = REMOTE_BUNDLE_CONNECT_TIMEOUT_MS + connection.readTimeout = REMOTE_BUNDLE_READ_TIMEOUT_MS + try { + connection.inputStream.use { input -> + cacheFile.outputStream().use { output -> input.copyTo(output) } + } + } finally { + connection.disconnect() } - } finally { - connection.disconnect() + } catch (e: Exception) { + downloadError = e } - } catch (e: Exception) { - downloadError = e } - } worker.start() worker.join(REMOTE_BUNDLE_DOWNLOAD_TIMEOUT_MS) if (downloadError != null || !cacheFile.exists() || cacheFile.length() == 0L) {