From ba7919be081055ebd04dcf7d596fac0460dbd037 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Tue, 20 Oct 2026 20:10:47 +0200 Subject: [PATCH] Skip unchanged children when slicing in the differ When a node changes, the differ slices the children of its old and new revision and compares them. Most of them are the same ShadowNode in both revisions, since a commit clones only the path to the root, but each one is still turned into a ShadowViewNodePair in both lists before the comparison skips it. Behind the new skipUnchangedChildrenInDifferentiator flag, the children of a matched pair are sliced side by side, leaving out each child that is the same node in both revisions at the same offset. Such a child produces no mutation, so the mutations are unchanged. The side-by-side slicing falls back to slicing both revisions in full when the child count, tags, flattening, position type, order index, visibility or ChildrenFormStackingContext differ, or when view culling is enabled. The per-child logic of the existing slicer moves into helpers shared by both. Co-Authored-By: Claude Opus 5.5 --- .../featureflags/ReactNativeFeatureFlags.kt | 8 +- .../ReactNativeFeatureFlagsCxxAccessor.kt | 12 +- .../ReactNativeFeatureFlagsCxxInterop.kt | 4 +- .../ReactNativeFeatureFlagsDefaults.kt | 4 +- .../ReactNativeFeatureFlagsLocalAccessor.kt | 13 +- .../ReactNativeFeatureFlagsProvider.kt | 4 +- .../JReactNativeFeatureFlagsCxxInterop.cpp | 16 +- .../JReactNativeFeatureFlagsCxxInterop.h | 5 +- .../featureflags/ReactNativeFeatureFlags.cpp | 6 +- .../featureflags/ReactNativeFeatureFlags.h | 7 +- .../ReactNativeFeatureFlagsAccessor.cpp | 52 ++- .../ReactNativeFeatureFlagsAccessor.h | 6 +- .../ReactNativeFeatureFlagsDefaults.h | 6 +- .../ReactNativeFeatureFlagsDynamicProvider.h | 11 +- .../ReactNativeFeatureFlagsProvider.h | 3 +- .../NativeReactNativeFeatureFlags.cpp | 7 +- .../NativeReactNativeFeatureFlags.h | 4 +- .../renderer/mounting/Differentiator.cpp | 74 +++- .../sliceChildShadowNodeViewPairs.cpp | 349 ++++++++++++++---- .../internal/sliceChildShadowNodeViewPairs.h | 22 ++ .../ReactNativeFeatureFlags.config.js | 11 + .../featureflags/ReactNativeFeatureFlags.js | 7 +- .../specs/NativeReactNativeFeatureFlags.js | 3 +- .../mounting/__tests__/Mounting-itest.js | 130 ++++++- 24 files changed, 643 insertions(+), 121 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index 0c879d6415f9..71eee4abd433 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<48b4420f0396dcad107bf821fcec61b3>> */ /** @@ -498,6 +498,12 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun skipActivityIdentityAssertionOnHostPause(): Boolean = accessor.skipActivityIdentityAssertionOnHostPause() + /** + * When a node changes, the differ leaves out its children that are unchanged in both trees instead of describing and comparing them. + */ + @JvmStatic + public fun skipUnchangedChildrenInDifferentiator(): Boolean = accessor.skipUnchangedChildrenInDifferentiator() + /** * Override getClipBounds on Android views to return the padding box when overflow is hidden */ diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt index 28329721fab8..112d87549783 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<71d0554dc45df7d5365f81b7ec8c3d5b>> + * @generated SignedSource<> */ /** @@ -98,6 +98,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var shouldPressibilityUseW3CPointerEventsForHoverCache: Boolean? = null private var shouldTriggerResponderTransferOnScrollAndroidCache: Boolean? = null private var skipActivityIdentityAssertionOnHostPauseCache: Boolean? = null + private var skipUnchangedChildrenInDifferentiatorCache: Boolean? = null private var syncAndroidClipBoundsWithOverflowCache: Boolean? = null private var traceTurboModulePromiseRejectionsOnAndroidCache: Boolean? = null private var updateRuntimeShadowNodeReferencesOnCommitCache: Boolean? = null @@ -817,6 +818,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces return cached } + override fun skipUnchangedChildrenInDifferentiator(): Boolean { + var cached = skipUnchangedChildrenInDifferentiatorCache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.skipUnchangedChildrenInDifferentiator() + skipUnchangedChildrenInDifferentiatorCache = cached + } + return cached + } + override fun syncAndroidClipBoundsWithOverflow(): Boolean { var cached = syncAndroidClipBoundsWithOverflowCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt index 6f83f1cc3dbd..c60fb1d1ca75 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<312cec99bd429d22e25f4e82d7fc4ee7>> + * @generated SignedSource<> */ /** @@ -184,6 +184,8 @@ public object ReactNativeFeatureFlagsCxxInterop { @DoNotStrip @JvmStatic public external fun skipActivityIdentityAssertionOnHostPause(): Boolean + @DoNotStrip @JvmStatic public external fun skipUnchangedChildrenInDifferentiator(): Boolean + @DoNotStrip @JvmStatic public external fun syncAndroidClipBoundsWithOverflow(): Boolean @DoNotStrip @JvmStatic public external fun traceTurboModulePromiseRejectionsOnAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index f4998cb8b416..4be939f92c45 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<669f16e131d4a5ddc026ffecc6893575>> + * @generated SignedSource<<3b2153df84f2c0193f32a5c5bf312700>> */ /** @@ -179,6 +179,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun skipActivityIdentityAssertionOnHostPause(): Boolean = false + override fun skipUnchangedChildrenInDifferentiator(): Boolean = false + override fun syncAndroidClipBoundsWithOverflow(): Boolean = false override fun traceTurboModulePromiseRejectionsOnAndroid(): Boolean = false diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt index 536ee78905d7..127e1645ec97 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<2a0c2c71f3da4657d0f7b26548542c0d>> */ /** @@ -102,6 +102,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var shouldPressibilityUseW3CPointerEventsForHoverCache: Boolean? = null private var shouldTriggerResponderTransferOnScrollAndroidCache: Boolean? = null private var skipActivityIdentityAssertionOnHostPauseCache: Boolean? = null + private var skipUnchangedChildrenInDifferentiatorCache: Boolean? = null private var syncAndroidClipBoundsWithOverflowCache: Boolean? = null private var traceTurboModulePromiseRejectionsOnAndroidCache: Boolean? = null private var updateRuntimeShadowNodeReferencesOnCommitCache: Boolean? = null @@ -899,6 +900,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc return cached } + override fun skipUnchangedChildrenInDifferentiator(): Boolean { + var cached = skipUnchangedChildrenInDifferentiatorCache + if (cached == null) { + cached = currentProvider.skipUnchangedChildrenInDifferentiator() + accessedFeatureFlags.add("skipUnchangedChildrenInDifferentiator") + skipUnchangedChildrenInDifferentiatorCache = cached + } + return cached + } + override fun syncAndroidClipBoundsWithOverflow(): Boolean { var cached = syncAndroidClipBoundsWithOverflowCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt index 0143013e3ccd..2c85ff852523 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<18267103ef4f3eb22f23a15b808528c2>> + * @generated SignedSource<> */ /** @@ -179,6 +179,8 @@ public interface ReactNativeFeatureFlagsProvider { @DoNotStrip public fun skipActivityIdentityAssertionOnHostPause(): Boolean + @DoNotStrip public fun skipUnchangedChildrenInDifferentiator(): Boolean + @DoNotStrip public fun syncAndroidClipBoundsWithOverflow(): Boolean @DoNotStrip public fun traceTurboModulePromiseRejectionsOnAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp index 526398c18c11..1fec3ed0299a 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<568d6e4175fb51e09bc3d4441ffa9741>> + * @generated SignedSource<> */ /** @@ -507,6 +507,12 @@ class ReactNativeFeatureFlagsJavaProvider return method(javaProvider_); } + bool skipUnchangedChildrenInDifferentiator() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("skipUnchangedChildrenInDifferentiator"); + return method(javaProvider_); + } + bool syncAndroidClipBoundsWithOverflow() override { static const auto method = getReactNativeFeatureFlagsProviderJavaClass()->getMethod("syncAndroidClipBoundsWithOverflow"); @@ -997,6 +1003,11 @@ bool JReactNativeFeatureFlagsCxxInterop::skipActivityIdentityAssertionOnHostPaus return ReactNativeFeatureFlags::skipActivityIdentityAssertionOnHostPause(); } +bool JReactNativeFeatureFlagsCxxInterop::skipUnchangedChildrenInDifferentiator( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::skipUnchangedChildrenInDifferentiator(); +} + bool JReactNativeFeatureFlagsCxxInterop::syncAndroidClipBoundsWithOverflow( facebook::jni::alias_ref /*unused*/) { return ReactNativeFeatureFlags::syncAndroidClipBoundsWithOverflow(); @@ -1342,6 +1353,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() { makeNativeMethod( "skipActivityIdentityAssertionOnHostPause", JReactNativeFeatureFlagsCxxInterop::skipActivityIdentityAssertionOnHostPause), + makeNativeMethod( + "skipUnchangedChildrenInDifferentiator", + JReactNativeFeatureFlagsCxxInterop::skipUnchangedChildrenInDifferentiator), makeNativeMethod( "syncAndroidClipBoundsWithOverflow", JReactNativeFeatureFlagsCxxInterop::syncAndroidClipBoundsWithOverflow), diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h index 5f3fdda386cd..01ff405d654c 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<2e0f936c003aafc456ab3f960ca4b2f3>> + * @generated SignedSource<<79e3a5e32cd586b32e2c20c4f586c207>> */ /** @@ -264,6 +264,9 @@ class JReactNativeFeatureFlagsCxxInterop static bool skipActivityIdentityAssertionOnHostPause( facebook::jni::alias_ref); + static bool skipUnchangedChildrenInDifferentiator( + facebook::jni::alias_ref); + static bool syncAndroidClipBoundsWithOverflow( facebook::jni::alias_ref); diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp index adc3531518ef..7d054417d5c4 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<> */ /** @@ -338,6 +338,10 @@ bool ReactNativeFeatureFlags::skipActivityIdentityAssertionOnHostPause() { return getAccessor().skipActivityIdentityAssertionOnHostPause(); } +bool ReactNativeFeatureFlags::skipUnchangedChildrenInDifferentiator() { + return getAccessor().skipUnchangedChildrenInDifferentiator(); +} + bool ReactNativeFeatureFlags::syncAndroidClipBoundsWithOverflow() { return getAccessor().syncAndroidClipBoundsWithOverflow(); } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index cd4d40533d5f..e7ee221223c8 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<62dbb584d6aaf595314636a81346e3e5>> + * @generated SignedSource<<349d1fffdceddf1b43927718ee193317>> */ /** @@ -431,6 +431,11 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool skipActivityIdentityAssertionOnHostPause(); + /** + * When a node changes, the differ leaves out its children that are unchanged in both trees instead of describing and comparing them. + */ + RN_EXPORT static bool skipUnchangedChildrenInDifferentiator(); + /** * Override getClipBounds on Android views to return the padding box when overflow is hidden */ diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp index 93ef1cc62ace..3196668902a1 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<3e4c90095377b1c9ec5280eeec3f93b5>> */ /** @@ -1433,6 +1433,24 @@ bool ReactNativeFeatureFlagsAccessor::skipActivityIdentityAssertionOnHostPause() return flagValue.value(); } +bool ReactNativeFeatureFlagsAccessor::skipUnchangedChildrenInDifferentiator() { + auto flagValue = skipUnchangedChildrenInDifferentiator_.load(); + + if (!flagValue.has_value()) { + // This block is not exclusive but it is not necessary. + // If multiple threads try to initialize the feature flag, we would only + // be accessing the provider multiple times but the end state of this + // instance and the returned flag value would be the same. + + markFlagAsAccessed(78, "skipUnchangedChildrenInDifferentiator"); + + flagValue = currentProvider_->skipUnchangedChildrenInDifferentiator(); + skipUnchangedChildrenInDifferentiator_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::syncAndroidClipBoundsWithOverflow() { auto flagValue = syncAndroidClipBoundsWithOverflow_.load(); @@ -1442,7 +1460,7 @@ bool ReactNativeFeatureFlagsAccessor::syncAndroidClipBoundsWithOverflow() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(78, "syncAndroidClipBoundsWithOverflow"); + markFlagAsAccessed(79, "syncAndroidClipBoundsWithOverflow"); flagValue = currentProvider_->syncAndroidClipBoundsWithOverflow(); syncAndroidClipBoundsWithOverflow_ = flagValue; @@ -1460,7 +1478,7 @@ bool ReactNativeFeatureFlagsAccessor::traceTurboModulePromiseRejectionsOnAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(79, "traceTurboModulePromiseRejectionsOnAndroid"); + markFlagAsAccessed(80, "traceTurboModulePromiseRejectionsOnAndroid"); flagValue = currentProvider_->traceTurboModulePromiseRejectionsOnAndroid(); traceTurboModulePromiseRejectionsOnAndroid_ = flagValue; @@ -1478,7 +1496,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommit( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(80, "updateRuntimeShadowNodeReferencesOnCommit"); + markFlagAsAccessed(81, "updateRuntimeShadowNodeReferencesOnCommit"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommit(); updateRuntimeShadowNodeReferencesOnCommit_ = flagValue; @@ -1496,7 +1514,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommitT // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(81, "updateRuntimeShadowNodeReferencesOnCommitThread"); + markFlagAsAccessed(82, "updateRuntimeShadowNodeReferencesOnCommitThread"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommitThread(); updateRuntimeShadowNodeReferencesOnCommitThread_ = flagValue; @@ -1514,7 +1532,7 @@ bool ReactNativeFeatureFlagsAccessor::useAlwaysAvailableJSErrorHandling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(82, "useAlwaysAvailableJSErrorHandling"); + markFlagAsAccessed(83, "useAlwaysAvailableJSErrorHandling"); flagValue = currentProvider_->useAlwaysAvailableJSErrorHandling(); useAlwaysAvailableJSErrorHandling_ = flagValue; @@ -1532,7 +1550,7 @@ bool ReactNativeFeatureFlagsAccessor::useFabricInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(83, "useFabricInterop"); + markFlagAsAccessed(84, "useFabricInterop"); flagValue = currentProvider_->useFabricInterop(); useFabricInterop_ = flagValue; @@ -1550,7 +1568,7 @@ bool ReactNativeFeatureFlagsAccessor::useNativeViewConfigsInBridgelessMode() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(84, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(85, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1568,7 +1586,7 @@ bool ReactNativeFeatureFlagsAccessor::useNestedScrollViewAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(85, "useNestedScrollViewAndroid"); + markFlagAsAccessed(86, "useNestedScrollViewAndroid"); flagValue = currentProvider_->useNestedScrollViewAndroid(); useNestedScrollViewAndroid_ = flagValue; @@ -1586,7 +1604,7 @@ bool ReactNativeFeatureFlagsAccessor::useSharedAnimatedBackend() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(86, "useSharedAnimatedBackend"); + markFlagAsAccessed(87, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1604,7 +1622,7 @@ bool ReactNativeFeatureFlagsAccessor::useTraitHiddenOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(87, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(88, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1622,7 +1640,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModuleInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(88, "useTurboModuleInterop"); + markFlagAsAccessed(89, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1640,7 +1658,7 @@ bool ReactNativeFeatureFlagsAccessor::useWindowDensityForDisplayMetricsAndroid() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(89, "useWindowDensityForDisplayMetricsAndroid"); + markFlagAsAccessed(90, "useWindowDensityForDisplayMetricsAndroid"); flagValue = currentProvider_->useWindowDensityForDisplayMetricsAndroid(); useWindowDensityForDisplayMetricsAndroid_ = flagValue; @@ -1658,7 +1676,7 @@ double ReactNativeFeatureFlagsAccessor::viewCullingOutsetRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(90, "viewCullingOutsetRatio"); + markFlagAsAccessed(91, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1676,7 +1694,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(91, "viewTransitionEnabled"); + markFlagAsAccessed(92, "viewTransitionEnabled"); flagValue = currentProvider_->viewTransitionEnabled(); viewTransitionEnabled_ = flagValue; @@ -1694,7 +1712,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionUseHardwareBitmapAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(92, "viewTransitionUseHardwareBitmapAndroid"); + markFlagAsAccessed(93, "viewTransitionUseHardwareBitmapAndroid"); flagValue = currentProvider_->viewTransitionUseHardwareBitmapAndroid(); viewTransitionUseHardwareBitmapAndroid_ = flagValue; @@ -1712,7 +1730,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(93, "virtualViewPrerenderRatio"); + markFlagAsAccessed(94, "virtualViewPrerenderRatio"); flagValue = currentProvider_->virtualViewPrerenderRatio(); virtualViewPrerenderRatio_ = flagValue; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h index 5c05c3bc5427..7e20b5ccca05 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<074e6ad1d773f289f93ce1d16656ec6e>> + * @generated SignedSource<<45f1e05d7349e95252b6d022e79f806c>> */ /** @@ -112,6 +112,7 @@ class ReactNativeFeatureFlagsAccessor { bool shouldPressibilityUseW3CPointerEventsForHover(); bool shouldTriggerResponderTransferOnScrollAndroid(); bool skipActivityIdentityAssertionOnHostPause(); + bool skipUnchangedChildrenInDifferentiator(); bool syncAndroidClipBoundsWithOverflow(); bool traceTurboModulePromiseRejectionsOnAndroid(); bool updateRuntimeShadowNodeReferencesOnCommit(); @@ -139,7 +140,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 94> accessedFeatureFlags_; + std::array, 95> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -219,6 +220,7 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> shouldPressibilityUseW3CPointerEventsForHover_; std::atomic> shouldTriggerResponderTransferOnScrollAndroid_; std::atomic> skipActivityIdentityAssertionOnHostPause_; + std::atomic> skipUnchangedChildrenInDifferentiator_; std::atomic> syncAndroidClipBoundsWithOverflow_; std::atomic> traceTurboModulePromiseRejectionsOnAndroid_; std::atomic> updateRuntimeShadowNodeReferencesOnCommit_; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index 82208e3861b9..eb80fb346539 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<52db1adfed5fd99e4ca145b50dc413a0>> + * @generated SignedSource<<05d7627f826b260b80cb5c849b9a3deb>> */ /** @@ -341,6 +341,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return false; } + bool skipUnchangedChildrenInDifferentiator() override { + return false; + } + bool syncAndroidClipBoundsWithOverflow() override { return false; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index 4d40ac107fc4..994f349e8339 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<8d27c5f3feb4c8000922012882701b7e>> + * @generated SignedSource<> */ /** @@ -749,6 +749,15 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef return ReactNativeFeatureFlagsDefaults::skipActivityIdentityAssertionOnHostPause(); } + bool skipUnchangedChildrenInDifferentiator() override { + auto value = values_["skipUnchangedChildrenInDifferentiator"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::skipUnchangedChildrenInDifferentiator(); + } + bool syncAndroidClipBoundsWithOverflow() override { auto value = values_["syncAndroidClipBoundsWithOverflow"]; if (!value.isNull()) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h index 3ba8405c14ff..1554cfc8177e 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<7e5839223cfce81a42708f876c4c77ee>> + * @generated SignedSource<> */ /** @@ -105,6 +105,7 @@ class ReactNativeFeatureFlagsProvider { virtual bool shouldPressibilityUseW3CPointerEventsForHover() = 0; virtual bool shouldTriggerResponderTransferOnScrollAndroid() = 0; virtual bool skipActivityIdentityAssertionOnHostPause() = 0; + virtual bool skipUnchangedChildrenInDifferentiator() = 0; virtual bool syncAndroidClipBoundsWithOverflow() = 0; virtual bool traceTurboModulePromiseRejectionsOnAndroid() = 0; virtual bool updateRuntimeShadowNodeReferencesOnCommit() = 0; diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp index f2d4210f8d23..b477518cbd29 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<04ac6206f8cbad557091ae4543103465>> */ /** @@ -434,6 +434,11 @@ bool NativeReactNativeFeatureFlags::skipActivityIdentityAssertionOnHostPause( return ReactNativeFeatureFlags::skipActivityIdentityAssertionOnHostPause(); } +bool NativeReactNativeFeatureFlags::skipUnchangedChildrenInDifferentiator( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::skipUnchangedChildrenInDifferentiator(); +} + bool NativeReactNativeFeatureFlags::syncAndroidClipBoundsWithOverflow( jsi::Runtime& /*runtime*/) { return ReactNativeFeatureFlags::syncAndroidClipBoundsWithOverflow(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h index 4aafb51f00fc..957e7f50311f 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<84b944e162fc53799326c584a5edd17d>> + * @generated SignedSource<<3356b4ddc2ac35a521698944d3dd5d49>> */ /** @@ -194,6 +194,8 @@ class NativeReactNativeFeatureFlags bool skipActivityIdentityAssertionOnHostPause(jsi::Runtime& runtime); + bool skipUnchangedChildrenInDifferentiator(jsi::Runtime& runtime); + bool syncAndroidClipBoundsWithOverflow(jsi::Runtime& runtime); bool traceTurboModulePromiseRejectionsOnAndroid(jsi::Runtime& runtime); diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp index 41de087d3f80..2e47428d3f58 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include "internal/CullingContext.h" @@ -91,6 +92,35 @@ sliceChildShadowNodeViewPairsFromViewNodePair( cullingContext); } +/* + * Slices the children of a matched pair in both trees. With + * `skipUnchangedChildrenInDifferentiator`, children that are the same node in + * both trees are left out when possible: they produce no mutations. + */ +static void sliceMatchedPairChildren( + const ShadowViewNodePair& oldPair, + const ShadowViewNodePair& newPair, + ViewNodePairScope& scope, + const CullingContext& oldCullingContext, + const CullingContext& newCullingContext, + std::vector& oldChildPairs, + std::vector& newChildPairs) { + if (ReactNativeFeatureFlags::skipUnchangedChildrenInDifferentiator() && + sliceChangedChildShadowNodeViewPairs( + oldPair, + newPair, + scope, + false /* allowFlattened */, + oldChildPairs, + newChildPairs)) { + return; + } + oldChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( + oldPair, scope, false /* allowFlattened */, oldCullingContext); + newChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( + newPair, scope, false /* allowFlattened */, newCullingContext); +} + /* * Before we start to diff, let's make sure all our core data structures are * in good shape to deliver the best performance. @@ -299,10 +329,16 @@ static void updateMatchedPairSubtrees( if (oldPair.shadowNode != newPair.shadowNode || oldCullingContextCopy != newCullingContextCopy) { ViewNodePairScope innerScope{}; - auto oldGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( - oldPair, innerScope, false, oldCullingContextCopy); - auto newGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( - newPair, innerScope, false, newCullingContextCopy); + std::vector oldGrandChildPairs; + std::vector newGrandChildPairs; + sliceMatchedPairChildren( + oldPair, + newPair, + innerScope, + oldCullingContextCopy, + newCullingContextCopy, + oldGrandChildPairs, + newGrandChildPairs); const size_t newGrandChildPairsSize = newGrandChildPairs.size(); calculateShadowViewMutations( @@ -948,10 +984,16 @@ static void calculateShadowViewMutations( (oldChildPair.shadowNode != newChildPair.shadowNode || adjustedOldCullingContext != adjustedNewCullingContext)) { ViewNodePairScope innerScope{}; - auto oldGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( - oldChildPair, innerScope, false, adjustedOldCullingContext); - auto newGrandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( - newChildPair, innerScope, false, adjustedNewCullingContext); + std::vector oldGrandChildPairs; + std::vector newGrandChildPairs; + sliceMatchedPairChildren( + oldChildPair, + newChildPair, + innerScope, + adjustedOldCullingContext, + adjustedNewCullingContext, + oldGrandChildPairs, + newGrandChildPairs); const size_t newGrandChildPairsSize = newGrandChildPairs.size(); @@ -1382,18 +1424,16 @@ ShadowViewMutation::List calculateShadowViewMutations( oldRootShadowView, newRootShadowView, {})); } - auto sliceOne = sliceChildShadowNodeViewPairs( + std::vector sliceOne; + std::vector sliceTwo; + sliceMatchedPairChildren( ShadowViewNodePair{.shadowNode = &oldRootShadowNode}, - viewNodePairScope, - false /* allowFlattened */, - {} /* layoutOffset */, - {} /* cullingContext */); - auto sliceTwo = sliceChildShadowNodeViewPairs( ShadowViewNodePair{.shadowNode = &newRootShadowNode}, viewNodePairScope, - false /* allowFlattened */, - {} /* layoutOffset */, - {} /* cullingContext */); + {} /* oldCullingContext */, + {} /* newCullingContext */, + sliceOne, + sliceTwo); mutations.reserve(256); diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp index 644d4e4d88a4..4fe57dd5a31a 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.cpp @@ -47,6 +47,95 @@ static void reorderInPlaceIfNeeded( pairs.begin(), pairs.end(), &shouldFirstPairComesBeforeSecondOne); } +/* + * Whether the slicer leaves out `shadowNode` and its descendants. + */ +static bool isHiddenWhenSlicing(const ShadowNode& shadowNode) { + // T153547836: Disabled on Android because the mounting infrastructure + // is not fully ready yet. + return +#ifdef ANDROID + ReactNativeFeatureFlags::useTraitHiddenOnAndroid() && +#endif + shadowNode.getTraits().check(ShadowNodeTraits::Trait::Hidden); +} + +/* + * A child of `parentShadowNode` as the slicer represents it: its view, moved + * by the offset of the flattened views it was hoisted out of, the origin its + * own flattened children are offset by, and whether it forms a view and + * flattens its children. + */ +struct ChildSlice { + ShadowView shadowView; + Point origin; + bool isConcreteView; + bool areChildrenFlattened; +}; + +static ChildSlice sliceChild( + const ShadowNode& parentShadowNode, + const ShadowNode& childShadowNode, + ShadowView shadowView, + Point layoutOffset) { + auto origin = layoutOffset; + if (shadowView.layoutMetrics != EmptyLayoutMetrics) { + origin += shadowView.layoutMetrics.frame.origin; + shadowView.layoutMetrics.frame.origin += layoutOffset; + } + + // This might not be a FormsView, or a FormsStackingContext. We let the + // differ handle removal of flattened views from the Mounting layer and + // shuffling their children around. + bool childrenFormStackingContexts = parentShadowNode.getTraits().check( + ShadowNodeTraits::Trait::ChildrenFormStackingContext); + bool isConcreteView = + (childShadowNode.getTraits().check(ShadowNodeTraits::Trait::FormsView) || + childrenFormStackingContexts) && + !childShadowNode.getTraits().check( + ShadowNodeTraits::Trait::ForceFlattenView); + bool areChildrenFlattened = + (!childShadowNode.getTraits().check( + ShadowNodeTraits::Trait::FormsStackingContext) && + !childrenFormStackingContexts) || + childShadowNode.getTraits().check( + ShadowNodeTraits::Trait::ForceFlattenView); + + return { + .shadowView = std::move(shadowView), + .origin = origin, + .isConcreteView = isConcreteView, + .areChildrenFlattened = areChildrenFlattened}; +} + +/* + * Adds `childSlice` to `scope` and its pair to `pairList`: statically + * positioned pairs go before absolutely positioned ones. + */ +static void addChildSlice( + std::vector& pairList, + size_t& startOfStaticIndex, + ViewNodePairScope& scope, + const ChildSlice& childSlice, + const ShadowNode& childShadowNode) { + scope.push_back( + {childSlice.shadowView, + &childShadowNode, + childSlice.areChildrenFlattened, + childSlice.isConcreteView, + childSlice.areChildrenFlattened ? childSlice.origin : Point{}}); + + if (childSlice.shadowView.layoutMetrics.positionType == + PositionType::Static) { + auto it = pairList.begin(); + std::advance(it, startOfStaticIndex); + pairList.insert(it, &scope.back()); + startOfStaticIndex++; + } else { + pairList.push_back(&scope.back()); + } +} + static void sliceChildShadowNodeViewPairsRecursively( std::vector& pairList, size_t& startOfStaticIndex, @@ -56,13 +145,7 @@ static void sliceChildShadowNodeViewPairsRecursively( const CullingContext& cullingContext) { for (const auto& sharedChildShadowNode : shadowNode.getChildren()) { auto& childShadowNode = *sharedChildShadowNode; - // T153547836: Disabled on Android because the mounting infrastructure - // is not fully ready yet. - if ( -#ifdef ANDROID - ReactNativeFeatureFlags::useTraitHiddenOnAndroid() && -#endif - childShadowNode.getTraits().check(ShadowNodeTraits::Trait::Hidden)) { + if (isHiddenWhenSlicing(childShadowNode)) { continue; } auto shadowView = ShadowView(childShadowNode); @@ -97,71 +180,208 @@ static void sliceChildShadowNodeViewPairsRecursively( } } - auto origin = layoutOffset; auto cullingContextCopy = cullingContext.adjustCullingContextIfNeeded( {.shadowView = shadowView, .shadowNode = &childShadowNode}); - if (shadowView.layoutMetrics != EmptyLayoutMetrics) { - origin += shadowView.layoutMetrics.frame.origin; - shadowView.layoutMetrics.frame.origin += layoutOffset; + auto childSlice = sliceChild( + shadowNode, childShadowNode, std::move(shadowView), layoutOffset); + addChildSlice( + pairList, startOfStaticIndex, scope, childSlice, childShadowNode); + + if (!childSlice.areChildrenFlattened) { + continue; + } + if (childSlice.shadowView.layoutMetrics.positionType == + PositionType::Static) { + sliceChildShadowNodeViewPairsRecursively( + pairList, + startOfStaticIndex, + scope, + childSlice.origin, + childShadowNode, + cullingContextCopy); + } else { + size_t pairListSize = pairList.size(); + sliceChildShadowNodeViewPairsRecursively( + pairList, + pairListSize, + scope, + childSlice.origin, + childShadowNode, + cullingContextCopy); } + } +} - // This might not be a FormsView, or a FormsStackingContext. We let the - // differ handle removal of flattened views from the Mounting layer and - // shuffling their children around. - bool childrenFormStackingContexts = shadowNode.getTraits().check( - ShadowNodeTraits::Trait::ChildrenFormStackingContext); - bool isConcreteView = (childShadowNode.getTraits().check( - ShadowNodeTraits::Trait::FormsView) || - childrenFormStackingContexts) && - !childShadowNode.getTraits().check( - ShadowNodeTraits::Trait::ForceFlattenView); - bool areChildrenFlattened = - (!childShadowNode.getTraits().check( - ShadowNodeTraits::Trait::FormsStackingContext) && - !childrenFormStackingContexts) || - childShadowNode.getTraits().check( - ShadowNodeTraits::Trait::ForceFlattenView); - - Point storedOrigin = {}; - if (areChildrenFlattened) { - storedOrigin = origin; +/* + * Sets the mount index of each pair after reordering. + */ +static void assignMountIndexes(std::vector& pairList) { + size_t mountIndex = 0; + for (auto child : pairList) { + child->mountIndex = + (child->isConcreteView ? mountIndex++ : static_cast(-1)); + } +} + +static bool sliceChangedChildShadowNodeViewPairsRecursively( + std::vector& oldPairList, + size_t& oldStartOfStaticIndex, + std::vector& newPairList, + size_t& newStartOfStaticIndex, + ViewNodePairScope& scope, + Point oldLayoutOffset, + Point newLayoutOffset, + const ShadowNode& oldShadowNode, + const ShadowNode& newShadowNode) { + const auto& oldChildren = oldShadowNode.getChildren(); + const auto& newChildren = newShadowNode.getChildren(); + if (oldChildren.size() != newChildren.size() || + oldShadowNode.getTraits().check( + ShadowNodeTraits::Trait::ChildrenFormStackingContext) != + newShadowNode.getTraits().check( + ShadowNodeTraits::Trait::ChildrenFormStackingContext)) { + return false; + } + + bool isSameLayoutOffset = oldLayoutOffset == newLayoutOffset; + for (size_t i = 0; i < oldChildren.size(); i++) { + const auto& oldChildShadowNode = *oldChildren[i]; + const auto& newChildShadowNode = *newChildren[i]; + + bool isHidden = isHiddenWhenSlicing(oldChildShadowNode); + if (isHidden != isHiddenWhenSlicing(newChildShadowNode)) { + return false; + } + if (isHidden) { + continue; + } + + // The same node at the same offset gives the same pairs in both lists, + // which the differ matches without a mutation. + if (&oldChildShadowNode == &newChildShadowNode && isSameLayoutOffset) { + continue; + } + + if (oldChildShadowNode.getTag() != newChildShadowNode.getTag() || + oldChildShadowNode.getOrderIndex() != + newChildShadowNode.getOrderIndex()) { + return false; + } + + auto oldChildSlice = sliceChild( + oldShadowNode, + oldChildShadowNode, + ShadowView(oldChildShadowNode), + oldLayoutOffset); + auto newChildSlice = sliceChild( + newShadowNode, + newChildShadowNode, + ShadowView(newChildShadowNode), + newLayoutOffset); + bool isStatic = newChildSlice.shadowView.layoutMetrics.positionType == + PositionType::Static; + if (oldChildSlice.isConcreteView != newChildSlice.isConcreteView || + oldChildSlice.areChildrenFlattened != + newChildSlice.areChildrenFlattened || + (oldChildSlice.shadowView.layoutMetrics.positionType == + PositionType::Static) != isStatic) { + return false; + } + + addChildSlice( + oldPairList, + oldStartOfStaticIndex, + scope, + oldChildSlice, + oldChildShadowNode); + addChildSlice( + newPairList, + newStartOfStaticIndex, + scope, + newChildSlice, + newChildShadowNode); + + if (!newChildSlice.areChildrenFlattened) { + continue; } - scope.push_back( - {shadowView, - &childShadowNode, - areChildrenFlattened, - isConcreteView, - storedOrigin}); - - if (shadowView.layoutMetrics.positionType == PositionType::Static) { - auto it = pairList.begin(); - std::advance(it, startOfStaticIndex); - pairList.insert(it, &scope.back()); - startOfStaticIndex++; - if (areChildrenFlattened) { - sliceChildShadowNodeViewPairsRecursively( - pairList, - startOfStaticIndex, - scope, - origin, - childShadowNode, - cullingContextCopy); + if (isStatic) { + if (!sliceChangedChildShadowNodeViewPairsRecursively( + oldPairList, + oldStartOfStaticIndex, + newPairList, + newStartOfStaticIndex, + scope, + oldChildSlice.origin, + newChildSlice.origin, + oldChildShadowNode, + newChildShadowNode)) { + return false; } } else { - pairList.push_back(&scope.back()); - if (areChildrenFlattened) { - size_t pairListSize = pairList.size(); - sliceChildShadowNodeViewPairsRecursively( - pairList, - pairListSize, - scope, - origin, - childShadowNode, - cullingContextCopy); + size_t oldPairListSize = oldPairList.size(); + size_t newPairListSize = newPairList.size(); + if (!sliceChangedChildShadowNodeViewPairsRecursively( + oldPairList, + oldPairListSize, + newPairList, + newPairListSize, + scope, + oldChildSlice.origin, + newChildSlice.origin, + oldChildShadowNode, + newChildShadowNode)) { + return false; } } } + return true; +} + +bool sliceChangedChildShadowNodeViewPairs( + const ShadowViewNodePair& oldShadowNodePair, + const ShadowViewNodePair& newShadowNodePair, + ViewNodePairScope& scope, + bool allowFlattened, + std::vector& oldPairList, + std::vector& newPairList) { + if (ReactNativeFeatureFlags::enableViewCulling()) { + return false; + } + + oldPairList.clear(); + newPairList.clear(); + bool isOldPairSkipped = oldShadowNodePair.flattened && + oldShadowNodePair.isConcreteView && !allowFlattened; + bool isNewPairSkipped = newShadowNodePair.flattened && + newShadowNodePair.isConcreteView && !allowFlattened; + if (isOldPairSkipped != isNewPairSkipped) { + return false; + } + if (isOldPairSkipped) { + return true; + } + + size_t oldStartOfStaticIndex = 0; + size_t newStartOfStaticIndex = 0; + if (!sliceChangedChildShadowNodeViewPairsRecursively( + oldPairList, + oldStartOfStaticIndex, + newPairList, + newStartOfStaticIndex, + scope, + oldShadowNodePair.contextOrigin, + newShadowNodePair.contextOrigin, + *oldShadowNodePair.shadowNode, + *newShadowNodePair.shadowNode)) { + return false; + } + + reorderInPlaceIfNeeded(oldPairList); + reorderInPlaceIfNeeded(newPairList); + assignMountIndexes(oldPairList); + assignMountIndexes(newPairList); + return true; } std::vector sliceChildShadowNodeViewPairs( @@ -191,12 +411,7 @@ std::vector sliceChildShadowNodeViewPairs( // Sorting pairs based on `orderIndex` if needed. reorderInPlaceIfNeeded(pairList); - // Set list and mountIndex for each after reordering - size_t mountIndex = 0; - for (auto child : pairList) { - child->mountIndex = - (child->isConcreteView ? mountIndex++ : static_cast(-1)); - } + assignMountIndexes(pairList); return pairList; } diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.h b/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.h index ffbdb168a573..428a36d7bdb9 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.h +++ b/packages/react-native/ReactCommon/react/renderer/mounting/internal/sliceChildShadowNodeViewPairs.h @@ -52,4 +52,26 @@ std::vector sliceChildShadowNodeViewPairs( Point layoutOffset, const CullingContext &cullingContext); +/* + * Slices the children of two revisions of a node side by side, like two calls + * to `sliceChildShadowNodeViewPairs`, but leaves out each child that is the + * same `ShadowNode` 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. + * + * Returns false if the revisions differ in a way that can change the order or + * the kind of the pairs (child count, tags, flattening, position type, order + * index, visibility or `ChildrenFormStackingContext`), or if view culling is + * enabled. The lists are then unspecified and the caller has to slice both + * revisions in full. + */ +bool sliceChangedChildShadowNodeViewPairs( + const ShadowViewNodePair &oldShadowNodePair, + const ShadowViewNodePair &newShadowNodePair, + ViewNodePairScope &viewNodePairScope, + bool allowFlattened, + std::vector &oldPairList, + std::vector &newPairList); + } // namespace facebook::react diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 2c0786728b99..c4e2de3f908e 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -889,6 +889,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + skipUnchangedChildrenInDifferentiator: { + defaultValue: false, + metadata: { + dateAdded: '2026-10-20', + description: + 'When a node changes, the differ leaves out its children that are unchanged in both trees instead of describing and comparing them.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, syncAndroidClipBoundsWithOverflow: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index c190a2488b08..13f1c066058d 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<5d5b555169e7a8ecb94003145039b4d3>> * @flow strict * @noformat */ @@ -127,6 +127,7 @@ export type ReactNativeFeatureFlags = Readonly<{ shouldPressibilityUseW3CPointerEventsForHover: Getter, shouldTriggerResponderTransferOnScrollAndroid: Getter, skipActivityIdentityAssertionOnHostPause: Getter, + skipUnchangedChildrenInDifferentiator: Getter, syncAndroidClipBoundsWithOverflow: Getter, traceTurboModulePromiseRejectionsOnAndroid: Getter, updateRuntimeShadowNodeReferencesOnCommit: Getter, @@ -531,6 +532,10 @@ export const shouldTriggerResponderTransferOnScrollAndroid: Getter = cr * Skip activity identity assertion in ReactHostImpl::onHostPause() */ export const skipActivityIdentityAssertionOnHostPause: Getter = createNativeFlagGetter('skipActivityIdentityAssertionOnHostPause', false); +/** + * When a node changes, the differ leaves out its children that are unchanged in both trees instead of describing and comparing them. + */ +export const skipUnchangedChildrenInDifferentiator: Getter = createNativeFlagGetter('skipUnchangedChildrenInDifferentiator', false); /** * Override getClipBounds on Android views to return the padding box when overflow is hidden */ diff --git a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js index 673dc0ed3bf8..69f131dab539 100644 --- a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<40cc7b87d7658fb5aa3d33f468eff3fc>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -103,6 +103,7 @@ export interface Spec extends TurboModule { readonly shouldPressibilityUseW3CPointerEventsForHover?: () => boolean; readonly shouldTriggerResponderTransferOnScrollAndroid?: () => boolean; readonly skipActivityIdentityAssertionOnHostPause?: () => boolean; + readonly skipUnchangedChildrenInDifferentiator?: () => boolean; readonly syncAndroidClipBoundsWithOverflow?: () => boolean; readonly traceTurboModulePromiseRejectionsOnAndroid?: () => boolean; readonly updateRuntimeShadowNodeReferencesOnCommit?: () => boolean; diff --git a/packages/react-native/src/private/renderer/mounting/__tests__/Mounting-itest.js b/packages/react-native/src/private/renderer/mounting/__tests__/Mounting-itest.js index c260930ad584..5f7af7cb14fd 100644 --- a/packages/react-native/src/private/renderer/mounting/__tests__/Mounting-itest.js +++ b/packages/react-native/src/private/renderer/mounting/__tests__/Mounting-itest.js @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. * * @fantom_flags enableFabricCommitBranching:* + * @fantom_flags skipUnchangedChildrenInDifferentiator:* * @flow strict-local * @format */ @@ -16,7 +17,7 @@ import type {HostInstance} from 'react-native'; import * as Fantom from '@react-native/fantom'; import nullthrows from 'nullthrows'; import * as React from 'react'; -import {View} from 'react-native'; +import {ScrollView, View} from 'react-native'; describe('ViewFlattening', () => { /** @@ -515,6 +516,133 @@ describe('ViewFlattening', () => { }); }); +describe('unchanged children', () => { + // Rendered once and never again, so its shadow node is the same in every + // later revision of the tree. + const Unchanged = React.memo(function Unchanged(props: {nativeID: string}) { + return ; + }); + + test('only the changed child of a node is updated', () => { + const root = Fantom.createRoot(); + const render = (width: number) => + Fantom.runTask(() => { + root.render( + + + + + , + ); + }); + + render(10); + root.takeMountingManagerLogs(); + + render(20); + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "View", nativeID: "changed"}', + ]); + }); + + test('an unchanged child of a flattened view that moves is updated', () => { + const root = Fantom.createRoot(); + const render = (marginTop: number) => + Fantom.runTask(() => { + root.render( + + + + + , + ); + }); + + render(0); + root.takeMountingManagerLogs(); + + // The parent grows by the margin, and A moves with the flattened view it + // is hoisted out of, so it is updated although its node is unchanged. + render(20); + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "View", nativeID: "parent"}', + 'Update {type: "View", nativeID: "A"}', + ]); + }); + + test('an unchanged child of a scroll view is not updated when it scrolls', () => { + const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100}); + const scrollViewRef = React.createRef(); + + Fantom.runTask(() => { + root.render( + + + + + + + , + ); + }); + root.takeMountingManagerLogs(); + + Fantom.scrollTo(nullthrows(scrollViewRef.current), {x: 0, y: 20}); + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "ScrollView", nativeID: "scrollView"}', + ]); + }); + + test('a child added next to unchanged children is inserted', () => { + const root = Fantom.createRoot(); + const render = (showAdded: boolean) => + Fantom.runTask(() => { + root.render( + + + {showAdded ? : null} + + , + ); + }); + + render(false); + root.takeMountingManagerLogs(); + + render(true); + expect(root.takeMountingManagerLogs()).toEqual([ + 'Create {type: "View", nativeID: "added"}', + 'Insert {type: "View", parentNativeID: "parent", index: 1, nativeID: "added"}', + ]); + }); + + test('a child whose zIndex changes next to unchanged children is reordered', () => { + const root = Fantom.createRoot(); + const render = (zIndex: number) => + Fantom.runTask(() => { + root.render( + + + + + , + ); + }); + + render(0); + root.takeMountingManagerLogs(); + + render(1); + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "View", nativeID: "changed"}', + 'Remove {type: "View", parentNativeID: "parent", index: 2, nativeID: "B"}', + 'Remove {type: "View", parentNativeID: "parent", index: 1, nativeID: "A"}', + 'Insert {type: "View", parentNativeID: "parent", index: 0, nativeID: "A"}', + 'Insert {type: "View", parentNativeID: "parent", index: 1, nativeID: "B"}', + ]); + }); +}); + describe('reconciliation of setNativeProps and React commit', () => { it('props set by setNativeProps must not be overriden by React commit', () => { const root = Fantom.createRoot();