Repository navigation
Description
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) andaccessibilityLanguage(12): neither platform reads them from a container that is not an element.accessibilityValue.text,aria-busyandaria-checked="mixed"(9): Android reads them from a container like the label, butupdateViewContentDescriptionputs them intocontentDescription, which then replaces the children's text (a kept View witharia-busyis 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
- Run the reproducer from
ReproducerApp:yarn install, thenyarn androidoryarn ios. - Each section pairs the View under test with the same View and
collapsable={false}as the control. Compare them with TalkBack,uiautomator dumpor 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