Skip to content

fix(android): keep ReactTextView line breaking advance-based on Android 15+ so the last line is not clipped - #58280

Open
idoyana wants to merge 1 commit into
react:mainfrom
idoyana:fix/android-text-view-advance-line-breaking
Open

fix(android): keep ReactTextView line breaking advance-based on Android 15+ so the last line is not clipped#58280
idoyana wants to merge 1 commit into
react:mainfrom
idoyana:fix/android-text-view-advance-line-breaking

Conversation

@idoyana

@idoyana idoyana commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

On Android 15+ (API 35), an app that targets API 35+ gets bounds-based line breaking in every TextView by default — the platform compat change TextView#USE_BOUNDS_FOR_WIDTH:

// frameworks/base/core/java/android/widget/TextView.java
@ChangeId
@EnabledSince(targetSdkVersion = VERSION_CODES.VANILLA_ICE_CREAM)
public static final long USE_BOUNDS_FOR_WIDTH = 63938206;
…
if (!hasUseBoundForWidthValue) {
    mUseBoundsForWidth = CompatChanges.isChangeEnabled(USE_BOUNDS_FOR_WIDTH);
}

React Native measures <Text> in TextLayoutManager with a StaticLayout that breaks lines on glyph advances (buildLayout never sets setUseBoundsForWidth). With enablePreparedTextLayout off (the default), the pixels on screen come from ReactTextView's own TextView layout — ReactTextView.setText() hands the Spannable to TextView and onDraw() defers to super.onDraw(). That layout breaks lines on glyph bounds.

So measurement and painting disagree on where lines break. For any font whose ink overhangs its advance (script/cursive fonts, several OEM system fonts, emoji fallbacks), a line that fits at measure time can wrap at draw time. The extra line lands outside the Yoga-measured height and is simply never seen: the last word of a <Text> disappears, while the view is sized as if it were there.

This is the mechanism behind #56402 / #53286 (and the shape of #57957: content-sized parent, last line gone). It is independent of lineHeight, and it affects both shrink-wrapped single-line text and width-constrained wrapped paragraphs.

The fix

Opt ReactTextView out of bounds-based breaking so the drawn layout uses the same advance-based line breaking as measurement. Applied in the constructor and again in recycleView() so recycled views cannot drift. The call is resolved reflectively, following the existing setUseBoundsForWidth pattern in TextLayoutManager, because some internal targets compile against an SDK older than 35 (see AndroidVersion).

This keeps the final layout on the advance-based behavior React Native has always had — the same principle #57117 states for the layouts it builds — but applies it where the pixels actually come from. It is complementary to #57117: that PR widens the desired width for AT_MOST/UNDEFINED measurement, which does not reach a width-constrained paragraph whose lines are re-broken by the TextView at draw time; this change makes both paths agree regardless of constraint mode.

Trade-off: React Native forgoes Android 15's automatic reservation of overhang space at the edges of a line (glyph ink may be clipped at the view edge as it was before Android 15). That is the pre-existing behavior on every prior Android version, and strictly better than losing whole words. A follow-up could make measurement bounds-aware instead (platform parity), but that changes wrapping app-wide and was the direction of the reverted #54721/#54871.

Fixes #56402
Related: #53286, #57957, #57117, #56864

Changelog:

[ANDROID] [FIXED] - Text: the last line no longer disappears on Android 15+ when a font's glyphs overhang their advance (ReactTextView now breaks lines on advances, matching measurement)

Test Plan

Deterministic repro (stock emulator, no custom font)

API 35/36 AVD, app targeting API 35+. Android's generic cursive family (Dancing Script) overhangs heavily. Inside a shrink-wrapping container:

<View style={{ alignSelf: 'flex-start' }}>
  <Text style={{ fontFamily: 'cursive', fontSize: 18, lineHeight: 27 }} allowFontScaling={false}>
    Enjoy your coffee<Text style={{ color: 'green' }}> f</Text>
  </Text>
</View>

Before: the green f is not painted. The view is sized for it (measure), but the TextView breaks the line on bounds, wraps the f to a second line, and that line is outside the measured height. Which strings trip it depends on where the bounds-based break falls relative to the advance-based one — in the rn-tester example below two of the four cursive rows lose the f — while a control row with a non-overhanging font (Roboto) always keeps it.

After: the f is painted on the first line.

Before (rn-tester Text example, API 36 emulator — the cursive column loses its f on two of the four rows; the default-font control column keeps every one):

before

After (same example, this branch):

after

rn-tester

Text"Android 15+ glyph overhang (last line must not disappear)" — the rows above, cursive on the left with a default-font control on the right. Every row must show its green f.

Unit tests

ReactTextViewTest:

  • breaksLinesOnAdvancesLikeMeasurementOnApi35 — a freshly constructed ReactTextView reports useBoundsForWidth == false on API 35.
  • recyclingRestoresAdvanceBasedLineBreaking — after useBoundsForWidth = true, recycleView() restores false.

Below API 35 the reflective lookup returns null and the view is untouched.

Origin

Reported in production by a user on a Samsung SM-A566B (Android 16, One UI system font): trailing words vanished from chat messages while the message bubble was sized for the full text. Pinning a bundled font (Alef) in the app made it stop — consistent with the mechanism above — and the same symptom then reproduced on an AOSP emulator with the cursive family as shown here.

🤖 Generated with Claude Code

@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 Sep 2, 2026
…id 15+

On Android 15+ a TextView in an app targeting API 35+ breaks lines on
glyph bounds (TextView#USE_BOUNDS_FOR_WIDTH), while TextLayoutManager
measures with a StaticLayout that breaks on glyph advances. With
enablePreparedTextLayout off, ReactTextView draws its own TextView
layout, so a font whose ink overhangs its advance can wrap one more
line at draw than at measure; that line lands outside the measured
height and the last word disappears.

Opt ReactTextView out of bounds-based breaking, in the constructor and
again on recycle, so the painted line count matches the measured one.
Resolved reflectively like TextLayoutManager's own SDK-35 lookup, since
internal targets compile against an older SDK.

Adds an rn-tester Text example that reproduces the clipping with the
stock `cursive` family, and Robolectric tests for the view default and
the recycle path.

Fixes react#56402

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@idoyana
idoyana force-pushed the fix/android-text-view-advance-line-breaking branch from 8dc996d to b423735 Compare September 2, 2026 03:04
@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 Sep 2, 2026
@idoyana

idoyana commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

The single red check (test_e2e_android_templateapp_retry_2 / report) is the debug-flavor Maestro start flow timing out on "Welcome to React Native" (~25 s). It fails the same way on main without this change — e.g. run 33540863593 at ea291d7 failed all three attempts, and run 33564990107 at ebdd848 (this PR's base) failed and then passed on retry. The release leg passes on the first attempt here as well. I can't re-run the job from outside the org; happy to have it re-run or to re-push if that's preferred.

@javache javache left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do we need to use reflection if we have the Build version SDK check?

Do we need to upgrade the RN build SDK version? cc @cortinico

@idoyana

idoyana commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

The SDK_INT check only guards execution; TextView.setUseBoundsForWidth still has to resolve at compile time, and the internal build compiles ReactAndroid against SDK 34 (AndroidVersion.kt, and the minCompileSdk note in ReactAndroid/build.gradle.kts), where the symbol doesn't exist. #56118 (D95994030, reviewed by @cortinico) hit exactly this with StaticLayout.Builder.setUseBoundsForWidth and settled on the constant + reflection pattern, which is what I followed here. OSS compileSdk is already 37, so nothing needs bumping on this side. If the internal compile SDK moves to 35, I'm glad to replace the lookup with a direct call under the same guard.

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.

Text rendering cut off on Android 15 & 16

2 participants