Skip to content

Keep hidden Android view subtrees out of keyboard navigation - #59052

Open
YevheniiKotyrlo wants to merge 2 commits into
react:mainfrom
YevheniiKotyrlo:fix/android-hidden-subtree-keyboard-focus
Open

YevheniiKotyrlo wants to merge 2 commits into
react:mainfrom
YevheniiKotyrlo:fix/android-hidden-subtree-keyboard-focus

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

On Android, aria-hidden={true} — and importantForAccessibility="no-hide-descendants", which View turns it into — hides a view's descendants from TalkBack, while Tab, Shift+Tab, the arrow keys and a D-pad still land on any focusable descendant. A keyboard user ends up on a control that assistive technology treats as absent and that the screen usually shows as covered or inactive: a modal's background, a stacked notification, an inactive screen.

Keyboard navigation reaches a subtree in three places, and none of them reads importantForAccessibility. FocusFinder collects candidates through addFocusables. With nothing focused, ViewRootImpl calls restoreDefaultFocus, which enters a subtree through onRequestFocusInDescendants. And when the next candidate lies outside a scroll view, ReactScrollView.focusSearch and its horizontal and nested siblings ask the shadow tree for the next focusable element (enableCustomFocusSearchOnClippedElementsAndroid), where FocusOrderingHelper::traverseAndUpdateNextFocusableElement descends into every child.

Every container React Native renders on Android — ReactViewGroup, the vertical, horizontal and nested scroll views, DrawerLayoutAndroid and RefreshControl — now leaves the descendants it hides out of addFocusables and onRequestFocusInDescendants, through one internal HiddenSubtreeFocusHelper that reads the view's importantForAccessibility. A descendant that holds focus stays among the candidates, so focus search moves on from it instead of looping to the first candidate, and the container itself keeps the place the platform gives it. BaseViewManager moves keyboard focus off a descendant when a view starts hiding it; in touch mode the focus stays, so a text field someone is typing in keeps its keyboard. A tap or ref.focus() still focuses a hidden descendant, and descendantFocusability is left as the app or a subclass set it. FocusOrderingHelper stops before a hiding node's descendants, so the shadow-tree search agrees with the view tree. The new public overrides are added to ReactAndroid.api.

ScrollView, DrawerLayoutAndroid and RefreshControl take importantForAccessibility directly; unlike View, they do not turn aria-hidden into it. This overlaps #59048, which also overrides ReactViewGroup.addFocusables; whichever lands second combines the two bodies in one function.

The nearest report I found is #30850 (closed), which expects no-hide-descendants to keep descendants from taking focus, on 0.63 and for screen-reader focus. I found no PR.

Changelog:

[ANDROID] [FIXED] - aria-hidden and importantForAccessibility="no-hide-descendants" keep a view's descendants out of hardware-keyboard navigation

Test Plan:

  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests "com.facebook.react.views.view.ReactViewGroupTest" --tests "com.facebook.react.uimanager.HiddenSubtreeKeyboardFocusTest" -Preact.internal.useHermesStable=true: on main, 8 of the 14 new tests fail. In ReactViewGroupTest, five: the hidden descendant is in the addFocusables list, requestFocus(FOCUS_DOWN) on the root gives it focus, it keeps keyboard focus after its parent hides it, while one keeps touch focus its hidden siblings are still candidates, and a focusable hiding view comes with its descendants. HiddenSubtreeKeyboardFocusTest runs each scroll view, the drawer and the refresh layout through the same scene, and three of its four tests fail: the hidden content is a candidate, takes default focus, and keeps keyboard focus as the container hides it. The six that pass either way hold what stays the same: hidden content returns once shown again, a direct requestFocus() still focuses it, touch focus stays, a hiding view keeps focus it holds itself, and descendantFocusability is untouched. With the change all 31 tests in the two classes pass.
  • ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest :packages:react-native:ReactAndroid:compileReleaseKotlin :packages:react-native:ReactAndroid:compileReleaseJavaWithJavac -PenableWarningsAsErrors=true -Preact.internal.useHermesStable=true: 735 tests in 104 suites, 0 failures, and the release sources compile.
  • node ./scripts/format-kotlin.js --check, node ./scripts/format-java.js --check, node ./scripts/clang-format.js --check on the changed C++ file, and node generate-nested-scroll-view.js --verify pass.
  • The RNTester debug build with this change on main at b8ec87e457 passes with -PenableWarningsAsErrors=true. The rest of build_android and the C++ API snapshot check were not run; the change declares nothing new in C++. FocusOrderingHelper has no C++ test target, so the scroll view scene below is what exercises that half.
  • RNTester on an Android 16 emulator, before and after: from the first control, the next Tab lands inside the hidden container without the change and on the next visible control with it — an aria-hidden view, an aria-hidden view in a scroll view, and a scroll view with importantForAccessibility="no-hide-descendants".
  • The ReactViewGroup half and the FocusOrderingHelper change on 0.86.3, in an app on a Galaxy S23 (Android 14), with Tab sent as key events: Tab passes over the controls in aria-hidden notifications stacked behind the front one, where it landed on them before.

@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 14, 2026
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — from the first box, the next Tab lands on the box inside the hidden container: an aria-hidden view, an aria-hidden view in a scroll view, and a scroll view with importantForAccessibility="no-hide-descendants".

FIXED — the next Tab passes over it to the next box outside the hidden container.

before — main at b8ec87e457 after — with this PR
hidden view
hidden view in a scroll view
hidden scroll view

RNTester debug builds from main at b8ec87e457 with and without this change, on an Android 16 (API 36) emulator, 1140×2400 at density 480. Each scene is three Pressables that print their onFocus and onBlur, the middle one inside the hidden container; in the second scene the first two sit in a ScrollView, so the next box lies outside it and the scroll view's own focus search picks the target. Each image is taken after Tab key presses from touch mode reach the first box and one more Tab follows.

A View with importantForAccessibility="no-hide-descendants", which is
what aria-hidden becomes, hid its descendants from TalkBack while Tab,
the arrow keys and a D-pad still landed on them. ReactViewGroup now
leaves those descendants out of the focus search and out of focus that
enters through the view, and keyboard focus sitting on one moves off
when the view hides it. A tap or a focus() call still focuses them,
and the view's own descendantFocusability is untouched. The shadow
tree search a scroll view falls back to when focus leaves it skips
those descendants too, so Tab past a scroll view's last control no
longer picks a hidden one and stops there.
A ScrollView, a horizontal ScrollView, DrawerLayoutAndroid or
RefreshControl with importantForAccessibility="no-hide-descendants"
left its content in keyboard navigation, because only ReactViewGroup
filtered the descendants it hides. The filtering moves into
HiddenSubtreeFocusHelper, which every container React Native renders
calls from addFocusables and onRequestFocusInDescendants, reading
importantForAccessibility rather than a copy of it, and BaseViewManager
moves keyboard focus off a subtree as it hides it, whatever the view
type. The new public overrides are added to ReactAndroid.api.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/android-hidden-subtree-keyboard-focus branch from 5a605ee to 27b042e Compare October 14, 2026 09:17
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 14, 2026 10:39
@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 14, 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.

1 participant