From 634f632998e9b51509b3b561a654270c868eff1f Mon Sep 17 00:00:00 2001 From: Pieter De Baets Date: Mon, 16 Mar 2026 04:51:11 -0700 Subject: [PATCH] Add LIS-based differentiator for minimal child reordering mutations (#56094) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/56094 The current Differentiator Stage 4 uses a greedy two-pointer algorithm to reconcile reordered children. When children are shuffled, it produces excessive REMOVE+INSERT pairs because it doesn't find the minimal edit. This adds an alternative code path that uses Longest Increasing Subsequence (LIS) to identify which children can stay in place vs which need to be moved. Items in the LIS maintain their relative order — only items outside the LIS need REMOVE+INSERT. Example: moving last element to front [A,B,C,D,E] → [E,A,B,C,D]: - Greedy: 4 REMOVEs + 5 INSERTs = 9 mutations - LIS: LIS=[A,B,C,D], only E moves = 1 REMOVE + 1 INSERT = 2 mutations The LIS algorithm is O(n log n) time, O(n) space. Since average child count is <10, the position mapping uses linear scan instead of hash tables. Guarded by `useLISAlgorithmInDifferentiator` feature flag (default off). Changelog: [Internal] Reviewed By: sammy-SC Differential Revision: D96334873 --- .../View/__tests__/View-benchmark-itest.js | 95 +++++ .../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 | 40 ++- .../ReactNativeFeatureFlagsAccessor.h | 6 +- .../ReactNativeFeatureFlagsDefaults.h | 6 +- .../ReactNativeFeatureFlagsDynamicProvider.h | 11 +- .../ReactNativeFeatureFlagsProvider.h | 3 +- .../NativeReactNativeFeatureFlags.cpp | 7 +- .../NativeReactNativeFeatureFlags.h | 4 +- .../renderer/mounting/Differentiator.cpp | 267 +++++++++++++- .../internal/LongestIncreasingSubsequence.h | 95 +++++ .../LongestIncreasingSubsequenceTest.cpp | 154 ++++++++ .../tests/ShadowTreeLifeCycleTest.cpp | 332 +++++++++++++++++- .../ReactNativeFeatureFlags.config.js | 11 + .../featureflags/ReactNativeFeatureFlags.js | 7 +- .../specs/NativeReactNativeFeatureFlags.js | 3 +- .../mounting/__tests__/Mounting-itest.js | 186 ++++++++-- 26 files changed, 1223 insertions(+), 83 deletions(-) create mode 100644 packages/react-native/ReactCommon/react/renderer/mounting/internal/LongestIncreasingSubsequence.h create mode 100644 packages/react-native/ReactCommon/react/renderer/mounting/tests/LongestIncreasingSubsequenceTest.cpp diff --git a/packages/react-native/Libraries/Components/View/__tests__/View-benchmark-itest.js b/packages/react-native/Libraries/Components/View/__tests__/View-benchmark-itest.js index 10930caf2f98..a0bbcb74c69b 100644 --- a/packages/react-native/Libraries/Components/View/__tests__/View-benchmark-itest.js +++ b/packages/react-native/Libraries/Components/View/__tests__/View-benchmark-itest.js @@ -4,6 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * + * @fantom_flags useLISAlgorithmInDifferentiator:* * @flow strict-local * @format */ @@ -173,4 +174,98 @@ Fantom.unstable_benchmark root.destroy(); }, }), + ) + .test.each( + [10, 50, 100], + n => `reorder ${n.toString()} children (move first to last)`, + () => { + Fantom.runTask(() => root.render(testViews)); + }, + n => { + let original: React.MixedElement; + let reordered: React.MixedElement; + return { + beforeAll: () => { + const children = []; + for (let i = 0; i < n; i++) { + children.push( + , + ); + } + original = ( + + {children} + + ); + // Move first child to last + const reorderedChildren = [...children.slice(1), children[0]]; + reordered = ( + + {reorderedChildren} + + ); + }, + beforeEach: () => { + root = Fantom.createRoot(); + Fantom.runTask(() => root.render(original)); + // $FlowExpectedError[incompatible-type] + testViews = reordered; + }, + afterEach: () => { + root.destroy(); + }, + }; + }, + ) + .test.each( + [10, 50, 100], + n => `reorder ${n.toString()} children (swap first two)`, + () => { + Fantom.runTask(() => root.render(testViews)); + }, + n => { + let original: React.MixedElement; + let reordered: React.MixedElement; + return { + beforeAll: () => { + const children = []; + for (let i = 0; i < n; i++) { + children.push( + , + ); + } + original = ( + + {children} + + ); + // Swap first two children — both algorithms handle this equally + const swapped = [children[1], children[0], ...children.slice(2)]; + reordered = ( + + {swapped} + + ); + }, + beforeEach: () => { + root = Fantom.createRoot(); + Fantom.runTask(() => root.render(original)); + // $FlowExpectedError[incompatible-type] + testViews = reordered; + }, + afterEach: () => { + root.destroy(); + }, + }; + }, ); 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 7be4cb067bba..d49efd1aadc7 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<<5144fb0350b71394206d614c68ef87f0>> + * @generated SignedSource<<61964fd9ddf11ed5c2848da3f4d0b490>> */ /** @@ -510,6 +510,12 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun useFabricInterop(): Boolean = accessor.useFabricInterop() + /** + * Use Longest Increasing Subsequence algorithm in the Differentiator to minimize REMOVE/INSERT mutations during child list reconciliation. + */ + @JvmStatic + public fun useLISAlgorithmInDifferentiator(): Boolean = accessor.useLISAlgorithmInDifferentiator() + /** * When enabled, the native view configs are used in bridgeless mode. */ 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 a77a2ff90fe9..fc7fc079ed5f 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<> + * @generated SignedSource<<4ce2605ff71e60b6096a211ff902e994>> */ /** @@ -100,6 +100,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var updateRuntimeShadowNodeReferencesOnCommitThreadCache: Boolean? = null private var useAlwaysAvailableJSErrorHandlingCache: Boolean? = null private var useFabricInteropCache: Boolean? = null + private var useLISAlgorithmInDifferentiatorCache: Boolean? = null private var useNativeViewConfigsInBridgelessModeCache: Boolean? = null private var useNestedScrollViewAndroidCache: Boolean? = null private var useSharedAnimatedBackendCache: Boolean? = null @@ -831,6 +832,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces return cached } + override fun useLISAlgorithmInDifferentiator(): Boolean { + var cached = useLISAlgorithmInDifferentiatorCache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.useLISAlgorithmInDifferentiator() + useLISAlgorithmInDifferentiatorCache = cached + } + return cached + } + override fun useNativeViewConfigsInBridgelessMode(): Boolean { var cached = useNativeViewConfigsInBridgelessModeCache 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 fa8758c0901b..e3ffd419a8e8 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<> + * @generated SignedSource<<5f3573c9983cb54c5df527a3053ebbae>> */ /** @@ -188,6 +188,8 @@ public object ReactNativeFeatureFlagsCxxInterop { @DoNotStrip @JvmStatic public external fun useFabricInterop(): Boolean + @DoNotStrip @JvmStatic public external fun useLISAlgorithmInDifferentiator(): Boolean + @DoNotStrip @JvmStatic public external fun useNativeViewConfigsInBridgelessMode(): Boolean @DoNotStrip @JvmStatic public external fun useNestedScrollViewAndroid(): 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 e467f8cd7327..e8791ff06299 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<> + * @generated SignedSource<<67d638f79b7b06a087f63563c2e5ff95>> */ /** @@ -183,6 +183,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun useFabricInterop(): Boolean = true + override fun useLISAlgorithmInDifferentiator(): Boolean = false + override fun useNativeViewConfigsInBridgelessMode(): Boolean = false override fun useNestedScrollViewAndroid(): 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 99d211c64a30..6ae836432104 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<<7b87f5541ecf881d8ce51c5edd5b99b0>> + * @generated SignedSource<> */ /** @@ -104,6 +104,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var updateRuntimeShadowNodeReferencesOnCommitThreadCache: Boolean? = null private var useAlwaysAvailableJSErrorHandlingCache: Boolean? = null private var useFabricInteropCache: Boolean? = null + private var useLISAlgorithmInDifferentiatorCache: Boolean? = null private var useNativeViewConfigsInBridgelessModeCache: Boolean? = null private var useNestedScrollViewAndroidCache: Boolean? = null private var useSharedAnimatedBackendCache: Boolean? = null @@ -915,6 +916,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc return cached } + override fun useLISAlgorithmInDifferentiator(): Boolean { + var cached = useLISAlgorithmInDifferentiatorCache + if (cached == null) { + cached = currentProvider.useLISAlgorithmInDifferentiator() + accessedFeatureFlags.add("useLISAlgorithmInDifferentiator") + useLISAlgorithmInDifferentiatorCache = cached + } + return cached + } + override fun useNativeViewConfigsInBridgelessMode(): Boolean { var cached = useNativeViewConfigsInBridgelessModeCache 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 de1d05f86ef3..a952c252dbdb 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<> + * @generated SignedSource<<540700c0c0f3259a093a98ad639478ba>> */ /** @@ -183,6 +183,8 @@ public interface ReactNativeFeatureFlagsProvider { @DoNotStrip public fun useFabricInterop(): Boolean + @DoNotStrip public fun useLISAlgorithmInDifferentiator(): Boolean + @DoNotStrip public fun useNativeViewConfigsInBridgelessMode(): Boolean @DoNotStrip public fun useNestedScrollViewAndroid(): 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 fe397f8b1e41..0bedbceeee25 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<> + * @generated SignedSource<<6c088ccf18868fc6e54d83c7483b6607>> */ /** @@ -519,6 +519,12 @@ class ReactNativeFeatureFlagsJavaProvider return method(javaProvider_); } + bool useLISAlgorithmInDifferentiator() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("useLISAlgorithmInDifferentiator"); + return method(javaProvider_); + } + bool useNativeViewConfigsInBridgelessMode() override { static const auto method = getReactNativeFeatureFlagsProviderJavaClass()->getMethod("useNativeViewConfigsInBridgelessMode"); @@ -983,6 +989,11 @@ bool JReactNativeFeatureFlagsCxxInterop::useFabricInterop( return ReactNativeFeatureFlags::useFabricInterop(); } +bool JReactNativeFeatureFlagsCxxInterop::useLISAlgorithmInDifferentiator( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::useLISAlgorithmInDifferentiator(); +} + bool JReactNativeFeatureFlagsCxxInterop::useNativeViewConfigsInBridgelessMode( facebook::jni::alias_ref /*unused*/) { return ReactNativeFeatureFlags::useNativeViewConfigsInBridgelessMode(); @@ -1304,6 +1315,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() { makeNativeMethod( "useFabricInterop", JReactNativeFeatureFlagsCxxInterop::useFabricInterop), + makeNativeMethod( + "useLISAlgorithmInDifferentiator", + JReactNativeFeatureFlagsCxxInterop::useLISAlgorithmInDifferentiator), makeNativeMethod( "useNativeViewConfigsInBridgelessMode", JReactNativeFeatureFlagsCxxInterop::useNativeViewConfigsInBridgelessMode), 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 08276eab5edd..e4b4e467fdd7 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<<5433b4a2f4a0574591a38017422edac8>> + * @generated SignedSource<<73cfe749b34b786e25b683c499889e48>> */ /** @@ -270,6 +270,9 @@ class JReactNativeFeatureFlagsCxxInterop static bool useFabricInterop( facebook::jni::alias_ref); + static bool useLISAlgorithmInDifferentiator( + facebook::jni::alias_ref); + static bool useNativeViewConfigsInBridgelessMode( 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 4f058038abbd..2a0bc39e528d 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<> */ /** @@ -346,6 +346,10 @@ bool ReactNativeFeatureFlags::useFabricInterop() { return getAccessor().useFabricInterop(); } +bool ReactNativeFeatureFlags::useLISAlgorithmInDifferentiator() { + return getAccessor().useLISAlgorithmInDifferentiator(); +} + bool ReactNativeFeatureFlags::useNativeViewConfigsInBridgelessMode() { return getAccessor().useNativeViewConfigsInBridgelessMode(); } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index 7185d625c251..9b24d7eec3b1 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<<4811a81c7839f2be5c8a127e6c8e310b>> + * @generated SignedSource<<6c175f21aaa8d084c7b0be0625d5d77d>> */ /** @@ -439,6 +439,11 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool useFabricInterop(); + /** + * Use Longest Increasing Subsequence algorithm in the Differentiator to minimize REMOVE/INSERT mutations during child list reconciliation. + */ + RN_EXPORT static bool useLISAlgorithmInDifferentiator(); + /** * When enabled, the native view configs are used in bridgeless mode. */ diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp index 936e3c590d50..3b529d3f83bf 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<> */ /** @@ -1469,6 +1469,24 @@ bool ReactNativeFeatureFlagsAccessor::useFabricInterop() { return flagValue.value(); } +bool ReactNativeFeatureFlagsAccessor::useLISAlgorithmInDifferentiator() { + auto flagValue = useLISAlgorithmInDifferentiator_.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(80, "useLISAlgorithmInDifferentiator"); + + flagValue = currentProvider_->useLISAlgorithmInDifferentiator(); + useLISAlgorithmInDifferentiator_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::useNativeViewConfigsInBridgelessMode() { auto flagValue = useNativeViewConfigsInBridgelessMode_.load(); @@ -1478,7 +1496,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(80, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(81, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1496,7 +1514,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(81, "useNestedScrollViewAndroid"); + markFlagAsAccessed(82, "useNestedScrollViewAndroid"); flagValue = currentProvider_->useNestedScrollViewAndroid(); useNestedScrollViewAndroid_ = flagValue; @@ -1514,7 +1532,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(82, "useSharedAnimatedBackend"); + markFlagAsAccessed(83, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1532,7 +1550,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(83, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(84, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1550,7 +1568,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(84, "useTurboModuleInterop"); + markFlagAsAccessed(85, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1568,7 +1586,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModules() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(85, "useTurboModules"); + markFlagAsAccessed(86, "useTurboModules"); flagValue = currentProvider_->useTurboModules(); useTurboModules_ = flagValue; @@ -1586,7 +1604,7 @@ bool ReactNativeFeatureFlagsAccessor::useUnorderedMapInDifferentiator() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(86, "useUnorderedMapInDifferentiator"); + markFlagAsAccessed(87, "useUnorderedMapInDifferentiator"); flagValue = currentProvider_->useUnorderedMapInDifferentiator(); useUnorderedMapInDifferentiator_ = flagValue; @@ -1604,7 +1622,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(87, "viewCullingOutsetRatio"); + markFlagAsAccessed(88, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1622,7 +1640,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(88, "viewTransitionEnabled"); + markFlagAsAccessed(89, "viewTransitionEnabled"); flagValue = currentProvider_->viewTransitionEnabled(); viewTransitionEnabled_ = flagValue; @@ -1640,7 +1658,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(89, "virtualViewPrerenderRatio"); + markFlagAsAccessed(90, "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 e76f1322f58d..b572c9d7b944 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<<06dba77b9b76d06d5c338ed8a97e33f5>> + * @generated SignedSource<<29e7a9ef1807aaecaf5440e024a5a2f2>> */ /** @@ -112,6 +112,7 @@ class ReactNativeFeatureFlagsAccessor { bool updateRuntimeShadowNodeReferencesOnCommitThread(); bool useAlwaysAvailableJSErrorHandling(); bool useFabricInterop(); + bool useLISAlgorithmInDifferentiator(); bool useNativeViewConfigsInBridgelessMode(); bool useNestedScrollViewAndroid(); bool useSharedAnimatedBackend(); @@ -133,7 +134,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 90> accessedFeatureFlags_; + std::array, 91> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -215,6 +216,7 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> updateRuntimeShadowNodeReferencesOnCommitThread_; std::atomic> useAlwaysAvailableJSErrorHandling_; std::atomic> useFabricInterop_; + std::atomic> useLISAlgorithmInDifferentiator_; std::atomic> useNativeViewConfigsInBridgelessMode_; std::atomic> useNestedScrollViewAndroid_; std::atomic> useSharedAnimatedBackend_; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index 1a6289888d15..c64f1748335b 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<<0934d867533630904fc69e30e7a929b3>> + * @generated SignedSource<<2cf1c7be6b0086da159550454273ce2d>> */ /** @@ -347,6 +347,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return true; } + bool useLISAlgorithmInDifferentiator() override { + return false; + } + bool useNativeViewConfigsInBridgelessMode() override { return false; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index 5c939775920a..141d4c5f2dbc 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<<25d1f9cb509dbd8274e3a00237d2ea62>> + * @generated SignedSource<<20a808bb8708d8088f3d7aae8d58b6b2>> */ /** @@ -765,6 +765,15 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef return ReactNativeFeatureFlagsDefaults::useFabricInterop(); } + bool useLISAlgorithmInDifferentiator() override { + auto value = values_["useLISAlgorithmInDifferentiator"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::useLISAlgorithmInDifferentiator(); + } + bool useNativeViewConfigsInBridgelessMode() override { auto value = values_["useNativeViewConfigsInBridgelessMode"]; if (!value.isNull()) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h index cc3fbc19b88d..42c8c97942d5 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<> + * @generated SignedSource<<8c52d3da48fbc27e8e23493f81a93d55>> */ /** @@ -105,6 +105,7 @@ class ReactNativeFeatureFlagsProvider { virtual bool updateRuntimeShadowNodeReferencesOnCommitThread() = 0; virtual bool useAlwaysAvailableJSErrorHandling() = 0; virtual bool useFabricInterop() = 0; + virtual bool useLISAlgorithmInDifferentiator() = 0; virtual bool useNativeViewConfigsInBridgelessMode() = 0; virtual bool useNestedScrollViewAndroid() = 0; virtual bool useSharedAnimatedBackend() = 0; diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp index 1fbd4198e5d2..54f33abe3339 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<<0b3534a570416860aa1ffc7e1d808090>> + * @generated SignedSource<<51f57b07e238ca9c13f4c7ec363eb9a0>> */ /** @@ -444,6 +444,11 @@ bool NativeReactNativeFeatureFlags::useFabricInterop( return ReactNativeFeatureFlags::useFabricInterop(); } +bool NativeReactNativeFeatureFlags::useLISAlgorithmInDifferentiator( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::useLISAlgorithmInDifferentiator(); +} + bool NativeReactNativeFeatureFlags::useNativeViewConfigsInBridgelessMode( jsi::Runtime& /*runtime*/) { return ReactNativeFeatureFlags::useNativeViewConfigsInBridgelessMode(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h index 081db38ca2fd..18ab3a19fd2f 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<<0aeea7c4fa2a8aa4180c83bbd0746250>> + * @generated SignedSource<<8368d761c6a95fcdbf804681a2bf65d3>> */ /** @@ -196,6 +196,8 @@ class NativeReactNativeFeatureFlags bool useFabricInterop(jsi::Runtime& runtime); + bool useLISAlgorithmInDifferentiator(jsi::Runtime& runtime); + bool useNativeViewConfigsInBridgelessMode(jsi::Runtime& runtime); bool useNestedScrollViewAndroid(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 89f27bf71f9a..10b6e24a12c1 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/Differentiator.cpp @@ -12,6 +12,7 @@ #include #include "internal/CullingContext.h" #include "internal/DiffMap.h" +#include "internal/LongestIncreasingSubsequence.h" #include "internal/ShadowViewNodePair.h" #include "internal/sliceChildShadowNodeViewPairs.h" @@ -1048,13 +1049,275 @@ static void calculateShadowViewMutations( oldCullingContext, newCullingContextCopy); } + } else if (ReactNativeFeatureFlags::useLISAlgorithmInDifferentiator()) { + // LIS-based Stage 4: find the Longest Increasing Subsequence of + // new-list positions among old children to minimize REMOVE/INSERT + // mutations. Items in the LIS maintain their relative order and + // don't need REMOVE+INSERT — only items outside the LIS are moved. + auto remainingOldCount = oldChildPairs.size() - lastIndexAfterFirstStage; + auto remainingNewCount = newChildPairs.size() - lastIndexAfterFirstStage; + + // Build newRemainingPairs (required by updateMatchedPairSubtrees for + // flattening logic) and tag→index map for O(1) lookups in Step 1. + auto newRemainingPairs = + DiffMap(remainingNewCount); + auto newTagToIndex = DiffMap(remainingNewCount); + for (size_t i = lastIndexAfterFirstStage; i < newChildPairs.size(); i++) { + auto& newChildPair = *newChildPairs[i]; + newRemainingPairs.insert({newChildPair.shadowView.tag, &newChildPair}); + newTagToIndex.insert({newChildPair.shadowView.tag, i}); + } + + // Step 1: Map old children to their positions in the new list. + std::vector oldToNewPos(remainingOldCount); + std::vector oldExistsInNew(remainingOldCount, false); + + for (size_t i = 0; i < remainingOldCount; i++) { + auto oldIdx = lastIndexAfterFirstStage + i; + Tag oldTag = oldChildPairs[oldIdx]->shadowView.tag; + auto it = newTagToIndex.find(oldTag); + if (it != newTagToIndex.end()) { + oldToNewPos[i] = it->second; + oldExistsInNew[i] = true; + } + } + + // Step 2: Compute LIS of new-list positions. + auto inLIS = longestIncreasingSubsequence(oldToNewPos, oldExistsInNew); + + auto deletionCandidatePairs = std::vector{}; + deletionCandidatePairs.reserve(remainingOldCount); + + // New-child-indexed flag: true = LIS match that stays in place. + auto isLISMatch = std::vector(remainingNewCount, false); + + // Step 4: Process old children. + // CRITICAL: check newRemainingPairs at runtime (not the pre-computed + // oldExistsInNew). The flattener erases entries from newRemainingPairs + // as it consumes them during flatten/unflatten — using the pre-computed + // snapshot would cause double-processing of flattened nodes. + react_native_assert(inLIS.size() == remainingOldCount); + for (size_t i = 0; i < remainingOldCount; i++) { + auto oldIdx = lastIndexAfterFirstStage + i; + auto& oldChildPair = *oldChildPairs[oldIdx]; + Tag oldTag = oldChildPair.shadowView.tag; + + auto newIt = newRemainingPairs.find(oldTag); + if (newIt == newRemainingPairs.end()) { + // Not in new list or consumed by flattening -> REMOVE. + if (!oldChildPair.isConcreteView) { + continue; + } + + DEBUG_LOGS({ + LOG(ERROR) << "Differ LIS Branch: Removing deleted tag: " + << oldChildPair << " with parent: [" << parentTag << "]"; + }); + + // Edge case: complex (un)flattening — node exists in other tree. + if (oldChildPair.inOtherTree() && + oldChildPair.otherTreePair->isConcreteView) { + const ShadowView& otherTreeView = + oldChildPair.otherTreePair->shadowView; + mutationContainer.removeMutations.push_back( + ShadowViewMutation::RemoveMutation( + parentTag, + otherTreeView, + static_cast(oldChildPair.mountIndex))); + continue; + } + + mutationContainer.removeMutations.push_back( + ShadowViewMutation::RemoveMutation( + parentTag, + oldChildPair.shadowView, + static_cast(oldChildPair.mountIndex))); + deletionCandidatePairs.push_back(&oldChildPair); + + } else if (inLIS[i]) { + // In LIS -> stays in place, just UPDATE + subtree recursion. + auto& newChildPair = *newIt->second; + + DEBUG_LOGS({ + LOG(ERROR) << "Differ LIS Branch: Matched in-order (LIS) at old " + << oldIdx << ": " << oldChildPair << " with parent: [" + << parentTag << "]"; + }); + + // For LIS matches with concrete-ness changes, we must use + // (true, false) to avoid generating INSERT in updateMatchedPair. + // INSERT mutations must be in new-child order (Step 5), not + // old-child order (Step 4). Generating INSERT here would put it + // out of order relative to Step 5's INSERTs. + bool concreteChanged = + oldChildPair.isConcreteView != newChildPair.isConcreteView; + + updateMatchedPair( + mutationContainer, + true, + !concreteChanged, + parentTag, + oldChildPair, + newChildPair); + + updateMatchedPairSubtrees( + scope, + mutationContainer, + newRemainingPairs, + oldChildPairs, + parentTag, + oldChildPair, + newChildPair, + oldCullingContext, + newCullingContext); + + if (!concreteChanged) { + // Check if this LIS match was consumed by flattening. + // updateMatchedPairSubtrees may erase entries from + // newRemainingPairs during flatten/unflatten transitions. + // If consumed, the node was reparented elsewhere and needs + // REMOVE from this parent. + if (newRemainingPairs.find(oldTag) != newRemainingPairs.end()) { + isLISMatch[oldToNewPos[i] - lastIndexAfterFirstStage] = true; + } else if (oldChildPair.isConcreteView) { + if (oldChildPair.inOtherTree() && + oldChildPair.otherTreePair->isConcreteView) { + mutationContainer.removeMutations.push_back( + ShadowViewMutation::RemoveMutation( + parentTag, + oldChildPair.otherTreePair->shadowView, + static_cast(oldChildPair.mountIndex))); + } else { + mutationContainer.removeMutations.push_back( + ShadowViewMutation::RemoveMutation( + parentTag, + oldChildPair.shadowView, + static_cast(oldChildPair.mountIndex))); + deletionCandidatePairs.push_back(&oldChildPair); + } + } + } + // concreteChanged: not in isLISMatch, Step 5 handles INSERT. + + } else { + // In new list but NOT in LIS -> REMOVE from old position. + // Will be re-inserted at new position in Step 5. + auto& newChildPair = *newIt->second; + + DEBUG_LOGS({ + LOG(ERROR) + << "Differ LIS Branch: Matched out-of-order (not in LIS) at old " + << oldIdx << ": " << oldChildPair << " with parent: [" + << parentTag << "]"; + }); + + updateMatchedPair( + mutationContainer, + true, + false, + parentTag, + oldChildPair, + newChildPair); + + updateMatchedPairSubtrees( + scope, + mutationContainer, + newRemainingPairs, + oldChildPairs, + parentTag, + oldChildPair, + newChildPair, + oldCullingContext, + newCullingContext); + } + } + + // Step 5: Process new children — INSERT + CREATE. + // Generate INSERT for every non-LIS-matched concrete new child. + // Only generate CREATE for genuinely new children (not in other tree + // from flattening). + for (size_t i = lastIndexAfterFirstStage; i < newChildPairs.size(); i++) { + auto& newChildPair = *newChildPairs[i]; + + if (!newChildPair.isConcreteView) { + continue; + } + + // LIS matches stay in place — no INSERT needed. + if (isLISMatch[i - lastIndexAfterFirstStage]) { + continue; + } + + DEBUG_LOGS({ + LOG(ERROR) << "Differ LIS Branch: Inserting tag: " << newChildPair + << " with parent: [" << parentTag << "]" + << (newChildPair.inOtherTree() ? " (in other tree)" : ""); + }); + + mutationContainer.insertMutations.push_back( + ShadowViewMutation::InsertMutation( + parentTag, + newChildPair.shadowView, + static_cast(newChildPair.mountIndex))); + + // Only CREATE genuinely new children (not matched by flattening). + if (!newChildPair.inOtherTree()) { + mutationContainer.createMutations.push_back( + ShadowViewMutation::CreateMutation(newChildPair.shadowView)); + + auto newCullingContextCopy = + newCullingContext.adjustCullingContextIfNeeded(newChildPair); + + ViewNodePairScope innerScope{}; + calculateShadowViewMutations( + innerScope, + mutationContainer.downwardMutations, + newChildPair.shadowView.tag, + {}, + sliceChildShadowNodeViewPairsFromViewNodePair( + newChildPair, innerScope, false, newCullingContextCopy), + oldCullingContext, + newCullingContextCopy); + } + } + + // Step 6: Generate DELETE for deletion candidates. + for (const auto* deletionCandidatePtr : deletionCandidatePairs) { + const auto& oldChildPair = *deletionCandidatePtr; + + DEBUG_LOGS({ + LOG(ERROR) << "Differ LIS Branch: Deleting removed tag: " + << oldChildPair << " with parent: [" << parentTag << "]"; + }); + + if (!oldChildPair.inOtherTree() && oldChildPair.isConcreteView) { + mutationContainer.deleteMutations.push_back( + ShadowViewMutation::DeleteMutation(oldChildPair.shadowView)); + auto oldCullingContextCopy = + oldCullingContext.adjustCullingContextIfNeeded(oldChildPair); + + ViewNodePairScope innerScope{}; + auto grandChildPairs = sliceChildShadowNodeViewPairsFromViewNodePair( + oldChildPair, innerScope, false, oldCullingContextCopy); + calculateShadowViewMutations( + innerScope, + mutationContainer.destructiveDownwardMutations, + oldChildPair.shadowView.tag, + std::move(grandChildPairs), + {}, + oldCullingContextCopy, + newCullingContext); + } + } } else { + // Existing greedy Stage 4 algorithm. // Collect map of tags in the new list - auto remainingCount = newChildPairs.size() - index; + auto remainingCount = newChildPairs.size() - lastIndexAfterFirstStage; auto newRemainingPairs = DiffMap(remainingCount); auto newInsertedPairs = DiffMap(remainingCount); auto deletionCandidatePairs = DiffMap{}; - for (; index < newChildPairs.size(); index++) { + for (index = lastIndexAfterFirstStage; index < newChildPairs.size(); + index++) { auto& newChildPair = *newChildPairs[index]; newRemainingPairs.insert({newChildPair.shadowView.tag, &newChildPair}); } diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/internal/LongestIncreasingSubsequence.h b/packages/react-native/ReactCommon/react/renderer/mounting/internal/LongestIncreasingSubsequence.h new file mode 100644 index 000000000000..1f31ead210cf --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/mounting/internal/LongestIncreasingSubsequence.h @@ -0,0 +1,95 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#pragma once + +#include +#include +#include + +namespace facebook::react { + +/** + * Computes the Longest Increasing Subsequence (LIS) of the given + * sequence of values using O(n log n) patience sorting. + * + * Returns a vector of the same size as `values`, where + * result[i] == true means values[i] is part of the LIS. + * + * Only elements where include[i] == true are considered; + * elements with include[i] == false are ignored and will always + * be false in the result. + */ +inline std::vector longestIncreasingSubsequence( + const std::vector &values, + const std::vector &include) +{ + react_native_assert(values.size() == include.size()); + + size_t n = values.size(); + std::vector inLIS(n, false); + + if (n == 0) { + return inLIS; + } + + // Collect indices of included elements. + std::vector indices; + indices.reserve(n); + for (size_t i = 0; i < n; i++) { + if (include[i]) { + indices.push_back(i); + } + } + + if (indices.empty()) { + return inLIS; + } + + // tails[i] = smallest tail value of all increasing subsequences + // of length i+1. + std::vector tails; + // tailIndices[i] = index into `indices` whose value is tails[i]. + std::vector tailIndices; + // predecessor[k] = index into `indices` of the predecessor of + // indices[k] in the LIS, or -1 if none. + std::vector predecessor(indices.size(), -1); + + tails.reserve(indices.size()); + tailIndices.reserve(indices.size()); + + for (size_t k = 0; k < indices.size(); k++) { + size_t val = values[indices[k]]; + + // Binary search for the first element in tails >= val. + auto it = std::lower_bound(tails.begin(), tails.end(), val); + auto pos = static_cast(it - tails.begin()); + + if (it == tails.end()) { + tails.push_back(val); + tailIndices.push_back(k); + } else { + *it = val; + tailIndices[pos] = k; + } + + if (pos > 0) { + predecessor[k] = static_cast(tailIndices[pos - 1]); + } + } + + // Reconstruct LIS by tracing predecessors from the last element. + int current = static_cast(tailIndices.back()); + while (current >= 0) { + inLIS[indices[static_cast(current)]] = true; + current = predecessor[static_cast(current)]; + } + + return inLIS; +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/tests/LongestIncreasingSubsequenceTest.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/tests/LongestIncreasingSubsequenceTest.cpp new file mode 100644 index 000000000000..428543348f7e --- /dev/null +++ b/packages/react-native/ReactCommon/react/renderer/mounting/tests/LongestIncreasingSubsequenceTest.cpp @@ -0,0 +1,154 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#include + +#include "internal/LongestIncreasingSubsequence.h" + +namespace facebook::react { + +TEST(LongestIncreasingSubsequenceTest, emptyInput) { + auto result = longestIncreasingSubsequence({}, {}); + EXPECT_TRUE(result.empty()); +} + +TEST(LongestIncreasingSubsequenceTest, singleElement) { + auto result = longestIncreasingSubsequence({5}, {true}); + ASSERT_EQ(result.size(), 1u); + EXPECT_TRUE(result[0]); +} + +TEST(LongestIncreasingSubsequenceTest, alreadySorted) { + // [0, 1, 2, 3, 4] — entire sequence is the LIS. + std::vector values = {0, 1, 2, 3, 4}; + std::vector include = {true, true, true, true, true}; + auto result = longestIncreasingSubsequence(values, include); + + ASSERT_EQ(result.size(), 5u); + for (size_t i = 0; i < 5; i++) { + EXPECT_TRUE(result[i]) << "index " << i << " should be in LIS"; + } +} + +TEST(LongestIncreasingSubsequenceTest, reverseSorted) { + // [4, 3, 2, 1, 0] — LIS length is 1. + std::vector values = {4, 3, 2, 1, 0}; + std::vector include = {true, true, true, true, true}; + auto result = longestIncreasingSubsequence(values, include); + + ASSERT_EQ(result.size(), 5u); + int count = 0; + for (size_t i = 0; i < 5; i++) { + if (result[i]) { + count++; + } + } + EXPECT_EQ(count, 1); +} + +TEST(LongestIncreasingSubsequenceTest, moveLastToFront) { + // Old: [A, B, C, D, E] mapped to new positions [1, 2, 3, 4, 0] + // LIS should be [1, 2, 3, 4] (indices 0-3), leaving index 4 out. + std::vector values = {1, 2, 3, 4, 0}; + std::vector include = {true, true, true, true, true}; + auto result = longestIncreasingSubsequence(values, include); + + ASSERT_EQ(result.size(), 5u); + EXPECT_TRUE(result[0]); // value 1 + EXPECT_TRUE(result[1]); // value 2 + EXPECT_TRUE(result[2]); // value 3 + EXPECT_TRUE(result[3]); // value 4 + EXPECT_FALSE(result[4]); // value 0 +} + +TEST(LongestIncreasingSubsequenceTest, moveFirstToLast) { + // Old: [A, B, C, D, E] mapped to new positions [4, 0, 1, 2, 3] + // LIS should be [0, 1, 2, 3] (indices 1-4), leaving index 0 out. + std::vector values = {4, 0, 1, 2, 3}; + std::vector include = {true, true, true, true, true}; + auto result = longestIncreasingSubsequence(values, include); + + ASSERT_EQ(result.size(), 5u); + EXPECT_FALSE(result[0]); // value 4 + EXPECT_TRUE(result[1]); // value 0 + EXPECT_TRUE(result[2]); // value 1 + EXPECT_TRUE(result[3]); // value 2 + EXPECT_TRUE(result[4]); // value 3 +} + +TEST(LongestIncreasingSubsequenceTest, withExcludedElements) { + // values = [1, _, 3, _, 0] where _ are excluded (deleted from new list) + std::vector values = {1, 999, 3, 999, 0}; + std::vector include = {true, false, true, false, true}; + auto result = longestIncreasingSubsequence(values, include); + + ASSERT_EQ(result.size(), 5u); + EXPECT_FALSE(result[1]); // excluded + EXPECT_FALSE(result[3]); // excluded + + // Among included: [1, 3, 0]. LIS is [1, 3] (length 2). + EXPECT_TRUE(result[0]); // value 1 + EXPECT_TRUE(result[2]); // value 3 + EXPECT_FALSE(result[4]); // value 0 +} + +TEST(LongestIncreasingSubsequenceTest, allExcluded) { + std::vector values = {3, 1, 2}; + std::vector include = {false, false, false}; + auto result = longestIncreasingSubsequence(values, include); + + ASSERT_EQ(result.size(), 3u); + EXPECT_FALSE(result[0]); + EXPECT_FALSE(result[1]); + EXPECT_FALSE(result[2]); +} + +TEST(LongestIncreasingSubsequenceTest, interleaved) { + // [3, 1, 4, 1, 5, 9, 2, 6] + // One valid LIS: [1, 4, 5, 9] or [1, 2, 6] etc. Length should be 4. + std::vector values = {3, 1, 4, 1, 5, 9, 2, 6}; + std::vector include(8, true); + auto result = longestIncreasingSubsequence(values, include); + + ASSERT_EQ(result.size(), 8u); + int count = 0; + size_t prev = 0; + bool first = true; + for (size_t i = 0; i < 8; i++) { + if (result[i]) { + count++; + if (!first) { + EXPECT_GT(values[i], prev) << "LIS must be strictly increasing"; + } + prev = values[i]; + first = false; + } + } + EXPECT_EQ(count, 4); +} + +TEST(LongestIncreasingSubsequenceTest, swapTwoElements) { + // Old: [A, B, C, D] → New: [A, C, B, D] → positions [0, 2, 1, 3] + // LIS: [0, 1, 3] or [0, 2, 3] — length 3, one element out. + std::vector values = {0, 2, 1, 3}; + std::vector include = {true, true, true, true}; + auto result = longestIncreasingSubsequence(values, include); + + ASSERT_EQ(result.size(), 4u); + int count = 0; + for (size_t i = 0; i < 4; i++) { + if (result[i]) { + count++; + } + } + EXPECT_EQ(count, 3); + // First and last should always be in LIS. + EXPECT_TRUE(result[0]); // value 0 + EXPECT_TRUE(result[3]); // value 3 +} + +} // namespace facebook::react diff --git a/packages/react-native/ReactCommon/react/renderer/mounting/tests/ShadowTreeLifeCycleTest.cpp b/packages/react-native/ReactCommon/react/renderer/mounting/tests/ShadowTreeLifeCycleTest.cpp index fb22e2372cc8..c7b501b91c25 100644 --- a/packages/react-native/ReactCommon/react/renderer/mounting/tests/ShadowTreeLifeCycleTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/mounting/tests/ShadowTreeLifeCycleTest.cpp @@ -13,9 +13,14 @@ #include #include #include +#include +#include +#include #include #include +#include +#include #include #include #include @@ -346,7 +351,35 @@ static void testShadowNodeTreeLifeCycleExtensiveFlatteningUnflattening( using namespace facebook::react; -TEST( +namespace { + +class LISFeatureFlagsOverride + : public facebook::react::ReactNativeFeatureFlagsDefaults { + public: + bool useLISAlgorithmInDifferentiator() override { + return true; + } +}; + +} // namespace + +// Parametrized lifecycle tests: each test runs with both the greedy +// algorithm (false) and the LIS algorithm (true). +class ShadowTreeLifecycleTest : public ::testing::TestWithParam { + protected: + void SetUp() override { + if (GetParam()) { + facebook::react::ReactNativeFeatureFlags::override( + std::make_unique()); + } + } + + void TearDown() override { + facebook::react::ReactNativeFeatureFlags::dangerouslyReset(); + } +}; + +TEST_P( ShadowTreeLifecycleTest, stableBiggerTreeFewerIterationsOptimizedMovesFlattener) { testShadowNodeTreeLifeCycle( @@ -356,7 +389,7 @@ TEST( /* stages */ 32); } -TEST( +TEST_P( ShadowTreeLifecycleTest, stableBiggerTreeFewerIterationsOptimizedMovesFlattener2) { testShadowNodeTreeLifeCycle( @@ -366,7 +399,7 @@ TEST( /* stages */ 32); } -TEST( +TEST_P( ShadowTreeLifecycleTest, stableSmallerTreeMoreIterationsOptimizedMovesFlattener) { testShadowNodeTreeLifeCycle( @@ -376,7 +409,7 @@ TEST( /* stages */ 32); } -TEST( +TEST_P( ShadowTreeLifecycleTest, unstableSmallerTreeFewerIterationsExtensiveFlatteningUnflattening) { testShadowNodeTreeLifeCycleExtensiveFlatteningUnflattening( @@ -386,7 +419,7 @@ TEST( /* stages */ 32); } -TEST( +TEST_P( ShadowTreeLifecycleTest, unstableBiggerTreeFewerIterationsExtensiveFlatteningUnflattening) { testShadowNodeTreeLifeCycleExtensiveFlatteningUnflattening( @@ -396,7 +429,7 @@ TEST( /* stages */ 32); } -TEST( +TEST_P( ShadowTreeLifecycleTest, unstableSmallerTreeMoreIterationsExtensiveFlatteningUnflattening) { testShadowNodeTreeLifeCycleExtensiveFlatteningUnflattening( @@ -407,20 +440,18 @@ TEST( } // failing test case found 4-25-2021 -// TODO: T213669056 -// TEST( -// ShadowTreeLifecycleTest, -// unstableSmallerTreeMoreIterationsExtensiveFlatteningUnflattening_1167342011) -// { -// testShadowNodeTreeLifeCycleExtensiveFlatteningUnflattening( -// /* seed */ 1167342011, -// /* size */ 32, -// /* repeats */ 512, -// /* stages */ 32); -// } +TEST_P( + ShadowTreeLifecycleTest, + unstableSmallerTreeMoreIterationsExtensiveFlatteningUnflattening_1167342011) { + testShadowNodeTreeLifeCycleExtensiveFlatteningUnflattening( + /* seed */ 1167342011, + /* size */ 32, + /* repeats */ 512, + /* stages */ 32); +} // You may uncomment this - locally only! - to generate failing seeds. -// TEST( +// TEST_P( // ShadowTreeLifecycleTest, // unstableSmallerTreeMoreIterationsExtensiveFlatteningUnflatteningManyRandom) // { @@ -435,3 +466,268 @@ TEST( // /* stages */ 32); // } // } + +// Demonstrates that the LIS algorithm produces fewer mutations than the +// greedy algorithm for a simple child reorder: [A,B,C,D,E] → [B,C,D,E,A]. +// The greedy two-pointer walk encounters A vs B mismatch and generates +// excessive REMOVE+INSERT pairs. The LIS algorithm identifies that B,C,D,E +// are already in increasing order and only moves A. +TEST_P(ShadowTreeLifecycleTest, moveFirstChildToLast) { + auto builder = simpleComponentBuilder(); + + auto makeProps = [](const std::string& id) { + auto props = std::make_shared(); + props->nativeId = id; + return props; + }; + + // clang-format off + auto rootElement = + Element() + .tag(1) + .children({ + Element().tag(2).props(makeProps("A")), + Element().tag(3).props(makeProps("B")), + Element().tag(4).props(makeProps("C")), + Element().tag(5).props(makeProps("D")), + Element().tag(6).props(makeProps("E")), + }); + // clang-format on + + auto rootNode = builder.build(rootElement); + + // Clone root with children reordered to [B, C, D, E, A]. + auto children = rootNode->getChildren(); + auto reorderedChildren = + std::make_shared>>(); + for (size_t i = 1; i < children.size(); i++) { + reorderedChildren->push_back(children[i]); + } + reorderedChildren->push_back(children[0]); + + auto reorderedRootNode = std::static_pointer_cast( + rootNode->ShadowNode::clone( + ShadowNodeFragment{ + .props = ShadowNodeFragment::propsPlaceholder(), + .children = reorderedChildren})); + + auto expected = + buildStubViewTreeWithoutUsingDifferentiator(*reorderedRootNode); + auto mutations = calculateShadowViewMutations(*rootNode, *reorderedRootNode); + + auto isMoveOp = [](const ShadowViewMutation& m) { + return m.type == ShadowViewMutation::Remove || + m.type == ShadowViewMutation::Insert; + }; + + if (ReactNativeFeatureFlags::useLISAlgorithmInDifferentiator()) { + // LIS: 1 REMOVE + 1 INSERT = 2 move ops. + EXPECT_EQ(std::count_if(mutations.begin(), mutations.end(), isMoveOp), 2); + } else { + // Greedy: 4 REMOVE + 4 INSERT = 8 move ops. + EXPECT_EQ(std::count_if(mutations.begin(), mutations.end(), isMoveOp), 8); + } + + auto viewTree = buildStubViewTreeWithoutUsingDifferentiator(*rootNode); + viewTree.mutate(mutations); + EXPECT_EQ(viewTree, expected); +} + +// Exercises the LIS path where concreteChanged=true AND flattening +// consumption occur simultaneously. Z(tag=7) is reordered to break head/tail +// matching, forcing A, W, and C into the LIS path. W(tag=3) loses testId +// (concrete→non-concrete, concreteChanged=true) while remaining in the LIS. +// updateMatchedPairSubtrees triggers the flattener which promotes B(tag=6). +// The if(!concreteChanged) block that normally checks consumption is bypassed, +// so this test verifies the combination still produces correct mutations. +TEST_P(ShadowTreeLifecycleTest, concreteChangedWithFlattening) { + auto builder = simpleComponentBuilder(); + + auto concreteProps = [](const std::string& id) { + auto props = std::make_shared(); + props->testId = id; + return props; + }; + + // clang-format off + // Old: Root -> [Z, A, W(concrete, child B), C] + // Old flattened: [Z(7), A(2), W(3), C(4)] + auto rootElement = + Element() + .tag(1) + .children({ + Element().tag(7).props(concreteProps("Z")), + Element().tag(2).props(concreteProps("A")), + Element().tag(3).props(concreteProps("W")) + .children({ + Element().tag(6).props(concreteProps("B")), + }), + Element().tag(4).props(concreteProps("C")), + }); + // clang-format on + + auto rootNode = builder.build(rootElement); + auto children = rootNode->getChildren(); + // children: [Z(7), A(2), W(3), C(4)] + + // W loses testId → non-concrete (flattened). B(tag=6) promoted. + auto flatW = children[2]->clone( + ShadowNodeFragment{.props = std::make_shared()}); + + // New: Root -> [A, W(flattened), C, Z] — Z moved to end. + // New flattened: [A(2), W(3,non-concrete), B(6), C(4), Z(7)] + // Z's move breaks head matching; A, W, C enter LIS path. + // Among old remaining [Z,A,W,C] mapped to new positions, + // LIS includes A,W,C (increasing order), W has concreteChanged=true. + auto newChildren = + std::make_shared>>(); + newChildren->push_back(children[1]); // A + newChildren->push_back(flatW); // W (flattened) + newChildren->push_back(children[3]); // C + newChildren->push_back(children[0]); // Z (moved to end) + + auto newRootNode = std::static_pointer_cast( + rootNode->ShadowNode::clone( + ShadowNodeFragment{ + .props = ShadowNodeFragment::propsPlaceholder(), + .children = newChildren})); + + auto expected = buildStubViewTreeWithoutUsingDifferentiator(*newRootNode); + auto mutations = calculateShadowViewMutations(*rootNode, *newRootNode); + auto viewTree = buildStubViewTreeWithoutUsingDifferentiator(*rootNode); + viewTree.mutate(mutations); + EXPECT_EQ(viewTree, expected); +} + +// Reverse: W(tag=3) gains testId (non-concrete→concrete, concreteChanged=true) +// while in the LIS path. B(tag=6) was promoted by flattening and now gets +// absorbed back into W via unflattening. Z reorder breaks head matching. +TEST_P(ShadowTreeLifecycleTest, concreteChangedWithUnflatteningInLIS) { + auto builder = simpleComponentBuilder(); + + auto concreteProps = [](const std::string& id) { + auto props = std::make_shared(); + props->testId = id; + return props; + }; + + // clang-format off + // Old: Root -> [Z, A, W(NON-concrete, child B(concrete)), C] + // Old flattened: [Z(7), A(2), W(3,non-concrete), B(6), C(4)] + auto rootElement = + Element() + .tag(1) + .children({ + Element().tag(7).props(concreteProps("Z")), + Element().tag(2).props(concreteProps("A")), + Element().tag(3) + .children({ + Element().tag(6).props(concreteProps("B")), + }), + Element().tag(4).props(concreteProps("C")), + }); + // clang-format on + + auto rootNode = builder.build(rootElement); + auto children = rootNode->getChildren(); + + // W gains testId → concrete (unflattened). B absorbed back into W. + auto concreteW = + children[2]->clone(ShadowNodeFragment{.props = concreteProps("W")}); + + // New: Root -> [A, W(concrete), C, Z] — Z moved to end. + // New flattened: [A(2), W(3), C(4), Z(7)] + auto newChildren = + std::make_shared>>(); + newChildren->push_back(children[1]); // A + newChildren->push_back(concreteW); // W (concrete) + newChildren->push_back(children[3]); // C + newChildren->push_back(children[0]); // Z (moved to end) + + auto newRootNode = std::static_pointer_cast( + rootNode->ShadowNode::clone( + ShadowNodeFragment{ + .props = ShadowNodeFragment::propsPlaceholder(), + .children = newChildren})); + + auto expected = buildStubViewTreeWithoutUsingDifferentiator(*newRootNode); + auto mutations = calculateShadowViewMutations(*rootNode, *newRootNode); + auto viewTree = buildStubViewTreeWithoutUsingDifferentiator(*rootNode); + viewTree.mutate(mutations); + EXPECT_EQ(viewTree, expected); +} + +// Tests that the LIS algorithm produces correct mutations when child +// reordering coincides with a view's flattening transition. When W(tag=3) +// becomes flattened (loses testId), its child B(tag=6) is promoted to the +// parent level in the flattened view. The LIS pre-computation maps old +// children to new positions, but the flattener may consume entries from +// newRemainingPairs during flatten/unflatten processing. This test verifies +// the runtime newRemainingPairs check correctly handles this interaction. +TEST_P(ShadowTreeLifecycleTest, reorderWithFlatteningTransition) { + auto builder = simpleComponentBuilder(); + + auto concreteProps = [](const std::string& id) { + auto props = std::make_shared(); + props->testId = id; + return props; + }; + + // clang-format off + // Old tree: Root -> [A, W(concrete with testId, child B), C, D] + auto rootElement = + Element() + .tag(1) + .children({ + Element().tag(2).props(concreteProps("A")), + Element().tag(3).props(concreteProps("W")) + .children({ + Element().tag(6).props(concreteProps("B")), + }), + Element().tag(4).props(concreteProps("C")), + Element().tag(5).props(concreteProps("D")), + }); + // clang-format on + + auto rootNode = builder.build(rootElement); + auto children = rootNode->getChildren(); + // children: [A(2), W(3), C(4), D(5)] + + // Clone W without testId to make it non-concrete (flattened). + // B(tag=6) will be promoted to the parent level. + auto flatW = children[1]->clone( + ShadowNodeFragment{.props = std::make_shared()}); + + // New tree: Root -> [C, D, A, W(flattened)] + // Reorder [A,W,C,D] -> [C,D,A,W(flattened)] + auto reorderedChildren = + std::make_shared>>(); + reorderedChildren->push_back(children[2]); // C + reorderedChildren->push_back(children[3]); // D + reorderedChildren->push_back(children[0]); // A + reorderedChildren->push_back(flatW); // W (flattened) + + auto newRootNode = std::static_pointer_cast( + rootNode->ShadowNode::clone( + ShadowNodeFragment{ + .props = ShadowNodeFragment::propsPlaceholder(), + .children = reorderedChildren})); + + auto expected = buildStubViewTreeWithoutUsingDifferentiator(*newRootNode); + auto mutations = calculateShadowViewMutations(*rootNode, *newRootNode); + auto viewTree = buildStubViewTreeWithoutUsingDifferentiator(*rootNode); + viewTree.mutate(mutations); + EXPECT_EQ(viewTree, expected); +} + +INSTANTIATE_TEST_SUITE_P( + Greedy, + ShadowTreeLifecycleTest, + ::testing::Values(false), + [](const auto&) { return "Greedy"; }); + +INSTANTIATE_TEST_SUITE_P( + LIS, + ShadowTreeLifecycleTest, + ::testing::Values(true), + [](const auto&) { return "LIS"; }); diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 1d6ffadb4af6..1f4108dc265d 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -901,6 +901,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + useLISAlgorithmInDifferentiator: { + defaultValue: false, + metadata: { + dateAdded: '2026-03-12', + description: + 'Use Longest Increasing Subsequence algorithm in the Differentiator to minimize REMOVE/INSERT mutations during child list reconciliation.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, useNativeViewConfigsInBridgelessMode: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 3deb4d238223..3d92ce834c9f 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<<5966ef11ee71a38059decda1c529fd6f>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -128,6 +128,7 @@ export type ReactNativeFeatureFlags = $ReadOnly<{ updateRuntimeShadowNodeReferencesOnCommitThread: Getter, useAlwaysAvailableJSErrorHandling: Getter, useFabricInterop: Getter, + useLISAlgorithmInDifferentiator: Getter, useNativeViewConfigsInBridgelessMode: Getter, useNestedScrollViewAndroid: Getter, useSharedAnimatedBackend: Getter, @@ -529,6 +530,10 @@ export const useAlwaysAvailableJSErrorHandling: Getter = createNativeFl * Should this application enable the Fabric Interop Layer for Android? If yes, the application will behave so that it can accept non-Fabric components and render them on Fabric. This toggle is controlling extra logic such as custom event dispatching that are needed for the Fabric Interop Layer to work correctly. */ export const useFabricInterop: Getter = createNativeFlagGetter('useFabricInterop', true); +/** + * Use Longest Increasing Subsequence algorithm in the Differentiator to minimize REMOVE/INSERT mutations during child list reconciliation. + */ +export const useLISAlgorithmInDifferentiator: Getter = createNativeFlagGetter('useLISAlgorithmInDifferentiator', false); /** * When enabled, the native view configs are used in bridgeless mode. */ diff --git a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js index ea68b2cf9eb3..d03933b754e6 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<<85ddaee522fc3be8546aef21cf236e9a>> + * @generated SignedSource<<971e04eed130adab56e3306a6a26ff1c>> * @flow strict * @noformat */ @@ -105,6 +105,7 @@ export interface Spec extends TurboModule { +updateRuntimeShadowNodeReferencesOnCommitThread?: () => boolean; +useAlwaysAvailableJSErrorHandling?: () => boolean; +useFabricInterop?: () => boolean; + +useLISAlgorithmInDifferentiator?: () => boolean; +useNativeViewConfigsInBridgelessMode?: () => boolean; +useNestedScrollViewAndroid?: () => boolean; +useSharedAnimatedBackend?: () => 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 c45a6551484f..49ebf42cb3d0 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 @@ -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. * - * @fantom_flags enableFabricCommitBranching:* + * @fantom_flags enableFabricCommitBranching:* useLISAlgorithmInDifferentiator:* * @flow strict-local * @format */ @@ -14,6 +14,7 @@ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; import type {HostInstance} from 'react-native'; import ensureInstance from '../../../__tests__/utilities/ensureInstance'; +import * as ReactNativeFeatureFlags from '../../../featureflags/ReactNativeFeatureFlags'; import * as Fantom from '@react-native/fantom'; import * as React from 'react'; import {View} from 'react-native'; @@ -86,6 +87,80 @@ describe('ViewFlattening', () => { ]); }); + /** + * Test worst-case reordering: moving the first child to the end. + * + * P -> [A,B,C,D,E] ==> P -> [B,C,D,E,A] + * + * The greedy two-pointer encounters A vs B mismatch at the first + * position and generates excessive REMOVE+INSERT pairs: + * 4 removes + 4 inserts = 8 mutations. + * + * The LIS algorithm identifies [B,C,D,E] as the longest increasing + * subsequence — those children stay in place, and only A needs to + * move: 1 remove + 1 insert = 2 mutations. + */ + test('reordering: move first child to last (worst case for greedy)', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render( + + + + + + + , + ); + }); + + root.takeMountingManagerLogs(); + + Fantom.runTask(() => { + root.render( + + + + + + + , + ); + }); + + expect(root.getRenderedOutput().toJSX()).toEqual( + + + + + + + , + ); + + const logs = root.takeMountingManagerLogs(); + if (ReactNativeFeatureFlags.useLISAlgorithmInDifferentiator()) { + // LIS = [B,C,D,E], only A moves: 1 remove + 1 insert = 2 mutations + expect(logs).toEqual([ + 'Remove {type: "View", parentNativeID: "P", index: 0, nativeID: "A"}', + 'Insert {type: "View", parentNativeID: "P", index: 4, nativeID: "A"}', + ]); + } else { + // Greedy: 4 removes + 4 inserts = 8 mutations + expect(logs).toEqual([ + 'Remove {type: "View", parentNativeID: "P", index: 4, nativeID: "E"}', + 'Remove {type: "View", parentNativeID: "P", index: 3, nativeID: "D"}', + 'Remove {type: "View", parentNativeID: "P", index: 2, nativeID: "C"}', + 'Remove {type: "View", parentNativeID: "P", index: 1, nativeID: "B"}', + 'Insert {type: "View", parentNativeID: "P", index: 0, nativeID: "B"}', + 'Insert {type: "View", parentNativeID: "P", index: 1, nativeID: "C"}', + 'Insert {type: "View", parentNativeID: "P", index: 2, nativeID: "D"}', + 'Insert {type: "View", parentNativeID: "P", index: 3, nativeID: "E"}', + ]); + } + }); + /** * Test reparenting mutation instruction generation. * We cannot practically handle all possible use-cases here. @@ -146,13 +221,23 @@ describe('ViewFlattening', () => { , ); - expect(root.takeMountingManagerLogs()).toEqual([ - 'Update {type: "View", nativeID: "A"}', - 'Remove {type: "View", parentNativeID: "G", index: 0, nativeID: "A"}', - 'Create {type: "View", nativeID: "H"}', - 'Insert {type: "View", parentNativeID: "G", index: 0, nativeID: "H"}', - 'Insert {type: "View", parentNativeID: "H", index: 0, nativeID: "A"}', - ]); + if (ReactNativeFeatureFlags.useLISAlgorithmInDifferentiator()) { + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "View", nativeID: "A"}', + 'Remove {type: "View", parentNativeID: "G", index: 0, nativeID: "A"}', + 'Create {type: "View", nativeID: "H"}', + 'Insert {type: "View", parentNativeID: "H", index: 0, nativeID: "A"}', + 'Insert {type: "View", parentNativeID: "G", index: 0, nativeID: "H"}', + ]); + } else { + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "View", nativeID: "A"}', + 'Remove {type: "View", parentNativeID: "G", index: 0, nativeID: "A"}', + 'Create {type: "View", nativeID: "H"}', + 'Insert {type: "View", parentNativeID: "G", index: 0, nativeID: "H"}', + 'Insert {type: "View", parentNativeID: "H", index: 0, nativeID: "A"}', + ]); + } // The view is reparented 1 level down with a different sibling // Root -> G* -> H* -> I* -> J -> [B*, A*] [nodes with * are _not_ flattened] @@ -182,14 +267,25 @@ describe('ViewFlattening', () => { , ); - expect(root.takeMountingManagerLogs()).toEqual([ - 'Remove {type: "View", parentNativeID: "H", index: 0, nativeID: "A"}', - 'Create {type: "View", nativeID: "I"}', - 'Create {type: "View", nativeID: "B"}', - 'Insert {type: "View", parentNativeID: "H", index: 0, nativeID: "I"}', - 'Insert {type: "View", parentNativeID: "I", index: 0, nativeID: "B"}', - 'Insert {type: "View", parentNativeID: "I", index: 1, nativeID: "A"}', - ]); + if (ReactNativeFeatureFlags.useLISAlgorithmInDifferentiator()) { + expect(root.takeMountingManagerLogs()).toEqual([ + 'Remove {type: "View", parentNativeID: "H", index: 0, nativeID: "A"}', + 'Create {type: "View", nativeID: "I"}', + 'Create {type: "View", nativeID: "B"}', + 'Insert {type: "View", parentNativeID: "I", index: 0, nativeID: "B"}', + 'Insert {type: "View", parentNativeID: "I", index: 1, nativeID: "A"}', + 'Insert {type: "View", parentNativeID: "H", index: 0, nativeID: "I"}', + ]); + } else { + expect(root.takeMountingManagerLogs()).toEqual([ + 'Remove {type: "View", parentNativeID: "H", index: 0, nativeID: "A"}', + 'Create {type: "View", nativeID: "I"}', + 'Create {type: "View", nativeID: "B"}', + 'Insert {type: "View", parentNativeID: "H", index: 0, nativeID: "I"}', + 'Insert {type: "View", parentNativeID: "I", index: 0, nativeID: "B"}', + 'Insert {type: "View", parentNativeID: "I", index: 1, nativeID: "A"}', + ]); + } // The view is reparented 1 level further down with its order with the sibling // swapped @@ -222,14 +318,25 @@ describe('ViewFlattening', () => { , ); - expect(root.takeMountingManagerLogs()).toEqual([ - 'Remove {type: "View", parentNativeID: "I", index: 1, nativeID: "A"}', - 'Remove {type: "View", parentNativeID: "I", index: 0, nativeID: "B"}', - 'Create {type: "View", nativeID: "J"}', - 'Insert {type: "View", parentNativeID: "I", index: 0, nativeID: "J"}', - 'Insert {type: "View", parentNativeID: "J", index: 0, nativeID: "A"}', - 'Insert {type: "View", parentNativeID: "J", index: 1, nativeID: "B"}', - ]); + if (ReactNativeFeatureFlags.useLISAlgorithmInDifferentiator()) { + expect(root.takeMountingManagerLogs()).toEqual([ + 'Remove {type: "View", parentNativeID: "I", index: 1, nativeID: "A"}', + 'Remove {type: "View", parentNativeID: "I", index: 0, nativeID: "B"}', + 'Create {type: "View", nativeID: "J"}', + 'Insert {type: "View", parentNativeID: "J", index: 0, nativeID: "A"}', + 'Insert {type: "View", parentNativeID: "J", index: 1, nativeID: "B"}', + 'Insert {type: "View", parentNativeID: "I", index: 0, nativeID: "J"}', + ]); + } else { + expect(root.takeMountingManagerLogs()).toEqual([ + 'Remove {type: "View", parentNativeID: "I", index: 1, nativeID: "A"}', + 'Remove {type: "View", parentNativeID: "I", index: 0, nativeID: "B"}', + 'Create {type: "View", nativeID: "J"}', + 'Insert {type: "View", parentNativeID: "I", index: 0, nativeID: "J"}', + 'Insert {type: "View", parentNativeID: "J", index: 0, nativeID: "A"}', + 'Insert {type: "View", parentNativeID: "J", index: 1, nativeID: "B"}', + ]); + } }); test('parent-child switching from unflattened-flattened to flattened-unflattened', () => { @@ -340,15 +447,28 @@ describe('ViewFlattening', () => { , ); }); - expect(root.takeMountingManagerLogs()).toEqual([ - 'Update {type: "View", nativeID: "child"}', - 'Remove {type: "View", parentNativeID: (root), index: 0, nativeID: (N/A)}', - 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', - 'Delete {type: "View", nativeID: (N/A)}', - 'Create {type: "View", nativeID: (N/A)}', - 'Insert {type: "View", parentNativeID: (root), index: 0, nativeID: (N/A)}', - 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', - ]); + + if (ReactNativeFeatureFlags.useLISAlgorithmInDifferentiator()) { + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "View", nativeID: "child"}', + 'Remove {type: "View", parentNativeID: (root), index: 0, nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', + 'Delete {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', + 'Insert {type: "View", parentNativeID: (root), index: 0, nativeID: (N/A)}', + ]); + } else { + expect(root.takeMountingManagerLogs()).toEqual([ + 'Update {type: "View", nativeID: "child"}', + 'Remove {type: "View", parentNativeID: (root), index: 0, nativeID: (N/A)}', + 'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', + 'Delete {type: "View", nativeID: (N/A)}', + 'Create {type: "View", nativeID: (N/A)}', + 'Insert {type: "View", parentNativeID: (root), index: 0, nativeID: (N/A)}', + 'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "child"}', + ]); + } }); test('#51378: view with rgba(255,255,255,127/256) background color is not flattened', () => {