Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -618,9 +618,12 @@ internal object TextLayoutManager {
boring != null &&
(widthYogaMeasureMode == YogaMeasureMode.UNDEFINED || boring.width <= floor(width))
) {
// Guard uses floor() but layout width below uses ceil() for EXACTLY mode intentionally:
// text that barely fails the floor-based guard falls through to StaticLayout, which also
// ceils for EXACTLY — no wrapping results, just a slightly less optimal layout class in a
// rare subpixel edge case.
val layoutWidth =
if (widthYogaMeasureMode == YogaMeasureMode.EXACTLY) floor(width).toInt()
else boring.width
if (widthYogaMeasureMode == YogaMeasureMode.EXACTLY) ceil(width).toInt() else boring.width
return BoringLayout.make(
text,
paint,
Expand All @@ -637,7 +640,7 @@ internal object TextLayoutManager {

val layoutWidth =
when (widthYogaMeasureMode) {
YogaMeasureMode.EXACTLY -> floor(width).toInt()
YogaMeasureMode.EXACTLY -> ceil(width).toInt()
YogaMeasureMode.AT_MOST -> min(desiredWidth, floor(width).toInt())
else -> desiredWidth
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

package com.facebook.react.views.text

import android.annotation.SuppressLint
import android.text.BoringLayout
import android.text.Layout
import android.text.SpannableString
import android.text.TextPaint
import android.text.TextUtils
import com.facebook.yoga.YogaMeasureMode
import kotlin.math.ceil
import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

/**
* Regression test for the "Keep watching" 1px text-wrap bug.
*
* Scenario: A dialog renders a primary button label inside an absolutely-positioned
* focused/unfocused overlay (left:0/right:0). Yoga hands the inner Text a fractional EXACTLY width
* (e.g. 258.5px on a 517px parent). Pre-fix, TextLayoutManager.createLayout floored the EXACTLY
* width to 258 while the text the layout actually renders needed 259px, leaving the layout 1px
* narrower than its own content — on a real device that forces the text to wrap to a second line
* (taller label height). The user-visible symptom is "Keep watching" rendering on two lines instead
* of one inside the dialog.
*
* Direct assertion: the layout.width returned by createLayout (the horizontal box StaticLayout was
* told it has) must be >= layout.getLineWidth(0) (the horizontal advance of the rendered line).
* When that invariant is violated, the rendered text doesn't fit in the layout's allocated space
* and the next layout pass wraps it. Robolectric stubs real font metrics (1px/char), but the
* relationship between allocated width and rendered line width is faithful enough to expose the
* floor/ceil bug.
*/
@RunWith(RobolectricTestRunner::class)
class TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest {

@Test
fun `EXACTLY mode with fractional width allocates a layout wide enough to fit its own text`() {
val text = SpannableString("Keep watching")
val paint = TextPaint(TextPaint.ANTI_ALIAS_FLAG).apply { textSize = 16f }

val desiredWidth = Layout.getDesiredWidth(text, paint)
val ceilDesired = ceil(desiredWidth).toInt()

// Construct the exact repro: a fractional EXACTLY width whose ceil equals
// ceil(desiredWidth) but whose floor is one pixel short. Yoga has reserved the full
// fractional width upstream, so flooring shaves 1px and triggers the wrap bug.
val fractionalWidth = ceilDesired - 0.5f

val layout = invokeCreateLayout(text, fractionalWidth, paint)
val renderedLineWidth = layout.getLineWidth(0)

assertThat(layout.width.toFloat())
.withFailMessage(
"Layout's allocated width (%d) is smaller than the rendered text it contains " +
"(line 0 width=%.2f) for '%s' at EXACTLY width=%.2f " +
"(layoutClass=%s, desiredWidth=%.2f, ceilDesired=%d). " +
"On a real Android device this 1px shortfall forces the label to wrap to a " +
"second line, doubling its height — the visible \"Keep watching\" bug in a " +
"dialog. Root cause: TextLayoutManager.createLayout used floor(width) for " +
"EXACTLY mode, producing a layout narrower than its own text.",
layout.width,
renderedLineWidth,
text.toString(),
fractionalWidth,
layout::class.java.simpleName,
desiredWidth,
ceilDesired,
)
.isGreaterThanOrEqualTo(renderedLineWidth)
}

/**
* Invokes the private TextLayoutManager.createLayout via reflection. We can't call it directly
* because it's `private` (friend_paths only opens up `internal`). Default values mirror what
* measureText() passes in the production path for a plain single-paragraph label.
*
* BREAK_STRATEGY_HIGH_QUALITY and HYPHENATION_FREQUENCY_NONE are API 23+ constants. The test runs
* only on Robolectric (JVM), never on a device, so the inlined integer values are safe.
*/
@SuppressLint("InlinedApi")
private fun invokeCreateLayout(
text: SpannableString,
width: Float,
paint: TextPaint,
): Layout {
val boring: BoringLayout.Metrics? = BoringLayout.isBoring(text, paint)
val method =
TextLayoutManager::class
.java
.getDeclaredMethod(
"createLayout",
android.text.Spannable::class.java,
BoringLayout.Metrics::class.java,
java.lang.Float.TYPE,
YogaMeasureMode::class.java,
java.lang.Boolean.TYPE,
java.lang.Integer.TYPE,
java.lang.Integer.TYPE,
Layout.Alignment::class.java,
java.lang.Integer.TYPE,
TextUtils.TruncateAt::class.java,
java.lang.Integer.TYPE,
TextPaint::class.java,
)
.apply { isAccessible = true }

return method.invoke(
TextLayoutManager,
text,
boring,
width,
YogaMeasureMode.EXACTLY,
/* includeFontPadding = */ false,
/* textBreakStrategy = */ Layout.BREAK_STRATEGY_HIGH_QUALITY,
/* hyphenationFrequency = */ Layout.HYPHENATION_FREQUENCY_NONE,
Layout.Alignment.ALIGN_NORMAL,
/* justificationMode = */ 0,
/* ellipsizeMode = */ null,
/* maxNumberOfLines = */ 2,
paint,
) as Layout
}
}
Loading