From b5a82e655b3459cb9a10d47296469b443e2b07f9 Mon Sep 17 00:00:00 2001 From: Peter Abbondanzo Date: Fri, 24 Apr 2026 09:53:01 -0700 Subject: [PATCH] Dispatch FPS events from ACTION_SCROLL events (#56601) Summary: `ACTION_SCROLL` events (mouse wheel, trackpad, joystick) were not participating in FPS performance logging. Touch-driven scrolls already called `enableFpsListener`/`disableFpsListener` through `handlePostTouchScrolling`, which uses a stable-frames heuristic to detect when scrolling has settled (3 consecutive frames with no position change). ACTION_SCROLL events bypassed this entirely. This adds FPS listener support to the `ACTION_SCROLL` path by calling `enableFpsListener` when a scroll event arrives, then delegating to `handlePostTouchScrolling` for lifecycle management: - **Snapping ScrollViews (`ReactScrollView`, `ReactHorizontalScrollView`):** The existing debounce runnable calls `flingAndSnap` to animate to a snap point. After the snap, `handlePostTouchScrolling` monitors the animation via the stable-frames mechanism and disables the FPS listener once settled. - **Non-snapping ScrollViews (`ReactScrollView`):** `handlePostTouchScrolling` is called directly. Its re-entry guard (`mPostTouchRunnable != null`) naturally deduplicates across rapid scroll events, while `onScrollChanged` keeps the monitor alive by setting `mActivelyScrolling`. Also nulls `mPostTouchRunnable` before reassignment in the debounce cancel path to avoid stale references. Changelog: [Android][Fixed] - Dispatch FPS performance events for mouse wheel and trackpad scroll interactions in ScrollView Differential Revision: D102347043 --- .../react/views/scroll/ReactHorizontalScrollView.java | 8 +++++++- .../react/views/scroll/ReactNestedScrollView.java | 7 ++++++- .../com/facebook/react/views/scroll/ReactScrollView.java | 5 +++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java index 75a9f3b77f02..19bcde777231 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java @@ -859,6 +859,7 @@ public boolean dispatchGenericMotionEvent(MotionEvent ev) { float hScroll = ev.getAxisValue(MotionEvent.AXIS_HSCROLL); if (hScroll != 0) { // Perform the scroll + enableFpsListener(); boolean result = super.dispatchGenericMotionEvent(ev); // Schedule snap alignment to run after scrolling stops if (result @@ -866,9 +867,10 @@ public boolean dispatchGenericMotionEvent(MotionEvent ev) { || mSnapInterval != 0 || mSnapOffsets != null || mSnapToAlignment != SNAP_ALIGNMENT_DISABLED)) { - // Cancel any pending runnable and reschedule + // Cancel any pending post-touch runnable and reschedule if (mPostTouchRunnable != null) { removeCallbacks(mPostTouchRunnable); + mPostTouchRunnable = null; } mPostTouchRunnable = new Runnable() { @@ -882,9 +884,12 @@ public void run() { velocityX = 0; } flingAndSnap(velocityX); + handlePostTouchScrolling(velocityX, 0); } }; postOnAnimationDelayed(mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY); + } else { + handlePostTouchScrolling(0, 0); } return result; } @@ -1212,6 +1217,7 @@ public void run() { } ReactScrollViewHelper.notifyUserDrivenScrollEnded_internal( ReactHorizontalScrollView.this); + disableFpsListener(); } else { if (mPagingEnabled && !mSnappingToPage) { // If we have pagingEnabled and we have not snapped to the page diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.java index c20be4c85646..6cd0b927654e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.java @@ -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<<41d03f4948562c0fae870a660e6e1eac>> */ /** @@ -700,6 +700,7 @@ public boolean dispatchGenericMotionEvent(MotionEvent ev) { float vScroll = ev.getAxisValue(MotionEvent.AXIS_VSCROLL); if (vScroll != 0) { // Perform the scroll + enableFpsListener(); boolean result = super.dispatchGenericMotionEvent(ev); // Schedule snap alignment to run after scrolling stops if (result @@ -710,6 +711,7 @@ public boolean dispatchGenericMotionEvent(MotionEvent ev) { // Cancel any pending post-touch runnable and reschedule if (mPostTouchRunnable != null) { removeCallbacks(mPostTouchRunnable); + mPostTouchRunnable = null; } mPostTouchRunnable = new Runnable() { @@ -723,9 +725,12 @@ public void run() { velocityY = 0; } flingAndSnap(velocityY); + handlePostTouchScrolling(0, velocityY); } }; postOnAnimationDelayed(mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY); + } else { + handlePostTouchScrolling(0, 0); } return result; } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java index c0a8e4e1202d..72e2db9c847a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java @@ -692,6 +692,7 @@ public boolean dispatchGenericMotionEvent(MotionEvent ev) { float vScroll = ev.getAxisValue(MotionEvent.AXIS_VSCROLL); if (vScroll != 0) { // Perform the scroll + enableFpsListener(); boolean result = super.dispatchGenericMotionEvent(ev); // Schedule snap alignment to run after scrolling stops if (result @@ -702,6 +703,7 @@ public boolean dispatchGenericMotionEvent(MotionEvent ev) { // Cancel any pending post-touch runnable and reschedule if (mPostTouchRunnable != null) { removeCallbacks(mPostTouchRunnable); + mPostTouchRunnable = null; } mPostTouchRunnable = new Runnable() { @@ -715,9 +717,12 @@ public void run() { velocityY = 0; } flingAndSnap(velocityY); + handlePostTouchScrolling(0, velocityY); } }; postOnAnimationDelayed(mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY); + } else { + handlePostTouchScrolling(0, 0); } return result; }