Defer cloning unchanged Yoga children until layout #59302

Open
hryhoriiK97 opened 11:45am on October 21, 2026 wants to merge 501 Ξ” into facebook/react-native main from
yoga-defer-child-cloning

Pull Request Overview

  • Opened on October 21, 2026
  • Status Open
  • Commit count 4 with first commit October 21, 2026

Total Delta

501 Total Diff Delta

Open Days

Open 15 weekdays

Test Delta

0 Diff Delta in Test Files
Breakdown by Phase

How long has this pull request spent in each phase of its lifecycle?

Fraction of total time Business days Phase
 
0.4 days Authoring 1 commit before pull request opened for review
 
0.0 days Awaiting first review
 
15.1 days Revising work with 3 commits in response to 0 reviews that left 1 comment

Total time for pull request still awaiting merge (longer than repo's target): 15.5 business days

Author avatar

Defer cloning unchanged Yoga children until layout

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

Comments Threads Pending Resolution

Resolved Comment Threads

No resolved comments have been left on this PR.