Skip to content

Fabric flattens a View whose accessibility props iOS and Android read from a container: label, role, state, actions, escape #59816

Open

Description

@faciledictu

Description

ViewShadowNode::initialize keeps a host view for a short list of accessibility props: accessible, accessibilityElementsHidden, accessibilityViewIsModal, accessibilityLiveRegion (#59335), importantForAccessibility and accessibilityOrder (ViewShadowNode.cpp#L44-L63). A View with any other accessibility prop and nothing else that keeps it (no style, testID or nativeID) is flattened, and the prop is lost with it.

That is right for props that only matter on an accessibility element, but iOS and Android read several props from a view that is not one:

# Prop Platform What the container does with it Flattened
1 accessibilityLabel Android Becomes contentDescription; TalkBack reads a node with text and no focusable ancestor The label is not read, only the children's text
2 accessibilityLabel inside an accessible ancestor iOS, Android The ancestor builds its label from its subviews The ancestor's label is built from the child's text instead
3 accessibilityRole / role tabbar iOS The TabBar trait goes on the container of the tabs VoiceOver does not announce the tab position ("1 of 2")
4 onAccessibilityEscape iOS Escape goes up from the focused element to the first handler Escape inside the View runs another handler on screen, or nothing
5 accessibilityShowsLargeContentViewer iOS The interaction serves the elements inside Long press at the largest text sizes shows nothing
6 accessibilityActions iOS UIKit passes custom actions to the elements inside The children have no custom actions
7 role="heading" Android The delegate marks the container as a heading No heading node
8 aria-checked Android The delegate makes the container checkable No checkable node
10 accessibilityIgnoresInvertColors iOS Smart Invert skips the subviews The subviews are inverted
13 aria-disabled, aria-selected, aria-expanded, aria-checked={false} Android The delegate adds the state to the container No node with the state

onAccessibilityMagicTap goes up the hierarchy like escape and has the same problem once the prop reaches native (it does not today on iOS, see #59530).

The old architecture kept these views: iOS Paper did not flatten, and on Android no accessibility prop is in ViewProps.LAYOUT_ONLY_PROPS. So this is a regression of the new architecture, not a missing feature.

The Fantom test does not unflatten view for accessibilityLabel (View-itest.js#L599) encodes case 1, and the tests for accessibilityRole and accessibilityActions encode 3, 6 and 7. #59329 was the same bug for accessibilityLiveRegion.

Props that do not need the view, checked in the reproducer:

  • accessibilityHint (11) and accessibilityLanguage (12): neither platform reads them from a container that is not an element.
  • accessibilityValue.text, aria-busy and aria-checked="mixed" (9): Android reads them from a container like the label, but updateViewContentDescription puts them into contentDescription, which then replaces the children's text (a kept View with aria-busy is read as "busy"). Keeping the view for them should wait for that bug, which I will file separately.

I will open a PR that adds the props in the table to the formsStackingContext condition and turns the Fantom tests above into unflattens view.

Related: #46098 (an ancestor's label built from its subviews, iOS) and #50123, which lists tabbar as having no effect on iOS; on a View that is kept, it does.

Steps to reproduce

  1. Run the reproducer from ReproducerApp: yarn install, then yarn android or yarn ios.
  2. Each section pairs the View under test with the same View and collapsable={false} as the control. Compare them with TalkBack, uiautomator dump or A11yProbe (in the reproducer) on Android, and with VoiceOver and Accessibility Inspector on iOS. Sections 3, 4, 5 and 10 need VoiceOver or Smart Invert on an iPhone.

Expected: the View under test behaves like its control.

Actual, Samsung SM-A276B (Android 16, TalkBack 16.2):

# Flattened Control
1 No node carries "Cart L1"; TalkBack reads "3 items L1" Node with content-desc="Cart L2"; TalkBack reads "Cart L2"
2 The button reads "Multiplied by P1" (from "× P1") The button reads "Close dialog P2"
7 No heading node A node with heading=true
8 No checkable node A node with checkable=true checked=true
13 No node with enabled=false, selected=true, the collapse action or checkable=true checked=false D2, S2, X2 and W6 have them

Actual, iPhone 11 Pro (iOS 27.0.1) and iOS 27.0 simulator:

# Flattened Control
2 Label "× P1" Label "Close dialog P2"
3 No container above the tabs (Inspector, Hierarchy); VoiceOver reads "Tab K1" VoiceOver reads "Tab K3 (control), tab, 1 of 2"
4 Escape on E1 runs E2's handler Escape on E2 runs E2's handler
5 Long press shows nothing Long press shows the viewer with "Home H2"
6 The child Q1 has no Archive action The child Q2 lists Archive, and it runs the parent's handler
10 Smart Invert inverts the square The square stays red

Case 4 in detail: VoiceOver first tries the focused element's own chain; when nothing there handles escape, it runs the first handler on screen, wherever the scrub is done, and with none on screen nothing runs. So in a screen with two dialogs, escape inside the flattened one closes the other.

React Native Version

0.88.0

Affected Platforms

Runtime - Android, Runtime - iOS

Output of npx @react-native-community/cli info

Same as in #59377.

Stacktrace or Logs

No crash or warning.

MANDATORY Reproducer

https://github.com/faciledictu/rn-a11y-flattening-repro

Screenshots and Videos

The tables above; the reproducer's README has the result for each section.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions