Skip to content

Fix Android Text accessibility leaking style spans#56881

Open
danyalahmed1995 wants to merge 2 commits into
facebook:mainfrom
danyalahmed1995:fix/text-talkback-style-leak
Open

Fix Android Text accessibility leaking style spans#56881
danyalahmed1995 wants to merge 2 commits into
facebook:mainfrom
danyalahmed1995:fix/text-talkback-style-leak

Conversation

@danyalahmed1995
Copy link
Copy Markdown

@danyalahmed1995 danyalahmed1995 commented May 18, 2026

Summary

Fixes #56873.

This also addresses the underlying Android <Text> accessibility issue previously reported in #55150.

On Android, TalkBack could announce React Native <Text> styling metadata as part of the accessible name. For example, a plain text node could be announced with implementation details such as pixel size and color before the actual visible text.

The root cause is that the host accessibility node was receiving the rendered Spanned text object. React Native text layout attaches visual spans to that object, such as absolute size and color spans. Those spans are correct for rendering, but they should not become part of the host node's spoken accessibility text.

This PR converts the host accessibility node text to plain text when the value is Spanned, while leaving the rendered/source text untouched.

Background / relation to previous reports

#55150 reported the same Android TalkBack behavior on React Native 0.78.2. That issue was later closed as Type: Too Old Version, but the underlying Android accessibility delegate path still exposed styled Spanned text to the accessibility node.

#56873 confirms the issue is still reproducible on a current React Native version, 0.85.3, using a minimal <Text> example with no custom styles.

#56872 provides a reproducer for the current issue, but it does not change the Android accessibility code path.

This PR fixes the root cause directly in ReactTextViewAccessibilityDelegate rather than relying on app-level labels or repro-only changes.

What changed

ReactTextViewAccessibilityDelegate now normalizes the host node's accessibility text:

  • if the accessibility text is a Spanned, expose toString() to AccessibilityNodeInfoCompat.text
  • otherwise preserve the original CharSequence
  • preserve the existing PreparedLayoutTextView path
  • preserve explicit contentDescription / accessibility label behavior

The important part is that this only affects the accessibility node text. It does not mutate the rendered text.

Why this is safe

The fix only changes the value assigned to AccessibilityNodeInfoCompat.text.

It does not:

  • call setText() on the host view
  • mutate TextView.text
  • remove spans from rendered text
  • affect text layout or measurement
  • affect visual styling
  • affect hit-testing
  • remove source ClickableSpans
  • change role, state, hint, actions, or virtual-view behavior

Inline link behavior is preserved because the link/accessibility virtual view logic still reads from the original source Spanned text on the host view / prepared layout text.

Specifically:

  • accessibility links are still built from the rendered/source Spanned
  • getVirtualViewAt() still finds ClickableSpans from the source text
  • onPerformActionForVirtualView() still calls span.onClick(hostView)
  • keyboard focus handling still updates the source clickable span focus state or prepared-layout selection
  • virtual link nodes still expose their descriptions, bounds, click action, role description, and class name

So this strips visual/style spans only from the host node's spoken text, not from rendering, link data, virtual links, or click actions.

Tests

Added ReactTextViewAccessibilityDelegateTest coverage for:

  • stripping style spans from Android <Text> accessibility node text
  • stripping style spans from prepared-layout text accessibility node text
  • preserving explicit contentDescription
  • preserving source clickable/style spans on the rendered text

Verification

  • Manual TalkBack smoke test on OPPO CPH2219 / Android 13 using RNTester debug build from this PR branch.

Ran the focused Android unit test:

./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests com.facebook.react.views.text.ReactTextViewAccessibilityDelegateTest --rerun-tasks

Verified fail-before / pass-after behavior:

with only the production fix temporarily reverted, the focused test failed
after restoring the production fix, the same focused test passed
reran with --rerun-tasks to avoid relying on cached results

The old implementation failed because nodeInfo.text was still a Spanned.

Additional checks:

./gradlew :packages:react-native:ReactAndroid:ktfmtCheckMain :packages:react-native:ReactAndroid:ktfmtCheckTest
git diff --check

Manual TalkBack smoke validation was later performed on a physical Android device:

  • Device: OPPO CPH2219
  • Android: 13
  • RNTester debug build from this PR branch
  • TalkBack enabled
  • Metro running over USB with adb reverse tcp:8081 tcp:8081

Validated that RNTester remained navigable with TalkBack focus across Components, APIs, Playground, Image, and AccessibilityAndroid examples, including styled/text-heavy and clickable/link-style examples. Screenshots and a sanitized log were attached in #56873.

Changelog:

[Android] [Fixed] - Prevent TalkBack from announcing React Native Text style spans as Android Text accessibility text.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label May 18, 2026
@github-actions
Copy link
Copy Markdown

Warning

JavaScript API change detected

This PR commits an update to ReactNativeApi.d.ts, indicating a change to React Native's public JavaScript API.

  • Please include a clear changelog message.
  • This change will be subject to additional review.

This change was flagged as: POTENTIALLY_BREAKING

@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label May 18, 2026
@meta-codesync
Copy link
Copy Markdown

meta-codesync Bot commented May 19, 2026

@javache has imported this pull request. If you are a Meta employee, you can view this in D105680819.

@javache
Copy link
Copy Markdown
Member

javache commented May 19, 2026

This regresses accessibility for single-link elements where developers haven't explicitly set accessibilityRole="link" — TalkBack won't provide link earcons or link navigation for these nodes.

When the entire text is a single ClickableSpan (isWholeTextSingleLink returns true), no virtual link nodes are created and TalkBack relies on the ClickableSpan in info.text to identify the node as a link — this change strips that span, so TalkBack will no longer announce these elements as links.

@danyalahmed1995
Copy link
Copy Markdown
Author

@javache Yes, you are absolutely right. I missed the whole-text single-link path where isWholeTextSingleLink avoids creating virtual link nodes and TalkBack relies on the ClickableSpan still being present in info.text.

I agree the fix should be narrower than converting the accessibility node text to a plain String.

One safer direction could be to preserve semantic/action spans such as ClickableSpan, while stripping only visual/presentation spans that can leak into TalkBack output, such as size/color/style spans. That should still prevent TalkBack from announcing styling metadata while preserving link semantics for whole-text single-link nodes.

Does that sound like the right direction? If so, I can update the PR and add coverage for the whole-text single-link case.

@uloco

This comment was marked as low quality.

@danyalahmed1995

This comment was marked as off-topic.

Sanitize Android Text accessibility node text by removing known visual spans while preserving ClickableSpan/URLSpan semantics. This avoids leaking style metadata to TalkBack without regressing whole-text single-link accessibility.
@danyalahmed1995
Copy link
Copy Markdown
Author

@javache Thank you for pointing me in the right direction. I updated the PR to avoid converting Spanned accessibility text to a plain String.

The new approach creates an accessibility-only SpannableString copy and removes only an allowlisted set of visual/presentation spans, while preserving ClickableSpan/URLSpan semantics. This should address the whole-text single-link path where isWholeTextSingleLink returns true and TalkBack relies on the span in info.text.

I also added coverage for:

  • whole-text single ClickableSpan preservation
  • mixed clickable + visual spans
  • source text remaining untouched
  • prepared-layout text sanitization

I verified fail-before/pass-after against the previous plain-string implementation, and reran the focused Android accessibility delegate test plus formatting/diff checks.

@javache
Copy link
Copy Markdown
Member

javache commented May 20, 2026

Does the original issue repro on your device?

@danyalahmed1995
Copy link
Copy Markdown
Author

I was not able to reproduce the exact original spoken output on this OPPO/Android 13 device before the PR fix.

What I validated on the physical device was a smoke test of the PR build: RNTester remained navigable with TalkBack enabled across the text-heavy/styled examples, clickable/link-style examples, Image, APIs, Playground, and AccessibilityAndroid screens, and the app did not crash or lose focus behavior.

The deterministic repro/verification for the original issue is covered by the focused unit tests: with the production sanitizer reverted to the previous plain/old behavior, the style-span leak tests fail because nodeInfo.text still carries visual spans; with the sanitizer restored, visual spans are removed while ClickableSpan/URLSpan semantics are preserved.

So the physical device test was mainly for TalkBack smoke/safety, while the unit tests cover the original style-span leak and the whole-text single-link regression concern.

Copy link
Copy Markdown
Member

@javache javache left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not confident that this is the right fix, given that the original root cause is not well understood, and the manual mapping of all the different Span types is very error-prone and likely to break int he future.

Can you research if AOSP does something similar in the default accessibility implementation?

@danyalahmed1995
Copy link
Copy Markdown
Author

@javache I looked into AOSP’s default TextView / AccessibilityNodeInfo path.

From what I found, TextView.onInitializeAccessibilityNodeInfoInternal() calls:

info.setText(getTextForAccessibility());

and getTextForAccessibility() returns the displayed/transformed text via:

TextUtils.trimToParcelableSize(mTransformed)

So TextView itself does not appear to maintain a manual allowlist of visual spans.

The span normalization seems to happen inside AccessibilityNodeInfo.setText(CharSequence). If the text is Spanned, it stores mOriginalText, then calls replaceClickableSpan(...) and replaceReplacementSpan(...) before assigning mText.

AccessibilityNodeInfo.getText() also documents that if the text contains ClickableSpan or URLSpan, those spans are replaced with accessibility-specific span wrappers.

So AOSP appears to avoid a broad visual-span allowlist and instead normalizes accessibility-relevant spans, especially clickable/url spans and replacement spans.

Given that, I agree the manual span allowlist in my current revision is probably too fragile. Should I look for a React Native-side approach that better matches this AOSP model rather than maintaining a list of visual span classes ? Or If you have any better suggestions kindly let me know

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

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android Talkback reads text styles

3 participants