From 1b5410eae2e274cc450dda39b15a5c77ae7b4198 Mon Sep 17 00:00:00 2001 From: Manuel Lopez <30648939+malopezr7@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:26:23 +0100 Subject: [PATCH] fix(android): allow initialize to be retried after it throws Fixes #515 --- .changeset/android-initialize-retry.md | 5 +++ .../ReactNativeBrownfield.kt | 24 ++++++------ .../reactnativebrownfield/RetryableOnce.kt | 27 ++++++++++++++ .../RetryableOnceTest.kt | 37 +++++++++++++++++++ 4 files changed, 81 insertions(+), 12 deletions(-) create mode 100644 .changeset/android-initialize-retry.md create mode 100644 packages/react-native-brownfield/android/src/main/java/com/callstack/reactnativebrownfield/RetryableOnce.kt create mode 100644 packages/react-native-brownfield/android/src/test/java/com/callstack/reactnativebrownfield/RetryableOnceTest.kt diff --git a/.changeset/android-initialize-retry.md b/.changeset/android-initialize-retry.md new file mode 100644 index 00000000..91c7cda4 --- /dev/null +++ b/.changeset/android-initialize-retry.md @@ -0,0 +1,5 @@ +--- +'@callstack/react-native-brownfield': patch +--- + +Android: `ReactNativeBrownfield.initialize` can now be called again after it throws. Before, a failure while loading native libraries or creating the `ReactHost` left it marked as initialized, so the next call returned without error and `ReactNativeBrownfield.shared` was never set. diff --git a/packages/react-native-brownfield/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt b/packages/react-native-brownfield/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt index 56751a1c..1fdbc146 100644 --- a/packages/react-native-brownfield/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt +++ b/packages/react-native-brownfield/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt @@ -18,7 +18,6 @@ import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost import com.facebook.react.soloader.OpenSourceMergedSoMapping import com.facebook.soloader.SoLoader import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.atomic.AtomicBoolean fun interface OnJSBundleLoaded { operator fun invoke(timings: JSBundleTimings) @@ -33,17 +32,15 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) { companion object { private lateinit var instance: ReactNativeBrownfield - private val initialized = AtomicBoolean() - private val nativeLibsLoaded = AtomicBoolean() + private val initialization = RetryableOnce() + private val nativeLibsLoading = RetryableOnce() private lateinit var applicationContext: Application @JvmStatic val shared: ReactNativeBrownfield get() = instance private fun loadNativeLibs(application: Application) { - if (!nativeLibsLoaded.getAndSet(true)) { - loadNativeLibsInternal(application) - } + nativeLibsLoading.run { loadNativeLibsInternal(application) } } private fun loadNativeLibsInternal(application: Application) { @@ -65,11 +62,12 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) { onJSBundleLoaded: OnJSBundleLoaded? = null ) { applicationContext = application - if (!initialized.getAndSet(true)) { + val ranNow = initialization.run { AndroidJSBundleTimingObserver.install() loadNativeLibs(application) installAndPreload(reactHost, onJSBundleLoaded) - } else { + } + if (!ranNow) { invokeWhenJSBundleLoaded(onJSBundleLoaded) } } @@ -81,11 +79,12 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) { reactHostFactory: () -> ReactHost ) { applicationContext = application - if (!initialized.getAndSet(true)) { + val ranNow = initialization.run { AndroidJSBundleTimingObserver.install() loadNativeLibs(application) installAndPreload(reactHostFactory(), onJSBundleLoaded) - } else { + } + if (!ranNow) { invokeWhenJSBundleLoaded(onJSBundleLoaded) } } @@ -98,7 +97,7 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) { onJSBundleLoaded: OnJSBundleLoaded? = null ) { applicationContext = application - if (!initialized.getAndSet(true)) { + val ranNow = initialization.run { AndroidJSBundleTimingObserver.install() loadNativeLibs(application) val reactHost = getDefaultReactHost( @@ -114,7 +113,8 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) { jsRuntimeFactory = null ) installAndPreload(reactHost, onJSBundleLoaded) - } else { + } + if (!ranNow) { invokeWhenJSBundleLoaded(onJSBundleLoaded) } } diff --git a/packages/react-native-brownfield/android/src/main/java/com/callstack/reactnativebrownfield/RetryableOnce.kt b/packages/react-native-brownfield/android/src/main/java/com/callstack/reactnativebrownfield/RetryableOnce.kt new file mode 100644 index 00000000..7a15d040 --- /dev/null +++ b/packages/react-native-brownfield/android/src/main/java/com/callstack/reactnativebrownfield/RetryableOnce.kt @@ -0,0 +1,27 @@ +package com.callstack.reactnativebrownfield + +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Runs a block at most once, counting only a run that completes. + * + * If the block throws, the next [run] call runs it again instead of treating + * the work as done. Calls made while a run is in progress are skipped. + */ +internal class RetryableOnce { + private val claimed = AtomicBoolean() + + /** Returns `true` when [block] ran and completed during this call. */ + fun run(block: () -> Unit): Boolean { + if (claimed.getAndSet(true)) { + return false + } + try { + block() + } catch (error: Throwable) { + claimed.set(false) + throw error + } + return true + } +} diff --git a/packages/react-native-brownfield/android/src/test/java/com/callstack/reactnativebrownfield/RetryableOnceTest.kt b/packages/react-native-brownfield/android/src/test/java/com/callstack/reactnativebrownfield/RetryableOnceTest.kt new file mode 100644 index 00000000..c8b97926 --- /dev/null +++ b/packages/react-native-brownfield/android/src/test/java/com/callstack/reactnativebrownfield/RetryableOnceTest.kt @@ -0,0 +1,37 @@ +package com.callstack.reactnativebrownfield + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class RetryableOnceTest { + @Test fun `runs the block once after it completes`() { + val once = RetryableOnce() + var runs = 0 + + assertTrue(once.run { runs++ }) + assertFalse(once.run { runs++ }) + assertEquals(1, runs) + } + + @Test fun `runs the block again after it throws`() { + val once = RetryableOnce() + var runs = 0 + + try { + once.run { + runs++ + throw UnsatisfiedLinkError("dlopen failed") + } + fail("expected the error to propagate") + } catch (expected: UnsatisfiedLinkError) { + assertEquals("dlopen failed", expected.message) + } + + assertTrue(once.run { runs++ }) + assertFalse(once.run { runs++ }) + assertEquals(2, runs) + } +}