From 7ab01edf14cd62e0aff6f9854a43348b80cfc622 Mon Sep 17 00:00:00 2001 From: Gregory Moskaliuk Date: Sat, 31 Oct 2026 14:40:20 +0100 Subject: [PATCH] Skip the unchanged scroll state update when a ScrollView mounts on Android onLayout calls scrollTo, which calls updateFabricScrollState. Its dedupe compares against lastStateUpdateScroll, which starts at (-1, -1), so the first offset always gets through, even when it is already in the state. That runs a full commit that changes nothing. Before a view's first update, read the offset from the state into lastStateUpdateScroll and let the existing dedupe decide. Co-Authored-By: Claude Opus 5.5 --- .../views/scroll/ReactScrollViewHelper.kt | 14 ++- .../scroll/ReactScrollViewHelperStateTest.kt | 119 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperStateTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt index e6e0660d174b..4edac4af7bb6 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt @@ -40,6 +40,7 @@ public object ReactScrollViewHelper { private val TAG = ReactScrollView::class.java.simpleName private val DEBUG_MODE = false // ReactBuildConfig.DEBUG + private const val UNSET_SCROLL_POSITION = -1 private const val CONTENT_OFFSET_LEFT = "contentOffsetLeft" private const val CONTENT_OFFSET_TOP = "contentOffsetTop" private const val SCROLL_AWAY_PADDING_TOP = "scrollAwayPaddingTop" @@ -373,6 +374,17 @@ public object ReactScrollViewHelper { // User driven scrolling should disable scroll state updates coming from Fabric scrollState.isUpdatedByScroll = true + // Until a view sends its first update it doesn't know the offset in its state. On mount that + // offset is usually already correct, and sending it again would only commit an unchanged tree. + if (scrollState.lastStateUpdateScroll.equals(UNSET_SCROLL_POSITION, UNSET_SCROLL_POSITION)) { + scrollView.stateWrapper?.stateData?.let { stateData -> + scrollState.setLastStateUpdateScroll( + toPixelFromDIP(stateData.getDouble(CONTENT_OFFSET_LEFT)).toInt(), + toPixelFromDIP(stateData.getDouble(CONTENT_OFFSET_TOP)).toInt(), + ) + } + } + // Dedupe events to reduce JNI traffic if (scrollState.lastStateUpdateScroll.equals(scrollX, scrollY)) { return @@ -660,7 +672,7 @@ public object ReactScrollViewHelper { /** Get the padding on the bottom for tab bar */ var scrollAwayPaddingBottom: Int = 0, /** Get the Fabric state of last scroll position */ - val lastStateUpdateScroll: Point = Point(-1, -1), + val lastStateUpdateScroll: Point = Point(UNSET_SCROLL_POSITION, UNSET_SCROLL_POSITION), /** Get true if the previous animation was canceled */ var isCanceled: Boolean = false, /** Get true if previous animation was finished */ diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperStateTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperStateTest.kt new file mode 100644 index 000000000000..667aa63b07ac --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/ReactScrollViewHelperStateTest.kt @@ -0,0 +1,119 @@ +/* + * 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. + */ + +package com.facebook.react.views.scroll + +import android.annotation.SuppressLint +import android.util.DisplayMetrics +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactTestHelper.createTestReactApplicationContext +import com.facebook.react.bridge.ReadableNativeMap +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.uimanager.DisplayMetricsHolder +import com.facebook.react.uimanager.StateWrapper +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowNativeMap +import com.facebook.testutils.shadows.ShadowReadableNativeMap +import com.facebook.testutils.shadows.ShadowSoLoader +import com.facebook.testutils.shadows.ShadowWritableNativeMap +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Verifies when a ScrollView sends its scroll offset to the Fabric state. */ +@SuppressLint("DeprecatedClass", "DeprecatedMethod") +@RunWith(RobolectricTestRunner::class) +@Config( + shadows = + [ + ShadowNativeLoader::class, + ShadowSoLoader::class, + ShadowNativeMap::class, + ShadowReadableNativeMap::class, + ShadowWritableNativeMap::class, + ], +) +class ReactScrollViewHelperStateTest { + + private lateinit var view: ReactScrollView + + @Before + fun setup() { + ReactNativeFeatureFlagsForTests.setUp() + val context: ReactApplicationContext = createTestReactApplicationContext() + DisplayMetricsHolder.setScreenDisplayMetrics(DisplayMetrics().apply { density = 1f }) + view = + ReactScrollViewManager().createViewInstance(ThemedReactContext(context, context, null, -1)) + } + + @After + fun teardown() { + DisplayMetricsHolder.setScreenDisplayMetrics(null) + } + + private fun stateWrapperWithOffset(x: Double, y: Double): StateWrapper { + val stateData = + mock { + on { getDouble("contentOffsetLeft") } doReturn x + on { getDouble("contentOffsetTop") } doReturn y + } + return mock { on { this.stateData } doReturn stateData } + } + + @Test + fun testFirstUpdateMatchingTheStateIsNotSent() { + val stateWrapper = stateWrapperWithOffset(0.0, 0.0) + view.stateWrapper = stateWrapper + + ReactScrollViewHelper.updateFabricScrollState(view, 0, 0) + + verify(stateWrapper, never()).updateState(any()) + } + + @Test + fun testFirstUpdateDifferingFromTheStateIsSent() { + val stateWrapper = stateWrapperWithOffset(0.0, 500.0) + view.stateWrapper = stateWrapper + + ReactScrollViewHelper.updateFabricScrollState(view, 0, 0) + + verify(stateWrapper, times(1)).updateState(any()) + } + + @Test + fun testFirstUpdateIsSentWithoutStateData() { + val stateWrapper = mock { on { stateData } doReturn null } + view.stateWrapper = stateWrapper + + ReactScrollViewHelper.updateFabricScrollState(view, 0, 0) + + verify(stateWrapper, times(1)).updateState(any()) + } + + @Test + fun testLaterUpdatesAreSentOncePerOffset() { + val stateWrapper = stateWrapperWithOffset(0.0, 0.0) + view.stateWrapper = stateWrapper + + ReactScrollViewHelper.updateFabricScrollState(view, 0, 0) + ReactScrollViewHelper.updateFabricScrollState(view, 0, 100) + ReactScrollViewHelper.updateFabricScrollState(view, 0, 100) + + verify(stateWrapper, times(1)).updateState(any()) + verify(stateWrapper, times(1)).stateData + } +}