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/3] 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/3] 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/3] 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, ) }