Repository navigation
Android: Fraunces last-line descenders clipped with tight lineHeight (0.86.0, candidate Kotlin regression tests) #59021
Description
Description
On React Native 0.86.0 Android, a Fraunces Light/Light Italic Text with fontSize 38 and lineHeight 42 clips the last line's descender even at system font scale 1.0. Our app owner observed the final italic "y" cut flat on Pixel 7 API 35. iOS is unaffected. The font hhea ascent/descent is 1956/-510 at 2000 units/em (natural height 1.233 em).
Related: #49886 and closed PR #57115. Filing this specific last-line/font-metrics case with the candidate patch and regression tests requested in our downstream investigation. This is not a claim that the proposed patch is ready to merge.
Reproduction
Load Fraunces Light and Light Italic (Google Fonts), then render on Android with system font scale 1.0 and 2.0:
<Text style={{fontFamily: 'Fraunces-Light', fontSize: 38, lineHeight: 42}} maxFontSizeMultiplier={1.35}>
Learn <Text style={{fontFamily: 'Fraunces-LightItalic'}}>visually</Text>
</Text>Also test wrapped text ending in gyp and compare with lineHeight omitted. Expected: complete final descender. Actual: final ink is cut flat. No crash logs.
Candidate patch and limits
CustomLineHeightSpan subtracts negative leading from descent and makes the last bottom equal that reduced descent. Preserving the original last descent is a candidate fix. Known limitation: repeated spans from nested Text can reapply the adjustment; this needs paragraph-level/idempotency handling and nested-span coverage before merging. API 27-and-earlier behavior also needs validation. We deferred shipping this native candidate downstream.
The attached-in-body Kotlin tests and patch were prepared against 0.86.0; they have NOT been executed in Gradle in this session. Device observations above are owner-provided, not fresh execution by the reporting agent.
Candidate Kotlin patch
diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt b/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt
--- a/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt
+++ b/ReactAndroid/src/main/java/com/facebook/react/views/text/internal/span/CustomLineHeightSpan.kt
@@ -40,7 +40,12 @@
val leading = lineHeight - ((-fm.ascent) + fm.descent)
fm.ascent -= ceil(leading / 2.0f).toInt()
- fm.descent += floor(leading / 2.0f).toInt()
+ // Android clips drawing at the measured paragraph bottom. Keep the font's
+ // descent on the final line when leading is negative; interior line boxes
+ // still overlap, so the requested baseline spacing is unchanged.
+ if (end != text.length || leading >= 0) {
+ fm.descent += floor(leading / 2.0f).toInt()
+ }
// The top of the first line, and the bottom of the last line, may influence bounds of the
// paragraph, so we match them to the text ascent/descent. It is otherwise desirable to allowCustomLineHeightSpanTest.kt (proposed regression coverage)
package com.facebook.react.views.text.internal.span
import android.graphics.Paint.FontMetricsInt
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, sdk = [33])
class CustomLineHeightSpanTest {
private fun metrics() = FontMetricsInt().apply {
top = -42
ascent = -38
descent = 10
bottom = 12
}
@Test
fun tightFinalLinePreservesFontDescent() {
val fm = metrics()
CustomLineHeightSpan(42f).chooseHeight("gyp\ngyp", 4, 7, 0, 42, fm)
assertEquals(-35, fm.ascent)
assertEquals(10, fm.descent)
assertEquals(10, fm.bottom)
assertEquals(-42, fm.top)
}
@Test
fun tightSingleLinePreservesFontDescent() {
val fm = metrics()
CustomLineHeightSpan(42f).chooseHeight("gyp", 0, 3, 0, 0, fm)
assertEquals(-35, fm.ascent)
assertEquals(-35, fm.top)
assertEquals(10, fm.descent)
assertEquals(10, fm.bottom)
}
@Test
fun interiorLinesKeepDesignedLeadingAndFontBounds() {
val fm = metrics()
CustomLineHeightSpan(42f).chooseHeight("gyp\ngyp\ngyp", 4, 8, 0, 42, fm)
assertEquals(42, fm.descent - fm.ascent)
assertEquals(-35, fm.ascent)
assertEquals(7, fm.descent)
assertEquals(-42, fm.top)
assertEquals(12, fm.bottom)
}
@Test
fun firstLineKeepsDesignedLeading() {
val fm = metrics()
CustomLineHeightSpan(42f).chooseHeight("gyp\ngyp", 0, 4, 0, 0, fm)
assertEquals(42, fm.descent - fm.ascent)
assertEquals(-35, fm.top)
assertEquals(12, fm.bottom)
}
@Test
fun nonNegativeLeadingIsUnchanged() {
for (height in listOf(48f, 53f)) {
val fm = metrics()
CustomLineHeightSpan(height).chooseHeight("gyp", 0, 3, 0, 0, fm)
assertEquals(height.toInt(), fm.descent - fm.ascent)
assertEquals(if (height == 48f) 10 else 12, fm.descent)
assertEquals(fm.descent, fm.bottom)
assertEquals(fm.ascent, fm.top)
}
}
@Test
fun fractionalHeightAndOddNegativeLeadingKeepRounding() {
val fm = metrics()
CustomLineHeightSpan(42.2f).chooseHeight("gyp", 0, 3, 0, 0, fm)
assertEquals(-36, fm.ascent)
assertEquals(10, fm.descent)
assertEquals(10, fm.bottom)
}
}
Activity
react-native-bot commentedon Oct 13, 2026
Warning
Missing reproducer: We could not detect a reproducible example in your issue report. Reproducers are mandatory and we can accept only one of those as a valid reproducer:
You can read more about about it on our website: How to report a bug.
sammydynamo-dev commentedon Oct 13, 2026
Correction, and closing this.
This report was made against React Native 0.86.0, and it described #57115 as a closed PR. That was wrong: #57115 was merged (commit bb3c121, 2026-07-03), it ships in 0.87.0, and it fixed #49886, which is the same class of symptom (descenders on the last line clipped when
lineHeightis tight).This Fraunces case has not been retested on 0.87 or later, and the Kotlin sketch above was never built or run, so please disregard it. I am closing this as a duplicate of #49886 rather than leave an unverified report open. If the clip still reproduces on a current release, I will come back with a proper reproducer.
Apologies for the noise.
(Posted by an AI coding agent on behalf of the account owner.)