Skip to content

Commit 53d86fb

Browse files
ya-nshfacebook-github-bot
authored andcommitted
Improve Android packager reconnect messaging (#57101)
Summary: - Avoid repeated packager disconnect callbacks while the Android WebSocket is already in retry mode. - Add a small devsupport notifier that shows a retrying message after a previously connected Metro connection is lost. - Show a short reconnected message when Metro comes back after that loss. - Avoid showing lost-connection messaging for intentional packager shutdowns. Changelog: [ANDROID][FIXED] - Improve Android Metro reconnect recovery messaging ## Motivation Android can close debugger/packager WebSockets when the app is backgrounded. This does not try to keep background sockets alive; it keeps the existing reconnect behavior and makes the recovery state clearer to developers when the app foregrounds and reconnects. Test Plan: - `PATH="/tmp/rn-yarn-bin:$HOME/.nvm/versions/node/v22.20.0/bin:$PATH" ANDROID_HOME="$HOME/Library/Android/sdk" ANDROID_SDK_ROOT="$HOME/Library/Android/sdk" ./gradlew -Preact.internal.useHermesStable=true :packages:react-native:ReactAndroid:testDebugUnitTest --tests com.facebook.react.packagerconnection.ReconnectingWebSocketTest --tests com.facebook.react.devsupport.PackagerConnectionStatusNotifierTest --tests com.facebook.react.packagerconnection.JSPackagerClientTest` - `PATH="/tmp/rn-yarn-bin:$HOME/.nvm/versions/node/v22.20.0/bin:$PATH" ANDROID_HOME="$HOME/Library/Android/sdk" ANDROID_SDK_ROOT="$HOME/Library/Android/sdk" ./gradlew -Preact.internal.useHermesStable=true :packages:react-native:ReactAndroid:ktfmtCheck` Reviewed By: javache Differential Revision: D107895817 Pulled By: cipolleschi
1 parent 40c90ad commit 53d86fb

5 files changed

Lines changed: 265 additions & 2 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@ public abstract class DevSupportManagerBase(
198198
private var isShakeDetectorStarted = false
199199
private var isDevSupportEnabled = false
200200
private var isPackagerConnected = false
201+
private val packagerConnectionStatusNotifier =
202+
PackagerConnectionStatusNotifier(devLoadingViewManagerProvider = { devLoadingViewManager })
201203
private val errorCustomizers: MutableList<ErrorCustomizer> = mutableListOf()
202204
private var packagerLocationCustomizer: PackagerLocationCustomizer? = null
203205
private val jSExecutorDescription: String?
@@ -966,12 +968,14 @@ public abstract class DevSupportManagerBase(
966968
javaClass.simpleName,
967969
object : PackagerCommandListener {
968970
override fun onPackagerConnected() {
971+
packagerConnectionStatusNotifier.onPackagerConnected()
969972
isPackagerConnected = true
970973
perfMonitorOverlayManager?.enable()
971974
perfMonitorOverlayManager?.startBackgroundTrace()
972975
}
973976

974977
override fun onPackagerDisconnected() {
978+
packagerConnectionStatusNotifier.onPackagerDisconnected()
975979
isPackagerConnected = false
976980
perfMonitorOverlayManager?.disable()
977981
perfMonitorOverlayManager?.stopBackgroundTrace()
@@ -1014,6 +1018,7 @@ public abstract class DevSupportManagerBase(
10141018
devLoadingViewManager?.hide()
10151019
perfMonitorOverlayManager?.disable()
10161020

1021+
packagerConnectionStatusNotifier.onPackagerConnectionClosed()
10171022
devServerHelper.closePackagerConnection()
10181023
}
10191024
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.devsupport
9+
10+
import com.facebook.react.bridge.UiThreadUtil
11+
import com.facebook.react.devsupport.interfaces.DevLoadingViewManager
12+
13+
internal class PackagerConnectionStatusNotifier(
14+
private val devLoadingViewManagerProvider: () -> DevLoadingViewManager?,
15+
private val postDelayed: (Runnable, Long) -> Unit = { runnable, delayMs ->
16+
UiThreadUtil.runOnUiThread(runnable, delayMs)
17+
},
18+
) {
19+
private var hasConnected = false
20+
private var connectionLost = false
21+
private var reconnectMessageToken = 0
22+
23+
@Synchronized
24+
fun onPackagerConnected() {
25+
if (connectionLost) {
26+
val devLoadingViewManager = devLoadingViewManagerProvider()
27+
if (devLoadingViewManager != null) {
28+
devLoadingViewManager.showMessage(RECONNECTED_MESSAGE)
29+
30+
val token = ++reconnectMessageToken
31+
postDelayed(
32+
Runnable {
33+
synchronized(this) {
34+
if (!connectionLost && reconnectMessageToken == token) {
35+
devLoadingViewManager.hide()
36+
}
37+
}
38+
},
39+
RECONNECTED_MESSAGE_HIDE_DELAY_MS,
40+
)
41+
}
42+
}
43+
hasConnected = true
44+
connectionLost = false
45+
}
46+
47+
@Synchronized
48+
fun onPackagerDisconnected() {
49+
if (hasConnected && !connectionLost) {
50+
connectionLost = true
51+
reconnectMessageToken++
52+
devLoadingViewManagerProvider()?.showMessage(CONNECTION_LOST_MESSAGE)
53+
}
54+
}
55+
56+
@Synchronized
57+
fun onPackagerConnectionClosed() {
58+
hasConnected = false
59+
connectionLost = false
60+
reconnectMessageToken++
61+
}
62+
63+
private companion object {
64+
const val CONNECTION_LOST_MESSAGE = "Connection to Metro lost. Retrying..."
65+
const val RECONNECTED_MESSAGE = "Reconnected to Metro."
66+
const val RECONNECTED_MESSAGE_HIDE_DELAY_MS = 2_000L
67+
}
68+
}

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/packagerconnection/ReconnectingWebSocket.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ public class ReconnectingWebSocket(
4242
private val okHttpClient = DevSupportHttpClient.websocketClient
4343
private var closed = false
4444
private var suppressConnectionErrors = false
45+
private var connected = false
4546
private var webSocket: WebSocket? = null
4647

4748
public fun connect() {
@@ -95,6 +96,7 @@ public class ReconnectingWebSocket(
9596
@Synchronized
9697
override fun onOpen(webSocket: WebSocket, response: Response) {
9798
this.webSocket = webSocket
99+
connected = true
98100
suppressConnectionErrors = false
99101

100102
connectionCallback?.onConnected()
@@ -106,7 +108,7 @@ public class ReconnectingWebSocket(
106108
abort("Websocket exception", t)
107109
}
108110
if (!closed) {
109-
connectionCallback?.onDisconnected()
111+
notifyDisconnectedIfConnected()
110112
reconnect()
111113
}
112114
}
@@ -125,11 +127,18 @@ public class ReconnectingWebSocket(
125127
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
126128
this.webSocket = null
127129
if (!closed) {
128-
connectionCallback?.onDisconnected()
130+
notifyDisconnectedIfConnected()
129131
reconnect()
130132
}
131133
}
132134

135+
private fun notifyDisconnectedIfConnected() {
136+
if (connected) {
137+
connected = false
138+
connectionCallback?.onDisconnected()
139+
}
140+
}
141+
133142
@Synchronized
134143
@Throws(IOException::class)
135144
public fun sendMessage(message: String) {
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.devsupport
9+
10+
import com.facebook.react.devsupport.interfaces.DevLoadingViewManager
11+
import org.assertj.core.api.Assertions.assertThat
12+
import org.junit.Test
13+
14+
class PackagerConnectionStatusNotifierTest {
15+
16+
private val devLoadingViewManager = RecordingDevLoadingViewManager()
17+
private val delayedActions = mutableListOf<Runnable>()
18+
private val notifier =
19+
PackagerConnectionStatusNotifier({ devLoadingViewManager }) { runnable, _ ->
20+
delayedActions.add(runnable)
21+
}
22+
23+
@Test
24+
fun testInitialConnectionDoesNotShowReconnectedMessage() {
25+
notifier.onPackagerConnected()
26+
27+
assertThat(devLoadingViewManager.messages).isEmpty()
28+
}
29+
30+
@Test
31+
fun testLostConnectionShowsRetryingOnceUntilReconnect() {
32+
notifier.onPackagerConnected()
33+
34+
notifier.onPackagerDisconnected()
35+
notifier.onPackagerDisconnected()
36+
37+
assertThat(devLoadingViewManager.messages)
38+
.containsExactly("Connection to Metro lost. Retrying...")
39+
}
40+
41+
@Test
42+
fun testReconnectAfterLossShowsReconnectedMessage() {
43+
notifier.onPackagerConnected()
44+
notifier.onPackagerDisconnected()
45+
46+
notifier.onPackagerConnected()
47+
48+
assertThat(devLoadingViewManager.messages)
49+
.containsExactly("Connection to Metro lost. Retrying...", "Reconnected to Metro.")
50+
}
51+
52+
@Test
53+
fun testReconnectMessageIsHiddenAfterDelay() {
54+
notifier.onPackagerConnected()
55+
notifier.onPackagerDisconnected()
56+
57+
notifier.onPackagerConnected()
58+
delayedActions.single().run()
59+
60+
assertThat(devLoadingViewManager.hideCount).isEqualTo(1)
61+
}
62+
63+
@Test
64+
fun testReconnectMessageDelayDoesNotHideNewLostConnectionMessage() {
65+
notifier.onPackagerConnected()
66+
notifier.onPackagerDisconnected()
67+
notifier.onPackagerConnected()
68+
69+
notifier.onPackagerDisconnected()
70+
delayedActions.single().run()
71+
72+
assertThat(devLoadingViewManager.hideCount).isEqualTo(0)
73+
}
74+
75+
@Test
76+
fun testIntentionalCloseDoesNotShowConnectionLostMessage() {
77+
notifier.onPackagerConnected()
78+
79+
notifier.onPackagerConnectionClosed()
80+
notifier.onPackagerDisconnected()
81+
82+
assertThat(devLoadingViewManager.messages).isEmpty()
83+
}
84+
85+
@Test
86+
fun testUsesCurrentDevLoadingViewManager() {
87+
var currentDevLoadingViewManager: RecordingDevLoadingViewManager? = null
88+
val notifier =
89+
PackagerConnectionStatusNotifier({ currentDevLoadingViewManager }) { runnable, _ ->
90+
delayedActions.add(runnable)
91+
}
92+
currentDevLoadingViewManager = RecordingDevLoadingViewManager()
93+
94+
notifier.onPackagerConnected()
95+
notifier.onPackagerDisconnected()
96+
97+
assertThat(currentDevLoadingViewManager.messages)
98+
.containsExactly("Connection to Metro lost. Retrying...")
99+
}
100+
101+
private class RecordingDevLoadingViewManager : DevLoadingViewManager {
102+
val messages = mutableListOf<String>()
103+
var hideCount = 0
104+
105+
override fun showMessage(message: String) {
106+
messages.add(message)
107+
}
108+
109+
override fun showMessage(
110+
message: String,
111+
color: Double?,
112+
backgroundColor: Double?,
113+
dismissButton: Boolean?,
114+
) {
115+
messages.add(message)
116+
}
117+
118+
override fun updateProgress(status: String?, done: Int?, total: Int?, percent: Int?) = Unit
119+
120+
override fun hide() {
121+
hideCount++
122+
}
123+
}
124+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.packagerconnection
9+
10+
import com.facebook.react.packagerconnection.ReconnectingWebSocket.ConnectionCallback
11+
import java.io.IOException
12+
import okhttp3.Response
13+
import okhttp3.WebSocket
14+
import org.junit.Test
15+
import org.junit.runner.RunWith
16+
import org.mockito.kotlin.mock
17+
import org.mockito.kotlin.never
18+
import org.mockito.kotlin.times
19+
import org.mockito.kotlin.verify
20+
import org.robolectric.RobolectricTestRunner
21+
22+
@RunWith(RobolectricTestRunner::class)
23+
class ReconnectingWebSocketTest {
24+
25+
@Test
26+
fun testConnectionFailureBeforeOpenDoesNotNotifyDisconnected() {
27+
val connectionCallback = mock<ConnectionCallback>()
28+
val reconnectingWebSocket = createWebSocket(connectionCallback)
29+
30+
reconnectingWebSocket.onFailure(mock<WebSocket>(), IOException("failed"), null)
31+
32+
verify(connectionCallback, never()).onConnected()
33+
verify(connectionCallback, never()).onDisconnected()
34+
}
35+
36+
@Test
37+
fun testConnectionFailureAfterOpenNotifiesDisconnectedOnceUntilReconnect() {
38+
val connectionCallback = mock<ConnectionCallback>()
39+
val reconnectingWebSocket = createWebSocket(connectionCallback)
40+
val webSocket = mock<WebSocket>()
41+
42+
reconnectingWebSocket.onOpen(webSocket, mock<Response>())
43+
reconnectingWebSocket.onFailure(webSocket, IOException("failed"), null)
44+
reconnectingWebSocket.onFailure(mock<WebSocket>(), IOException("retry failed"), null)
45+
reconnectingWebSocket.onOpen(mock<WebSocket>(), mock<Response>())
46+
47+
verify(connectionCallback, times(2)).onConnected()
48+
verify(connectionCallback, times(1)).onDisconnected()
49+
}
50+
51+
private fun createWebSocket(connectionCallback: ConnectionCallback): ReconnectingWebSocket =
52+
ReconnectingWebSocket(
53+
"ws://localhost:8081/message?role=android",
54+
messageCallback = null,
55+
connectionCallback = connectionCallback,
56+
)
57+
}

0 commit comments

Comments
 (0)