From eec98085168bf5eda51c623a6f88f87f297f35da Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 8 Jun 2026 01:14:34 +0530 Subject: [PATCH 1/2] Improve Android packager reconnect messaging --- .../react/devsupport/DevSupportManagerBase.kt | 4 ++ .../PackagerConnectionStatusNotifier.kt | 37 ++++++++++ .../ReconnectingWebSocket.kt | 13 +++- .../PackagerConnectionStatusNotifierTest.kt | 68 +++++++++++++++++++ .../ReconnectingWebSocketTest.kt | 57 ++++++++++++++++ 5 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifier.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifierTest.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/packagerconnection/ReconnectingWebSocketTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt index 0608c07b78b5..2966e8d79ae1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt @@ -198,6 +198,8 @@ public abstract class DevSupportManagerBase( private var isShakeDetectorStarted = false private var isDevSupportEnabled = false private var isPackagerConnected = false + private val packagerConnectionStatusNotifier = + PackagerConnectionStatusNotifier(devLoadingViewManager) private val errorCustomizers: MutableList = mutableListOf() private var packagerLocationCustomizer: PackagerLocationCustomizer? = null private val jSExecutorDescription: String? @@ -966,12 +968,14 @@ public abstract class DevSupportManagerBase( javaClass.simpleName, object : PackagerCommandListener { override fun onPackagerConnected() { + packagerConnectionStatusNotifier.onPackagerConnected() isPackagerConnected = true perfMonitorOverlayManager?.enable() perfMonitorOverlayManager?.startBackgroundTrace() } override fun onPackagerDisconnected() { + packagerConnectionStatusNotifier.onPackagerDisconnected() isPackagerConnected = false perfMonitorOverlayManager?.disable() perfMonitorOverlayManager?.stopBackgroundTrace() diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifier.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifier.kt new file mode 100644 index 000000000000..448d5e3e3e2e --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifier.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.devsupport + +import com.facebook.react.devsupport.interfaces.DevLoadingViewManager + +internal class PackagerConnectionStatusNotifier( + private val devLoadingViewManager: DevLoadingViewManager? +) { + private var hasConnected = false + private var connectionLost = false + + fun onPackagerConnected() { + if (connectionLost) { + devLoadingViewManager?.showMessage(RECONNECTED_MESSAGE) + } + hasConnected = true + connectionLost = false + } + + fun onPackagerDisconnected() { + if (hasConnected && !connectionLost) { + connectionLost = true + devLoadingViewManager?.showMessage(CONNECTION_LOST_MESSAGE) + } + } + + private companion object { + const val CONNECTION_LOST_MESSAGE = "Connection to Metro lost. Retrying..." + const val RECONNECTED_MESSAGE = "Reconnected to Metro." + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/ReconnectingWebSocket.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/ReconnectingWebSocket.kt index 87d80827a336..e418f0f8e85d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/ReconnectingWebSocket.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/ReconnectingWebSocket.kt @@ -42,6 +42,7 @@ public class ReconnectingWebSocket( private val okHttpClient = DevSupportHttpClient.websocketClient private var closed = false private var suppressConnectionErrors = false + private var connected = false private var webSocket: WebSocket? = null public fun connect() { @@ -95,6 +96,7 @@ public class ReconnectingWebSocket( @Synchronized override fun onOpen(webSocket: WebSocket, response: Response) { this.webSocket = webSocket + connected = true suppressConnectionErrors = false connectionCallback?.onConnected() @@ -106,7 +108,7 @@ public class ReconnectingWebSocket( abort("Websocket exception", t) } if (!closed) { - connectionCallback?.onDisconnected() + notifyDisconnectedIfConnected() reconnect() } } @@ -125,11 +127,18 @@ public class ReconnectingWebSocket( override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { this.webSocket = null if (!closed) { - connectionCallback?.onDisconnected() + notifyDisconnectedIfConnected() reconnect() } } + private fun notifyDisconnectedIfConnected() { + if (connected) { + connected = false + connectionCallback?.onDisconnected() + } + } + @Synchronized @Throws(IOException::class) public fun sendMessage(message: String) { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifierTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifierTest.kt new file mode 100644 index 000000000000..cdd988a0dc5c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifierTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.devsupport + +import com.facebook.react.devsupport.interfaces.DevLoadingViewManager +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class PackagerConnectionStatusNotifierTest { + + private val devLoadingViewManager = RecordingDevLoadingViewManager() + private val notifier = PackagerConnectionStatusNotifier(devLoadingViewManager) + + @Test + fun testInitialConnectionDoesNotShowReconnectedMessage() { + notifier.onPackagerConnected() + + assertThat(devLoadingViewManager.messages).isEmpty() + } + + @Test + fun testLostConnectionShowsRetryingOnceUntilReconnect() { + notifier.onPackagerConnected() + + notifier.onPackagerDisconnected() + notifier.onPackagerDisconnected() + + assertThat(devLoadingViewManager.messages) + .containsExactly("Connection to Metro lost. Retrying...") + } + + @Test + fun testReconnectAfterLossShowsReconnectedMessage() { + notifier.onPackagerConnected() + notifier.onPackagerDisconnected() + + notifier.onPackagerConnected() + + assertThat(devLoadingViewManager.messages) + .containsExactly("Connection to Metro lost. Retrying...", "Reconnected to Metro.") + } + + private class RecordingDevLoadingViewManager : DevLoadingViewManager { + val messages = mutableListOf() + + override fun showMessage(message: String) { + messages.add(message) + } + + override fun showMessage( + message: String, + color: Double?, + backgroundColor: Double?, + dismissButton: Boolean?, + ) { + messages.add(message) + } + + override fun updateProgress(status: String?, done: Int?, total: Int?, percent: Int?) = Unit + + override fun hide() = Unit + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/packagerconnection/ReconnectingWebSocketTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/packagerconnection/ReconnectingWebSocketTest.kt new file mode 100644 index 000000000000..1062860223b7 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/packagerconnection/ReconnectingWebSocketTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.packagerconnection + +import com.facebook.react.packagerconnection.ReconnectingWebSocket.ConnectionCallback +import java.io.IOException +import okhttp3.Response +import okhttp3.WebSocket +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ReconnectingWebSocketTest { + + @Test + fun testConnectionFailureBeforeOpenDoesNotNotifyDisconnected() { + val connectionCallback = mock() + val reconnectingWebSocket = createWebSocket(connectionCallback) + + reconnectingWebSocket.onFailure(mock(), IOException("failed"), null) + + verify(connectionCallback, never()).onConnected() + verify(connectionCallback, never()).onDisconnected() + } + + @Test + fun testConnectionFailureAfterOpenNotifiesDisconnectedOnceUntilReconnect() { + val connectionCallback = mock() + val reconnectingWebSocket = createWebSocket(connectionCallback) + val webSocket = mock() + + reconnectingWebSocket.onOpen(webSocket, mock()) + reconnectingWebSocket.onFailure(webSocket, IOException("failed"), null) + reconnectingWebSocket.onFailure(mock(), IOException("retry failed"), null) + reconnectingWebSocket.onOpen(mock(), mock()) + + verify(connectionCallback, times(2)).onConnected() + verify(connectionCallback, times(1)).onDisconnected() + } + + private fun createWebSocket(connectionCallback: ConnectionCallback): ReconnectingWebSocket = + ReconnectingWebSocket( + "ws://localhost:8081/message?role=android", + messageCallback = null, + connectionCallback = connectionCallback, + ) +} From c70407c9eabe31158d00707df1496c8ab55094bd Mon Sep 17 00:00:00 2001 From: Deva Annamaraju Date: Mon, 8 Jun 2026 02:06:30 +0530 Subject: [PATCH 2/2] Handle packager reconnect message lifecycle --- .../react/devsupport/DevSupportManagerBase.kt | 3 +- .../PackagerConnectionStatusNotifier.kt | 30 +++++++++- .../PackagerConnectionStatusNotifierTest.kt | 60 ++++++++++++++++++- 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt index 2966e8d79ae1..5c39a76811c0 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt @@ -199,7 +199,7 @@ public abstract class DevSupportManagerBase( private var isDevSupportEnabled = false private var isPackagerConnected = false private val packagerConnectionStatusNotifier = - PackagerConnectionStatusNotifier(devLoadingViewManager) + PackagerConnectionStatusNotifier(devLoadingViewManagerProvider = { devLoadingViewManager }) private val errorCustomizers: MutableList = mutableListOf() private var packagerLocationCustomizer: PackagerLocationCustomizer? = null private val jSExecutorDescription: String? @@ -1018,6 +1018,7 @@ public abstract class DevSupportManagerBase( devLoadingViewManager?.hide() perfMonitorOverlayManager?.disable() + packagerConnectionStatusNotifier.onPackagerConnectionClosed() devServerHelper.closePackagerConnection() } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifier.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifier.kt index 448d5e3e3e2e..e8036806002e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifier.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifier.kt @@ -7,17 +7,35 @@ package com.facebook.react.devsupport +import android.os.Handler +import android.os.Looper import com.facebook.react.devsupport.interfaces.DevLoadingViewManager internal class PackagerConnectionStatusNotifier( - private val devLoadingViewManager: DevLoadingViewManager? + private val devLoadingViewManagerProvider: () -> DevLoadingViewManager?, + private val postDelayed: (Runnable, Long) -> Unit = { runnable, delayMs -> + Handler(Looper.getMainLooper()).postDelayed(runnable, delayMs) + }, ) { private var hasConnected = false private var connectionLost = false + private var reconnectMessageToken = 0 fun onPackagerConnected() { if (connectionLost) { + val devLoadingViewManager = devLoadingViewManagerProvider() devLoadingViewManager?.showMessage(RECONNECTED_MESSAGE) + if (devLoadingViewManager != null) { + val token = ++reconnectMessageToken + postDelayed( + Runnable { + if (!connectionLost && reconnectMessageToken == token) { + devLoadingViewManager.hide() + } + }, + RECONNECTED_MESSAGE_HIDE_DELAY_MS, + ) + } } hasConnected = true connectionLost = false @@ -26,12 +44,20 @@ internal class PackagerConnectionStatusNotifier( fun onPackagerDisconnected() { if (hasConnected && !connectionLost) { connectionLost = true - devLoadingViewManager?.showMessage(CONNECTION_LOST_MESSAGE) + reconnectMessageToken++ + devLoadingViewManagerProvider()?.showMessage(CONNECTION_LOST_MESSAGE) } } + fun onPackagerConnectionClosed() { + hasConnected = false + connectionLost = false + reconnectMessageToken++ + } + private companion object { const val CONNECTION_LOST_MESSAGE = "Connection to Metro lost. Retrying..." const val RECONNECTED_MESSAGE = "Reconnected to Metro." + const val RECONNECTED_MESSAGE_HIDE_DELAY_MS = 2_000L } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifierTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifierTest.kt index cdd988a0dc5c..ed5f8cedcff7 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifierTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/PackagerConnectionStatusNotifierTest.kt @@ -14,7 +14,11 @@ import org.junit.Test class PackagerConnectionStatusNotifierTest { private val devLoadingViewManager = RecordingDevLoadingViewManager() - private val notifier = PackagerConnectionStatusNotifier(devLoadingViewManager) + private val delayedActions = mutableListOf() + private val notifier = + PackagerConnectionStatusNotifier({ devLoadingViewManager }) { runnable, _ -> + delayedActions.add(runnable) + } @Test fun testInitialConnectionDoesNotShowReconnectedMessage() { @@ -45,8 +49,58 @@ class PackagerConnectionStatusNotifierTest { .containsExactly("Connection to Metro lost. Retrying...", "Reconnected to Metro.") } + @Test + fun testReconnectMessageIsHiddenAfterDelay() { + notifier.onPackagerConnected() + notifier.onPackagerDisconnected() + + notifier.onPackagerConnected() + delayedActions.single().run() + + assertThat(devLoadingViewManager.hideCount).isEqualTo(1) + } + + @Test + fun testReconnectMessageDelayDoesNotHideNewLostConnectionMessage() { + notifier.onPackagerConnected() + notifier.onPackagerDisconnected() + notifier.onPackagerConnected() + + notifier.onPackagerDisconnected() + delayedActions.single().run() + + assertThat(devLoadingViewManager.hideCount).isEqualTo(0) + } + + @Test + fun testIntentionalCloseDoesNotShowConnectionLostMessage() { + notifier.onPackagerConnected() + + notifier.onPackagerConnectionClosed() + notifier.onPackagerDisconnected() + + assertThat(devLoadingViewManager.messages).isEmpty() + } + + @Test + fun testUsesCurrentDevLoadingViewManager() { + var currentDevLoadingViewManager: RecordingDevLoadingViewManager? = null + val notifier = + PackagerConnectionStatusNotifier({ currentDevLoadingViewManager }) { runnable, _ -> + delayedActions.add(runnable) + } + currentDevLoadingViewManager = RecordingDevLoadingViewManager() + + notifier.onPackagerConnected() + notifier.onPackagerDisconnected() + + assertThat(currentDevLoadingViewManager.messages) + .containsExactly("Connection to Metro lost. Retrying...") + } + private class RecordingDevLoadingViewManager : DevLoadingViewManager { val messages = mutableListOf() + var hideCount = 0 override fun showMessage(message: String) { messages.add(message) @@ -63,6 +117,8 @@ class PackagerConnectionStatusNotifierTest { override fun updateProgress(status: String?, done: Int?, total: Int?, percent: Int?) = Unit - override fun hide() = Unit + override fun hide() { + hideCount++ + } } }