From a2d01476faa91b6c139fe81e6c2d6c34a192f30f Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 02:48:49 +0300 Subject: [PATCH 1/4] Budget Android pre-mounting by the display's frame, not a 60 Hz one MountItemDispatcher stopped pre-mounting views at half of a 60 Hz frame (8.33 ms) on every display: all of a 120 Hz frame and more than a 144 Hz one. It now pre-mounts for half of the shortest interval between the frames it has run on, which is the display's frame at its fastest refresh rate, and never for longer than before. The loop reads React Native's SystemClock so MountItemDispatcherTest can drive it. --- .../fabric/mounting/MountItemDispatcher.kt | 18 ++- .../mounting/MountItemDispatcherTest.kt | 135 ++++++++++++++++++ 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt 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 72efd7c42058..611af971826c 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 @@ -7,7 +7,6 @@ package com.facebook.react.fabric.mounting -import android.os.SystemClock import android.view.View import androidx.annotation.UiThread import com.facebook.common.logging.FLog @@ -17,6 +16,7 @@ import com.facebook.react.bridge.ReactIgnorableMountingException import com.facebook.react.bridge.ReactNoCrashSoftException import com.facebook.react.bridge.ReactSoftExceptionLogger import com.facebook.react.bridge.RetryableMountingLayerException +import com.facebook.react.common.SystemClock import com.facebook.react.fabric.FabricUIManager import com.facebook.react.fabric.mounting.mountitems.DispatchCommandMountItem import com.facebook.react.fabric.mounting.mountitems.MountItem @@ -55,6 +55,10 @@ internal class MountItemDispatcher( private var lastFrameTimeNanos: Long = 0L + // The display's frame at its fastest refresh rate: frame timestamps step by the vsync period, and + // by a multiple of it across a skipped frame. + private var shortestFrameIntervalNanos: Long = FRAME_TIME_NS + fun addViewCommandMountItem(mountItem: DispatchCommandMountItem) { if (!ReactNativeFeatureFlags.disableEarlyViewCommandExecution()) { viewCommandMountItems.add(mountItem) @@ -329,6 +333,14 @@ internal class MountItemDispatcher( @UiThread @ThreadConfined(UI) fun dispatchPreMountItems(frameTimeNanos: Long) { + val frameIntervalNanos = frameTimeNanos - lastFrameTimeNanos + if ( + lastFrameTimeNanos != 0L && + frameIntervalNanos > 0L && + frameIntervalNanos < shortestFrameIntervalNanos + ) { + shortestFrameIntervalNanos = frameIntervalNanos + } lastFrameTimeNanos = frameTimeNanos if (preMountItems.isEmpty()) { @@ -336,7 +348,7 @@ internal class MountItemDispatcher( return } - val deadline = lastFrameTimeNanos + FRAME_TIME_NS / 2 + val deadline = lastFrameTimeNanos + shortestFrameIntervalNanos / 2 dispatchPreMountItemsImpl(deadline) } @@ -355,7 +367,7 @@ internal class MountItemDispatcher( try { while (true) { - if (System.nanoTime() > deadline) { + if (SystemClock.nanoTime() > deadline) { break } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt new file mode 100644 index 000000000000..839cd162d86b --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt @@ -0,0 +1,135 @@ +/* + * 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.fabric.mounting + +import com.facebook.react.common.SystemClock +import com.facebook.react.fabric.mounting.mountitems.MountItem +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowPerformanceTracer +import com.facebook.testutils.shadows.ShadowSoLoader +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config( + shadows = [ShadowSoLoader::class, ShadowNativeLoader::class, ShadowPerformanceTracer::class] +) +class MountItemDispatcherTest { + private lateinit var systemClock: MockedStatic + private lateinit var dispatcher: MountItemDispatcher + private var nowNanos: Long = 0L + private var executedItems: Int = 0 + + @Before + fun setUp() { + ReactNativeFeatureFlagsForTests.setUp() + systemClock = mockStatic(SystemClock::class.java) + systemClock.`when` { SystemClock.nanoTime() }.thenAnswer { nowNanos } + val mountingManager = mock() + whenever(mountingManager.surfaceIsStopped(any())).thenReturn(false) + whenever(mountingManager.isWaitingForViewAttach(any())).thenReturn(false) + dispatcher = MountItemDispatcher(mountingManager, mock()) + } + + @After + fun tearDown() { + systemClock.close() + } + + /** Queues items that each take [costNanos] of the frame to execute. */ + private fun queuePreMountItems(count: Int, costNanos: Long) { + repeat(count) { + dispatcher.addPreAllocateMountItem( + object : MountItem { + override fun execute(mountingManager: MountingManager) { + executedItems++ + nowNanos += costNanos + } + + override fun getSurfaceId(): Int = SURFACE_ID + } + ) + } + } + + /** + * Runs one frame of pre-mounting starting at [frameTimeNanos] and returns how many items it + * executed. + */ + private fun preMountFrame(frameTimeNanos: Long): Int { + nowNanos = frameTimeNanos + val before = executedItems + dispatcher.dispatchPreMountItems(frameTimeNanos) + return executedItems - before + } + + @Test + fun preMountsForHalfOfASixtyHertzFrameBeforeAnyFrameIntervalIsKnown() { + queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + + // Items start at 0, 1, ... 8 ms; the one starting past 8.33 ms waits for the next frame. + assertThat(preMountFrame(FIRST_FRAME_NANOS)).isEqualTo(9) + } + + @Test + fun preMountsForHalfOfTheFrameIntervalTheDisplayRunsAt() { + preMountFrame(FIRST_FRAME_NANOS) + preMountFrame(FIRST_FRAME_NANOS + ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS) + queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + + // At 120 Hz half a frame is 4.17 ms: items start at 0 to 4 ms. + assertThat(preMountFrame(FIRST_FRAME_NANOS + 2 * ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS)) + .isEqualTo(5) + } + + @Test + fun aSkippedFrameDoesNotWidenTheBudget() { + preMountFrame(FIRST_FRAME_NANOS) + preMountFrame(FIRST_FRAME_NANOS + ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS) + queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + + assertThat(preMountFrame(FIRST_FRAME_NANOS + 4 * ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS)) + .isEqualTo(5) + } + + @Test + fun framesSlowerThanSixtyHertzKeepTheSixtyHertzBudget() { + preMountFrame(FIRST_FRAME_NANOS) + preMountFrame(FIRST_FRAME_NANOS + 40 * MILLISECOND_NANOS) + queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + + assertThat(preMountFrame(FIRST_FRAME_NANOS + 80 * MILLISECOND_NANOS)).isEqualTo(9) + } + + @Test + fun aRepeatedFrameTimestampDoesNotShrinkTheBudget() { + preMountFrame(FIRST_FRAME_NANOS) + preMountFrame(FIRST_FRAME_NANOS) + queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + + assertThat(preMountFrame(FIRST_FRAME_NANOS + 20 * MILLISECOND_NANOS)).isEqualTo(9) + } + + private companion object { + const val SURFACE_ID: Int = 1 + const val MILLISECOND_NANOS: Long = 1_000_000L + const val FIRST_FRAME_NANOS: Long = 1_000_000_000L + const val ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 120 + } +} From 35f83553eceac0e1439ec4640b6abefa408d41a1 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 17:37:09 +0300 Subject: [PATCH 2/4] Budget pre-mounting by the display's current refresh rate The shortest frame interval seen so far stays at the display's highest rate, so after a drop from 120 Hz back to 60 Hz pre-mounting keeps a 120 Hz budget, half of what the frame allows. DisplayFrameInterval reads the default display's refresh rate from DisplayManager when the display reports a change, never per frame, and MountItemDispatcher pre-mounts for half of that frame. --- .../react/fabric/FabricUIManager.java | 8 +- .../fabric/mounting/DisplayFrameInterval.kt | 56 ++++++++++++++ .../fabric/mounting/MountItemDispatcher.kt | 22 ++---- .../mounting/DisplayFrameIntervalTest.kt | 74 +++++++++++++++++++ .../mounting/MountItemDispatcherTest.kt | 41 +++------- 5 files changed, 156 insertions(+), 45 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt 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 97de9935b8cf..30ab976a4941 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 @@ -58,6 +58,7 @@ import com.facebook.react.fabric.internal.interop.InteropUIBlockListener; import com.facebook.react.fabric.interop.UIBlock; import com.facebook.react.fabric.interop.UIBlockViewResolver; +import com.facebook.react.fabric.mounting.DisplayFrameInterval; import com.facebook.react.fabric.mounting.MountItemDispatcher; import com.facebook.react.fabric.mounting.MountingManager; import com.facebook.react.fabric.mounting.SurfaceMountingManager; @@ -182,6 +183,7 @@ public class FabricUIManager private final ReactApplicationContext mReactApplicationContext; private final MountingManager mMountingManager; private final FabricEventDispatcher mEventDispatcher; + private final DisplayFrameInterval mDisplayFrameInterval; private final MountItemDispatcher mMountItemDispatcher; private final ViewManagerRegistry mViewManagerRegistry; @@ -252,8 +254,10 @@ public FabricUIManager( mDispatchUIFrameCallback = new DispatchUIFrameCallback(reactContext); mReactApplicationContext = reactContext; mMountingManager = new MountingManager(viewManagerRegistry, mMountItemExecutor); + mDisplayFrameInterval = new DisplayFrameInterval(reactContext); mMountItemDispatcher = - new MountItemDispatcher(mMountingManager, new MountItemDispatchListener()); + new MountItemDispatcher( + mMountingManager, new MountItemDispatchListener(), mDisplayFrameInterval); mEventDispatcher = new FabricEventDispatcher(reactContext, new FabricEventEmitter(this)); mBatchEventDispatchedListener = batchEventDispatchedListener; mReactApplicationContext.addLifecycleEventListener(this); @@ -459,6 +463,7 @@ public void stopSurface(final int surfaceID) { @Override public void initialize() { mEventDispatcher.addBatchEventDispatchedListener(mBatchEventDispatchedListener); + mDisplayFrameInterval.start(); if (ReactNativeFeatureFlags.enableFabricLogs()) { mDevToolsReactPerfLogger = new DevToolsReactPerfLogger(); mDevToolsReactPerfLogger.addDevToolsReactPerfLoggerListener(FABRIC_PERF_LOGGER); @@ -494,6 +499,7 @@ public void invalidate() { mEventDispatcher.removeBatchEventDispatchedListener(mBatchEventDispatchedListener); mEventDispatcher.invalidate(); + mDisplayFrameInterval.stop(); mReactApplicationContext.unregisterComponentCallbacks(mViewManagerRegistry); mViewManagerRegistry.invalidate(); diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt new file mode 100644 index 000000000000..c5c3285710dc --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt @@ -0,0 +1,56 @@ +/* + * 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.fabric.mounting + +import android.content.Context +import android.hardware.display.DisplayManager +import android.view.Display +import com.facebook.react.bridge.UiThreadUtil + +/** + * The default display's frame interval at its current refresh rate. It is read again when the + * display reports a change, so a frame never reads the display itself. + */ +internal class DisplayFrameInterval(context: Context) : + MountItemDispatcher.FrameIntervalSource, DisplayManager.DisplayListener { + private val displayManager: DisplayManager? = context.getSystemService(DisplayManager::class.java) + + @Volatile private var frameIntervalNanos: Long = readFrameIntervalNanos() + + fun start() { + displayManager?.registerDisplayListener(this, UiThreadUtil.getUiThreadHandler()) + frameIntervalNanos = readFrameIntervalNanos() + } + + fun stop() { + displayManager?.unregisterDisplayListener(this) + } + + override fun frameIntervalNanos(): Long = frameIntervalNanos + + override fun onDisplayChanged(displayId: Int) { + if (displayId == Display.DEFAULT_DISPLAY) { + frameIntervalNanos = readFrameIntervalNanos() + } + } + + override fun onDisplayAdded(displayId: Int): Unit = Unit + + override fun onDisplayRemoved(displayId: Int): Unit = Unit + + private fun readFrameIntervalNanos(): Long { + val refreshRate = displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.refreshRate ?: 0f + return if (refreshRate > 0f) (NANOSECONDS_PER_SECOND / refreshRate).toLong() + else SIXTY_HERTZ_FRAME_NANOS + } + + private companion object { + private const val NANOSECONDS_PER_SECOND: Double = 1_000_000_000.0 + private const val SIXTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 60 + } +} 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 611af971826c..3fa59de396f2 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 @@ -29,6 +29,7 @@ import java.util.concurrent.ConcurrentLinkedQueue internal class MountItemDispatcher( private val mountingManager: MountingManager, private val itemDispatchListener: ItemDispatchListener, + private val frameIntervalSource: FrameIntervalSource, ) { private val viewCommandMountItems: Queue = ConcurrentLinkedQueue() private val mountItems: Queue = ConcurrentLinkedQueue() @@ -55,10 +56,6 @@ internal class MountItemDispatcher( private var lastFrameTimeNanos: Long = 0L - // The display's frame at its fastest refresh rate: frame timestamps step by the vsync period, and - // by a multiple of it across a skipped frame. - private var shortestFrameIntervalNanos: Long = FRAME_TIME_NS - fun addViewCommandMountItem(mountItem: DispatchCommandMountItem) { if (!ReactNativeFeatureFlags.disableEarlyViewCommandExecution()) { viewCommandMountItems.add(mountItem) @@ -333,14 +330,6 @@ internal class MountItemDispatcher( @UiThread @ThreadConfined(UI) fun dispatchPreMountItems(frameTimeNanos: Long) { - val frameIntervalNanos = frameTimeNanos - lastFrameTimeNanos - if ( - lastFrameTimeNanos != 0L && - frameIntervalNanos > 0L && - frameIntervalNanos < shortestFrameIntervalNanos - ) { - shortestFrameIntervalNanos = frameIntervalNanos - } lastFrameTimeNanos = frameTimeNanos if (preMountItems.isEmpty()) { @@ -348,7 +337,7 @@ internal class MountItemDispatcher( return } - val deadline = lastFrameTimeNanos + shortestFrameIntervalNanos / 2 + val deadline = lastFrameTimeNanos + frameIntervalSource.frameIntervalNanos() / 2 dispatchPreMountItemsImpl(deadline) } @@ -431,11 +420,14 @@ internal class MountItemDispatcher( fun onItemsQueued() } + fun interface FrameIntervalSource { + /** The display's current frame interval; pre-mounting takes half of it each frame. */ + fun frameIntervalNanos(): Long + } + private companion object { private const val TAG: String = "MountItemDispatcher" - private const val FRAME_TIME_NS: Long = (1000000000 / 60).toLong() - private fun drainConcurrentItemQueue(queue: Queue): List? { if (queue.isEmpty()) { return null diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt new file mode 100644 index 000000000000..1d19ecb69883 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt @@ -0,0 +1,74 @@ +/* + * 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.fabric.mounting + +import android.os.Looper +import android.view.Display +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowDisplay +import org.robolectric.shadows.ShadowDisplayManager + +@RunWith(RobolectricTestRunner::class) +class DisplayFrameIntervalTest { + private val display: Display = ShadowDisplay.getDefaultDisplay() + + private fun setRefreshRate(refreshRate: Float) { + shadowOf(display).setRefreshRate(refreshRate) + ShadowDisplayManager.changeDisplay(Display.DEFAULT_DISPLAY, "") + shadowOf(Looper.getMainLooper()).idle() + } + + @Test + fun readsTheDefaultDisplaysFrame() { + setRefreshRate(120f) + + assertThat(DisplayFrameInterval(RuntimeEnvironment.getApplication()).frameIntervalNanos()) + .isEqualTo(ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS) + } + + @Test + fun followsARefreshRateChangeTheDisplayReports() { + setRefreshRate(120f) + val frameInterval = DisplayFrameInterval(RuntimeEnvironment.getApplication()) + frameInterval.start() + + setRefreshRate(60f) + + assertThat(frameInterval.frameIntervalNanos()).isEqualTo(SIXTY_HERTZ_FRAME_NANOS) + } + + @Test + fun keepsItsFrameOnceStopped() { + setRefreshRate(120f) + val frameInterval = DisplayFrameInterval(RuntimeEnvironment.getApplication()) + frameInterval.start() + frameInterval.stop() + + setRefreshRate(60f) + + assertThat(frameInterval.frameIntervalNanos()).isEqualTo(ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS) + } + + @Test + fun takesASixtyHertzFrameWhenTheDisplayReportsNoRefreshRate() { + setRefreshRate(0f) + + assertThat(DisplayFrameInterval(RuntimeEnvironment.getApplication()).frameIntervalNanos()) + .isEqualTo(SIXTY_HERTZ_FRAME_NANOS) + } + + private companion object { + const val SIXTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 60 + const val ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 120 + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt index 839cd162d86b..285f81552265 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt @@ -35,6 +35,7 @@ class MountItemDispatcherTest { private lateinit var dispatcher: MountItemDispatcher private var nowNanos: Long = 0L private var executedItems: Int = 0 + private var displayFrameIntervalNanos: Long = SIXTY_HERTZ_FRAME_NANOS @Before fun setUp() { @@ -44,7 +45,7 @@ class MountItemDispatcherTest { val mountingManager = mock() whenever(mountingManager.surfaceIsStopped(any())).thenReturn(false) whenever(mountingManager.isWaitingForViewAttach(any())).thenReturn(false) - dispatcher = MountItemDispatcher(mountingManager, mock()) + dispatcher = MountItemDispatcher(mountingManager, mock()) { displayFrameIntervalNanos } } @After @@ -80,7 +81,7 @@ class MountItemDispatcherTest { } @Test - fun preMountsForHalfOfASixtyHertzFrameBeforeAnyFrameIntervalIsKnown() { + fun preMountsForHalfOfTheDisplaysFrameAtSixtyHertz() { queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) // Items start at 0, 1, ... 8 ms; the one starting past 8.33 ms waits for the next frame. @@ -88,48 +89,30 @@ class MountItemDispatcherTest { } @Test - fun preMountsForHalfOfTheFrameIntervalTheDisplayRunsAt() { - preMountFrame(FIRST_FRAME_NANOS) - preMountFrame(FIRST_FRAME_NANOS + ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS) + fun preMountsForHalfOfTheDisplaysFrameAtOneHundredTwentyHertz() { + displayFrameIntervalNanos = ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) // At 120 Hz half a frame is 4.17 ms: items start at 0 to 4 ms. - assertThat(preMountFrame(FIRST_FRAME_NANOS + 2 * ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS)) - .isEqualTo(5) + assertThat(preMountFrame(FIRST_FRAME_NANOS)).isEqualTo(5) } @Test - fun aSkippedFrameDoesNotWidenTheBudget() { - preMountFrame(FIRST_FRAME_NANOS) - preMountFrame(FIRST_FRAME_NANOS + ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS) + fun theBudgetFollowsTheDisplayBackToSixtyHertz() { + displayFrameIntervalNanos = ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + assertThat(preMountFrame(FIRST_FRAME_NANOS)).isEqualTo(5) - assertThat(preMountFrame(FIRST_FRAME_NANOS + 4 * ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS)) - .isEqualTo(5) - } - - @Test - fun framesSlowerThanSixtyHertzKeepTheSixtyHertzBudget() { - preMountFrame(FIRST_FRAME_NANOS) - preMountFrame(FIRST_FRAME_NANOS + 40 * MILLISECOND_NANOS) - queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) - - assertThat(preMountFrame(FIRST_FRAME_NANOS + 80 * MILLISECOND_NANOS)).isEqualTo(9) - } - - @Test - fun aRepeatedFrameTimestampDoesNotShrinkTheBudget() { - preMountFrame(FIRST_FRAME_NANOS) - preMountFrame(FIRST_FRAME_NANOS) - queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS - assertThat(preMountFrame(FIRST_FRAME_NANOS + 20 * MILLISECOND_NANOS)).isEqualTo(9) + assertThat(preMountFrame(FIRST_FRAME_NANOS + SIXTY_HERTZ_FRAME_NANOS)).isEqualTo(9) } private companion object { const val SURFACE_ID: Int = 1 const val MILLISECOND_NANOS: Long = 1_000_000L const val FIRST_FRAME_NANOS: Long = 1_000_000_000L + const val SIXTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 60 const val ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 120 } } From 3d780fb55a60c459b198232ce001913575d5d859 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Wed, 14 Oct 2026 18:26:13 +0300 Subject: [PATCH 3/4] Keep pre-mounting within half of a 60 Hz frame Half of the display's frame grows below 60 Hz, to 500 ms at the 1 Hz an adaptive panel can idle at, so the budget is now the shorter of half the display's frame and half of a 60 Hz frame. A display that reports no refresh rate gets half of a 60 Hz frame rather than an assumed rate. --- .../fabric/mounting/DisplayFrameInterval.kt | 11 ++-- .../fabric/mounting/MountItemDispatcher.kt | 19 +++++-- .../mounting/DisplayFrameIntervalTest.kt | 27 +++++++--- .../mounting/MountItemDispatcherTest.kt | 50 ++++++++++++++----- 4 files changed, 80 insertions(+), 27 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt index c5c3285710dc..8b5b63f140f3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt @@ -20,7 +20,8 @@ internal class DisplayFrameInterval(context: Context) : MountItemDispatcher.FrameIntervalSource, DisplayManager.DisplayListener { private val displayManager: DisplayManager? = context.getSystemService(DisplayManager::class.java) - @Volatile private var frameIntervalNanos: Long = readFrameIntervalNanos() + // From Android 16 a listener gets refresh-rate changes only once the process has read the rate. + @Volatile private var frameIntervalNanos: Long? = readFrameIntervalNanos() fun start() { displayManager?.registerDisplayListener(this, UiThreadUtil.getUiThreadHandler()) @@ -31,7 +32,7 @@ internal class DisplayFrameInterval(context: Context) : displayManager?.unregisterDisplayListener(this) } - override fun frameIntervalNanos(): Long = frameIntervalNanos + override fun frameIntervalNanos(): Long? = frameIntervalNanos override fun onDisplayChanged(displayId: Int) { if (displayId == Display.DEFAULT_DISPLAY) { @@ -43,14 +44,12 @@ internal class DisplayFrameInterval(context: Context) : override fun onDisplayRemoved(displayId: Int): Unit = Unit - private fun readFrameIntervalNanos(): Long { + private fun readFrameIntervalNanos(): Long? { val refreshRate = displayManager?.getDisplay(Display.DEFAULT_DISPLAY)?.refreshRate ?: 0f - return if (refreshRate > 0f) (NANOSECONDS_PER_SECOND / refreshRate).toLong() - else SIXTY_HERTZ_FRAME_NANOS + return if (refreshRate > 0f) (NANOSECONDS_PER_SECOND / refreshRate).toLong() else null } private companion object { private const val NANOSECONDS_PER_SECOND: Double = 1_000_000_000.0 - private const val SIXTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 60 } } 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 3fa59de396f2..769c376f5cec 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 @@ -337,10 +337,17 @@ internal class MountItemDispatcher( return } - val deadline = lastFrameTimeNanos + frameIntervalSource.frameIntervalNanos() / 2 + val deadline = lastFrameTimeNanos + computePreMountBudgetNanos() dispatchPreMountItemsImpl(deadline) } + /** Half of the display's current frame, and never longer than [LONGEST_PRE_MOUNT_NANOS]. */ + private fun computePreMountBudgetNanos(): Long { + val frameIntervalNanos = + frameIntervalSource.frameIntervalNanos() ?: return LONGEST_PRE_MOUNT_NANOS + return minOf(frameIntervalNanos / 2, LONGEST_PRE_MOUNT_NANOS) + } + private fun dispatchPreMountItemsImpl(deadline: Long) { Systrace.beginSection(Systrace.TRACE_TAG_REACT, "MountItemDispatcher::premountViews") @@ -421,13 +428,19 @@ internal class MountItemDispatcher( } fun interface FrameIntervalSource { - /** The display's current frame interval; pre-mounting takes half of it each frame. */ - fun frameIntervalNanos(): Long + /** The display's current frame interval, or null while the display reports no refresh rate. */ + fun frameIntervalNanos(): Long? } private companion object { private const val TAG: String = "MountItemDispatcher" + /** + * The longest a frame pre-mounts for: half of a 60 Hz frame, so that below 60 Hz pre-mounting + * holds the UI thread no longer than it does at 60 Hz. + */ + private const val LONGEST_PRE_MOUNT_NANOS: Long = 1_000_000_000L / 60 / 2 + private fun drainConcurrentItemQueue(queue: Queue): List? { if (queue.isEmpty()) { return null diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt index 1d19ecb69883..cd7bfe807e7d 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt @@ -29,11 +29,26 @@ class DisplayFrameIntervalTest { } @Test - fun readsTheDefaultDisplaysFrame() { - setRefreshRate(120f) + fun readsTheDefaultDisplaysFrameAtEveryRefreshRate() { + val frameIntervalNanosByRefreshRate = + listOf(1f, 60f, 90f, 120f, 144f, 165f, 185f, 240f).associateWith { refreshRate -> + setRefreshRate(refreshRate) + DisplayFrameInterval(RuntimeEnvironment.getApplication()).frameIntervalNanos() + } - assertThat(DisplayFrameInterval(RuntimeEnvironment.getApplication()).frameIntervalNanos()) - .isEqualTo(ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS) + assertThat(frameIntervalNanosByRefreshRate) + .isEqualTo( + mapOf( + 1f to 1_000_000_000L, + 60f to 16_666_666L, + 90f to 11_111_111L, + 120f to 8_333_333L, + 144f to 6_944_444L, + 165f to 6_060_606L, + 185f to 5_405_405L, + 240f to 4_166_666L, + ) + ) } @Test @@ -60,11 +75,11 @@ class DisplayFrameIntervalTest { } @Test - fun takesASixtyHertzFrameWhenTheDisplayReportsNoRefreshRate() { + fun reportsNoFrameWhileTheDisplayReportsNoRefreshRate() { setRefreshRate(0f) assertThat(DisplayFrameInterval(RuntimeEnvironment.getApplication()).frameIntervalNanos()) - .isEqualTo(SIXTY_HERTZ_FRAME_NANOS) + .isNull() } private companion object { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt index 285f81552265..7329c3b2255d 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt @@ -35,7 +35,7 @@ class MountItemDispatcherTest { private lateinit var dispatcher: MountItemDispatcher private var nowNanos: Long = 0L private var executedItems: Int = 0 - private var displayFrameIntervalNanos: Long = SIXTY_HERTZ_FRAME_NANOS + private var displayFrameIntervalNanos: Long? = SIXTY_HERTZ_FRAME_NANOS @Before fun setUp() { @@ -80,21 +80,35 @@ class MountItemDispatcherTest { return executedItems - before } + /** Runs one frame at each refresh rate and returns how many items each frame pre-mounted. */ + private fun preMountFrameAtEachRefreshRate(refreshRates: List): Map = + refreshRates.withIndex().associate { (index, refreshRate) -> + displayFrameIntervalNanos = NANOSECONDS_PER_SECOND / refreshRate + queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + refreshRate to preMountFrame(FIRST_FRAME_NANOS + index * NANOSECONDS_PER_SECOND) + } + @Test - fun preMountsForHalfOfTheDisplaysFrameAtSixtyHertz() { - queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + fun preMountsForHalfOfTheDisplaysFrameFromSixtyHertzUp() { + // Items start at 0, 1, 2, ... ms, and the first one starting past half of the frame waits for + // the next frame: half of a 60 Hz frame is 8.33 ms, of a 185 Hz frame 2.70 ms. + assertThat(preMountFrameAtEachRefreshRate(listOf(60, 90, 120, 144, 165, 185, 240))) + .isEqualTo(mapOf(60 to 9, 90 to 6, 120 to 5, 144 to 4, 165 to 4, 185 to 3, 240 to 3)) + } - // Items start at 0, 1, ... 8 ms; the one starting past 8.33 ms waits for the next frame. - assertThat(preMountFrame(FIRST_FRAME_NANOS)).isEqualTo(9) + @Test + fun neverPreMountsLongerThanHalfOfASixtyHertzFrameBelowSixtyHertz() { + // Half of a 1 Hz frame would be 500 ms; every rate below 60 Hz keeps 8.33 ms. + assertThat(preMountFrameAtEachRefreshRate(listOf(1, 10, 24, 30, 48))) + .isEqualTo(mapOf(1 to 9, 10 to 9, 24 to 9, 30 to 9, 48 to 9)) } @Test - fun preMountsForHalfOfTheDisplaysFrameAtOneHundredTwentyHertz() { - displayFrameIntervalNanos = ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS + fun preMountsForHalfOfASixtyHertzFrameWhileTheDisplayReportsNoRate() { + displayFrameIntervalNanos = null queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) - // At 120 Hz half a frame is 4.17 ms: items start at 0 to 4 ms. - assertThat(preMountFrame(FIRST_FRAME_NANOS)).isEqualTo(5) + assertThat(preMountFrame(FIRST_FRAME_NANOS)).isEqualTo(9) } @Test @@ -108,11 +122,23 @@ class MountItemDispatcherTest { assertThat(preMountFrame(FIRST_FRAME_NANOS + SIXTY_HERTZ_FRAME_NANOS)).isEqualTo(9) } + @Test + fun aSkippedFrameKeepsTheDisplaysBudget() { + displayFrameIntervalNanos = ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + assertThat(preMountFrame(FIRST_FRAME_NANOS)).isEqualTo(5) + + // The next frame runs two vsyncs later; the budget still comes from the display. + assertThat(preMountFrame(FIRST_FRAME_NANOS + 2 * ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS)) + .isEqualTo(5) + } + private companion object { const val SURFACE_ID: Int = 1 const val MILLISECOND_NANOS: Long = 1_000_000L - const val FIRST_FRAME_NANOS: Long = 1_000_000_000L - const val SIXTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 60 - const val ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 120 + const val NANOSECONDS_PER_SECOND: Long = 1_000_000_000L + const val FIRST_FRAME_NANOS: Long = NANOSECONDS_PER_SECOND + const val SIXTY_HERTZ_FRAME_NANOS: Long = NANOSECONDS_PER_SECOND / 60 + const val ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS: Long = NANOSECONDS_PER_SECOND / 120 } } From 9e2fa9c3e97e90f263e7d3509b2e06abafdc761d Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 15 Oct 2026 13:44:45 +0300 Subject: [PATCH 4/4] Keep pre-mounting within half of a 30 Hz frame A display the system slows below 60 Hz can spend more of each frame pre-mounting, so the ceiling is now half of a 30 Hz frame (16.67 ms) rather than half of a 60 Hz one: from 30 Hz up the budget is half of the display's frame, and below 30 Hz it is 16.67 ms. A display that reports no refresh rate is still budgeted as a 60 Hz one. --- .../fabric/mounting/MountItemDispatcher.kt | 16 +++++++---- .../mounting/MountItemDispatcherTest.kt | 28 +++++++++++++------ 2 files changed, 30 insertions(+), 14 deletions(-) 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 769c376f5cec..5334d4afcc07 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 @@ -341,10 +341,12 @@ internal class MountItemDispatcher( dispatchPreMountItemsImpl(deadline) } - /** Half of the display's current frame, and never longer than [LONGEST_PRE_MOUNT_NANOS]. */ + /** + * Half of the display's current frame, and never longer than [LONGEST_PRE_MOUNT_NANOS]. A display + * that reports no refresh rate is budgeted as a 60 Hz one. + */ private fun computePreMountBudgetNanos(): Long { - val frameIntervalNanos = - frameIntervalSource.frameIntervalNanos() ?: return LONGEST_PRE_MOUNT_NANOS + val frameIntervalNanos = frameIntervalSource.frameIntervalNanos() ?: FRAME_TIME_NS return minOf(frameIntervalNanos / 2, LONGEST_PRE_MOUNT_NANOS) } @@ -435,11 +437,13 @@ internal class MountItemDispatcher( private companion object { private const val TAG: String = "MountItemDispatcher" + private const val FRAME_TIME_NS: Long = (1000000000 / 60).toLong() + /** - * The longest a frame pre-mounts for: half of a 60 Hz frame, so that below 60 Hz pre-mounting - * holds the UI thread no longer than it does at 60 Hz. + * The longest a frame pre-mounts for: half of a 30 Hz frame, so that a display below 30 Hz, + * such as an adaptive panel idling at 1 Hz, holds the UI thread no longer than at 30 Hz. */ - private const val LONGEST_PRE_MOUNT_NANOS: Long = 1_000_000_000L / 60 / 2 + private const val LONGEST_PRE_MOUNT_NANOS: Long = 1_000_000_000L / 30 / 2 private fun drainConcurrentItemQueue(queue: Queue): List? { if (queue.isEmpty()) { diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt index 7329c3b2255d..98dc4655d5a5 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt @@ -89,18 +89,30 @@ class MountItemDispatcherTest { } @Test - fun preMountsForHalfOfTheDisplaysFrameFromSixtyHertzUp() { + fun preMountsForHalfOfTheDisplaysFrameFromThirtyHertzUp() { // Items start at 0, 1, 2, ... ms, and the first one starting past half of the frame waits for - // the next frame: half of a 60 Hz frame is 8.33 ms, of a 185 Hz frame 2.70 ms. - assertThat(preMountFrameAtEachRefreshRate(listOf(60, 90, 120, 144, 165, 185, 240))) - .isEqualTo(mapOf(60 to 9, 90 to 6, 120 to 5, 144 to 4, 165 to 4, 185 to 3, 240 to 3)) + // the next frame: half of a 30 Hz frame is 16.67 ms, of a 185 Hz frame 2.70 ms. + assertThat(preMountFrameAtEachRefreshRate(listOf(30, 48, 60, 90, 120, 144, 165, 185, 240))) + .isEqualTo( + mapOf( + 30 to 17, + 48 to 11, + 60 to 9, + 90 to 6, + 120 to 5, + 144 to 4, + 165 to 4, + 185 to 3, + 240 to 3, + ) + ) } @Test - fun neverPreMountsLongerThanHalfOfASixtyHertzFrameBelowSixtyHertz() { - // Half of a 1 Hz frame would be 500 ms; every rate below 60 Hz keeps 8.33 ms. - assertThat(preMountFrameAtEachRefreshRate(listOf(1, 10, 24, 30, 48))) - .isEqualTo(mapOf(1 to 9, 10 to 9, 24 to 9, 30 to 9, 48 to 9)) + fun neverPreMountsLongerThanHalfOfAThirtyHertzFrameBelowThirtyHertz() { + // Half of a 1 Hz frame would be 500 ms; every rate below 30 Hz keeps 16.67 ms. + assertThat(preMountFrameAtEachRefreshRate(listOf(1, 10, 24))) + .isEqualTo(mapOf(1 to 17, 10 to 17, 24 to 17)) } @Test