From 70f59c133d75d89c153d6fe9ca81b99887aeefc5 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Wed, 14 Oct 2026 11:45:50 +0200 Subject: [PATCH 1/4] 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/4] 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_; + } } } From d97e572dc16aeafe96ea39fc015487bca72702c5 Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Mon, 19 Oct 2026 17:34:35 +0200 Subject: [PATCH 3/4] Explain which inputs getContent checks for carried-over content Co-Authored-By: Claude Opus 5.5 --- .../react/renderer/components/text/ParagraphShadowNode.cpp | 6 ++++++ 1 file changed, 6 insertions(+) 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 18ce0a63a052..b5eef8fe7cbd 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp @@ -92,6 +92,12 @@ const Content& ParagraphShadowNode::getContent( if (content_.has_value()) { const auto& baseTextAttributes = content_->attributedString.getBaseTextAttributes(); + // The content is built from this node's props and children, and it is + // only carried over to revisions that have neither new props nor new + // children (see the copy constructor). The only other inputs are the + // font size multiplier from the layout context and the layout direction + // Yoga resolves from the parent, and those can change without new props + // or children, so they are the two things to check here. if (baseTextAttributes.fontSizeMultiplier == layoutContext.fontSizeMultiplier && baseTextAttributes.layoutDirection == layoutDirection) { From 3ee16a3adea445eb4b48196e177688aa698bc1dc Mon Sep 17 00:00:00 2001 From: Tomek Zawadzki Date: Mon, 19 Oct 2026 17:40:20 +0200 Subject: [PATCH 4/4] Don't reuse paragraph layouts prepared at another pixel density Measured layouts are now carried over to clones with no new props or children, including the ones configureYogaTree makes when the pixel density changes. measureContent and findUsableLayout matched them only by constraints or size, so the paragraph reused a layout prepared at the old density and, with fixTextLayoutOnPixelDensityChange, never updated its state to the new density. Only reuse layouts prepared at the current pointScaleFactor. Co-Authored-By: Claude Opus 5.5 --- .../components/text/ParagraphShadowNode.cpp | 15 ++++++++++----- .../components/text/ParagraphShadowNode.h | 5 +++-- 2 files changed, 13 insertions(+), 7 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 b5eef8fe7cbd..a14ce0a01e94 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp @@ -221,7 +221,8 @@ void ParagraphShadowNode::updateStateIfNeeded(const Content& content) { textLayoutManager_}); } -MeasuredPreparedTextLayout* ParagraphShadowNode::findUsableLayout() { +MeasuredPreparedTextLayout* ParagraphShadowNode::findUsableLayout( + Float pointScaleFactor) { MeasuredPreparedTextLayout* ret = nullptr; if constexpr (TextLayoutManagerExtended::supportsPreparedTextLayout()) { @@ -233,7 +234,8 @@ MeasuredPreparedTextLayout* ParagraphShadowNode::findUsableLayout() { // adjusted by a constraint (like min-size). auto expectedSize = rawContentSize(); for (auto& prevLayout : measuredLayouts_) { - if (floatEquality( + if (prevLayout.pointScaleFactor == pointScaleFactor && + floatEquality( prevLayout.measurement.size.width, expectedSize.width) && floatEquality( prevLayout.measurement.size.height, expectedSize.height)) { @@ -262,8 +264,11 @@ Size ParagraphShadowNode::measureContent( const LayoutContext& layoutContext, const LayoutConstraints& layoutConstraints) const { if constexpr (TextLayoutManagerExtended::supportsPreparedTextLayout()) { + // Layouts carried over from a previous revision may have been prepared + // at another pixel density, which they can't be reused for. for (const auto& layout : measuredLayouts_) { - if (layout.layoutConstraints == layoutConstraints) { + if (layout.layoutConstraints == layoutConstraints && + layout.pointScaleFactor == layoutContext.pointScaleFactor) { return layout.measurement.size; } } @@ -353,7 +358,7 @@ void ParagraphShadowNode::layout(LayoutContext layoutContext) { auto content = getContentWithMeasuredAttachments(layoutContext, layoutConstraints); - auto measuredLayout = findUsableLayout(); + auto measuredLayout = findUsableLayout(layoutContext.pointScaleFactor); if constexpr ( TextLayoutManagerExtended::supportsPreparedTextLayout() && @@ -370,7 +375,7 @@ void ParagraphShadowNode::layout(LayoutContext layoutContext) { // culled if (measuredLayout == nullptr) { measureContent(layoutContext, layoutConstraints); - measuredLayout = findUsableLayout(); + measuredLayout = findUsableLayout(layoutContext.pointScaleFactor); } react_native_assert(measuredLayout); updateStateIfNeeded(content, *measuredLayout); 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..6378b268d3df 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.h +++ b/packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.h @@ -117,9 +117,10 @@ class ParagraphShadowNode /** * Returns any previously prepared layout, generated for measurement, which - * may be used, given the currently assigned layout to the ShadowNode + * may be used, given the currently assigned layout to the ShadowNode and + * the current pixel density */ - MeasuredPreparedTextLayout *findUsableLayout(); + MeasuredPreparedTextLayout *findUsableLayout(Float pointScaleFactor); /** * Returns the final measure of the content frame, before layout rounding