Skip to content

Fix Android 15 text clipping with safe visual bounds measurement - #57117

Open
TorinAsakura wants to merge 5 commits into
react:mainfrom
torin-asakura:fix/android-15-text-clipping
Open

TorinAsakura wants to merge 5 commits into
react:mainfrom
torin-asakura:fix/android-15-text-clipping

Conversation

@TorinAsakura

@TorinAsakura TorinAsakura commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Summary:

Fixes #56402.
Fixes #57110.
Close torin-asakura/workspace#116.
Close torin-asakura/workspace#117.

Android 15+ can clip text whose visual glyph bounds are wider than its advance-based desired width. The previous visual-bounds path fixed that by enabling setUseBoundsForWidth(true) on the final StaticLayout, but that path also changed final layout behavior and was later removed after multiline and wrapping regressions.

This keeps the final layout on the existing advance-based behavior, but uses an Android 15+ visual-bounds probe only when React Native needs to compute the desired width for AT_MOST or UNDEFINED measure modes. EXACTLY continues to use the width from Yoga directly. The desired width is never allowed to shrink below the old advance-based width.

Changelog:

[ANDROID] [FIXED] - Prevent Android 15+ text clipping without changing final StaticLayout bounds mode

Test Plan:

./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests com.facebook.react.views.text.TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest -Preact.internal.useHermesStable=true
BUILD SUCCESSFUL
./gradlew :packages:react-native:ReactAndroid:ktfmtCheck -Preact.internal.useHermesStable=true
BUILD SUCCESSFUL

RNTester visual repro on a Pixel 7 API 35 emulator. Both runs used the same fixture and NotoSans font assets. The baseline is main at d6ba88e; the after screenshot uses the same fixture with this PR's fix applied locally.

Fixture:

Details

const reporterFontsData = [
  {name: 'extraLight', suffix: '200ExtraLight', weight: '200'},
  {name: 'extraLightItalic', suffix: '200ExtraLight_Italic', weight: '200'},
  {name: 'light', suffix: '300Light', weight: '300'},
  {name: 'lightItalic', suffix: '300Light_Italic', weight: '300'},
  {name: 'regular', suffix: '400Regular', weight: '400'},
  {name: 'regularItalic', suffix: '400Regular_Italic', weight: '400'},
  {name: 'medium', suffix: '500Medium', weight: '500'},
  {name: 'mediumItalic', suffix: '500Medium_Italic', weight: '500'},
  {name: 'semiBold', suffix: '600SemiBold', weight: '600'},
  {name: 'semiBoldItalic', suffix: '600SemiBold_Italic', weight: '600'},
  {name: 'bold', suffix: '700Bold', weight: '700'},
  {name: 'boldItalic', suffix: '700Bold_Italic', weight: '700'},
  {name: 'extraBold', suffix: '800ExtraBold', weight: '800'},
];

const reporterLocalFont = (family: string = 'NotoSans') => {
  const fonts = {};

  for (const {suffix, name} of reporterFontsData) {
    const localSuffix = suffix.replace(/^\d+/, '').replace('_Italic', 'Italic');
    fonts[name] = {
      fontFamily: `${family}-${localSuffix}`,
      fontWeight: undefined,
      ...(suffix.includes('_Italic') && {fontStyle: 'italic'}),
    };
  }

  return fonts;
};

const reporterNotoSansFonts = reporterLocalFont('NotoSans');
const reporterFontSizes = [10, 12, 14, 16, 18, 20, 22, 24];

const reporterReproStyles = StyleSheet.create({
  safeArea: {
    backgroundColor: 'white',
    flex: 1,
  },
  container: {
    gap: 8,
    padding: 16,
  },
  section: {
    gap: 8,
  },
  subtitle: {
    fontSize: 20,
    fontWeight: '600',
    marginBottom: 4,
  },
  body: {
    color: '#444',
    fontSize: 14,
  },
  fontTitle: {
    fontSize: 18,
    fontWeight: 'bold',
    marginBottom: 8,
  },
  fontSubtitle: {
    color: '#666',
    fontSize: 16,
    fontWeight: '600',
    marginBottom: 4,
  },
});

{
  title: 'Android #56402 reporter parity',
  name: 'androidTextClippingRepro',
  render: function (): React.Node {
    return (
      <SafeAreaView style={reporterReproStyles.safeArea}>
        <ScrollView contentInsetAdjustmentBehavior="automatic">
          <View style={reporterReproStyles.container}>
            <View style={reporterReproStyles.section}>
              <Text style={reporterReproStyles.subtitle}>Font Showcase</Text>
              <Text style={reporterReproStyles.body}>
                Here are all the custom fonts configured in this app with
                different sizes (10-24px):
              </Text>
              <Text style={reporterReproStyles.fontTitle}>
                NotoSans Local Fonts:
              </Text>
              {reporterFontsData.map(fontData => (
                <React.Fragment key={fontData.name}>
                  <Text style={reporterReproStyles.fontSubtitle}>
                    {fontData.suffix
                      .replace(/^\d+/, '')
                      .replace('_Italic', '')}{' '}
                    ({fontData.weight})
                    {fontData.suffix.includes('_Italic') ? ' Italic' : ''}:
                  </Text>
                  <ScrollView
                    horizontal
                    style={{flexDirection: 'row', marginBottom: 16}}>
                    {reporterFontSizes.map(size => (
                      <View
                        key={`${fontData.name}-${size}`}
                        style={{
                          maxWidth: '100%',
                          minWidth: 48,
                          paddingHorizontal: 8,
                        }}>
                        <Text
                          style={{
                            ...reporterNotoSansFonts[fontData.name],
                            fontSize: size,
                            lineHeight: size * 1.75,
                          }}>
                          Prison Break
                        </Text>
                      </View>
                    ))}
                  </ScrollView>
                </React.Fragment>
              ))}
            </View>
          </View>
        </ScrollView>
      </SafeAreaView>
    );
  },
}

Visual evidence:

Before

Before RNTester screenshot on Pixel 7 API 35

After

After RNTester screenshot on Pixel 7 API 35

@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 Jun 8, 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 Jun 8, 2026
@joaquinvaz

Copy link
Copy Markdown

This should be merged ASAP!

@Yokool

Yokool commented Jul 20, 2026

Copy link
Copy Markdown

I don't have enough experience to speak about whether the code itself is correct

but I tested out the code by patching it into my application and it fixes both the issue where a part of the text is cut off and replaced by empty space and the issue where the text is visible but it's split into 2 lines even though it shouldn't be and there is enough space for it 👍

@linhvovan29546

Copy link
Copy Markdown

@chrfalch Can anyone please take a look at this PR? Thanks

@j-piasecki j-piasecki 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.

Could you post before & after screenshots along with the code you used to test?

Heave you checked if this is still needed as of React Native 0.87?

// rare subpixel edge case.
val layoutWidth =
if (widthYogaMeasureMode == YogaMeasureMode.EXACTLY) ceil(width).toInt() else boring.width
return BoringLayout.make(

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.

What about boring layouts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

After the order swap (layoutWidth being declared before BoringLayout.make early return), the BoringLayout fast path is no longer fast. It requires a StaticLayout build (buildLayout inside getDesiredWidth) in addition to the BoringLayout.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

After the order swap (layoutWidth being declared before BoringLayout.make early return), the BoringLayout fast path is no longer fast. It requires a StaticLayout build (buildLayout inside getDesiredWidth) in addition to the BoringLayout.

I think we can avoid the extra StaticLayout here. BoringLayout.isBoring already gives us both the advance width and the drawing bounds from the same TextLine.metrics call, and TextView uses those bounds directly when it measures boring text. So for this path we could calculate the width from BoringLayout.Metrics and keep the fast path, while leaving non-boring text on the current probe path.

The catch is that [this code is still compiled internally against SDK 34](

/**
* This is the version code for Android 15 (SDK Level 35). Internally at Meta this code is also
* compiled against SDK 34, so we need to retain this constant instead of using
* [Build.VERSION_CODES.VANILLA_ICE_CREAM] directly.
*/
internal const val VERSION_CODE_VANILLA_ICE_CREAM: Int = 35
/**
* This is the version code for Android 16 (SDK Level 36). Internally at Meta this code is also
* compiled against SDK 34, so we need to retain this constant instead of using
* [Build.VERSION_CODES.BAKLAVA] directly.
*/
internal const val VERSION_CODE_BAKLAVA: Int = 36
), while Metrics.getDrawingBoundingBox only exists since API 35. Is that still true on your side? If it is, a direct call won’t compile there even with a runtime check. I guess we’d either need to look up this one method the same way setUseBoundsForWidth is already looked up, or wait until that internal target can compile against 35. Which way would work better for you?

The current boring test would need changing too. Right now it sets Metrics.width to 1 and checks that the probe wins, so it basically locks us into the extra layout. I’d replace it with cases around the actual bounds, the Yoga modes, and the API 34 fallback.

There’s also the left overhang. Public BoringLayout.make doesn’t enable shiftDrawingOffsetForStartOverhang, so using the bounds would reserve enough width and fix the right edge, but it wouldn’t move a glyph that starts left of zero. I’d leave that out of this change unless you expect this PR to match TextView on that side too.

If that direction makes sense, I’ll rework the patch and tests around it.

@TorinAsakura

TorinAsakura commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Could you post before & after screenshots along with the code you used to test?

Heave you checked if this is still needed as of React Native 0.87?

I checked the React Native 0.87.1 and i see TextLayoutManager still use the unconstrained width through Layout.getDesiredWidth, without accounting for Android 15

I also reproduced the issue on the latest main on Android 15/API 35 and the NotoSans matrix from the original reproducer. The baseline clips or unnecessarily wraps the smaller italic cells, while the same fixture with this PR applied keeps “Prison Break” on one line.

Before:

Details

Изображение Codex 27 авг  2026 г , 17_56_40

After:

Details

Изображение Codex 27 авг  2026 г , 17_56_31

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
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] Parts of Text disappear due to incorrect Text rendering cut off on Android 15 & 16

5 participants