From 68cd7359cde6ca80e3115e1bcad9b4525a73aba7 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 13 Oct 2026 21:09:08 +0300 Subject: [PATCH 1/4] 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/4] 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/4] 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() From 7df50452df5d875a99886560fa783fc92ee61934 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 16 Oct 2026 13:03:27 +0300 Subject: [PATCH 4/4] Keep a recycled view's accessibility delegate and reset its cached actions Clearing the whole delegate in prepareToRecycleView also removed the ReactScrollViewAccessibilityDelegate that ReactScrollView, ReactHorizontalScrollView and ReactNestedScrollView install in their constructors, which recycleView never installs again. The delegate now stays, and ReactAccessibilityDelegate.prepareToRecycle() clears the actions it cached from the previous props. accessibilityLabelledBy was only read in the call that set it, so it is a local there. --- .../react/uimanager/BaseViewManager.java | 8 ++- .../uimanager/ReactAccessibilityDelegate.kt | 13 +++-- .../react/uimanager/BaseViewManagerTest.kt | 49 +++++++++++++++---- 3 files changed, 54 insertions(+), 16 deletions(-) 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 793b97f6210d..f4c8744f5ba4 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 @@ -19,6 +19,7 @@ import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.view.AccessibilityDelegateCompat; import androidx.core.view.ViewCompat; import com.facebook.common.logging.FLog; import com.facebook.react.R; @@ -167,8 +168,11 @@ public BaseViewManager(@Nullable ReactApplicationContext reactContext) { 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); + // stays, since a view's constructor may have installed it, and forgets what it cached. + AccessibilityDelegateCompat accessibilityDelegate = ViewCompat.getAccessibilityDelegate(view); + if (accessibilityDelegate instanceof ReactAccessibilityDelegate) { + ((ReactAccessibilityDelegate) accessibilityDelegate).internal_prepareToRecycle(); + } view.setContentDescription(null); view.setSelected(false); view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt index 6a227761f256..94e906f6995e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.kt @@ -57,7 +57,6 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac } } private val accessibilityActionsMap = HashMap() - private var accessibilityLabelledBy: View? = null init { // We need to reset these two properties, as ExploreByTouchHelper sets focusable to "true" and @@ -68,6 +67,12 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac hostView.importantForAccessibility = originalImportantForAccessibility } + /** Forgets the actions cached from the props of a view that is about to be recycled. */ + @JvmName("internal_prepareToRecycle") + internal fun prepareToRecycle() { + accessibilityActionsMap.clear() + } + override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { super.onInitializeAccessibilityNodeInfo(host, info) @@ -90,9 +95,9 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac val accessibilityLabelledBy = host.getTag(R.id.labelled_by) if (accessibilityLabelledBy != null) { - this.accessibilityLabelledBy = findView(host.rootView, accessibilityLabelledBy as String) - if (this.accessibilityLabelledBy != null) { - info.setLabeledBy(this.accessibilityLabelledBy) + val labellingView = findView(host.rootView, accessibilityLabelledBy as String) + if (labellingView != null) { + info.setLabeledBy(labellingView) } } 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 99e8a3fb1a45..00d1ea51ea47 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 @@ -9,6 +9,7 @@ package com.facebook.react.uimanager +import android.view.View import android.view.View.OnFocusChangeListener import android.view.accessibility.AccessibilityNodeInfo import androidx.core.view.ViewCompat @@ -21,6 +22,10 @@ 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.scroll.ReactHorizontalScrollViewManager +import com.facebook.react.views.scroll.ReactNestedScrollViewManager +import com.facebook.react.views.scroll.ReactScrollViewAccessibilityDelegate +import com.facebook.react.views.scroll.ReactScrollViewManager import com.facebook.react.views.view.ReactViewGroup import com.facebook.react.views.view.ReactViewManager import com.facebook.testutils.shadows.ShadowArguments @@ -238,11 +243,27 @@ class BaseViewManagerTest { } @Test - fun testRecycledViewWithoutAccessibilityPropsHasNoAccessibilityDelegate() { - val recycled = recycleView(JavaOnlyMap.of(ViewProps.ROLE, "button")) + fun testRecycledScrollViewsKeepTheAccessibilityDelegateTheirConstructorsInstall() { + ReactNativeFeatureFlags.dangerouslyReset() + ReactNativeFeatureFlags.override( + object : ReactNativeFeatureFlagsDefaults() { + override fun enableViewRecycling(): Boolean = true + + override fun enableViewRecyclingForScrollView(): Boolean = true + }, + ) + + val recycledScrollViews = + listOf( + recycleView(ReactScrollViewManager(), JavaOnlyMap()), + recycleView(ReactHorizontalScrollViewManager(), JavaOnlyMap()), + recycleView(ReactNestedScrollViewManager(), JavaOnlyMap()), + ) - Assertions.assertThat(ViewCompat.hasAccessibilityDelegate(recycled)) - .isEqualTo(ViewCompat.hasAccessibilityDelegate(view)) + for (recycled in recycledScrollViews) { + Assertions.assertThat(ViewCompat.getAccessibilityDelegate(recycled)) + .isInstanceOf(ReactScrollViewAccessibilityDelegate::class.java) + } } @Test @@ -303,17 +324,25 @@ class BaseViewManagerTest { private fun recycleView( previousProps: JavaOnlyMap, beforeDrop: (ReactViewGroup) -> Unit = {}, - ): ReactViewGroup { - val previous = createView(previousProps) + ): ReactViewGroup = recycleView(viewManager, previousProps, beforeDrop) + + private fun recycleView( + manager: ViewManager, + previousProps: JavaOnlyMap, + beforeDrop: (T) -> Unit = {}, + ): T { + val previous = createView(manager, previousProps) beforeDrop(previous) - viewManager.onDropViewInstance(previous) - val recycled = createView(JavaOnlyMap()) + manager.onDropViewInstance(previous) + val recycled = createView(manager, JavaOnlyMap()) Assertions.assertThat(recycled).isSameAs(previous) return recycled } - private fun createView(props: JavaOnlyMap): ReactViewGroup = - viewManager.createView( + private fun createView(props: JavaOnlyMap): ReactViewGroup = createView(viewManager, props) + + private fun createView(manager: ViewManager, props: JavaOnlyMap): T = + manager.createView( 1, themedReactContext, ReactStylesDiffMap(props),