From 329964723344e3278fb1dfc5c7036a25d6ee5b49 Mon Sep 17 00:00:00 2001 From: Yavor Belakov <60155068+ybelakov@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:53:38 +0300 Subject: [PATCH] Text: keep the last line when Yoga's pixel-grid rounding leaves the box a float step short Yoga rounds a node's frame to the pixel grid but subtracts the rounded edges as floats, so far from the origin a paragraph's height can come out a float step under the grid value it was measured at (43.9998 for two lines of 22 at y = 2048 on a 3x screen). Given that height, TextKit treats the last line as not fitting and lays it out clipped. Snap such a height back onto the grid before drawing and before measuring lines; anything else rounds up, so a container is never made smaller. Fixes #58970. --- .../Text/RCTParagraphComponentView.mm | 27 ++++++++++++++-- .../components/text/ParagraphShadowNode.cpp | 31 ++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm index e169755fe8e2..2aed79fa28bb 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -20,11 +20,34 @@ #import #import +#import #import "RCTConversions.h" #import "RCTFabricComponentsPlugins.h" using namespace facebook::react; +/* + * The content frame with its height snapped back onto the pixel grid. Yoga + * rounds the frame to the grid but subtracts the two rounded edges as floats, + * so far from the origin the height can come out a float step under the grid + * value the paragraph was measured at: 43.9998 instead of 44 for two lines of + * lineHeight 22 at y = 2048 on a 3x screen. TextKit treats a container that + * short as too small for the last line and lays it out clipped. Anything else + * is rounded up, so the container is never made smaller. + * See https://github.com/facebook/react-native/issues/58970. + */ +static CGRect RCTParagraphTextFrame(const LayoutMetrics &layoutMetrics) +{ + CGRect frame = RCTCGRectFromRect(layoutMetrics.getContentFrame()); + CGFloat scale = RCTScreenScale(); + if (scale > 0 && !isnan(frame.size.height)) { + CGFloat scaled = frame.size.height * scale; + CGFloat nearest = round(scaled); + frame.size.height = (fabs(scaled - nearest) < 0.01 ? nearest : ceil(scaled)) / scale; + } + return frame; +} + @interface RCTTextLayoutManager (RCTParagraphComponentViewPrivate) - (CGRect)drawingFrameForAttributedString:(facebook::react::AttributedString)attributedString @@ -178,7 +201,7 @@ - (void)prepareForRecycle - (void)_updateTextViewFrame { CGRect textViewFrame = self.bounds; - CGRect drawingFrame = RCTCGRectFromRect(_layoutMetrics.getContentFrame()); + CGRect drawingFrame = RCTParagraphTextFrame(_layoutMetrics); if (ReactNativeFeatureFlags::enableIOSCompressedTextFrameAdjustment() && _textView.state && drawingFrame.size.height > 0) { @@ -474,4 +497,4 @@ - (void)drawRect:(CGRect)rect }]; } -@end +@end \ No newline at end of file diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp index 17b14c439f16..a5315c3ca0bb 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp @@ -33,6 +33,31 @@ (layoutConstraints).maximumSize.height) namespace facebook::react { + +namespace { + +/* + * Yoga rounds a node's frame to the pixel grid, but it subtracts the two + * rounded edges as floats, so far from the origin the height can come out a + * float step under the grid value the paragraph was measured at: 43.9998 + * instead of 44 for two lines of lineHeight 22 at y = 2048 on a 3x screen. + * Laid out in a container that short, the last line does not fit and is + * drawn clipped. Snap such a height back onto the grid; anything else is + * rounded up, so a container is never made smaller. + * See https://github.com/facebook/react-native/issues/58970. + */ +Float snapHeightToPixelGrid(Float height, Float pointScaleFactor) { + if (pointScaleFactor <= 0 || std::isnan(height)) { + return height; + } + const auto scaled = height * pointScaleFactor; + const auto nearest = std::round(scaled); + const auto snapped = + std::abs(scaled - nearest) < 0.01f ? nearest : std::ceil(scaled); + return snapped / pointScaleFactor; +} + +} // namespace using Content = ParagraphShadowNode::Content; // NOLINTNEXTLINE(facebook-hte-CArray, modernize-avoid-c-arrays) @@ -305,6 +330,10 @@ void ParagraphShadowNode::layout(LayoutContext layoutContext) { auto size = ReactNativeFeatureFlags::enablePreparedTextLayout() ? rawContentSize() : layoutMetrics.getContentFrame().size; + if (!ReactNativeFeatureFlags::enablePreparedTextLayout()) { + size.height = + snapHeightToPixelGrid(size.height, layoutContext.pointScaleFactor); + } LayoutConstraints layoutConstraints{ .minimumSize = size, @@ -445,4 +474,4 @@ void ParagraphShadowNode::layout(LayoutContext layoutContext) { } } -} // namespace facebook::react +} // namespace facebook::react \ No newline at end of file