From c74031a0be9d55c2636400a766e4601e5c1bbea3 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 16:49:34 +0300 Subject: [PATCH 1/2] Stop routing TalkBack's click to removed accessibility actions on Android A view whose accessibilityActions declared activate kept dispatching it on TalkBack's click after the actions were removed or replaced with an empty list, so the view's own click never ran. setAccessibilityActions returned before writing the tag when the prop was null, and ReactAccessibilityDelegate routed an action through a map of every action it had built node info with, which only recycling cleared. setAccessibilityActions now writes null to the tag, and the delegate resolves an action against the view's current accessibilityActions. The map goes, and prepareToRecycle with it: recycling already resets the actions tag the delegate now reads. --- .../react/uimanager/BaseViewManager.java | 13 +---- .../uimanager/ReactAccessibilityDelegate.kt | 29 ++++++---- .../react/uimanager/BaseViewManagerTest.kt | 53 +++++++++++++++++++ 3 files changed, 74 insertions(+), 21 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 db3dd2871cc6..52fefed75e40 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 @@ -21,7 +21,6 @@ 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; @@ -174,11 +173,7 @@ 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 - // 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(); - } + // stays, since a view's constructor may have installed it. view.setContentDescription(null); view.setSelected(false); view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); @@ -498,11 +493,7 @@ private void updateViewContentDescription(@NonNull T view) { } @ReactProp(name = ViewProps.ACCESSIBILITY_ACTIONS) - public void setAccessibilityActions(T view, ReadableArray accessibilityActions) { - if (accessibilityActions == null) { - return; - } - + public void setAccessibilityActions(T view, @Nullable ReadableArray accessibilityActions) { view.setTag(R.id.accessibility_actions, accessibilityActions); } 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 6c76b73ffca6..f1fe6dccf762 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 host?.sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED) } } - private val accessibilityActionsMap = HashMap() init { // We need to reset these two properties, as ExploreByTouchHelper sets focusable to "true" and @@ -68,12 +67,6 @@ 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) @@ -135,7 +128,6 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac val actionId: Int = actionIdMap.get(actionName) ?: customActionIdMap.getOrPut(actionName) { customActionCounter++ } - accessibilityActionsMap[actionId] = actionName val accessibilityAction = AccessibilityActionCompat(actionId, actionLabel) info.addAction(accessibilityAction) } @@ -230,9 +222,10 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac if (action == AccessibilityNodeInfoCompat.ACTION_EXPAND) { host.setTag(R.id.accessibility_state_expanded, true) } - if (accessibilityActionsMap.containsKey(action)) { + val actionName = findDeclaredActionName(host, action) + if (actionName != null) { val eventData = createMap() - eventData.putString("actionName", accessibilityActionsMap[action]) + eventData.putString("actionName", actionName) if ( action == AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS.id && args?.containsKey(AccessibilityNodeInfo.ACTION_ARGUMENT_PROGRESS_VALUE) == true @@ -278,6 +271,22 @@ public open class ReactAccessibilityDelegate( // The View this delegate is attac return super.performAccessibilityAction(host, action, args) } + private fun findDeclaredActionName(host: View, actionId: Int): String? { + val accessibilityActions = + host.getTag(R.id.accessibility_actions) as ReadableArray? ?: return null + for (i in 0.. Unit = {}, @@ -447,4 +486,18 @@ class BaseViewManagerTest { null, JSResponderHandler(), ) + + private fun applyAccessibilityActions(accessibilityActions: JavaOnlyArray?) { + viewManager.updateProperties( + view, + ReactStylesDiffMap(JavaOnlyMap.of(ViewProps.ACCESSIBILITY_ACTIONS, accessibilityActions)), + ) + } + + private fun performAccessibilityClick(): Int { + var clicks = 0 + view.setOnClickListener { clicks++ } + view.performAccessibilityAction(AccessibilityNodeInfo.ACTION_CLICK, null) + return clicks + } } From ffb8318e17451c97997da7a7d0975a08640e653f Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Fri, 13 Nov 2026 19:57:36 +0200 Subject: [PATCH 2/2] Cover TalkBack's click on a view that keeps another accessibility action The click reaches the view once activate is removed and longpress stays declared, so the delegate's lookup also runs over a list that holds no action for it. --- .../facebook/react/uimanager/BaseViewManagerTest.kt | 11 +++++++++++ 1 file changed, 11 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 b3e0e9556dcc..549c0485b914 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 @@ -445,6 +445,17 @@ class BaseViewManagerTest { Assertions.assertThat(performAccessibilityClick()).isEqualTo(1) } + @Test + fun testRemainingAccessibilityActionsDoNotTakeTheAccessibilityClick() { + applyAccessibilityActions( + JavaOnlyArray.of(JavaOnlyMap.of("name", "activate"), JavaOnlyMap.of("name", "longpress")) + ) + view.createAccessibilityNodeInfo() + applyAccessibilityActions(JavaOnlyArray.of(JavaOnlyMap.of("name", "longpress"))) + + Assertions.assertThat(performAccessibilityClick()).isEqualTo(1) + } + @Test fun testRemovedAccessibilityActionsLeaveTheNodeInfo() { applyAccessibilityActions(