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 a9e1a1e0cb74..7136c0a4758a 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<> */ /** @@ -153,7 +153,7 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun hideOffscreenVirtualViewsOnIOS(): Boolean = false - override fun overrideBySynchronousMountPropsAtMountingAndroid(): Boolean = false + override fun overrideBySynchronousMountPropsAtMountingAndroid(): Boolean = true override fun perfIssuesEnabled(): Boolean = false diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/SurfaceMountingManagerSynchronousMountPropsTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/SurfaceMountingManagerSynchronousMountPropsTest.kt new file mode 100644 index 000000000000..2c0787d17c84 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/SurfaceMountingManagerSynchronousMountPropsTest.kt @@ -0,0 +1,178 @@ +/* + * 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. + */ + +@file:Suppress("DEPRECATION") + +package com.facebook.react.fabric + +import com.facebook.react.ReactRootView +import com.facebook.react.bridge.JavaOnlyMap +import com.facebook.react.bridge.ReactTestHelper +import com.facebook.react.fabric.mounting.MountingManager +import com.facebook.react.fabric.mounting.MountingManager.MountItemExecutor +import com.facebook.react.fabric.mounting.SurfaceMountingManager +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewManager +import com.facebook.react.uimanager.ViewManagerRegistry +import com.facebook.react.views.view.ReactViewManager +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowNativeMap +import com.facebook.testutils.shadows.ShadowReadableNativeArray +import com.facebook.testutils.shadows.ShadowReadableNativeMap +import com.facebook.testutils.shadows.ShadowSoLoader +import com.facebook.testutils.shadows.ShadowWritableNativeArray +import com.facebook.testutils.shadows.ShadowWritableNativeMap +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Tests for the synchronous mount props override behavior in [SurfaceMountingManager], controlled + * by the `overrideBySynchronousMountPropsAtMountingAndroid` feature flag (default: true). + * + * This fixes a race condition where Native Animated applies props (e.g. opacity, transform) + * synchronously on native view, but a regular Fabric mount update with stale props arrives later + * and overwrites the fresh values, causing the view to visibly jump/flicker. + */ +@RunWith(RobolectricTestRunner::class) +@Config( + shadows = + [ + ShadowSoLoader::class, + ShadowNativeLoader::class, + ShadowNativeMap::class, + ShadowWritableNativeMap::class, + ShadowReadableNativeMap::class, + ShadowWritableNativeArray::class, + ShadowReadableNativeArray::class, + ] +) +class SurfaceMountingManagerSynchronousMountPropsTest { + private lateinit var mountingManager: MountingManager + private lateinit var themedReactContext: ThemedReactContext + private val surfaceId = 1 + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + val reactContext = ReactTestHelper.createCatalystContextForTest() + themedReactContext = ThemedReactContext(reactContext, reactContext, null, -1) + mountingManager = + MountingManager( + ViewManagerRegistry(listOf>(ReactViewManager())), + MountItemExecutor {}, + ) + } + + private fun startSurface(): SurfaceMountingManager { + val rootView = ReactRootView(themedReactContext) + mountingManager.startSurface(surfaceId, themedReactContext, rootView) + return mountingManager.getSurfaceManagerEnforced(surfaceId, "test") + } + + private fun createAndAttachView(smm: SurfaceMountingManager, tag: Int) { + smm.preallocateView("RCTView", tag, JavaOnlyMap.of(), null, true) + smm.addViewAt(surfaceId, tag, 0) + } + + /** Stored synchronous opacity should override a stale Fabric mount update. */ + @Test + fun storeSynchronousProps_overridesStaleOpacityInUpdateProps() { + val smm = startSurface() + val tag = 42 + createAndAttachView(smm, tag) + + // Native Animated sets opacity=0.3 synchronously + smm.storeSynchronousMountPropsOverride(tag, JavaOnlyMap.of("opacity", 0.3)) + + // Stale Fabric mount update arrives with opacity=1.0 + smm.updateProps(tag, JavaOnlyMap.of("opacity", 1.0)) + + // The synchronous value (0.3) should win + assertThat(smm.getView(tag).alpha).isEqualTo(0.3f) + } + + /** Multiple storeSynchronousMountPropsOverride calls should merge — later values win. */ + @Test + fun storeSynchronousProps_mergesMultipleCalls() { + val smm = startSurface() + val tag = 42 + createAndAttachView(smm, tag) + + smm.storeSynchronousMountPropsOverride(tag, JavaOnlyMap.of("opacity", 0.3)) + smm.storeSynchronousMountPropsOverride(tag, JavaOnlyMap.of("opacity", 0.7)) + + smm.updateProps(tag, JavaOnlyMap.of("opacity", 1.0)) + + assertThat(smm.getView(tag).alpha).isEqualTo(0.7f) + } + + /** + * Full race condition scenario: synchronous animated props survive a stale Fabric mount update. + */ + @Test + fun raceCondition_synchronousPropsWinOverStaleMount() { + val smm = startSurface() + val tag = 42 + createAndAttachView(smm, tag) + + // Native Animated applies fresh props synchronously + val freshAnimatedProps = JavaOnlyMap.of("opacity", 0.2) + smm.storeSynchronousMountPropsOverride(tag, freshAnimatedProps) + smm.updatePropsSynchronously(tag, freshAnimatedProps) + assertThat(smm.getView(tag).alpha).isEqualTo(0.2f) + + // Stale Fabric mount update arrives + smm.updateProps(tag, JavaOnlyMap.of("opacity", 1.0)) + + // Synchronous value preserved + assertThat(smm.getView(tag).alpha).isEqualTo(0.2f) + } + + /** + * When a view is deleted, stored synchronous props should be cleaned up. A recreated view with + * the same tag should not be affected by the old stored props. + */ + @Test + fun deleteView_cleansUpStoredSynchronousProps() { + val smm = startSurface() + val tag = 42 + createAndAttachView(smm, tag) + + smm.storeSynchronousMountPropsOverride(tag, JavaOnlyMap.of("opacity", 0.3)) + smm.deleteView(tag) + + // Recreate with same tag + smm.createView("RCTView", tag, JavaOnlyMap.of(), null, null, true) + smm.addViewAt(surfaceId, tag, 0) + + smm.updateProps(tag, JavaOnlyMap.of("opacity", 0.9)) + assertThat(smm.getView(tag).alpha).isEqualTo(0.9f) + } + + /** Synchronous props stored for one tag should not affect a different tag. */ + @Test + fun synchronousPropsAreIsolatedPerTag() { + val smm = startSurface() + createAndAttachView(smm, 42) + createAndAttachView(smm, 43) + + smm.storeSynchronousMountPropsOverride(42, JavaOnlyMap.of("opacity", 0.3)) + + smm.updateProps(42, JavaOnlyMap.of("opacity", 1.0)) + smm.updateProps(43, JavaOnlyMap.of("opacity", 1.0)) + + // Tag 42: synchronous override applies (0.3) + assertThat(smm.getView(42).alpha).isEqualTo(0.3f) + // Tag 43: no override, incoming props apply (1.0) + assertThat(smm.getView(43).alpha).isEqualTo(1.0f) + } +} diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index bfbe407374af..10df0490068d 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<<4a2fd61cbcdb28042f09ccb03c970674>> + * @generated SignedSource<> */ /** @@ -288,7 +288,7 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { } bool overrideBySynchronousMountPropsAtMountingAndroid() override { - return false; + return true; } bool perfIssuesEnabled() override { diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 8f5856661e98..6008e3209f1b 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -743,7 +743,7 @@ const definitions: FeatureFlagDefinitions = { ossReleaseStage: 'none', }, overrideBySynchronousMountPropsAtMountingAndroid: { - defaultValue: false, + defaultValue: true, metadata: { dateAdded: '2025-09-04', description: diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index ad14fb3138a6..8f5f75e62a14 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<<1dd51a152bb30c0e2073a14566c8368d>> * @flow strict * @noformat */ @@ -468,7 +468,7 @@ export const hideOffscreenVirtualViewsOnIOS: Getter = createNativeFlagG /** * Override props at mounting with synchronously mounted (i.e. direct manipulation) props from Native Animated. */ -export const overrideBySynchronousMountPropsAtMountingAndroid: Getter = createNativeFlagGetter('overrideBySynchronousMountPropsAtMountingAndroid', false); +export const overrideBySynchronousMountPropsAtMountingAndroid: Getter = createNativeFlagGetter('overrideBySynchronousMountPropsAtMountingAndroid', true); /** * Enable reporting Performance Issues (`detail.devtools.performanceIssue`). Displayed in the V2 Performance Monitor and the "Performance Issues" sub-panel in DevTools. */