Skip to content

Commit cafb750

Browse files
fix(android): route Modal hardware ESC through onBackPressedDispatcher so onRequestClose fires (#56411)
Hardware ESC was not invoking JS onRequestClose on Android Modals. Subclass ComponentDialog and override dispatchKeyEvent so KEYCODE_ESCAPE ACTION_UP routes through onBackPressedDispatcher.onBackPressed(), the same path KEYCODE_BACK already uses. Removed the redundant ESC branch from setOnKeyListener to prevent double-dispatch.
1 parent 0c153e2 commit cafb750

2 files changed

Lines changed: 127 additions & 6 deletions

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/modal/ReactModalHostView.kt

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ public class ReactModalHostView(context: ThemedReactContext) :
273273
}
274274

275275
val currentActivity = getCurrentActivity()
276-
val newDialog = ComponentDialog(currentActivity ?: context, theme)
276+
val newDialog = ReactModalDialog(currentActivity ?: context, theme)
277277
dialog = newDialog
278278
val window = requireNotNull(newDialog.window)
279279
window.setFlags(
@@ -306,11 +306,14 @@ public class ReactModalHostView(context: ThemedReactContext) :
306306
object : DialogInterface.OnKeyListener {
307307
override fun onKey(dialog: DialogInterface, keyCode: Int, event: KeyEvent): Boolean {
308308
if (event.action == KeyEvent.ACTION_UP) {
309-
// We need to stop the BACK button and ESCAPE key from closing the dialog by default
310-
// so we capture that event and instead inform JS so that it can make the decision as
311-
// to whether or not to allow the back/escape key to close the dialog. If it chooses
312-
// to, it can just set visible to false on the Modal and the Modal will go away
313-
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_ESCAPE) {
309+
// We need to stop the BACK button from closing the dialog by default so we capture
310+
// that event and instead inform JS so that it can make the decision as to whether or
311+
// not to allow the back key to close the dialog. If it chooses to, it can just set
312+
// visible to false on the Modal and the Modal will go away.
313+
// ESCAPE is routed through ReactModalDialog.dispatchKeyEvent which forwards it to
314+
// onBackPressedDispatcher, so it is intentionally not handled here to avoid
315+
// double-dispatch.
316+
if (keyCode == KeyEvent.KEYCODE_BACK) {
314317
handleCloseAction()
315318
return true
316319
} else {
@@ -491,6 +494,25 @@ public class ReactModalHostView(context: ThemedReactContext) :
491494
private const val TAG = "ReactModalHost"
492495
}
493496

497+
/**
498+
* Subclass of [ComponentDialog] that explicitly routes the hardware ESCAPE key through the
499+
* dialog's [onBackPressedDispatcher], mirroring how the platform routes the BACK key. This is
500+
* required because some Android versions and external keyboards do not deliver KEYCODE_ESCAPE to
501+
* [DialogInterface.OnKeyListener] in a way that allows the modal's onRequestClose callback to be
502+
* invoked, so we intercept it here to guarantee a single, consistent dispatch path. Marked
503+
* `internal` so a same-module Robolectric test can verify the ESC routing contract.
504+
*/
505+
internal class ReactModalDialog(context: Context, themeResId: Int) :
506+
ComponentDialog(context, themeResId) {
507+
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
508+
if (event.keyCode == KeyEvent.KEYCODE_ESCAPE && event.action == KeyEvent.ACTION_UP) {
509+
onBackPressedDispatcher.onBackPressed()
510+
return true
511+
}
512+
return super.dispatchKeyEvent(event)
513+
}
514+
}
515+
494516
/**
495517
* DialogRootViewGroup is the ViewGroup which contains all the children of a Modal. It gets all
496518
* child information forwarded from [ReactModalHostView] and uses that to create children. It is
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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.views.modal
9+
10+
import android.app.Activity
11+
import android.view.KeyEvent
12+
import androidx.activity.OnBackPressedCallback
13+
import com.facebook.react.R
14+
import org.assertj.core.api.Assertions.assertThat
15+
import org.junit.Before
16+
import org.junit.Test
17+
import org.junit.runner.RunWith
18+
import org.robolectric.Robolectric
19+
import org.robolectric.RobolectricTestRunner
20+
21+
/**
22+
* Regression test for issue #56411: pressing the hardware ESCAPE key on Android must invoke the
23+
* `onRequestClose` flow on a Modal. The fix routes KEYCODE_ESCAPE through the dialog's
24+
* [androidx.activity.OnBackPressedDispatcher] from inside [ReactModalHostView.ReactModalDialog],
25+
* which guarantees a single dispatch path consistent with KEYCODE_BACK.
26+
*
27+
* Robolectric cannot exercise device-level key dispatch end-to-end, so this test verifies the
28+
* dispatcher contract: a registered [OnBackPressedCallback] fires when an ESCAPE ACTION_UP key
29+
* event is dispatched to the dialog, and other key events fall through unchanged.
30+
*/
31+
@RunWith(RobolectricTestRunner::class)
32+
class ReactModalDialogTest {
33+
34+
private lateinit var activity: Activity
35+
36+
@Before
37+
fun setUp() {
38+
activity = Robolectric.buildActivity(Activity::class.java).create().get()
39+
}
40+
41+
@Test
42+
fun `escape key up dispatches through onBackPressedDispatcher`() {
43+
val dialog =
44+
ReactModalHostView.ReactModalDialog(activity, R.style.Theme_FullScreenDialog)
45+
var backPressedCount = 0
46+
dialog.onBackPressedDispatcher.addCallback(
47+
object : OnBackPressedCallback(true) {
48+
override fun handleOnBackPressed() {
49+
backPressedCount++
50+
}
51+
}
52+
)
53+
54+
val handled =
55+
dialog.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_ESCAPE))
56+
57+
assertThat(handled).isTrue()
58+
assertThat(backPressedCount).isEqualTo(1)
59+
}
60+
61+
@Test
62+
fun `escape key down does not trigger onBackPressedDispatcher`() {
63+
val dialog =
64+
ReactModalHostView.ReactModalDialog(activity, R.style.Theme_FullScreenDialog)
65+
var backPressedCount = 0
66+
dialog.onBackPressedDispatcher.addCallback(
67+
object : OnBackPressedCallback(true) {
68+
override fun handleOnBackPressed() {
69+
backPressedCount++
70+
}
71+
}
72+
)
73+
74+
// Only ACTION_UP should consume; ACTION_DOWN must fall through to super so the platform's
75+
// existing key-dispatch lifecycle (long-press, repeat, etc.) is not disturbed.
76+
dialog.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ESCAPE))
77+
78+
assertThat(backPressedCount).isEqualTo(0)
79+
}
80+
81+
@Test
82+
fun `non-escape keys are delegated to super dispatchKeyEvent`() {
83+
val dialog =
84+
ReactModalHostView.ReactModalDialog(activity, R.style.Theme_FullScreenDialog)
85+
var backPressedCount = 0
86+
dialog.onBackPressedDispatcher.addCallback(
87+
object : OnBackPressedCallback(true) {
88+
override fun handleOnBackPressed() {
89+
backPressedCount++
90+
}
91+
}
92+
)
93+
94+
// An arbitrary letter key must not be intercepted by the ESC override.
95+
dialog.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_A))
96+
97+
assertThat(backPressedCount).isEqualTo(0)
98+
}
99+
}

0 commit comments

Comments
 (0)