Pull Request Overview
- Opened on October 21, 2026
- Status Open
- Commit count 4 with first commit October 21, 2026
Total Delta
Open Days
Test Delta
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
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-unchangedpasses. The pinnedclang-format(21.1.2) passes on the changed C++ file, andprettier,eslintandflow focus-checkpass on the new test file. -
Fantom. The newView-deferYogaChildCloning-itest.jsruns with@fantom_flags deferYogaChildCloningUntilLayout:*and checks the mutations and layouts for: a color change on one of 50 children (oneUpdatemutation), 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 ofView-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 aposition: 'static'node,display: contentsand baseline-aligned text.yarn fantom View-deferYogaChildCloning-itest: 20 passed (10 tests Γ flag off and on), andView-yogaNodeOwnerAssertion-iteststill passes. The full suite (yarn fantom) passes with the flag's default switched totruelocally, twice (3,346 passed, 110 skipped), on the Debug tester wherereact_native_assertis on, including the ownership check inYogaLayoutableShadowNode::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. -
Thelayout()guard. With Yoga's change that stopsdisplay: nonechildren from keeping a stalehasNewLayoutreverted locally, the steps ofView-yogaNodeOwnerAssertion-itest.jswith 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 withxcodebuildin 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 fromTransactionTelemetry, with a local counter, everyShadowNodeclone made throughConcreteComponentDescriptor::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.