From 68cd7359cde6ca80e3115e1bcad9b4525a73aba7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 13 Oct 2026 21:09:08 +0300 Subject: [PATCH 1/3] Reset accessibility state when recycling Android views 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. --- .../react/uimanager/BaseViewManager.java | 14 +++ .../react/views/text/ReactTextView.java | 2 + .../react/uimanager/BaseViewManagerTest.kt | 98 +++++++++++++++++++ .../views/text/ReactTextViewPropertyTest.kt | 27 +++++ 4 files changed, 141 insertions(+) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index 55daec06a8c6..802ed54a6367 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -93,6 +93,9 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { view.setTag(R.id.accessibility_actions, null); view.setTag(R.id.accessibility_value, null); view.setTag(R.id.accessibility_state_expanded, null); + view.setTag(R.id.accessibility_collection, null); + view.setTag(R.id.accessibility_collection_item, null); + view.setTag(R.id.role, null); view.setTag(R.id.accessibility_state_disabled, null); view.setEnabled(true); view.setTag(R.id.view_clipped, null); @@ -161,6 +164,17 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { view.setFocusable(false); view.setFocusableInTouchMode(false); + // 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); + view.setContentDescription(null); + view.setSelected(false); + view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); + ViewCompat.setAccessibilityLiveRegion(view, ViewCompat.ACCESSIBILITY_LIVE_REGION_NONE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + view.setScreenReaderFocusable(false); + } + // Predictably, alpha defaults to 1: // https://android.googlesource.com/platform/frameworks/base/+/a175a5b/core/java/android/view/View.java#2186 // This accounts for resetting mBackfaceOpacity and mBackfaceVisibility diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java index 677eb472660b..ae22a06e8094 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/ReactTextView.java @@ -192,6 +192,8 @@ private void initView() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { setFocusable(View.FOCUSABLE_AUTO); } + // TextView's constructor makes the view important for accessibility + setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE); updateView(); // call after changing ellipsizeLocation in particular diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt index 95e47b23e9a2..3d567cd491ce 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt @@ -10,17 +10,22 @@ package com.facebook.react.uimanager import android.view.View.OnFocusChangeListener +import androidx.core.view.ViewCompat import com.facebook.react.R import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.bridge.DynamicFromObject import com.facebook.react.bridge.JavaOnlyArray import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.touch.JSResponderHandler import com.facebook.react.views.view.ReactViewGroup import com.facebook.react.views.view.ReactViewManager import com.facebook.testutils.shadows.ShadowArguments import java.util.Locale import org.assertj.core.api.Assertions +import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -41,12 +46,24 @@ class BaseViewManagerTest { @Before fun setUp() { ReactNativeFeatureFlagsForTests.setUp() + ReactNativeFeatureFlags.override( + object : ReactNativeFeatureFlagsDefaults() { + override fun enableViewRecycling(): Boolean = true + }, + ) + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(RuntimeEnvironment.getApplication()) viewManager = ReactViewManager() val context = BridgeReactContext(RuntimeEnvironment.getApplication()) themedReactContext = ThemedReactContext(context, context, null, -1) view = ReactViewGroup(themedReactContext) } + @After + fun tearDown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + ReactNativeFeatureFlags.dangerouslyReset() + } + @Test fun testAccessibilityRoleNone() { viewManager.setAccessibilityRole(view, "none") @@ -162,4 +179,85 @@ class BaseViewManagerTest { viewManager.setAccessibilityLabelledBy(view, DynamicFromObject(JavaOnlyArray())) Assertions.assertThat(view.getTag(R.id.labelled_by)).isNull() } + + @Test + fun testRecycledViewHasTheAccessibilityStateOfANewView() { + val recycled = + recycleView( + JavaOnlyMap.of( + ViewProps.ACCESSIBILITY_LABEL, + "Dismiss sheet", + ViewProps.ACCESSIBILITY_STATE, + JavaOnlyMap.of("selected", true), + ViewProps.ACCESSIBILITY_LIVE_REGION, + "assertive", + ViewProps.IMPORTANT_FOR_ACCESSIBILITY, + "no-hide-descendants", + ViewProps.SCREEN_READER_FOCUSABLE, + true, + ) + ) + + Assertions.assertThat(recycled.contentDescription).isEqualTo(view.contentDescription) + Assertions.assertThat(recycled.isSelected).isEqualTo(view.isSelected) + Assertions.assertThat(recycled.accessibilityLiveRegion).isEqualTo(view.accessibilityLiveRegion) + Assertions.assertThat(recycled.importantForAccessibility) + .isEqualTo(view.importantForAccessibility) + Assertions.assertThat(recycled.isScreenReaderFocusable).isEqualTo(view.isScreenReaderFocusable) + } + + @Test + fun testRecycledViewDropsTheRoleAndCollectionOfItsPreviousProps() { + val recycled = + recycleView( + JavaOnlyMap.of( + ViewProps.ROLE, + "list", + ViewProps.ACCESSIBILITY_COLLECTION, + JavaOnlyMap.of("rowCount", 1, "columnCount", 1, "hierarchical", false), + ViewProps.ACCESSIBILITY_COLLECTION_ITEM, + JavaOnlyMap.of( + "rowIndex", + 0, + "rowSpan", + 1, + "columnIndex", + 0, + "columnSpan", + 1, + "heading", + false, + ), + ) + ) + + Assertions.assertThat(recycled.getTag(R.id.role)).isNull() + Assertions.assertThat(recycled.getTag(R.id.accessibility_collection)).isNull() + Assertions.assertThat(recycled.getTag(R.id.accessibility_collection_item)).isNull() + } + + @Test + fun testRecycledViewWithoutAccessibilityPropsHasNoAccessibilityDelegate() { + val recycled = recycleView(JavaOnlyMap.of(ViewProps.ROLE, "button")) + + Assertions.assertThat(ViewCompat.hasAccessibilityDelegate(recycled)) + .isEqualTo(ViewCompat.hasAccessibilityDelegate(view)) + } + + private fun recycleView(previousProps: JavaOnlyMap): ReactViewGroup { + val previous = createView(previousProps) + viewManager.onDropViewInstance(previous) + val recycled = createView(JavaOnlyMap()) + Assertions.assertThat(recycled).isSameAs(previous) + return recycled + } + + private fun createView(props: JavaOnlyMap): ReactViewGroup = + viewManager.createView( + 1, + themedReactContext, + ReactStylesDiffMap(props), + null, + JSResponderHandler(), + ) } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt index 12adce0d1308..b0e5258e65a3 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/ReactTextViewPropertyTest.kt @@ -16,7 +16,10 @@ import com.facebook.react.bridge.BridgeReactContext import com.facebook.react.bridge.CatalystInstance import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.bridge.ReactTestHelper.createMockCatalystInstance +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.touch.JSResponderHandler import com.facebook.react.uimanager.BackgroundStyleApplicator import com.facebook.react.uimanager.DisplayMetricsHolder import com.facebook.react.uimanager.LengthPercentage @@ -44,6 +47,11 @@ class ReactTextViewPropertyTest { @Before fun setup() { ReactNativeFeatureFlagsForTests.setUp() + ReactNativeFeatureFlags.override( + object : ReactNativeFeatureFlagsDefaults() { + override fun enableViewRecycling(): Boolean = true + }, + ) context = BridgeReactContext(RuntimeEnvironment.getApplication()) catalystInstanceMock = createMockCatalystInstance() context.initializeWithInstance(catalystInstanceMock) @@ -55,6 +63,7 @@ class ReactTextViewPropertyTest { @After fun teardown() { DisplayMetricsHolder.setScreenDisplayMetrics(null) + ReactNativeFeatureFlags.dangerouslyReset() } private fun buildStyles(vararg keysAndValues: Any?): ReactStylesDiffMap { @@ -78,4 +87,22 @@ class ReactTextViewPropertyTest { assertThat(BackgroundStyleApplicator.getBorderRadius(view, BorderRadiusProp.BORDER_RADIUS)) .isNull() } + + @Test + fun testRecycledViewIsAsImportantForAccessibilityAsANewView() { + val previous = + manager.createView( + 1, + themedContext, + buildStyles("importantForAccessibility", "no-hide-descendants"), + null, + JSResponderHandler(), + ) + manager.onDropViewInstance(previous) + val recycled = manager.createView(2, themedContext, buildStyles(), null, JSResponderHandler()) + + assertThat(recycled).isSameAs(previous) + assertThat(recycled.importantForAccessibility) + .isEqualTo(manager.createViewInstance(themedContext).importantForAccessibility) + } } From 68661377d74b3aa78ea2a54bbb94e731d5c9636c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 09:55:01 +0300 Subject: [PATCH 2/3] Reset the accessibility order a recycled view keeps 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. --- .../react/uimanager/BaseViewManager.java | 2 + .../react/views/view/ReactViewGroup.kt | 1 + .../react/uimanager/BaseViewManagerTest.kt | 42 ++++++++++++++++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index 802ed54a6367..793b97f6210d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -96,6 +96,8 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { view.setTag(R.id.accessibility_collection, null); view.setTag(R.id.accessibility_collection_item, null); view.setTag(R.id.role, null); + view.setTag(R.id.accessibility_order_parent, null); + view.setTag(R.id.original_focusability, null); view.setTag(R.id.accessibility_state_disabled, null); view.setEnabled(true); view.setTag(R.id.view_clipped, null); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt index 363f1336d000..acb54c03f66f 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/view/ReactViewGroup.kt @@ -189,6 +189,7 @@ public open class ReactViewGroup public constructor(context: Context?) : allChildrenCount = 0 clippingRect = null hitSlopRect = null + axOrderList = null // pointerEvents setter reads _overflow, so _overflow must be assigned first. _overflow = Overflow.VISIBLE pointerEvents = PointerEvents.AUTO diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt index 3d567cd491ce..30528113b29f 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt @@ -244,8 +244,48 @@ class BaseViewManagerTest { .isEqualTo(ViewCompat.hasAccessibilityDelegate(view)) } - private fun recycleView(previousProps: JavaOnlyMap): ReactViewGroup { + @Test + fun testRecycledViewDropsTheAccessibilityOrderOfItsPreviousProps() { + ReactNativeFeatureFlags.dangerouslyReset() + ReactNativeFeatureFlags.override( + object : ReactNativeFeatureFlagsDefaults() { + override fun enableViewRecycling(): Boolean = true + + override fun enableAccessibilityOrder(): Boolean = true + }, + ) + + val recycled = + recycleView(JavaOnlyMap.of(ViewProps.ACCESSIBILITY_ORDER, JavaOnlyArray.of("first"))) + + Assertions.assertThat(recycled.axOrderList).isNull() + } + + @Test + fun testRecycledViewDropsTheAccessibilityOrderItsParentApplied() { + val parent = ReactViewGroup(themedReactContext) + + val recycled = + recycleView(JavaOnlyMap()) { previous -> + ReactAxOrderHelper.buildAxOrderList( + previous, + parent, + mutableListOf(), + arrayOfNulls(0), + ) + ReactAxOrderHelper.disableFocusForSubtree(previous, mutableListOf()) + } + + Assertions.assertThat(recycled.getTag(R.id.accessibility_order_parent)).isNull() + Assertions.assertThat(recycled.getTag(R.id.original_focusability)).isNull() + } + + private fun recycleView( + previousProps: JavaOnlyMap, + beforeDrop: (ReactViewGroup) -> Unit = {}, + ): ReactViewGroup { val previous = createView(previousProps) + beforeDrop(previous) viewManager.onDropViewInstance(previous) val recycled = createView(JavaOnlyMap()) Assertions.assertThat(recycled).isSameAs(previous) From a7847e5180efa6e976e86c393a4651bece130793 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 16:30:54 +0300 Subject: [PATCH 3/3] Test that a recycled view performs its own accessibility click 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. --- .../react/uimanager/BaseViewManagerTest.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt index 30528113b29f..99e8a3fb1a45 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.kt @@ -10,6 +10,7 @@ package com.facebook.react.uimanager import android.view.View.OnFocusChangeListener +import android.view.accessibility.AccessibilityNodeInfo import androidx.core.view.ViewCompat import com.facebook.react.R import com.facebook.react.bridge.BridgeReactContext @@ -244,6 +245,25 @@ class BaseViewManagerTest { .isEqualTo(ViewCompat.hasAccessibilityDelegate(view)) } + @Test + fun testRecycledViewPerformsItsOwnAccessibilityClick() { + val recycled = + recycleView( + JavaOnlyMap.of( + ViewProps.ACCESSIBILITY_ACTIONS, + JavaOnlyArray.of(JavaOnlyMap.of("name", "activate")), + ) + ) { previous -> + previous.createAccessibilityNodeInfo() + } + var clicks = 0 + recycled.setOnClickListener { clicks++ } + + recycled.performAccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, null) + + Assertions.assertThat(clicks).isEqualTo(1) + } + @Test fun testRecycledViewDropsTheAccessibilityOrderOfItsPreviousProps() { ReactNativeFeatureFlags.dangerouslyReset()