Skip to content

Defer cloning unchanged Yoga children until layout - #59302

Open
hryhoriiK97 wants to merge 4 commits into
react:mainfrom
hryhoriiK97:yoga-defer-child-cloning
Open

hryhoriiK97 wants to merge 4 commits into
react:mainfrom
hryhoriiK97:yoga-defer-child-cloning

Conversation

@hryhoriiK97

@hryhoriiK97 hryhoriiK97 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary:

When a node gets a new list of children, YogaLayoutableShadowNode::adoptYogaChild (ReactCommon/react/renderer/components/view/YogaLayoutableShadowNode.cpp) makes the node own the Yoga node of each child. A child that a previous revision of the node still owns, which is every unchanged child, is cloned with clone({}) right away.

Current behavior. A commit clones the path from the changed node to the root, and React clones the parent of every changed node. Each of those clones gets a new list of children, so every unchanged sibling along the path is cloned too, whether or not anything is laid out. While a list scrolls, the ScrollView writes its content offset into its state on every frame, so every frame clones every sibling of every ancestor of the ScrollView. A color change on one row of a list clones all the other rows. Most of these clones are never laid out: Yoga only needs to own a child when it lays the child out, and it already clones a child it doesn't own at that point, through yogaNodeCloneCallbackConnector, the same way it does today for children of a node cloned without new children.

With this change. Behind the new deferYogaChildCloningUntilLayout feature flag (default false), adoptYogaChild leaves a child owned by another node shared. Yoga clones every child it doesn't own when it lays out or measures the parent, unless it restores the parent from its layout cache. Yoga can mark such a cached parent as having a new layout without cloning its children, so YogaLayoutableShadowNode::layout now clones a shared child that carries a new layout before writing into it. A child owned by the node itself is still cloned, so the existing guard against a reused Yoga node address keeps working.

Two side effects. Unchanged children are now the same ShadowNode in both revisions, which lets the differ skip more of them with skipUnchangedChildrenInDifferentiator (#59242). And unchanged measurable siblings are no longer dirtied: clone({}) runs completeClone and adopt, which dirty a TextInput, Switch or Android ProgressBar (the default shouldNewRevisionDirtyMeasurement returns true) and always dirty an Android TextInput (AndroidTextInputComponentDescriptor::adopt calls dirtyLayout). So a change next to one of them laid out their parent again.

Changelog:

[Internal]

Test Plan:

  • yarn featureflags --verify-unchanged passes. The pinned clang-format (21.1.2) passes on the changed C++ file, and prettier, eslint and flow focus-check pass on the new test file.
  • Fantom. The new View-deferYogaChildCloning-itest.js runs with @fantom_flags deferYogaChildCloningUntilLayout:* and checks the mutations and layouts for: a color change on one of 50 children (one Update mutation), a child that grows after several commits that left its siblings shared, a nested child whose parent was shared, scrolling a ScrollView and then changing its content, hiding and showing a node whose children were shared, and the steps of View-yogaNodeOwnerAssertion-itest.js. Four more tests reach their last state through commits that leave children shared and compare every layout with a fresh render of that state: a node restored from Yoga's layout cache, an absolute child of a position: 'static' node, display: contents and baseline-aligned text. yarn fantom View-deferYogaChildCloning-itest: 20 passed (10 tests × flag off and on), and View-yogaNodeOwnerAssertion-itest still passes. The full suite (yarn fantom) passes with the flag's default switched to true locally, twice (3,346 passed, 110 skipped), on the Debug tester where react_native_assert is on, including the ownership check in YogaLayoutableShadowNode::layout. In one earlier run with the flag on, the tester crashed (SIGSEGV) in three files, and in a run with the flag off it crashed in a fourth; none of the three crashed again in 40 runs each, 8 at a time, with the flag on or off.
  • The layout() guard. With Yoga's change that stops display: none children from keeping a stale hasNewLayout reverted locally, the steps of View-yogaNodeOwnerAssertion-itest.js with the flag on trip the ownership assertion without the guard and pass with it.
  • AddressSanitizer and ThreadSanitizer were not run: they are only available through Buck.
  • Android was not built or tested. The change is shared C++.
  • Measurement. RNTester built with xcodebuild in Release and run on a physical iPhone 15 Pro (iOS 26.6.1). The two arms are the same binary with the flag switched by a launch argument, in the order before, after, after, before. Scrolling was driven by Argent on the physical iPhone. Timed: commit and diff time of each transaction from TransactionTelemetry, with a local counter, every ShadowNode clone made through ConcreteComponentDescriptor::cloneShadowNode, and the JS thread's CPU time.
RNTester Playground, a FlatList of 500 multi-line messages, 20 flings down and 20 up per launch (~8,800 transactions) before this PR change
ShadowNode clones per launch 218,900 117,400 −46.3%
time spent cloning per launch 1,052 ms 481 ms −54.3%
clones in adoptYogaChild / clones requested by Yoga during layout 121,200 / 18,200 0 / 37,500
commit time per 1–2-mutation transaction (the per-frame scroll updates) 109.8 µs 71.6 µs −34.8%
diff time per 1–2-mutation transaction 106.4 µs 99.9 µs −6.0%
commit time per larger transaction 1.29 ms 1.55 ms +19.9%
JS thread CPU time per launch (React rendering, commits and layout) 6,265 ms 6,022 ms −3.9%

About 102,000 of the 121,000 clones made in adoptYogaChild were never laid out. The commit time of the larger transactions grows because the clones that are needed now happen during layout, inside the commit, while the eager clones they replace happened when React built the new tree, before the commit started. The JS thread's total CPU time per launch, which covers both, is lower. The last two rows come from a later run of the same scenario with the code in this PR, read with thread_info.

With skipUnchangedChildrenInDifferentiator (#59242) on in both arms:

Same scenario, skipUnchangedChildrenInDifferentiator on in both arms before this PR change
diff time per launch 505 ms 372 ms −26.4%
diff time per 1–2-mutation transaction 43.0 µs 27.4 µs −36.4%
commit time per 1–2-mutation transaction 110.7 µs 72.8 µs −34.3%

🤖 Generated with Claude Code

When a node gets a new list of children, `YogaLayoutableShadowNode::adoptYogaChild`
clones every child that a previous revision of the node still owns, so that
the new node owns the Yoga node of each child. Yoga already clones a child it
doesn't own before it lays the child out, through
`yogaNodeCloneCallbackConnector`, so the eager clone is only needed when the
node is laid out.

Behind the new `deferYogaChildCloningUntilLayout` feature flag (default
`false`), such children stay shared with the previous revision, and Yoga clones
the ones it lays out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 21, 2026
@facebook-github-tools facebook-github-tools Bot added p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. labels Oct 21, 2026
hryhoriiK97 and others added 3 commits October 21, 2026 16:22
Yoga can set a new layout on a node from its cache without cloning the
node's children, so with `deferYogaChildCloningUntilLayout` a child that is
still shared with another revision could be written to by
`YogaLayoutableShadowNode::layout`. Clone such a child first.

Add Fantom tests for a node restored from Yoga's cache, an absolute child of
a static node, `display: contents` and baseline-aligned text, each compared
with a fresh render of the same tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regenerate the feature flag files with `yarn featureflags`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Regenerate the feature flag files with `yarn featureflags`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hryhoriiK97

Copy link
Copy Markdown
Contributor Author

@javache This changes how YogaLayoutableShadowNode adopts children, so I'd value your view. Is leaving unchanged children shared until Yoga lays them out a direction you'd accept? It also builds on #59242: unchanged children stay the same node, so the differ skips more of them.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. p: Software Mansion Partner: Software Mansion Partner Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant