expo-dev-menu 56.0.16 → 56.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,17 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 56.0.18 — 2026-07-01
14
+
15
+ ### 🐛 Bug fixes
16
+
17
+ - [Android] Restore the "Open React Native dev menu" entry in the dev menu's Tools section (regressed in [#38759](https://github.com/expo/expo/pull/38759)). ([#47047](https://github.com/expo/expo/pull/47047) by [@lindboe](https://github.com/lindboe))
18
+ - [Android] Re-enable the Fast Refresh toggle in the dev menu's Tools section. ([#47136](https://github.com/expo/expo/pull/47136) by [@lukmccall](https://github.com/lukmccall))
19
+
20
+ ## 56.0.17 — 2026-06-10
21
+
22
+ _This version does not introduce any user-facing changes._
23
+
13
24
  ## 56.0.16 — 2026-05-29
14
25
 
15
26
  _This version does not introduce any user-facing changes._
@@ -12,7 +12,7 @@ apply plugin: 'expo-module-gradle-plugin'
12
12
  apply plugin: 'org.jetbrains.kotlin.plugin.compose'
13
13
 
14
14
  group = 'host.exp.exponent'
15
- version = '56.0.16'
15
+ version = '56.0.18'
16
16
 
17
17
  def hasDevLauncher = findProject(":expo-dev-launcher") != null
18
18
  def configureInRelease = findProperty("expo.devmenu.configureInRelease") == "true"
@@ -29,7 +29,7 @@ android {
29
29
 
30
30
  defaultConfig {
31
31
  versionCode 10
32
- versionName '56.0.16'
32
+ versionName '56.0.18'
33
33
  }
34
34
 
35
35
  buildTypes {
@@ -122,6 +122,22 @@ object MenuIcons {
122
122
  )
123
123
  }
124
124
 
125
+ @Composable
126
+ fun Gear(
127
+ size: Dp,
128
+ tint: Color,
129
+ modifier: Modifier = Modifier
130
+ ) {
131
+ Icon(
132
+ painter = painterResource(R.drawable.gear_fill),
133
+ contentDescription = "React Native dev menu",
134
+ tint = tint,
135
+ modifier = Modifier
136
+ .size(size)
137
+ .then(modifier)
138
+ )
139
+ }
140
+
125
141
  @Composable
126
142
  fun Refresh(
127
143
  size: Dp,
@@ -86,31 +86,50 @@ fun ToolsSection(
86
86
  }
87
87
  )
88
88
 
89
- // TODO(@lukmccall): Re-enable when toggling fast refresh is not longer crashing app
90
- // Divider(thickness = 0.5.dp)
91
- //
92
- // NewMenuButton(
93
- // withSurface = false,
94
- // icon = {
95
- // MenuIcons.Refresh(
96
- // size = 20.dp,
97
- // tint = NewAppTheme.colors.icon.tertiary
98
- // )
99
- // },
100
- // content = {
101
- // NewText(
102
- // text = "Fast Refresh"
103
- // )
104
- // },
105
- // rightComponent = {
106
- // ToggleSwitch(
107
- // isToggled = devToolsSettings.isHotLoadingEnabled
108
- // )
109
- // },
110
- // onClick = {
111
- // onAction(DevMenuAction.ToggleFastRefresh(!devToolsSettings.isHotLoadingEnabled))
112
- // }
113
- // )
89
+ Divider(thickness = 0.5.dp)
90
+
91
+ NewMenuButton(
92
+ withSurface = false,
93
+ icon = {
94
+ MenuIcons.Gear(
95
+ size = 20.dp,
96
+ tint = NewAppTheme.colors.icon.tertiary
97
+ )
98
+ },
99
+ content = {
100
+ NewText(
101
+ text = "Open React Native dev menu"
102
+ )
103
+ },
104
+ onClick = {
105
+ onAction(DevMenuAction.OpenReactNativeDevMenu)
106
+ }
107
+ )
108
+
109
+ Divider(thickness = 0.5.dp)
110
+
111
+ NewMenuButton(
112
+ withSurface = false,
113
+ icon = {
114
+ MenuIcons.Refresh(
115
+ size = 20.dp,
116
+ tint = NewAppTheme.colors.icon.tertiary
117
+ )
118
+ },
119
+ content = {
120
+ NewText(
121
+ text = "Fast Refresh"
122
+ )
123
+ },
124
+ rightComponent = {
125
+ ToggleSwitch(
126
+ isToggled = devToolsSettings.isHotLoadingEnabled
127
+ )
128
+ },
129
+ onClick = {
130
+ onAction(DevMenuAction.ToggleFastRefresh(!devToolsSettings.isHotLoadingEnabled))
131
+ }
132
+ )
114
133
 
115
134
  // Hide FAB toggle on Quest devices since FAB is always on there
116
135
  if (!VRUtilities.isQuest()) {
@@ -19,6 +19,7 @@ import kotlinx.coroutines.Dispatchers
19
19
  import kotlinx.coroutines.GlobalScope
20
20
  import kotlinx.coroutines.launch
21
21
  import java.lang.ref.WeakReference
22
+ import java.lang.reflect.Proxy
22
23
 
23
24
  class DevMenuDevToolsDelegate(
24
25
  private val weakDevSupportManager: WeakReference<out DevSupportManager>
@@ -77,9 +78,9 @@ class DevMenuDevToolsDelegate(
77
78
  internalSettings.isHotModuleReplacementEnabled = nextEnabled
78
79
 
79
80
  if (nextEnabled) {
80
- reactContext?.getJSModule(HMRClient::class.java)?.enable()
81
+ reactContext?.callHMRClientMethod("enable")
81
82
  } else {
82
- reactContext?.getJSModule(HMRClient::class.java)?.disable()
83
+ reactContext?.callHMRClientMethod("disable")
83
84
  }
84
85
 
85
86
  if (nextEnabled && !internalSettings.isJSDevModeEnabled) {
@@ -88,6 +89,33 @@ class DevMenuDevToolsDelegate(
88
89
  }
89
90
  }
90
91
 
92
+ /**
93
+ * Invokes a no-argument [HMRClient] method (`enable`/`disable`) without tripping a bug in React
94
+ * Native's bridgeless JS module proxy.
95
+ *
96
+ * When a zero-argument method is invoked through the proxy returned by [ReactContext.getJSModule],
97
+ * the JDK passes a `null` args array to the invocation handler. In bridgeless mode that handler
98
+ * (`BridgelessReactContext.BridgelessJSModuleInvocationHandler`) declares the parameter non-null,
99
+ * so Kotlin's null check throws a [NullPointerException] before the call ever reaches JS. We
100
+ * invoke the handler directly with an explicit empty args array to sidestep the null. The legacy
101
+ * (bridge) handler tolerates the empty array too, so this is safe on both architectures.
102
+ */
103
+ private fun ReactContext.callHMRClientMethod(methodName: String) {
104
+ val hmrClient = getJSModule(HMRClient::class.java) ?: return
105
+ val method = HMRClient::class.java.getMethod(methodName)
106
+ if (Proxy.isProxyClass(hmrClient.javaClass)) {
107
+ Proxy
108
+ .getInvocationHandler(hmrClient)
109
+ .invoke(
110
+ hmrClient,
111
+ method,
112
+ emptyArray<Any?>()
113
+ )
114
+ } else {
115
+ method.invoke(hmrClient)
116
+ }
117
+ }
118
+
91
119
  @OptIn(DelicateCoroutinesApi::class)
92
120
  fun openJSInspector() {
93
121
  val devSettings = devSettings ?: return
@@ -110,6 +110,9 @@ open class DevMenuManager: NSObject {
110
110
 
111
111
  private var isNavigatingHome = false
112
112
 
113
+ private var isReloading = false
114
+ private var lastReloadEventAt: Date?
115
+
113
116
  weak var hostDelegate: DevMenuHostDelegate?
114
117
 
115
118
  @objc
@@ -214,6 +217,8 @@ open class DevMenuManager: NSObject {
214
217
  public func setAppContext(_ appContext: AppContext?) {
215
218
  currentAppContext = appContext
216
219
  if appContext != nil {
220
+ isReloading = false
221
+ lastReloadEventAt = Date()
217
222
  isNavigatingHome = false
218
223
  isReactAppRunning = true
219
224
  // Re-run packager connection setup now that the app context (and devSettings) is available.
@@ -225,6 +230,20 @@ open class DevMenuManager: NSObject {
225
230
  updateAutoLaunchObserver()
226
231
  }
227
232
 
233
+ /**
234
+ Clears the app context, but only if `context` is still the active one.
235
+ On reload the incoming context's `OnCreate` can run before the outgoing context's
236
+ `OnDestroy`, so an unconditional reset would wipe the new context and leave the dev
237
+ menu unable to open.
238
+ */
239
+ @objc
240
+ public func clearAppContext(current context: AppContext?) {
241
+ if currentAppContext != nil && currentAppContext !== context {
242
+ return
243
+ }
244
+ setAppContext(nil)
245
+ }
246
+
228
247
  @objc
229
248
  public func updateCurrentManifest(_ manifest: Manifest?, manifestURL: URL?) {
230
249
  currentManifest = manifest
@@ -524,8 +543,21 @@ open class DevMenuManager: NSObject {
524
543
  }
525
544
 
526
545
  func reload() {
527
- let devToolsDelegate = getDevToolsDelegate()
528
- devToolsDelegate?.reload()
546
+ let now = Date()
547
+ if isReloading || (lastReloadEventAt.map { now.timeIntervalSince($0) < 0.5 } ?? false) {
548
+ lastReloadEventAt = now
549
+ return
550
+ }
551
+ guard let devToolsDelegate = getDevToolsDelegate() else {
552
+ return
553
+ }
554
+ isReloading = true
555
+ lastReloadEventAt = now
556
+ // Clear the guard even if a new app context never registers, for example a failed reload.
557
+ DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
558
+ self?.isReloading = false
559
+ }
560
+ devToolsDelegate.reload()
529
561
  }
530
562
 
531
563
  func togglePerformanceMonitor() {
@@ -92,7 +92,7 @@ class DevMenuPackagerConnectionHandler {
92
92
 
93
93
  switch command {
94
94
  case "reload":
95
- devDelegate.reload()
95
+ manager.reload()
96
96
  case "toggleDevMenu":
97
97
  self.manager?.toggleMenu()
98
98
  case "toggleElementInspector":
@@ -10,7 +10,7 @@ open class DevMenuModule: Module {
10
10
  }
11
11
 
12
12
  OnDestroy {
13
- DevMenuManager.shared.setAppContext(nil)
13
+ DevMenuManager.shared.clearAppContext(current: self.appContext)
14
14
  // Cleanup registered callbacks when the module is destroyed to prevent leaking into other bridges.
15
15
  if DevMenuManager.wasInitilized {
16
16
  DevMenuManager.shared.registeredCallbacks = []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-dev-menu",
3
- "version": "56.0.16",
3
+ "version": "56.0.18",
4
4
  "description": "Expo/React Native module with the developer menu.",
5
5
  "main": "build/DevMenu.js",
6
6
  "types": "build/DevMenu.d.ts",
@@ -32,15 +32,15 @@
32
32
  "@types/node": "^22.14.0",
33
33
  "react": "19.2.3",
34
34
  "react-native": "0.85.3",
35
- "expo-module-scripts": "56.0.2",
36
- "expo": "56.0.7",
37
- "babel-preset-expo": "56.0.13"
35
+ "babel-preset-expo": "56.0.16",
36
+ "expo-module-scripts": "56.0.3",
37
+ "expo": "56.0.13"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "expo": "*",
41
41
  "react-native": "*"
42
42
  },
43
- "gitHead": "97faadd78ca3d73250127fedf09dd6a27b21db5b",
43
+ "gitHead": "b293744c7b199c6cbe910188863e93a174c21250",
44
44
  "scripts": {
45
45
  "build": "expo-module build",
46
46
  "clean": "expo-module clean",
@@ -1 +1 @@
1
- {"root":["./src/withdevmenu.ts"],"version":"5.9.2"}
1
+ {"root":["./src/index.ts","./src/withdevmenu.ts"],"version":"6.0.3"}