From 70f59c133d75d89c153d6fe9ca81b99887aeefc5 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Wed, 14 Oct 2026 11:45:50 +0200 Subject: [PATCH] Keep a paragraph's content and measured layouts across unchanged clones A ParagraphShadowNode cloned with neither new props nor new children (for example by Yoga because a sibling changed) dropped its memoized content and measured layouts. layout() then rebuilt the attributed string, which no longer compared equal to the one in state because the fragments' parentShadowView layout metrics had changed, so every such sibling got a state update and was re-set on the UI thread. With prepared text layout the sibling was also laid out again. Co-Authored-By: Claude Opus 5.5 --- .../components/text/ParagraphShadowNode.cpp | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) 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 7602b616e79..ea49fc65250 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp @@ -59,6 +59,17 @@ ParagraphShadowNode::ParagraphShadowNode( const ShadowNodeFragment& fragment) : ConcreteViewShadowNode(sourceShadowNode, fragment) { initialize(); + // A revision with neither new props nor new children (e.g. one Yoga cloned + // because a sibling changed) renders the same content, so keep the + // attributed string and the layouts measured for it instead of rebuilding + // and re-measuring them. `getContent` drops both if the layout context they + // were built for no longer matches. + if (fragment.props == nullptr && fragment.children == nullptr) { + const auto& source = + static_cast(sourceShadowNode); + content_ = source.content_; + measuredLayouts_ = source.measuredLayouts_; + } } bool ParagraphShadowNode::shouldNewRevisionDirtyMeasurement( @@ -69,8 +80,20 @@ bool ParagraphShadowNode::shouldNewRevisionDirtyMeasurement( const Content& ParagraphShadowNode::getContent( const LayoutContext& layoutContext) const { + auto layoutDirection = YGNodeLayoutGetDirection(&yogaNode_) == YGDirectionRTL + ? LayoutDirection::RightToLeft + : LayoutDirection::LeftToRight; if (content_.has_value()) { - return content_.value(); + const auto& baseTextAttributes = + content_->attributedString.getBaseTextAttributes(); + if (baseTextAttributes.fontSizeMultiplier == + layoutContext.fontSizeMultiplier && + baseTextAttributes.layoutDirection == layoutDirection) { + return content_.value(); + } + // Carried over from a previous revision, but built for another context. + content_.reset(); + measuredLayouts_.clear(); } ensureUnsealed(); @@ -78,10 +101,7 @@ const Content& ParagraphShadowNode::getContent( auto textAttributes = TextAttributes::defaultTextAttributes(); textAttributes.fontSizeMultiplier = layoutContext.fontSizeMultiplier; textAttributes.apply(getConcreteProps().textAttributes); - textAttributes.layoutDirection = - YGNodeLayoutGetDirection(&yogaNode_) == YGDirectionRTL - ? LayoutDirection::RightToLeft - : LayoutDirection::LeftToRight; + textAttributes.layoutDirection = layoutDirection; auto attributedString = AttributedString{}; auto attachments = Attachments{}; buildAttributedString(textAttributes, *this, attributedString, attachments);