From 1f3ff92b0a2a3b966dc4ed854a55791b86e0a523 Mon Sep 17 00:00:00 2001 From: capt-muji Date: Mon, 7 Sep 2026 00:34:58 +0100 Subject: [PATCH 1/2] fix(android): stop re-arming idle Choreographer frame callbacks at vsync rate Four Android frame callbacks re-post themselves unconditionally on every doFrame, keeping the Choreographer armed at ~60fps while an app is foreground-idle with zero pending work (zero timers, zero animations, zero mount items, zero frames rendered): - JavaTimerManager.TimerFrameCallback (JavaTimerManager.kt) - FabricEventDispatcher.ScheduleDispatchFrameCallback - NativeAnimatedModule.animatedFrameCallback - FabricUIManager.DispatchUIFrameCallback Each now re-arms only when it has work, and re-arms lazily from its registration/posting path (createTimer / didDispatchMountItems / schedule). Measured on a stock RN 0.86.3 template app, idle foreground: ~600 doFrames/10s at 0.2-2ms each and 3.5-22.5% process CPU across API 28-36 devices; after this change: 0 doFrames, ~0% CPU, app fully functional. --- .../react/animated/NativeAnimatedModule.kt | 11 +++++++++-- .../react/fabric/FabricUIManager.java | 7 ++++++- .../fabric/mounting/MountItemDispatcher.kt | 4 ++++ .../react/modules/core/JavaTimerManager.kt | 19 ++++++++++++++++++- .../uimanager/events/FabricEventDispatcher.kt | 9 ++++++--- 5 files changed, 43 insertions(+), 7 deletions(-) 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") From 1b4268321a20b145d3f3deb004af525fab593168 Mon Sep 17 00:00:00 2001 From: capt-muji Date: Mon, 7 Sep 2026 01:14:28 +0100 Subject: [PATCH 2/2] =?UTF-8?q?repro:=20idle=20Choreographer=20loop=20(#58?= =?UTF-8?q?367)=20=E2=80=94=20empty=20playground=20still=20pumps=2060=20do?= =?UTF-8?q?Frames/s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../examples/Playground/RNTesterPlayground.js | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) 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);