Skip to content

Send Android key events to the tapped view while no view has focus - #59593

Open
YevheniiKotyrlo wants to merge 1 commit into
react:mainfrom
YevheniiKotyrlo:fix/android-key-events-without-focus
Open

YevheniiKotyrlo wants to merge 1 commit into
react:mainfrom
YevheniiKotyrlo:fix/android-key-events-without-focus

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

With enableKeyEvents on, Android sends onKeyDown and onKeyUp to the view that holds Android focus, and to nothing while no view holds it. A touch leaves the window with no focused view: ViewRootImpl enters touch mode when a touch goes down and takes focus from every view that is not focusable in touch mode, which is every React Native view but a text input. So a key pressed after a tap that neither types a character nor moves focus — Escape, a function key, a letter held with Ctrl — reaches no JS handler, and a hardware Escape cannot close a dialog opened by touch (#59537): ViewGroup.dispatchKeyEvent hands the key to no view, so ReactRootView.dispatchKeyEvent never runs, and JSKeyDispatcher has no target anyway.

A browser in the same place targets the element the tap focused (HTML, click focusable), keeps that focus through a scroll, and targets the body once a tap lands on nothing focusable or the focused element blurs (UI Events, keydown). This change gives Android that target while no view holds focus:

  • ReactRootView registers a View.OnUnhandledKeyEventListener through ViewCompat, Android's path for a key no view took, which AndroidX extends below API 28 under a ComponentActivity. With no view focused in the window, it sends the key to JS; it returns false, so Back and the Activity's own keys run as before.
  • TapFocusTracker keeps, for the whole window, the nearest focusable React view of the last tap — one pointer that never moves past the touch slop — or of the view whose focus entering touch mode took away, as the first touch after keyboard navigation does. A scroll, a cancelled touch or a second pointer leaves it; a tap on nothing focusable ends it, and so does any view taking Android's focus, so after a blur a key reaches no view, as a key aimed at the web's body reaches no handler below it.
  • JSKeyDispatcher sends the key to that view while it is still in its root, shown and focusable, and never reads focusedViewTag on this path, which stays stale after a descendant loses focus.

The focused path is unchanged. The root starts the listener and the tracker where it starts its layout listener, once it is attached to a window and a React instance, so making a root reads no flag, and with the flag off nothing is registered. Targeting the root element, which UI Events names when a page has no body, would reach no handler here: the Android root has no event emitter under Fabric.

A key that leaves touch mode — a typed character, Tab, an arrow, Enter or Space — is outside this change: Android focuses its default view for it, as before.

Fixes #59537.

Changelog:

[ANDROID] [FIXED] - With enableKeyEvents on, a key that neither types nor moves focus, pressed while no view has focus, reaches the focusable view the last tap landed on

Test Plan:

  • ReactSurfaceViewKeyEventTest (new; Robolectric, every touch and key dispatched through the window's own input stages): on main the cases that expect a key with no view focused get no event; with this change all 22 pass. ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests com.facebook.react.runtime.ReactSurfaceViewKeyEventTest -PenableWarningsAsErrors=true
  • RNTester release builds of main and this change on a Galaxy S23 (Android 14), with enableKeyEvents turned on in RNTesterApplication and a root component of a focusable Pressable, a ScrollView of text and a root onKeyDown: after a tap, Escape, Ctrl+A and F2 reach the root's handler through the Pressable with this change and nothing on main, as does Escape after a scroll and after a mouse click; a typed letter, and Tab then Escape, reach it once on both. The frames are in the evidence comment.
  • What it costs: with the flag on, ReactSurfaceView.onInterceptTouchEvent takes about 29 µs more on a tap's up, where it hit-tests the tap, and nothing measurable on its down, with the tapped view 30 views deep on a REDMAGIC NX809J (Android 16); a move adds a distance check. The numbers are in the evidence comment.
  • CI's jobs pass when run locally on this branch, except ios, which could not run here as the Mac I have could not sign for a device; this change touches Android sources only.

With `enableKeyEvents` on, a key pressed after a tap that neither types a
character nor moves focus reached no JS handler: Android leaves no view
focused in touch mode, so it hands the key to no view and
`JSKeyDispatcher` has no target.

The root view now takes such a key from Android's unhandled-key listener
and sends it to the view a browser would hold focused: the nearest
focusable React view of the window's last tap, or of the view whose focus
entering touch mode took away. A scroll keeps that target; a tap on
nothing focusable, or any view taking Android's focus, ends it.

Fixes react#59537
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 28, 2026
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — On main, a tap leaves no view focused, so Escape pressed after it reaches no handler: the probe's root View has counted no key.

FIXED — With this PR, the same Escape reaches the tapped Pressable and bubbles to the root View: both count it, last key Escape.

before — main dfed2d925d after — with this PR

Galaxy S23, Android 14 (API 34): RNTester release builds of main (Build: base in the frame) and of this PR (Build: head), each with enableKeyEvents turned on in RNTesterApplication and the probe below as its root component, in one session; a tap on the Pressable, then adb shell input keyevent KEYCODE_ESCAPE. The rest of that session, in the order pressed — what the root View's onKeyDown received:

key main this PR
Escape, after the tap nothing Escape
Ctrl+A (input keycombination KEYCODE_CTRL_LEFT KEYCODE_A) nothing Control, then A with ctrlKey; its key reads Unidentified, a gap in the key names #59120 closes
F2 nothing F2, its key Unidentified the same way
Escape, after scrolling the ScrollView below the Pressable nothing Escape
a typed a (input text a), which leaves touch mode and focuses the Pressable a a
Escape, with the Pressable focused Escape Escape
Tab after another tap, which focuses the Pressable and reaches no handler, then Escape Escape, once Escape, once
Escape, after a mouse click on the Pressable (input mouse tap) nothing Escape
Back, after another tap the activity closes the activity closes

What a tap costs ReactSurfaceView

main — its onInterceptTouchEvent takes a median 120 and 119 µs on a tap's down, and 67 and 66 µs on its up, in two runs, the tapped Pressable 30 views deep.

This PR — with enableKeyEvents on: 116 and 121 µs on the down, inside main's quartiles, and 97 and 95 µs on the up, where TapFocusTracker hit-tests the tap to find the view that a key pressed while no view has focus goes to: about 30 µs once per tap, against a 120 Hz frame of 8.3 ms. enableKeyEvents is off by default, and with it off the root makes no TapFocusTracker, so onInterceptTouchEvent only tests a field for null.

onInterceptTouchEvent, µs, median (p25–p75) main this PR main, again this PR, again
a tap's down 119.8 (110.7–131.5) 116.1 (100.5–131.3) 118.9 (106.2–133.5) 121.4 (103.1–138.3)
a tap's up 66.8 (61.2–75.3) 96.5 (89.5–105.8) 65.9 (61.4–74.6) 94.5 (88.5–105.1)

REDMAGIC NX809J, Android 16 (API 36): RNTester release builds of main and of this PR with enableKeyEvents on, the probe below with NESTING set to '30', and trace sections around ReactSurfaceView's touch handlers in both; four runs in the order the table reads, each 150 adb shell input tap on the Pressable, read from a Perfetto trace of the app's sections; each run's samples: main · this PR · main, again · this PR, again.

Probe

The root component. Each build sets BUILD to base or head, which the frames show; the cost runs set NESTING to '30', which puts the Pressable 30 views deep, and the frames come from the same component without the nesting. The cost runs also add these trace sections to ReactSurfaceView; no tap reached its onTouchEvent:

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
  android.os.Trace.beginSection("RootTouch.onInterceptTouchEvent")
  try {
    return super.onInterceptTouchEvent(ev)
  } finally {
    android.os.Trace.endSection()
  }
}

override fun onTouchEvent(ev: MotionEvent): Boolean {
  android.os.Trace.beginSection("RootTouch.onTouchEvent")
  try {
    return super.onTouchEvent(ev)
  } finally {
    android.os.Trace.endSection()
  }
}
import type {KeyEvent, NativeSyntheticEvent} from 'react-native';

import * as React from 'react';
import {useState} from 'react';
import {Pressable, ScrollView, StyleSheet, Text, View} from 'react-native';

const BUILD = 'unset';
const NESTING = '0';

const LINES: $ReadOnlyArray<number> = Array.from(
  {length: 60},
  (_, index) => index + 1,
);

function describeKey(event: NativeSyntheticEvent<KeyEvent>): string {
  const {key, ctrlKey, altKey, metaKey, shiftKey} = event.nativeEvent;
  const modifiers = [
    ctrlKey ? 'Ctrl' : null,
    altKey ? 'Alt' : null,
    metaKey ? 'Meta' : null,
    shiftKey ? 'Shift' : null,
  ].filter(Boolean);
  return [...modifiers, key].join('+');
}

function Nested({children, depth}: {children: React.Node, depth: number}) {
  return depth === 0 ? (
    children
  ) : (
    <View collapsable={false}>
      <Nested depth={depth - 1}>{children}</Nested>
    </View>
  );
}

export default function KeyEventsWithoutFocusProbe(): React.Node {
  const [keys, setKeys] = useState<$ReadOnlyArray<string>>([]);
  const [buttonKeys, setButtonKeys] = useState(0);
  const [taps, setTaps] = useState(0);

  return (
    <View
      onKeyDown={event => setKeys(previous => [...previous, describeKey(event)])}
      style={styles.screen}
      testID="root">
      <Text testID="build">Build: {BUILD}</Text>
      <Nested depth={Number(NESTING)}>
        <Pressable
          onKeyDown={() => setButtonKeys(count => count + 1)}
          onPress={() => setTaps(count => count + 1)}
          style={styles.button}
          testID="tap-target">
          <Text>Tapped {taps} times</Text>
        </Pressable>
      </Nested>
      <Text testID="key-count">Keys received: {keys.length}</Text>
      <Text testID="button-key-count">Keys on the button: {buttonKeys}</Text>
      <Text testID="last-key">
        Last key: {keys.length === 0 ? 'none' : keys[keys.length - 1]}
      </Text>
      <ScrollView style={styles.list} testID="scroll-area">
        {LINES.map(line => (
          <Text key={line} style={styles.line}>
            Line {line}, text to scroll
          </Text>
        ))}
      </ScrollView>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: {
    backgroundColor: 'white',
    flex: 1,
    gap: 16,
    padding: 24,
    paddingTop: 96,
  },
  button: {
    borderWidth: 1,
    padding: 16,
  },
  list: {
    borderTopWidth: 1,
    flex: 1,
  },
  line: {
    paddingVertical: 8,
  },
});

RNTesterApplication.onCreate, after loadReactNative(this):

ReactNativeFeatureFlags.dangerouslyForceOverride(
    object :
        ReactNativeFeatureFlagsProvider by ReactNativeFeatureFlagsOverrides_RNOSS_Stable_Android() {
      override fun enableKeyEvents(): Boolean = true
    },
)

@github-actions

Copy link
Copy Markdown

Visual regression

No visual differences at 73c69bd525f1.

ios: no capture

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 29, 2026 00:15
@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 29, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android: with enableKeyEvents on, a hardware key reaches no JS handler while no view has focus

1 participant