Skip to content

Commit c9d21b2

Browse files
hryhoriiK97meta-codesync[bot]
authored andcommitted
Skip unchanged children when slicing in the differ (#59242)
Summary: > This touches the differ, so I'm happy to split it if that's easier to review: (1) a refactor that moves the per-child logic of `sliceChildShadowNodeViewPairs` into helpers, with no behavior change, and (2) the change below, behind the new feature flag. When a node changes, the differ (`calculateShadowViewMutations`, `ReactCommon/react/renderer/mounting/Differentiator.cpp`) slices the children of its old and new revision with `sliceChildShadowNodeViewPairs` and compares the two lists. A commit clones only the path from the changed node to the root, so most of those children are the same `ShadowNode` in both revisions. **Current behavior.** Slicing builds a `ShadowViewNodePair`, including a `ShadowView`, for every child in both lists, recursing into flattened children, before the comparison finds that a child is unchanged and skips it. While a list scrolls, the ScrollView writes its content offset into its state on every frame, so every frame produces a commit that changes one node deep in the tree, and the differ slices every sibling along the path to it, twice. In the RNTester chat scenario below, 36 of the 56 child pairs compared in such a diff are the same node in both revisions. **With this change.** Behind the new `skipUnchangedChildrenInDifferentiator` feature flag (default `false`), the differ slices the children of a matched pair side by side with the new `sliceChangedChildShadowNodeViewPairs` and leaves out each child that is the same node in both revisions at the same offset. Such a child and its flattened descendants give equal pairs in both lists, which the differ matches without a mutation, so the mutations computed from the shorter lists are the same. A child is kept when its offset differs, because a flattened ancestor moved and its frame has to be updated. The side-by-side slicing gives up, and the differ slices both revisions in full as before, when the revisions differ in a way that could change the order or the kind of the pairs: child count, tags, flattening, position type, order index (`zIndex`), visibility or `ChildrenFormStackingContext`, or when view culling is enabled. The per-child logic of the existing slicer moves into helpers that both slicers share, so they can't drift apart. ## Changelog: [Internal] Pull Request resolved: #59242 Test Plan: - `clang-format --dry-run --Werror` on the changed C++ files passes. `yarn featureflags` regenerated the accessors. - **Fantom.** `Mounting-itest.js` now runs with `fantom_flags skipUnchangedChildrenInDifferentiator:*`, so every existing differ test also runs with the flag on, and it adds tests for an unchanged child next to a changed one, an unchanged child of a flattened view that moves, a scrolled ScrollView with unchanged siblings, a child added next to unchanged children and a child whose `zIndex` changes. `yarn fantom Mounting-itest`: 56 passed (14 tests × 4 flag combinations). The full suite (`FANTOM_FORCE_CI_MODE=1 yarn fantom`) passes with the flag's default switched to `true` locally, so that every test runs with it on (3,325 passed, 108 skipped), and with it off (same counts), twice each. In a first run with the flag on, the tester crashed once in `setUpDefaultReactNativeEnvironment-Globals-ResizeObserver-itest.js`; it did not crash again in three runs of that file alone or in two more full runs. - **Same mutations on device.** With a local checker that ran the differ twice for every commit, with and without the side-by-side slicing, and compared the full mutation lists, about 7,500 commits in RNTester on a physical iPhone produced no difference: mounting and unmounting 1000 views, re-rendering, a reflow that moves siblings, opacity animation, list item updates, `removeClippedSubviews`, `adjustsFontSizeToFit` text updates, scrolling a chat FlatList, typing in a TextInput, FlatList and SectionList scrolling. - Android was not built or tested. The slicing is shared C++, and the Android-specific branches (`useTraitHiddenOnAndroid`) are mirrored from the existing slicer. - **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](https://github.com/software-mansion/argent) on the physical iPhone. Timed: the diff phase of each mount transaction from `TransactionTelemetry`, on the main thread. | RNTester Playground, a FlatList of 500 multi-line messages, 20 flings down and 20 up per launch (~8,800 transactions) | before | this PR | change | |---|--:|--:|--:| | diff time per launch | 1,102 ms | 518 ms | −53.4% | | diff time per transaction | 126.1 µs | 58.8 µs | −53.4% | | diff time per 1–2-mutation transaction (the per-frame scroll updates) | 107.2 µs | 44.5 µs | −58.4% | | RNTester Playground, 1000 labels mounted and unmounted, 10 times per arm | before | this PR | change | |---|--:|--:|--:| | diff time per mount or unmount (median) | 2.54 ms | 2.45 ms | within noise | A mount gains nothing, since every node is new; the second table shows that giving up on the side-by-side slicing costs nothing measurable. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed By: christophpurrer Differential Revision: D126471123 Pulled By: javache fbshipit-source-id: a5e3e44e749ab82d9900f5d1ef65c5970deebe1c
1 parent 2c635d7 commit c9d21b2

24 files changed

Lines changed: 643 additions & 121 deletions

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<a9fc02c20449f356bba332d1d3c4b25b>>
7+
* @generated SignedSource<<574a9a84e11574df2358ed087516b9dc>>
88
*/
99

1010
/**
@@ -504,6 +504,12 @@ public object ReactNativeFeatureFlags {
504504
@JvmStatic
505505
public fun skipActivityIdentityAssertionOnHostPause(): Boolean = accessor.skipActivityIdentityAssertionOnHostPause()
506506

507+
/**
508+
* When a node changes, the differ leaves out its children that are unchanged in both trees instead of describing and comparing them.
509+
*/
510+
@JvmStatic
511+
public fun skipUnchangedChildrenInDifferentiator(): Boolean = accessor.skipUnchangedChildrenInDifferentiator()
512+
507513
/**
508514
* Override getClipBounds on Android views to return the padding box when overflow is hidden
509515
*/

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<1d46693f9d74064a63d906637b20c108>>
7+
* @generated SignedSource<<d2d170d333194ce2f2a4f1d93321ac6d>>
88
*/
99

1010
/**
@@ -99,6 +99,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
9999
private var shouldPressibilityUseW3CPointerEventsForHoverCache: Boolean? = null
100100
private var shouldTriggerResponderTransferOnScrollAndroidCache: Boolean? = null
101101
private var skipActivityIdentityAssertionOnHostPauseCache: Boolean? = null
102+
private var skipUnchangedChildrenInDifferentiatorCache: Boolean? = null
102103
private var syncAndroidClipBoundsWithOverflowCache: Boolean? = null
103104
private var traceTurboModulePromiseRejectionsOnAndroidCache: Boolean? = null
104105
private var updateRuntimeShadowNodeReferencesOnCommitCache: Boolean? = null
@@ -827,6 +828,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces
827828
return cached
828829
}
829830

831+
override fun skipUnchangedChildrenInDifferentiator(): Boolean {
832+
var cached = skipUnchangedChildrenInDifferentiatorCache
833+
if (cached == null) {
834+
cached = ReactNativeFeatureFlagsCxxInterop.skipUnchangedChildrenInDifferentiator()
835+
skipUnchangedChildrenInDifferentiatorCache = cached
836+
}
837+
return cached
838+
}
839+
830840
override fun syncAndroidClipBoundsWithOverflow(): Boolean {
831841
var cached = syncAndroidClipBoundsWithOverflowCache
832842
if (cached == null) {

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<d6fb6eda2b264730439d587e0f9da3b0>>
7+
* @generated SignedSource<<ec3e384af62c0e672da53b57354bca62>>
88
*/
99

1010
/**
@@ -186,6 +186,8 @@ public object ReactNativeFeatureFlagsCxxInterop {
186186

187187
@DoNotStrip @JvmStatic public external fun skipActivityIdentityAssertionOnHostPause(): Boolean
188188

189+
@DoNotStrip @JvmStatic public external fun skipUnchangedChildrenInDifferentiator(): Boolean
190+
189191
@DoNotStrip @JvmStatic public external fun syncAndroidClipBoundsWithOverflow(): Boolean
190192

191193
@DoNotStrip @JvmStatic public external fun traceTurboModulePromiseRejectionsOnAndroid(): Boolean

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<e5a314c2b7c0a124a805641ca8d8b296>>
7+
* @generated SignedSource<<0151a6981c002284da2c1415de4e968e>>
88
*/
99

1010
/**
@@ -181,6 +181,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi
181181

182182
override fun skipActivityIdentityAssertionOnHostPause(): Boolean = false
183183

184+
override fun skipUnchangedChildrenInDifferentiator(): Boolean = false
185+
184186
override fun syncAndroidClipBoundsWithOverflow(): Boolean = false
185187

186188
override fun traceTurboModulePromiseRejectionsOnAndroid(): Boolean = false

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<8ac941ef9289ab543d0adca8bf06a28d>>
7+
* @generated SignedSource<<c36b0b2d7173aa78b7cb175658fc89d2>>
88
*/
99

1010
/**
@@ -103,6 +103,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
103103
private var shouldPressibilityUseW3CPointerEventsForHoverCache: Boolean? = null
104104
private var shouldTriggerResponderTransferOnScrollAndroidCache: Boolean? = null
105105
private var skipActivityIdentityAssertionOnHostPauseCache: Boolean? = null
106+
private var skipUnchangedChildrenInDifferentiatorCache: Boolean? = null
106107
private var syncAndroidClipBoundsWithOverflowCache: Boolean? = null
107108
private var traceTurboModulePromiseRejectionsOnAndroidCache: Boolean? = null
108109
private var updateRuntimeShadowNodeReferencesOnCommitCache: Boolean? = null
@@ -910,6 +911,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc
910911
return cached
911912
}
912913

914+
override fun skipUnchangedChildrenInDifferentiator(): Boolean {
915+
var cached = skipUnchangedChildrenInDifferentiatorCache
916+
if (cached == null) {
917+
cached = currentProvider.skipUnchangedChildrenInDifferentiator()
918+
accessedFeatureFlags.add("skipUnchangedChildrenInDifferentiator")
919+
skipUnchangedChildrenInDifferentiatorCache = cached
920+
}
921+
return cached
922+
}
923+
913924
override fun syncAndroidClipBoundsWithOverflow(): Boolean {
914925
var cached = syncAndroidClipBoundsWithOverflowCache
915926
if (cached == null) {

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<db69be97c6b66b5384ddbf25ecf53eb2>>
7+
* @generated SignedSource<<c5e9b41b76c5580c0a4ba848af4e36a5>>
88
*/
99

1010
/**
@@ -181,6 +181,8 @@ public interface ReactNativeFeatureFlagsProvider {
181181

182182
@DoNotStrip public fun skipActivityIdentityAssertionOnHostPause(): Boolean
183183

184+
@DoNotStrip public fun skipUnchangedChildrenInDifferentiator(): Boolean
185+
184186
@DoNotStrip public fun syncAndroidClipBoundsWithOverflow(): Boolean
185187

186188
@DoNotStrip public fun traceTurboModulePromiseRejectionsOnAndroid(): Boolean

‎packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<f21f6a613ae5168de1d170cdde572f25>>
7+
* @generated SignedSource<<1ab2eec7b9c5bdd181ec5fbfd37149ab>>
88
*/
99

1010
/**
@@ -513,6 +513,12 @@ class ReactNativeFeatureFlagsJavaProvider
513513
return method(javaProvider_);
514514
}
515515

516+
bool skipUnchangedChildrenInDifferentiator() override {
517+
static const auto method =
518+
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("skipUnchangedChildrenInDifferentiator");
519+
return method(javaProvider_);
520+
}
521+
516522
bool syncAndroidClipBoundsWithOverflow() override {
517523
static const auto method =
518524
getReactNativeFeatureFlagsProviderJavaClass()->getMethod<jboolean()>("syncAndroidClipBoundsWithOverflow");
@@ -1008,6 +1014,11 @@ bool JReactNativeFeatureFlagsCxxInterop::skipActivityIdentityAssertionOnHostPaus
10081014
return ReactNativeFeatureFlags::skipActivityIdentityAssertionOnHostPause();
10091015
}
10101016

1017+
bool JReactNativeFeatureFlagsCxxInterop::skipUnchangedChildrenInDifferentiator(
1018+
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
1019+
return ReactNativeFeatureFlags::skipUnchangedChildrenInDifferentiator();
1020+
}
1021+
10111022
bool JReactNativeFeatureFlagsCxxInterop::syncAndroidClipBoundsWithOverflow(
10121023
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop> /*unused*/) {
10131024
return ReactNativeFeatureFlags::syncAndroidClipBoundsWithOverflow();
@@ -1356,6 +1367,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() {
13561367
makeNativeMethod(
13571368
"skipActivityIdentityAssertionOnHostPause",
13581369
JReactNativeFeatureFlagsCxxInterop::skipActivityIdentityAssertionOnHostPause),
1370+
makeNativeMethod(
1371+
"skipUnchangedChildrenInDifferentiator",
1372+
JReactNativeFeatureFlagsCxxInterop::skipUnchangedChildrenInDifferentiator),
13591373
makeNativeMethod(
13601374
"syncAndroidClipBoundsWithOverflow",
13611375
JReactNativeFeatureFlagsCxxInterop::syncAndroidClipBoundsWithOverflow),

‎packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<997a74618b4a955bce1ac6ef14b203aa>>
7+
* @generated SignedSource<<2c8f9205f1de55591ede3845ba2fa877>>
88
*/
99

1010
/**
@@ -267,6 +267,9 @@ class JReactNativeFeatureFlagsCxxInterop
267267
static bool skipActivityIdentityAssertionOnHostPause(
268268
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
269269

270+
static bool skipUnchangedChildrenInDifferentiator(
271+
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
272+
270273
static bool syncAndroidClipBoundsWithOverflow(
271274
facebook::jni::alias_ref<JReactNativeFeatureFlagsCxxInterop>);
272275

‎packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<a49f9bb223f576378418f4b7845ca98f>>
7+
* @generated SignedSource<<7542508e6a3b0dd942557748186527b3>>
88
*/
99

1010
/**
@@ -342,6 +342,10 @@ bool ReactNativeFeatureFlags::skipActivityIdentityAssertionOnHostPause() {
342342
return getAccessor().skipActivityIdentityAssertionOnHostPause();
343343
}
344344

345+
bool ReactNativeFeatureFlags::skipUnchangedChildrenInDifferentiator() {
346+
return getAccessor().skipUnchangedChildrenInDifferentiator();
347+
}
348+
345349
bool ReactNativeFeatureFlags::syncAndroidClipBoundsWithOverflow() {
346350
return getAccessor().syncAndroidClipBoundsWithOverflow();
347351
}

‎packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<d77173a9237cebe4468236f6a52980a0>>
7+
* @generated SignedSource<<ec78b5d1f026f52f188c7592f6f6bae8>>
88
*/
99

1010
/**
@@ -436,6 +436,11 @@ class ReactNativeFeatureFlags {
436436
*/
437437
RN_EXPORT static bool skipActivityIdentityAssertionOnHostPause();
438438

439+
/**
440+
* When a node changes, the differ leaves out its children that are unchanged in both trees instead of describing and comparing them.
441+
*/
442+
RN_EXPORT static bool skipUnchangedChildrenInDifferentiator();
443+
439444
/**
440445
* Override getClipBounds on Android views to return the padding box when overflow is hidden
441446
*/

0 commit comments

Comments
 (0)