Skip to content

fix(android): Prevent text cut-off by clamping lineHeight to font height - #56864

Open
aswinandro wants to merge 1 commit into
react:mainfrom
aswinandro:fix-56402/android-text-cutoff-lineheight
Open

aswinandro wants to merge 1 commit into
react:mainfrom
aswinandro:fix-56402/android-text-cutoff-lineheight

Conversation

@aswinandro

@aswinandro aswinandro commented May 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On Android 15 & 16, text was being cut off when lineHeight was set smaller than the font’s ascent + descent, due to changes in the platform’s text rendering.
This patch updates CustomLineHeightSpan to always ensure the line box is at least as large as the font’s required height, and sets fm.top/fm.bottom to match the adjusted ascent/descent for all lines.
This prevents descenders and ascenders from being clipped, matching native TextView and web behavior, and does not affect other text features or layouts.
Fixes #56402, #53286, #56402

Changelog:

[Android] [Fixed] - Prevent text cut-off when lineHeight is less than font size by clamping line height to font metrics in CustomLineHeightSpan. This ensures text is never clipped on Android 15 & 16 and matches native TextView/web behavior. Fixes #56402, #53286, 56402

For more details, see:
https://reactnative.dev/contributing/changelogs-in-pull-requests

Test Plan

  • Render text with various combinations of fontSize and lineHeight, including cases where lineHeight < fontSize.
  • Verify that no text is clipped on Android 15 & 16, especially for characters with descenders (e.g., “g”, “y”, “p”).
  • Confirm that text renders correctly on older Android versions and that layouts are unaffected for normal line heights.
  • Run existing text rendering tests and visual regression tests to ensure no regressions.

Example test code:

<Text style={{fontSize: 24, lineHeight: 8, borderWidth: 1, borderColor: 'red'}}>
  gyqp
</Text>

On Android 15 & 16, text was being cut off when lineHeight was set smaller than the font’s ascent + descent, due to changes in the platform’s text rendering.
This patch updates CustomLineHeightSpan to always ensure the line box is at least as large as the font’s required height, and sets fm.top/fm.bottom to match the adjusted ascent/descent for all lines.
This prevents descenders and ascenders from being clipped, matching native TextView and web behavior, and does not affect other text features or layouts.
Fixes react#56402, react#53286.
@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 17, 2026
@aswinandro aswinandro closed this May 17, 2026
@aswinandro aswinandro reopened this May 17, 2026
@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 17, 2026
@pangziqiang

Copy link
Copy Markdown

Additional reproduction data from Xiaomi devices, for confirmation:

  • RN 0.78.3 Fabric/Bridgeless, Android 16 (API 36), Xiaomi HyperOS with MiSans Pro Bold (system-wide bold font).
  • With lineHeight smaller than the font's natural height, descenders of g/j/p/q/y and digits were clipped.
  • A local patch that preserves the font descender when compressing line height fixes the issue.
  • The approach in this PR (clamping lineHeight to font height / keeping line box at least as large as font metrics) looks aligned with what we verified on device.

This comment was added with the assistance of an AI coding agent; please treat the data as a supplementary reproduction note.

@meta-codesync

meta-codesync Bot commented Aug 24, 2026

Copy link
Copy Markdown

@j-piasecki has imported this pull request. If you are a Meta employee, you can view this in D117161924.

@j-piasecki

Copy link
Copy Markdown
Contributor

This change makes it impossible to set line height lower than the font size.

meta-codesync Bot pushed a commit that referenced this pull request Sep 11, 2026
…id 15+ so the last line is not clipped (#58280)

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`:

```java
// 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.

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)

Pull Request resolved: #58280

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:

```tsx
<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](https://raw.githubusercontent.com/idoyana/react-native/pr-assets/android-text-line-breaking/before-cursive-api36.png)

**After** (same example, this branch):

![after](https://raw.githubusercontent.com/idoyana/react-native/pr-assets/android-text-line-breaking/after-cursive-api36.png)

### 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](https://claude.com/claude-code)

Reviewed By: christophpurrer

Differential Revision: D118632937

Pulled By: javache

fbshipit-source-id: bb60e5549132c22d635c7ef96a9dba940e61b8e1

This branch has not been deployed

No deployments
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

3 participants