Skip to content

fix(Android, Tabs): expose tab item testID as viewIdResourceName - #4497

Open
mackbrowne wants to merge 1 commit into
software-mansion:mainfrom
mackbrowne:fix/android-tab-item-view-id-resource-name
Open

fix(Android, Tabs): expose tab item testID as viewIdResourceName#4497
mackbrowne wants to merge 1 commit into
software-mansion:mainfrom
mackbrowne:fix/android-tab-item-view-id-resource-name

Conversation

@mackbrowne

Copy link
Copy Markdown

Description

Closes #4496.

A bottom tab item's testID is written to the view's tag property only:

// when matching view by id, espresso driver seems to look for tag property
findTabItemView(menuItem.itemId)?.tag = tabsScreen.tabBarItemTestID

As the comment says, that works for Espresso. But UiAutomator-based drivers — Maestro and Appium — match on AccessibilityNodeInfo.getViewIdResourceName() (exposed as resource-id) and never read the view tag. For them the id is invisible, and a selector silently matches nothing rather than failing loudly, which reads as a flaky tap.

This is inconsistent with the rest of React Native. ReactAccessibilityDelegate does info.viewIdResourceName = testId for every ordinary view, which is why id selectors work everywhere else in an RN app on Android but stop working on native tab items specifically.

Worth noting the intent behind the prop: the JS-side counterpart, expo/expo#47472, added testID to NativeTabs.Trigger explicitly "so native tabs can be matched in end-to-end tests". On Android that currently only holds for Espresso.

Changes

  • TabsHostA11yCoordinator.setA11yPropertiesToTabItem now also sets viewIdResourceName on the tab item's accessibility node, via a AccessibilityDelegateCompat.
  • The existing tag assignment is kept, so Espresso is unaffected.
  • super.onInitializeAccessibilityNodeInfo is called first, so Material's own node population — including the contentDescription and the "tab, N of M" announcement — is preserved.
  • The delegate reads tabsScreen.tabBarItemTestID at node-init time rather than capturing it, so a prop update is reflected without reinstalling the delegate.

No public API change, and no visual change, so the visual documentation section is omitted.

Test plan

Verified on a Pixel 8 emulator (API 34) against react-native-screens 4.26.2 applied as a patch, with expo-router 57.0.11 unstable-native-tabs and Maestro 2.6.0. The same change against main's shape is what this PR contains; ktlint 1.3.0 (matching android/spotless.gradle) reports clean on the modified file.

Minimal example:

import { NativeTabs } from 'expo-router/unstable-native-tabs';

export default function Layout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name="index" testID="tab-home">
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
        <NativeTabs.Trigger.Icon md="home" />
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name="search" testID="tab-search">
        <NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
        <NativeTabs.Trigger.Icon md="search" />
      </NativeTabs.Trigger>
    </NativeTabs>
  );
}

Inspect the accessibility tree, no test framework required:

adb shell uiautomator dump /sdcard/ui.xml && adb shell cat /sdcard/ui.xml | tr '>' '>\n' | grep navigation_bar_item
tab item nodes
before no resource-id
after resource-id="tab-home" / resource-id="tab-search"

In both cases content-desc still holds the label and selected still tracks the active tab, so the screen-reader behaviour is unchanged. Equivalently, a Maestro step - tapOn: { id: "tab-search" } matches nothing before the change and taps the tab after it; in a real suite all four tabs of an app shell flow now tap by id.

I have not been able to run the repo's own Android e2e suite locally, so I would appreciate CI confirming that. Happy to add a Test*.tsx example under apps/src/tests if you would like one for this.

Checklist

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change. (no visual change)
  • For API changes, updated relevant public types. (no API change)
  • Ensured that CI passes (cannot run the Android e2e suite locally; relying on CI)

A bottom tab item's testID is written to the view's tag property only. That
works for Espresso, as the existing comment notes, but UiAutomator-based
drivers (Maestro, Appium) match on AccessibilityNodeInfo.viewIdResourceName
and never read the tag, so for them the id is invisible and selectors silently
match nothing.

This is inconsistent with the rest of React Native: ReactAccessibilityDelegate
sets info.viewIdResourceName = testId for every ordinary view, which is why id
selectors work everywhere else in an RN app on Android but not on native tab
items.

Mirror the testID onto the accessibility node while keeping the tag assignment,
so both driver families work. super is called first so Material's own node
population, including the contentDescription and the "tab, N of M"
announcement, is preserved.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Android] Bottom tab item testID is exposed only as a View tag, so UiAutomator-based drivers (Maestro, Appium) cannot match it

1 participant