Skip to content

Commit e7766ab

Browse files
tomekzawmeta-codesync[bot]
authored andcommitted
Keep paragraph content and measured layouts across unchanged clones (#59063)
Summary: When Yoga lays out a container again, it clones every child whose layout it touches, including unchanged siblings. A `ParagraphShadowNode` cloned that way (no new props, no new children) drops its memoized `content_` (the attributed string) and `measuredLayouts_`. **Current behavior.** In `layout()` the clone rebuilds its `AttributedString` and compares it with the one in its `ParagraphState`. After the paragraph's first commit the comparison fails even though nothing changed. `Fragment::operator==` compares `parentShadowView.layoutMetrics`, and the string stored in state captured those metrics before the paragraph had been laid out. So `updateStateIfNeeded` creates a new state for every sibling paragraph: - On Android each sibling then gets an `UPDATE_STATE` mount instruction. The UI thread rebuilds its Spannable, calls `setText`, lays the text out again and redraws it. - A trace of "mount 1000 labels, then change one" shows 1000 `ReactTextViewManager.updateState`, 1000 `setText` and 1000 `onDraw` calls for the one change. - With `enablePreparedTextLayout`, the clone also has no measured layout to reuse, so every sibling is laid out again on the JS thread. **With this change.** A clone whose fragment has neither props nor children keeps `content_` and `measuredLayouts_`. `getContent` drops both if the font-size multiplier or layout direction they were built for no longer matches. Unchanged siblings then produce no state update and no re-layout. A kept layout is only reused at the pixel density it was prepared for. After a density change the paragraph prepares a new layout, so the state refresh from #59049 (`fixTextLayoutOnPixelDensityChange`) still applies with `enablePreparedTextLayout`. Content with attachments (inline views inside the text) is not kept: the attachments point at the children the content was built from, and `layout()` replaces those children with laid-out clones, so in a later revision they would point at nodes only the previous revision owns. **Why.** Any update inside a container with many text siblings (non-virtualized lists, settings screens, forms, tables) currently costs UI-thread work for every sibling, and with prepared text layout also a JS-thread layout for every sibling. ## Changelog: [GENERAL] [FIXED] - Avoid state updates and re-layout of unchanged `Text` siblings when a sibling changes Pull Request resolved: #59063 Test Plan: - `./gradlew :packages:rn-tester:android:app:assembleDebug -PreactNativeArchitectures=arm64-v8a` and `yarn format-check-cpp` pass on this branch. - Fantom could not be built locally: the bundled `nlohmann/json.hpp` fails with this machine's Apple clang. I'm relying on CI for it. - Screenshots were pixel-identical before and after a sequence of updates (all text, one item, colour, font size, container width), in four configurations with and without `enablePreparedTextLayout`. - iOS shares this code path but was not built or measured. - The pixel-density check (last commit) was compiled against the Android build's flags with `-fsyntax-only` but not run; no density change was tested on a device. - Galaxy A16 release build, cold start and 15 flings through two long feeds whose text contains inline views, 16 runs per variant: the first version of this PR crashed in 4 (SIGSEGV in `ParagraphShadowNode::layout`, reading an attachment the previous revision had released), the current version in none. **Benchmarks.** RNTester release build with a text benchmark screen that is not part of this PR. Medians, main vs. this PR, variants interleaved. Emulator (Pixel 10 Pro AVD, API 37), current `main` vs. this branch, 6 runs: | scenario | metric | main | this PR | change | |---|---|--:|--:|--:| | 1000 labels, change one | UI-thread CPU | 21.8 ms | 5.2 ms | −76% | | | time to pixels | 58.6 ms | 33.6 ms | −43% | | 300 strings, change one | UI-thread CPU | 29.0 ms | 6.0 ms | −79% | | | time to pixels | 49.3 ms | 32.5 ms | −34% | | 1000 labels, change one, `enablePreparedTextLayout` | JS-thread CPU | 30.4 ms | 18.5 ms | −39% | | | time to pixels | 66.2 ms | 49.1 ms | −26% | | mounts | | | | no significant change | Physical Samsung Galaxy A16, the same change on an earlier `main` (`d4d0813969c`), 8 runs. #59016 was applied in both arms. | scenario | main | this change | change | |---|--:|--:|--:| | 1000 labels, change one right after mount: time to pixels | 222 ms | 78 ms | −65% | | 300 strings, change one right after mount: time to pixels | 164 ms | 36 ms | −78% | | 1000 labels, change one, prepared text: time to pixels | 228 ms | 73 ms | −68% | | same: JS-thread CPU | 132 ms | 44 ms | −67% | 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed By: cipolleschi Differential Revision: D126131800 Pulled By: javache fbshipit-source-id: 88d934a7ecb188430c19a6c62135f4602da7b99f
1 parent 631fd89 commit e7766ab

2 files changed

Lines changed: 50 additions & 12 deletions

File tree

‎packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.cpp‎

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ ParagraphShadowNode::ParagraphShadowNode(
5959
const ShadowNodeFragment& fragment)
6060
: ConcreteViewShadowNode(sourceShadowNode, fragment) {
6161
initialize();
62+
// A revision with neither new props nor new children (e.g. one Yoga cloned
63+
// because a sibling changed) renders the same content, so keep the
64+
// attributed string and the layouts measured for it instead of rebuilding
65+
// and re-measuring them. `getContent` drops both if the layout context they
66+
// were built for no longer matches.
67+
//
68+
// Content with attachments is not kept: they point at the children the
69+
// content was built from, and `layout` replaces those children with laid-out
70+
// clones, so in a later revision the pointers would outlive the nodes.
71+
if (fragment.props == nullptr && fragment.children == nullptr) {
72+
const auto& source =
73+
static_cast<const ParagraphShadowNode&>(sourceShadowNode);
74+
if (source.content_.has_value() && source.content_->attachments.empty()) {
75+
content_ = source.content_;
76+
measuredLayouts_ = source.measuredLayouts_;
77+
}
78+
}
6279
}
6380

6481
bool ParagraphShadowNode::shouldNewRevisionDirtyMeasurement(
@@ -69,19 +86,34 @@ bool ParagraphShadowNode::shouldNewRevisionDirtyMeasurement(
6986

7087
const Content& ParagraphShadowNode::getContent(
7188
const LayoutContext& layoutContext) const {
89+
auto layoutDirection = YGNodeLayoutGetDirection(&yogaNode_) == YGDirectionRTL
90+
? LayoutDirection::RightToLeft
91+
: LayoutDirection::LeftToRight;
7292
if (content_.has_value()) {
73-
return content_.value();
93+
const auto& baseTextAttributes =
94+
content_->attributedString.getBaseTextAttributes();
95+
// The content is built from this node's props and children, and it is
96+
// only carried over to revisions that have neither new props nor new
97+
// children (see the copy constructor). The only other inputs are the
98+
// font size multiplier from the layout context and the layout direction
99+
// Yoga resolves from the parent, and those can change without new props
100+
// or children, so they are the two things to check here.
101+
if (baseTextAttributes.fontSizeMultiplier ==
102+
layoutContext.fontSizeMultiplier &&
103+
baseTextAttributes.layoutDirection == layoutDirection) {
104+
return content_.value();
105+
}
106+
// Carried over from a previous revision, but built for another context.
107+
content_.reset();
108+
measuredLayouts_.clear();
74109
}
75110

76111
ensureUnsealed();
77112

78113
auto textAttributes = TextAttributes::defaultTextAttributes();
79114
textAttributes.fontSizeMultiplier = layoutContext.fontSizeMultiplier;
80115
textAttributes.apply(getConcreteProps().textAttributes);
81-
textAttributes.layoutDirection =
82-
YGNodeLayoutGetDirection(&yogaNode_) == YGDirectionRTL
83-
? LayoutDirection::RightToLeft
84-
: LayoutDirection::LeftToRight;
116+
textAttributes.layoutDirection = layoutDirection;
85117
auto attributedString = AttributedString{};
86118
auto attachments = Attachments{};
87119
buildAttributedString(textAttributes, *this, attributedString, attachments);
@@ -204,7 +236,8 @@ void ParagraphShadowNode::updateStateIfNeeded(const Content& content) {
204236
#endif
205237
}
206238

207-
MeasuredPreparedTextLayout* ParagraphShadowNode::findUsableLayout() {
239+
MeasuredPreparedTextLayout* ParagraphShadowNode::findUsableLayout(
240+
Float pointScaleFactor) {
208241
MeasuredPreparedTextLayout* ret = nullptr;
209242

210243
if constexpr (TextLayoutManagerExtended::supportsPreparedTextLayout()) {
@@ -216,7 +249,8 @@ MeasuredPreparedTextLayout* ParagraphShadowNode::findUsableLayout() {
216249
// adjusted by a constraint (like min-size).
217250
auto expectedSize = rawContentSize();
218251
for (auto& prevLayout : measuredLayouts_) {
219-
if (floatEquality(
252+
if (prevLayout.pointScaleFactor == pointScaleFactor &&
253+
floatEquality(
220254
prevLayout.measurement.size.width, expectedSize.width) &&
221255
floatEquality(
222256
prevLayout.measurement.size.height, expectedSize.height)) {
@@ -245,8 +279,11 @@ Size ParagraphShadowNode::measureContent(
245279
const LayoutContext& layoutContext,
246280
const LayoutConstraints& layoutConstraints) const {
247281
if constexpr (TextLayoutManagerExtended::supportsPreparedTextLayout()) {
282+
// Layouts carried over from a previous revision may have been prepared
283+
// at another pixel density, which they can't be reused for.
248284
for (const auto& layout : measuredLayouts_) {
249-
if (layout.layoutConstraints == layoutConstraints) {
285+
if (layout.layoutConstraints == layoutConstraints &&
286+
layout.pointScaleFactor == layoutContext.pointScaleFactor) {
250287
return layout.measurement.size;
251288
}
252289
}
@@ -336,7 +373,7 @@ void ParagraphShadowNode::layout(LayoutContext layoutContext) {
336373
auto content =
337374
getContentWithMeasuredAttachments(layoutContext, layoutConstraints);
338375

339-
auto measuredLayout = findUsableLayout();
376+
auto measuredLayout = findUsableLayout(layoutContext.pointScaleFactor);
340377

341378
if constexpr (
342379
TextLayoutManagerExtended::supportsPreparedTextLayout() &&
@@ -353,7 +390,7 @@ void ParagraphShadowNode::layout(LayoutContext layoutContext) {
353390
// culled
354391
if (measuredLayout == nullptr) {
355392
measureContent(layoutContext, layoutConstraints);
356-
measuredLayout = findUsableLayout();
393+
measuredLayout = findUsableLayout(layoutContext.pointScaleFactor);
357394
}
358395
react_native_assert(measuredLayout);
359396
updateStateIfNeeded<ParagraphState>(content, *measuredLayout);

‎packages/react-native/ReactCommon/react/renderer/components/text/ParagraphShadowNode.h‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,10 @@ class ParagraphShadowNode
117117

118118
/**
119119
* Returns any previously prepared layout, generated for measurement, which
120-
* may be used, given the currently assigned layout to the ShadowNode
120+
* may be used, given the currently assigned layout to the ShadowNode and
121+
* the current pixel density
121122
*/
122-
MeasuredPreparedTextLayout *findUsableLayout();
123+
MeasuredPreparedTextLayout *findUsableLayout(Float pointScaleFactor);
123124

124125
/**
125126
* Returns the final measure of the content frame, before layout rounding

0 commit comments

Comments
 (0)