From e30b9ce9969cb69e2a34d6ec629bcb0838c82875 Mon Sep 17 00:00:00 2001 From: Kyle Howarth Date: Thu, 30 Apr 2026 10:19:05 -0700 Subject: [PATCH] Fix text wrapping in absolutely positioned elements on Android (#56651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: ## Context On Android TV, text inside absolutely-positioned Views wraps unexpectedly when the computed width from Yoga is fractional. This manifests in Instagram TV as "Keep watching" breaking to two lines in the exit dialog when the button text is focused (rendered via an absolutely-positioned overlay for color animation). The root cause is a rounding mismatch in React Native's Android `TextLayoutManager`: `desiredWidth` (the text's needed width) uses `ceil()` (rounds up), but `layoutWidth` in `EXACTLY` mode uses `floor()` (rounds down). When Yoga passes a fractional width (e.g. 258.5px), the container gets `floor(258.5) = 258px` but the text needs `ceil(258.3) = 259px`, causing a 1px shortfall that triggers wrapping. ## Fix In `TextLayoutManager.createLayout()`, change `floor(width).toInt()` to `ceil(width).toInt()` for `YogaMeasureMode.EXACTLY` in **both** layout paths so the behavior is consistent regardless of which `Layout` class is chosen: - BoringLayout (single-line text that fits) - StaticLayout (multi-line or complex text) `YogaMeasureMode.AT_MOST` is intentionally left as `floor(width).toInt()`. `AT_MOST` is a constraint contract from Yoga ("do not exceed this width"), so flooring remains the correct conservative behavior — ceiling there could violate the constraint by up to 1px. The BoringLayout entry guard (`boring.width <= floor(width)`) is also left unchanged. If a boring text fails the guard, it falls through to the StaticLayout path, which now also ceils for `EXACTLY`, so no truncation results — the only effect is a slightly less optimal layout class choice in a narrow edge case. ## Why ceiling `EXACTLY` is safe `EXACTLY` means Yoga has guaranteed this width — the container has been allocated at least the full fractional width upstream. Ceiling the local layout width by 1px cannot exceed what Yoga has reserved, while flooring it produced a 1px shortfall that mismatched `desiredWidth`'s ceiling and triggered wrapping. The compensating mechanism in `calculateWidth()` — which returns the raw fractional width to Yoga for `EXACTLY` mode rather than the floored `layout.width` — is preserved, so Yoga's upstream allocation reasoning is unchanged. This was the subpixel-safety property introduced in D74685353; only the local pixel rounding inside `createLayout` changes from "round down 1px and risk wrapping" to "round up 1px and match `desiredWidth`". ## Testing Added a new test case to cover this behavior. If this needs to be adjusted in the future, a failure will highlight this bug and ensure it's covered or mitigated with additional test cases. ## Notes This was confirmed as a problem with an absolutely positioned style with `left:0, right:0` applied. Width sizing was confirmed to be the issue when `left:-1, right:-1` resolved the issue. Further investigation found this fix in text sizing. Only `EXACTLY` is needed to fix the observed Instagram TV bug. `AT_MOST` is left untouched because the constraint semantics differ. ## Changelog: [Android] [Fixed] - Fix 1px text wrapping in absolutely positioned elements caused by fractional Yoga widths Reviewed By: Abbondanzo Differential Revision: D102920508 --- .../react/views/text/TextLayoutManager.kt | 9 +- ...erAbsoluteLayoutWithFractionalPixelTest.kt | 131 ++++++++++++++++++ 2 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt index f2c88294f656..eacec697670d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextLayoutManager.kt @@ -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, @@ -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 } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt new file mode 100644 index 000000000000..33e00f0af10c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextLayoutManagerAbsoluteLayoutWithFractionalPixelTest.kt @@ -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 + } +}