diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.kt index 67474abf0f72..db406f595f2b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.kt @@ -299,6 +299,11 @@ public class NativeAnimatedModule(reactContext: ReactApplicationContext) : preOperations.executeBatch(batchNumber, nodesManager) operations.executeBatch(batchNumber, nodesManager) + + // Operations executed above may have started animations (e.g. startAnimatingNode); the + // frame callback disarms itself when no animations are active, so re-arm it here. + // didDispatchMountItems is UI-confined, like enqueueFrameCallback. + enqueueFrameCallback() } // For non-FabricUIManager only (no-op since Fabric is the only supported UIManager) @@ -348,9 +353,11 @@ public class NativeAnimatedModule(reactContext: ReactApplicationContext) : val nodesManager = nodesManager ?: return if (nodesManager.hasActiveAnimations()) { nodesManager.runUpdates(frameTimeNanos) + // Only keep the Choreographer armed while animations are actually running. + // didDispatchMountItems re-arms this callback when new animation operations + // (e.g. startAnimatingNode) execute. + enqueueFrameCallback() } - - enqueueFrameCallback() } catch (ex: Exception) { throw RuntimeException(ex) } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index 1ba1a11fabd2..92e9f6c8c118 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -1663,7 +1663,12 @@ public void doFrameGuarded(long frameTimeNanos) { mIsMountingEnabled = false; throw ex; } finally { - schedule(); + // Keep the Choreographer armed only while items remain pending; posting new items + // calls schedule() directly. An unconditional re-schedule here kept the Choreographer + // running at vsync rate while idle. + if (mMountItemDispatcher.hasPendingItems()) { + schedule(); + } } if (ReactNativeFeatureFlags.useSharedAnimatedBackend() && mBinding != null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt index 72257c165077..643ca1dbf023 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt @@ -34,6 +34,10 @@ internal class MountItemDispatcher( private val mountItems: Queue = ConcurrentLinkedQueue() private val preMountItems: Queue = ConcurrentLinkedQueue() + /** @return true if any mount items, pre-mount items or view commands are still pending */ + fun hasPendingItems(): Boolean = + !viewCommandMountItems.isEmpty() || !mountItems.isEmpty() || !preMountItems.isEmpty() + private var inDispatch: Boolean = false var batchedExecutionTime: Long = 0L private set diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt index 548409be1c2f..fd452d9c13b5 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.kt @@ -185,6 +185,14 @@ public open class JavaTimerManager( synchronized(timerGuard) { timers.add(timer) timerIdsToTimers.put(timerId, timer) + if (!frameCallbackPosted && !isPaused.get()) { + // Re-arm the timers frame callback lazily: it disarms itself whenever the queue drains. + reactChoreographer.postFrameCallback( + ReactChoreographer.CallbackType.TIMERS_EVENTS, + timerFrameCallback, + ) + frameCallbackPosted = true + } } } @@ -292,6 +300,7 @@ public open class JavaTimerManager( return } val frameTimeMillis = frameTimeNanos / 1000000 + var shouldRepost: Boolean synchronized(timerGuard) { while (!timers.isEmpty() && timers.peek()!!.targetTime < frameTimeMillis) { var timer = timers.poll() @@ -309,12 +318,20 @@ public open class JavaTimerManager( timerIdsToTimers.remove(timer.timerId) } } + shouldRepost = timers.isNotEmpty() + if (!shouldRepost) { + // The timer queue is empty: disarm instead of re-posting this callback at vsync rate. + // createTimer re-arms the callback when a new timer arrives. + frameCallbackPosted = false + } } timersToCall?.let { timers -> javaScriptTimerExecutor.callTimers(timers) timersToCall = null } - reactChoreographer.postFrameCallback(ReactChoreographer.CallbackType.TIMERS_EVENTS, this) + if (shouldRepost) { + reactChoreographer.postFrameCallback(ReactChoreographer.CallbackType.TIMERS_EVENTS, this) + } } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/FabricEventDispatcher.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/FabricEventDispatcher.kt index 8e21310b3f5d..a7ab2cf1c53a 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/FabricEventDispatcher.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/events/FabricEventDispatcher.kt @@ -147,10 +147,13 @@ internal class FabricEventDispatcher( override fun doFrame(frameTimeNanos: Long) { UiThreadUtil.assertOnUiThread() + // This callback is one-shot per schedule request (see maybeDispatchBatchedEvents): events + // are dispatched synchronously in dispatchEvent, so there is nothing to re-post here. + // Re-posting unconditionally kept the Choreographer armed at vsync rate while idle. + isFrameCallbackDispatchScheduled = false + if (shouldStop) { - isFrameCallbackDispatchScheduled = false - } else { - dispatchBatchedEvents() + return } Systrace.beginSection(Systrace.TRACE_TAG_REACT, "BatchEventDispatchedListeners") diff --git a/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js b/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js index 8e6b10479de7..e8f9137476b5 100644 --- a/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js +++ b/packages/rn-tester/js/examples/Playground/RNTesterPlayground.js @@ -10,29 +10,39 @@ import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; -import RNTesterText from '../../components/RNTesterText'; import * as React from 'react'; -import {StyleSheet, View} from 'react-native'; +import {View} from 'react-native'; +/** + * Reproducer for #58367 — do NOT merge, reproducer only. + * + * Renders nothing but an empty View: the framework itself keeps the + * main-thread Choreographer armed at ~60 doFrames/s while this screen is + * foreground-idle, with zero frames rendered. Verified on API 28/29/36. + * + * To observe on any Android device/emulator with RNTester running this + * playground: + * + * adb shell atrace -t 10 -b 32768 view input -z -o /data/local/tmp/idle.atrace.gz + * adb pull /data/local/tmp/idle.atrace.gz . + * # decompress, then: grep "Choreographer#doFrame" idle.text | wc -l + * # -> ~600 sections for the app pid in 10 idle seconds, each containing + * only an empty "animation" stage (no layout, no draw) + * + * adb shell dumpsys gfxinfo reset && sleep 10 \ + * && adb shell dumpsys gfxinfo | grep "Total frames rendered" + * # -> 0 (the loop renders nothing) + * + * Control: a plain native Activity rendering an empty View receives 0 + * doFrames at idle. + */ function Playground() { - return ( - - - Edit "RNTesterPlayground.js" to change this file - - - ); + return ; } -const styles = StyleSheet.create({ - container: { - padding: 10, - }, -}); - -export default { +export default ({ title: 'Playground', name: 'playground', description: 'Test out new features and ideas.', render: (): React.Node => , -} as RNTesterModuleExample; +}: RNTesterModuleExample);