Skip to content

Reset accessibility state when recycling Android views - #59047

Open
YevheniiKotyrlo wants to merge 3 commits into
react:mainfrom
YevheniiKotyrlo:fix/android-recycled-view-accessibility-state
Open

YevheniiKotyrlo wants to merge 3 commits into
react:mainfrom
YevheniiKotyrlo:fix/android-recycled-view-accessibility-state

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

With enableViewRecycling on, a recycled view keeps the accessibility state its previous props applied. BaseViewManager.prepareToRecycleView clears the accessibility tags, but not the content description, importantForAccessibility, the selected state, the live region, screenReaderFocusable, the role and collection tags, or the accessibility delegate. The next view of that type is built from it and applies only the props it declares, so TalkBack announces a label and a role the view never had, the kept delegate sends TalkBack's click to an action only the previous props declared, and a view last used inside an aria-hidden subtree hides its new content from accessibility services. With experimental_accessibilityOrder on as well, a recycled ReactViewGroup keeps its previous order list, and a recycled child keeps the parent and focusability tags its former parent's order put on it.

This resets that state in prepareToRecycleView to what a new view has, clears the order list in ReactViewGroup.initView, and restores TextView's default importance in ReactTextView.recycleView. The delegate is cleared with View.setAccessibilityDelegate(null): ViewCompat.setAccessibilityDelegate(view, null) replaces a compat delegate with an empty one and turns auto importance into yes. It sits beside the enabled reset #58799 added.

Changelog:

[ANDROID] [FIXED] - Recycled views no longer keep the accessibility state of their previous props

Test Plan:

New cases in BaseViewManagerTest and ReactTextViewPropertyTest recycle a view through onDropViewInstance and createView and compare it with a new one. Before the change seven fail: content description "Dismiss sheet", role tag LIST, a leftover delegate, a click dispatched to the previous props' activate action instead of performed, importance 4 where a new text view has 1, the order list ["first"], and the former parent in accessibility_order_parent. With it, ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest -PenableWarningsAsErrors=true passes (727 tests), the release sources compile with the same flag, and so does the RNTester debug build; the rest of build_android was not run.

RNTester with view recycling on, on an Android 16 emulator with TalkBack, before and after: plain rows mounted after labelled rows were removed are read as a selected "Dismiss sheet" button without the change, and as their text with it.

On a Galaxy S23 (Android 14) app with view recycling on, plain rows mounted after rows carrying aria-label, role, aria-selected, aria-disabled and aria-hidden were removed reported the removed rows' label as their content description before the change. Built from source with it, the same rows report no label, role, selected or disabled state, and their text is in the accessibility tree.

With view recycling on, prepareToRecycleView clears the accessibility
tags a view's props wrote but not the state those props applied to the
view itself. The next view built from it applies only the props it
declares, so it announces the previous view's content description and
role, reads as selected, keeps its live region and screen-reader
focusability, and stays hidden from accessibility services when the
previous view was.

Reset that state to a new view's, clear the accessibility delegate on
the view, and restore TextView's default importance in
ReactTextView.recycleView.
@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 13, 2026
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — once the labelled rows are removed, TalkBack focuses the plain rows' container, built from a removed row's view, as a selected "Dismiss sheet" button.

FIXED — TalkBack focuses the first plain row and reads "Row 1".

before — main at 977ca0a204 after — with this PR

RNTester debug builds from main at 977ca0a204 with and without this change, both with enableViewRecycling turned on in RNTesterApplication, on an Android 16 (API 36) emulator, 1140×2400 at density 480. The scene renders three rows with accessibilityLabel="Dismiss sheet", role="button" and aria-selected, removes them, then renders three plain rows with a Text each, and the first row is touched with TalkBack on. TalkBack's speech output shows one phrase at a time: without the change it speaks "selected", "Dismiss sheet", "Button", "Double-tap to activate"; with it, "Row 1".

With experimental_accessibilityOrder on, a recycled ReactViewGroup kept
the order list its previous props set, and a recycled child kept the
parent and focusability tags its former parent's order put on it, the
parent tag holding on to that view. ReactViewGroup.initView clears the
list and prepareToRecycleView clears both tags, as a new view has them.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 14, 2026 09:10
@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

// Accessibility state the props apply to the view itself, as a new view has it. The delegate
// is cleared on the view: ViewCompat would install an empty one and mark the view important.
view.setAccessibilityDelegate(null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about this one - the accessibility delegate dynamically updates with the state of the view, there should be no need to explicitly reset it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of what it reports follows the view's tags, but performAccessibilityAction routes through accessibilityActionsMap, which still holds the previous props' actions: a view recycled from one with an activate action dispatches that action on TalkBack's click instead of clicking. testRecycledViewPerformsItsOwnAccessibilityClick in a7847e5 fails without this line.

A delegate keeps the actions it registered while building node info, so
a view recycled from one that declared an activate action dispatched
that action on TalkBack's click instead of performing the click. The
case fails without prepareToRecycleView clearing the delegate.

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.

2 participants