From eca32672e951f38179b21987096ea041cf0b1df3 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Sat, 17 Oct 2026 10:40:24 +0200 Subject: [PATCH] Keep iOS text line breaks stable while LayoutAnimation resizes a paragraph LayoutAnimation interpolates a paragraph's frame on every frame, and RCTParagraphComponentView laid out its text in that intermediate size. With an unlimited number of lines the text container clips its last line instead of wrapping it, so the last visible line showed words from the next line, cut off at the edge, and they jumped down once the next line was revealed. ParagraphState (outside of Android) now stores the size of the content frame the paragraph was laid out with, and the view lays out text in that size and clips it to its current bounds, like the old architecture did. LayoutAnimation keeps the final state while it interpolates the frame, so this needs no animation-specific code. Fixes #59161 Co-Authored-By: Claude Opus 5.5 --- .../Text/RCTParagraphComponentView.mm | 7 +++++++ .../components/text/ParagraphShadowNode.cpp | 15 +++++++++++++++ .../components/text/ParagraphShadowNode.h | 4 ++-- .../renderer/components/text/ParagraphState.h | 9 +++++++++ 4 files changed, 33 insertions(+), 2 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 da6658cc1319..6e5fd181563a 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/Text/RCTParagraphComponentView.mm @@ -188,6 +188,13 @@ - (void)_updateTextViewFrame CGRect textViewFrame = self.bounds; CGRect drawingFrame = RCTCGRectFromRect(_layoutMetrics.getContentFrame()); + // Lay out the text in the size the paragraph has in the shadow tree rather than in the current frame, which + // LayoutAnimation interpolates. Otherwise, line breaks change on every frame and words jump between lines. + // The text view clips the text to the current bounds. + if (_textView.state) { + drawingFrame.size = RCTCGSizeFromSize(_textView.state->getData().layoutSize); + } + if (ReactNativeFeatureFlags::enableIOSCompressedTextFrameAdjustment() && _textView.state && drawingFrame.size.height > 0) { const auto &stateData = _textView.state->getData(); 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 7602b616e798..bad770b36246 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp @@ -178,6 +178,7 @@ void ParagraphShadowNode::updateStateIfNeeded(const Content& content) { auto& state = getStateData(); react_native_assert(textLayoutManager_); +#ifdef ANDROID if (state.attributedString == content.attributedString) { return; } @@ -187,6 +188,20 @@ void ParagraphShadowNode::updateStateIfNeeded(const Content& content) { content.attributedString, content.paragraphAttributes, textLayoutManager_}); +#else + auto layoutSize = getLayoutMetrics().getContentFrame().size; + if (state.attributedString == content.attributedString && + state.layoutSize == layoutSize) { + return; + } + + setStateData( + ParagraphState{ + content.attributedString, + content.paragraphAttributes, + textLayoutManager_, + layoutSize}); +#endif } MeasuredPreparedTextLayout* ParagraphShadowNode::findUsableLayout() { diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.h b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.h index 5d0537d363df..f569067717bb 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.h +++ b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.h @@ -110,8 +110,8 @@ class ParagraphShadowNode void updateStateIfNeeded(const Content &content, const MeasuredPreparedTextLayout &layout); /* - * Creates a `State` object (with `AttributedText` and - * `TextLayoutManager`) if needed. + * Creates a `State` object (with `AttributedText`, `TextLayoutManager` and, + * outside of Android, the size of the content frame) if needed. */ void updateStateIfNeeded(const Content &content); diff --git a/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/ParagraphState.h b/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/ParagraphState.h index 07783b401366..bf3ca15d2182 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/ParagraphState.h +++ b/packages/react-native/ReactCommon/react/renderer/components/text/platform/cxx/react/renderer/components/text/ParagraphState.h @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -39,6 +40,14 @@ class ParagraphState final { * `AttributedString`. */ std::weak_ptr layoutManager; + + /* + * Size of the content frame that the paragraph was laid out with. + * The mounted view can temporarily have a different size (e.g. while + * LayoutAnimation interpolates its frame), so it lays out text in this size + * to keep line breaks stable. + */ + Size layoutSize; }; } // namespace facebook::react