From 70f59c133d75d89c153d6fe9ca81b99887aeefc5 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Wed, 14 Oct 2026 11:45:50 +0200 Subject: [PATCH 1/2] 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 7602b616e798..ea49fc652507 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); From fae21d33dd4647e4fd9a8c8169bdf3beaff1f0b7 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Fri, 16 Oct 2026 17:32:27 +0200 Subject: [PATCH 2/2] Don't keep paragraph content with attachments across clones Attachments hold raw pointers to the children the content was built from, and layout() replaces those children with laid-out clones. A clone that kept that content pointed at nodes only the previous revision still owned, and once that revision was released, layout() read freed memory and crashed in ParagraphShadowNode::layout. Only keep content that has no attachments. Co-Authored-By: Claude Opus 5.5 --- .../renderer/components/text/ParagraphShadowNode.cpp | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 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 ea49fc652507..18ce0a63a052 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp @@ -64,11 +64,17 @@ ParagraphShadowNode::ParagraphShadowNode( // 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. + // + // Content with attachments is not kept: they point at the children the + // content was built from, and `layout` replaces those children with laid-out + // clones, so in a later revision the pointers would outlive the nodes. if (fragment.props == nullptr && fragment.children == nullptr) { const auto& source = static_cast(sourceShadowNode); - content_ = source.content_; - measuredLayouts_ = source.measuredLayouts_; + if (source.content_.has_value() && source.content_->attachments.empty()) { + content_ = source.content_; + measuredLayouts_ = source.measuredLayouts_; + } } }