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 2727e880d083..c7eb18475f55 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 @@ -21,6 +21,7 @@ import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Point; +import android.os.Debug; import android.os.SystemClock; import android.view.View; import android.view.accessibility.AccessibilityEvent; @@ -58,8 +59,11 @@ 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.FrameIntervalSources; import com.facebook.react.fabric.mounting.MountItemDispatcher; import com.facebook.react.fabric.mounting.MountingManager; +import com.facebook.react.fabric.mounting.PreMountItemsBetweenFrames; import com.facebook.react.fabric.mounting.SurfaceMountingManager; import com.facebook.react.fabric.mounting.mountitems.BatchMountItem; import com.facebook.react.fabric.mounting.mountitems.BatchedAnimatedPropsMountItem; @@ -182,6 +186,8 @@ public class FabricUIManager private final MountingManager mMountingManager; private final FabricEventDispatcher mEventDispatcher; private final MountItemDispatcher mMountItemDispatcher; + private final DisplayFrameInterval mDisplayFrameInterval; + private final PreMountItemsBetweenFrames mPreMountItemsBetweenFrames; private final ViewManagerRegistry mViewManagerRegistry; private final TextEffectRegistry mTextEffectRegistry = new TextEffectRegistry(); @@ -258,6 +264,15 @@ public FabricUIManager( mMountingManager = new MountingManager(viewManagerRegistry, mMountItemExecutor); mMountItemDispatcher = new MountItemDispatcher(mMountingManager, new MountItemDispatchListener()); + mDisplayFrameInterval = new DisplayFrameInterval(reactContext); + mPreMountItemsBetweenFrames = + new PreMountItemsBetweenFrames( + mMountItemDispatcher, + FrameIntervalSources.create(mDisplayFrameInterval), + Debug::threadCpuTimeNanos, + mDispatchUIFrameCallback::isMountingAllowed, + UiThreadUtil.getUiThreadHandler(), + mDispatchUIFrameCallback::handleBetweenFramesException); mEventDispatcher = new FabricEventDispatcher(reactContext, new FabricEventEmitter(this)); mBatchEventDispatchedListener = batchEventDispatchedListener; mReactApplicationContext.addLifecycleEventListener(this); @@ -463,6 +478,9 @@ public void stopSurface(final int surfaceID) { @Override public void initialize() { mEventDispatcher.addBatchEventDispatchedListener(mBatchEventDispatchedListener); + if (ReactNativeFeatureFlags.enablePremountItemsBetweenFramesAndroid()) { + mDisplayFrameInterval.start(); + } if (ReactNativeFeatureFlags.enableFabricLogs()) { mDevToolsReactPerfLogger = new DevToolsReactPerfLogger(); mDevToolsReactPerfLogger.addDevToolsReactPerfLoggerListener(FABRIC_PERF_LOGGER); @@ -498,6 +516,7 @@ public void invalidate() { mEventDispatcher.removeBatchEventDispatchedListener(mBatchEventDispatchedListener); mEventDispatcher.invalidate(); + mDisplayFrameInterval.stop(); mReactApplicationContext.unregisterComponentCallbacks(mViewManagerRegistry); mViewManagerRegistry.invalidate(); @@ -1680,15 +1699,33 @@ void resume() { schedule(); } - @UiThread - @ThreadConfined(UI) + @AnyThread void pause() { ReactChoreographer.getInstance() .removeFrameCallback(ReactChoreographer.CallbackType.DISPATCH_UI, this); + // invalidate() pauses from any thread; the slices it stops belong to the UI thread. + if (UiThreadUtil.isOnUiThread()) { + mPreMountItemsBetweenFrames.cancel(); + } else { + UiThreadUtil.runOnUiThread(mPreMountItemsBetweenFrames::cancel); + } mShouldSchedule = false; mIsScheduled = false; } + @AnyThread + boolean isMountingAllowed() { + return mIsMountingEnabled && !mDestroyed; + } + + @UiThread + @ThreadConfined(UI) + void handleBetweenFramesException(Exception exception) { + FLog.e(TAG, "Exception thrown when pre-mounting or mounting between frames", exception); + mIsMountingEnabled = false; + mReactApplicationContext.getExceptionHandler().handleException(exception); + } + @Override @UiThread @ThreadConfined(UI) @@ -1728,13 +1765,20 @@ public void doFrameGuarded(long frameTimeNanos) { } try { - // First, execute as many pre mount items as we can within frameTimeNanos time. - // If not all pre mount items were executed, following may happen: - // 1. In case there are view commands or mount items in MountItemDispatcher: execute - // remaining pre mount items. - // 2. In case there are no view commands or mount items, wait until next frame. - mMountItemDispatcher.dispatchPreMountItems(frameTimeNanos); - mMountItemDispatcher.tryDispatchMountItems(); + // A dispatch runs every pending pre-mount item before its pulls and mount items. What is + // left runs either here, for half of a 60 Hz frame, or after this frame's traversal until + // the next vsync, and here only when the last frame left no time after it: for as long as + // its draw slept, or for half a frame once two frames in a row were busy for a whole frame + // interval. The slices are queued once this frame's mount items have run, and run only + // while mounting is enabled, so a frame that fails to mount leaves nothing to pre-mount. + if (ReactNativeFeatureFlags.enablePremountItemsBetweenFramesAndroid()) { + mPreMountItemsBetweenFrames.preMountBeforeTraversal(); + mMountItemDispatcher.tryDispatchMountItems(); + mPreMountItemsBetweenFrames.scheduleAfterFrame(frameTimeNanos); + } else { + mMountItemDispatcher.dispatchPreMountItems(frameTimeNanos); + mMountItemDispatcher.tryDispatchMountItems(); + } } catch (Exception ex) { FLog.e(TAG, "Exception thrown when executing UIFrameGuarded", ex); mIsMountingEnabled = false; 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..70b6515275e8 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt @@ -0,0 +1,61 @@ +/* + * 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) : + PreMountItemsBetweenFrames.FrameIntervalSource, DisplayManager.DisplayListener { + private val displayManager: DisplayManager? = context.getSystemService(DisplayManager::class.java) + + @Volatile private var frameIntervalNanos: Long? = null + + fun start() { + displayManager?.registerDisplayListener(this, UiThreadUtil.getUiThreadHandler()) + // Read once the listener is registered, so a change between the two is not lost, and on the + // listener's thread, so neither store overwrites the other with an older rate. From Android 16 + // a listener also gets refresh-rate changes only once the process has read the rate. + if (UiThreadUtil.isOnUiThread()) { + frameIntervalNanos = readFrameIntervalNanos() + } else { + UiThreadUtil.runOnUiThread { 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 null + } + + private companion object { + private const val NANOSECONDS_PER_SECOND: Double = 1_000_000_000.0 + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/FrameIntervalSources.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/FrameIntervalSources.kt new file mode 100644 index 000000000000..2ff324767e24 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/FrameIntervalSources.kt @@ -0,0 +1,37 @@ +/* + * 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.Build +import androidx.annotation.DoNotInline +import androidx.annotation.RequiresApi + +internal object FrameIntervalSources { + /** + * The frame interval source for the running Android version: from Android 13 the vsync's frame + * timelines, with [displayFrameInterval] behind them, and before it [displayFrameInterval] alone. + */ + @JvmStatic + fun create( + displayFrameInterval: DisplayFrameInterval + ): PreMountItemsBetweenFrames.FrameIntervalSource = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Api33Utils.createVsyncFrameInterval(displayFrameInterval) + } else { + displayFrameInterval + } + + /** Holds the only reference to the Android 13 class, so an older Android never verifies it. */ + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private object Api33Utils { + @DoNotInline + fun createVsyncFrameInterval( + displayFrameInterval: DisplayFrameInterval + ): PreMountItemsBetweenFrames.FrameIntervalSource = VsyncFrameInterval(displayFrameInterval) + } +} 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 c666bfd6f1b8..a55335437b17 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 @@ -27,6 +27,9 @@ import java.util.Queue import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue +/** A 60 Hz frame: half of it is a frame's pre-mount budget from its start. */ +internal const val SIXTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 60 + internal class MountItemDispatcher( private val mountingManager: MountingManager, private val itemDispatchListener: ItemDispatchListener, @@ -49,6 +52,12 @@ internal class MountItemDispatcher( /** Pull model: @return true if any surface has a transaction waiting to be pulled. */ fun hasSurfaceIdsToPull(): Boolean = surfaceIdsToPull.isNotEmpty() + /** + * @return true if any mount items or view commands are waiting to be dispatched. A dispatch also + * pulls the surfaces waiting to be pulled, before the mount items. + */ + fun hasMountItems(): Boolean = !viewCommandMountItems.isEmpty() || !mountItems.isEmpty() + // Items can be queued from any thread while the DISPATCH_UI frame callback is disarmed at // idle (see FabricUIManager's doFrameGuarded); the listener re-arms it. private fun notifyItemsQueued() { @@ -64,8 +73,6 @@ internal class MountItemDispatcher( var runStartTime: Long = 0L private set - private var lastFrameTimeNanos: Long = 0L - fun addViewCommandMountItem(mountItem: DispatchCommandMountItem) { if (!ReactNativeFeatureFlags.disableEarlyViewCommandExecution()) { viewCommandMountItems.add(mountItem) @@ -355,15 +362,25 @@ internal class MountItemDispatcher( @UiThread @ThreadConfined(UI) fun dispatchPreMountItems(frameTimeNanos: Long) { - lastFrameTimeNanos = frameTimeNanos + dispatchPreMountItemsUntil(frameTimeNanos + SIXTY_HERTZ_FRAME_NANOS / 2) + } + + fun hasPreMountItems(): Boolean = !preMountItems.isEmpty() + /** + * Executes pre-mount items until [deadlineNanos], and returns whether any are left. An item + * starts only while the deadline has not passed, so the last one can run past it by its own + * duration. + */ + @UiThread + @ThreadConfined(UI) + fun dispatchPreMountItemsUntil(deadlineNanos: Long): Boolean { if (preMountItems.isEmpty()) { // Avoid starting systrace if there are no pre mount items. - return + return false } - - val deadline = lastFrameTimeNanos + FRAME_TIME_NS / 2 - dispatchPreMountItemsImpl(deadline) + dispatchPreMountItemsImpl(deadlineNanos) + return !preMountItems.isEmpty() } private fun dispatchPreMountItemsImpl(deadline: Long) { @@ -381,7 +398,7 @@ internal class MountItemDispatcher( try { while (true) { - if (System.nanoTime() > deadline) { + if (SystemClock.nanoTime() > deadline) { break } @@ -473,8 +490,6 @@ internal class MountItemDispatcher( 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/main/java/com/facebook/react/fabric/mounting/PreMountItemsBetweenFrames.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/PreMountItemsBetweenFrames.kt new file mode 100644 index 000000000000..7d9492877e6b --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/PreMountItemsBetweenFrames.kt @@ -0,0 +1,273 @@ +/* + * 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.Handler +import android.os.Message +import androidx.annotation.UiThread +import com.facebook.infer.annotation.ThreadConfined +import com.facebook.infer.annotation.ThreadConfined.UI +import com.facebook.react.bridge.GuardedRunnable +import com.facebook.react.bridge.JSExceptionHandler +import com.facebook.react.common.SystemClock + +/** + * Pre-mounts views in the time a frame leaves free. Once a frame's callbacks have run, it executes + * pre-mount items in slices posted to the UI thread's looper, each of which starts items for about + * 1 ms, and it stops at the next vsync, so the pre-mounting fits in the time the frame left idle + * instead of delaying the frame's own work. The next frame, an input event or any other message + * waits for one slice at most, unless a commit's mount items arrive meanwhile: the next slice + * mounts them rather than leaving them for the next frame, and runs every pre-mount item left + * before them, as a frame's mount does. A commit the pull model queues as a surface to pull waits + * for the frame, which pulls it once, and while one waits, mount items wait with it, since a + * dispatch pulls before it mounts. + * + * While the render thread waits on SurfaceFlinger for a buffer, the UI thread sleeps in each + * frame's draw until the render thread takes the frame, and the frame leaves no time before the + * next vsync. After such a frame, the next one pre-mounts before its traversal for as long as the + * UI thread slept after the callbacks, plus what that frame pre-mounted before its own traversal, + * less one slice, at most half a frame and half of a 30 Hz frame: the work shortens that sleep + * instead of delaying the frame, and a frame that no longer sleeps ends it. + * + * Frames whose own work takes a whole frame interval, as while the UI thread is busy past every + * vsync, leave no time at all, and a commit's mount would run every item still waiting. Once two + * frames in a row have taken one, each frame pre-mounts before its traversal for half a frame, at + * most half of a 30 Hz frame, so pre-mounting still advances while the frames stay busy, and one + * busy frame costs the next nothing. A frame's own work is timed from its vsync, or from the end of + * the frame before it if that came later, and leaves out what it pre-mounted before its traversal, + * so the time it spends pre-mounting never keeps the run going, and the first frame whose own work + * fits its interval ends it. + * + * A frame that left time gets no time before the next traversal, and neither does a frame after a + * vsync that ran no frame callback, as while the callback is not re-armed with nothing to mount. + * Slices run only while the frame callback would mount: not once mounting has failed or the UI + * manager is destroyed. + */ +internal class PreMountItemsBetweenFrames( + private val dispatcher: MountItemDispatcher, + private val frameIntervalSource: FrameIntervalSource, + private val threadCpuClock: ThreadCpuClock, + private val mountingGate: MountingGate, + private val uiThreadHandler: Handler, + exceptionHandler: JSExceptionHandler, +) { + private var nextFrameNanos: Long = 0L + private var frameIntervalNanos: Long = SIXTY_HERTZ_FRAME_NANOS + private var isSliceQueued: Boolean = false + private var unmeasuredFrame: UnmeasuredFrame? = null + private var beforeTraversalBudget: BeforeTraversalBudget? = null + private var preMountedBeforeTraversalNanos: Long = 0L + private var lastMeasuredFrameEndNanos: Long = 0L + private var lastFrameTookItsInterval: Boolean = false + private val slice: Runnable = + object : GuardedRunnable(exceptionHandler) { + override fun runGuarded() { + isSliceQueued = false + runSlice() + } + } + + /** + * Pre-mounts before this frame's traversal for the budget the last frame left it, unless a vsync + * has passed since that frame was measured. + */ + @UiThread + @ThreadConfined(UI) + fun preMountBeforeTraversal() { + val budget = beforeTraversalBudget + beforeTraversalBudget = null + preMountedBeforeTraversalNanos = 0L + if (budget == null || !dispatcher.hasPreMountItems()) { + return + } + val startNanos = SystemClock.nanoTime() + if (startNanos - budget.measuredAtNanos > frameIntervalNanos) { + lastFrameTookItsInterval = false + return + } + dispatcher.dispatchPreMountItemsUntil(startNanos + budget.nanos) + preMountedBeforeTraversalNanos = SystemClock.nanoTime() - startNanos + } + + /** Queues pre-mounting after the frame that started at [frameTimeNanos], until the next one. */ + @UiThread + @ThreadConfined(UI) + fun scheduleAfterFrame(frameTimeNanos: Long) { + if (!dispatcher.hasPreMountItems() && preMountedBeforeTraversalNanos == 0L) { + lastFrameTookItsInterval = false + return + } + frameIntervalNanos = frameIntervalSource.frameIntervalNanos() ?: SIXTY_HERTZ_FRAME_NANOS + if (dispatcher.hasPreMountItems()) { + frameIntervalSource.requestNextFrameInterval() + } + nextFrameNanos = frameTimeNanos + frameIntervalNanos + unmeasuredFrame = + UnmeasuredFrame( + startNanos = maxOf(frameTimeNanos, lastMeasuredFrameEndNanos), + callbacksEndNanos = SystemClock.nanoTime(), + callbacksEndThreadCpuNanos = threadCpuClock.threadCpuTimeNanos(), + ) + queueSlice() + } + + @UiThread + @ThreadConfined(UI) + fun cancel() { + uiThreadHandler.removeCallbacks(slice) + frameIntervalSource.cancel() + isSliceQueued = false + unmeasuredFrame = null + beforeTraversalBudget = null + lastFrameTookItsInterval = false + } + + /** + * The next frame's budget before its traversal, after a frame that left no time before the next + * vsync: half a frame once that frame and the one before it each took a frame interval of their + * own work; otherwise how long the UI thread slept between the frame's callbacks and its end, + * plus what it pre-mounted before its traversal, which that sleep absorbed, less one slice. A + * frame that left time gets none, and so does one that slept less than half a slice or whose + * thread clock cannot tell, unless it ends a run of busy frames. + */ + private fun measureBeforeTraversalBudget( + frame: UnmeasuredFrame, + nowNanos: Long, + ): BeforeTraversalBudget? { + lastMeasuredFrameEndNanos = nowNanos + if (nowNanos < nextFrameNanos) { + lastFrameTookItsInterval = false + return null + } + val tookItsInterval = + nowNanos - frame.startNanos - preMountedBeforeTraversalNanos >= frameIntervalNanos + val endsARunOfBusyFrames = tookItsInterval && lastFrameTookItsInterval + lastFrameTookItsInterval = tookItsInterval + val halfFrameNanos = minOf(frameIntervalNanos / 2, MAX_BEFORE_TRAVERSAL_NANOS) + val budgetNanos = + if (endsARunOfBusyFrames) { + halfFrameNanos + } else { + val asleepNanos = measureAsleepNanos(frame, nowNanos) + if (asleepNanos < SLICE_NANOS / 2) { + return null + } + minOf(halfFrameNanos, asleepNanos + preMountedBeforeTraversalNanos - SLICE_NANOS) + } + return if (budgetNanos > 0L) BeforeTraversalBudget(budgetNanos, nowNanos) else null + } + + /** + * How long the UI thread slept between the frame's callbacks and [nowNanos], or zero where its + * clock cannot tell. + */ + private fun measureAsleepNanos(frame: UnmeasuredFrame, nowNanos: Long): Long { + val threadCpuNanos = threadCpuClock.threadCpuTimeNanos() + if (threadCpuNanos < 0L || frame.callbacksEndThreadCpuNanos < 0L) { + return 0L + } + return (nowNanos - frame.callbacksEndNanos) - + (threadCpuNanos - frame.callbacksEndThreadCpuNanos) + } + + private fun runSlice() { + if (!mountingGate.isMountingAllowed()) { + return + } + val sliceStartNanos = SystemClock.nanoTime() + unmeasuredFrame?.let { frame -> + unmeasuredFrame = null + beforeTraversalBudget = measureBeforeTraversalBudget(frame, sliceStartNanos) + } + if (hasMountItemsForASlice()) { + dispatcher.tryDispatchMountItems() + queueSliceWhileWorkIsLeft(dispatcher.hasPreMountItems()) + return + } + if (sliceStartNanos >= nextFrameNanos) { + return + } + val preMountItemsLeft = + dispatcher.dispatchPreMountItemsUntil(minOf(nextFrameNanos, sliceStartNanos + SLICE_NANOS)) + queueSliceWhileWorkIsLeft(preMountItemsLeft) + } + + private fun queueSliceWhileWorkIsLeft(preMountItemsLeft: Boolean) { + if ( + (preMountItemsLeft || hasMountItemsForASlice()) && SystemClock.nanoTime() < nextFrameNanos + ) { + queueSlice() + } + } + + /** Mount items a slice may dispatch: none while a surface waits to be pulled before them. */ + private fun hasMountItemsForASlice(): Boolean = + dispatcher.hasMountItems() && !dispatcher.hasSurfaceIdsToPull() + + private fun queueSlice() { + if (!isSliceQueued) { + isSliceQueued = true + // Asynchronous, like the Choreographer's vsync: a view that invalidates while it draws, such + // as a ScrollView during a fling, leaves the next frame's traversal barrier in the queue + // between frames, and an ordinary message would wait behind it until that frame. + uiThreadHandler.sendMessage( + Message.obtain(uiThreadHandler, slice).apply { isAsynchronous = true } + ) + } + } + + /** + * A frame its first slice has yet to measure: when it started, and where its callbacks ended, on + * the wall clock and on the UI thread's CPU clock. + */ + private class UnmeasuredFrame( + val startNanos: Long, + val callbacksEndNanos: Long, + val callbacksEndThreadCpuNanos: Long, + ) + + /** + * How long to pre-mount before the next traversal, and when the frame it follows was measured. + */ + private class BeforeTraversalBudget(val nanos: Long, val measuredAtNanos: Long) + + fun interface FrameIntervalSource { + /** The interval the frames run at, or null while none is known. */ + fun frameIntervalNanos(): Long? + + /** Asks the source to read the interval off the next frame, where it reads frames at all. */ + @UiThread fun requestNextFrameInterval() = Unit + + /** Drops anything the source asked of the frames to come. */ + @UiThread fun cancel() = Unit + } + + fun interface ThreadCpuClock { + /** + * The CPU time the calling thread has run for, which stops while it sleeps, or a negative value + * where the system cannot tell, as `Debug.threadCpuTimeNanos()` reports. + */ + fun threadCpuTimeNanos(): Long + } + + fun interface MountingGate { + /** Whether the frame callback would mount now: not once mounting failed or after teardown. */ + fun isMountingAllowed(): Boolean + } + + private companion object { + /** + * How long a slice pre-mounts before it yields to the looper. It bounds how late the next frame + * can start when the reported frame interval is longer than the one the frames run at. + */ + private const val SLICE_NANOS: Long = 1_000_000L + + /** However long a frame the display reports, pre-mounting before a traversal stops here. */ + private const val MAX_BEFORE_TRAVERSAL_NANOS: Long = 1_000_000_000L / 30 / 2 + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/VsyncFrameInterval.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/VsyncFrameInterval.kt new file mode 100644 index 000000000000..c00ed892d956 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/VsyncFrameInterval.kt @@ -0,0 +1,93 @@ +/* + * 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.Build +import android.view.Choreographer +import androidx.annotation.RequiresApi +import androidx.annotation.UiThread +import com.facebook.infer.annotation.ThreadConfined +import com.facebook.infer.annotation.ThreadConfined.UI +import com.facebook.react.common.SystemClock + +/** + * The interval the app's frames run at, from the frame timelines the Choreographer hands a vsync + * callback from Android 13: they are spaced by the rate the frames run at, where the display's + * refresh rate can report its mode's. [requestNextFrameInterval] asks for the next vsync's + * timelines, and a spacing more than two frames old, or a vsync that offers fewer than two + * timelines, leaves the answer to [displayFrameInterval]. + */ +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +internal class VsyncFrameInterval( + private val displayFrameInterval: PreMountItemsBetweenFrames.FrameIntervalSource, +) : PreMountItemsBetweenFrames.FrameIntervalSource, Choreographer.VsyncCallback { + /** Zero while no vsync has offered a spacing. */ + private var timelineSpacingNanos: Long = 0L + private var timelineFrameTimeNanos: Long = 0L + private var isVsyncCallbackPosted: Boolean = false + + @UiThread + @ThreadConfined(UI) + override fun frameIntervalNanos(): Long? { + val spacingNanos = timelineSpacingNanos + return if ( + spacingNanos > 0L && + SystemClock.nanoTime() - timelineFrameTimeNanos <= MAX_SPACING_AGE_FRAMES * spacingNanos + ) { + spacingNanos + } else { + displayFrameInterval.frameIntervalNanos() + } + } + + @UiThread + @ThreadConfined(UI) + override fun requestNextFrameInterval() { + if (!isVsyncCallbackPosted) { + isVsyncCallbackPosted = true + Choreographer.getInstance().postVsyncCallback(this) + } + } + + @UiThread + @ThreadConfined(UI) + override fun onVsync(data: Choreographer.FrameData) { + isVsyncCallbackPosted = false + val timelines = data.frameTimelines + timelineSpacingNanos = + if (timelines.size < 2) { + 0L + } else { + computeTimelineSpacingNanos( + timelines[0].expectedPresentationTimeNanos, + timelines[1].expectedPresentationTimeNanos, + ) + } + timelineFrameTimeNanos = data.frameTimeNanos + } + + @UiThread + @ThreadConfined(UI) + override fun cancel() { + if (isVsyncCallbackPosted) { + Choreographer.getInstance().removeVsyncCallback(this) + isVsyncCallbackPosted = false + } + timelineSpacingNanos = 0L + } + + internal companion object { + private const val MAX_SPACING_AGE_FRAMES: Long = 2L + + /** How far apart two consecutive frame timelines present, or zero when they do not advance. */ + fun computeTimelineSpacingNanos( + firstPresentationTimeNanos: Long, + secondPresentationTimeNanos: Long, + ): Long = (secondPresentationTimeNanos - firstPresentationTimeNanos).coerceAtLeast(0L) + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index 243a3eae3fa8..bd6f06565601 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt @@ -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<<52787301929ca056f46cd30d2a895acf>> */ /** @@ -318,6 +318,12 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun enablePreallocatedPropsDiffOnInsertAndroid(): Boolean = accessor.enablePreallocatedPropsDiffOnInsertAndroid() + /** + * Pre-mount views on Android in the time each frame leaves free: after the frame, in short slices that stop before the next vsync, and, after a frame that left none, before the next traversal for as long as the last draw slept, or for half a frame once two frames in a row were busy for a whole frame, at most half a frame, instead of before every traversal for half of a 60 Hz frame. + */ + @JvmStatic + public fun enablePremountItemsBetweenFramesAndroid(): Boolean = accessor.enablePremountItemsBetweenFramesAndroid() + /** * Enables caching text layout artifacts for later reuse */ diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt index 0f110b756115..f1e0cd8d8eda 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxAccessor.kt @@ -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<> */ /** @@ -68,6 +68,7 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces private var enableMutationObserverByDefaultCache: Boolean? = null private var enableNativeCSSParsingCache: Boolean? = null private var enablePreallocatedPropsDiffOnInsertAndroidCache: Boolean? = null + private var enablePremountItemsBetweenFramesAndroidCache: Boolean? = null private var enablePreparedTextLayoutCache: Boolean? = null private var enablePropsUpdateReconciliationAndroidCache: Boolean? = null private var enableResizeObserverByDefaultCache: Boolean? = null @@ -549,6 +550,15 @@ internal class ReactNativeFeatureFlagsCxxAccessor : ReactNativeFeatureFlagsAcces return cached } + override fun enablePremountItemsBetweenFramesAndroid(): Boolean { + var cached = enablePremountItemsBetweenFramesAndroidCache + if (cached == null) { + cached = ReactNativeFeatureFlagsCxxInterop.enablePremountItemsBetweenFramesAndroid() + enablePremountItemsBetweenFramesAndroidCache = cached + } + return cached + } + override fun enablePreparedTextLayout(): Boolean { var cached = enablePreparedTextLayoutCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt index fc4b23624bbd..99fd341b619b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsCxxInterop.kt @@ -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<<7b1e20f9a5adaa305f9bb4d9cd1f377c>> */ /** @@ -124,6 +124,8 @@ public object ReactNativeFeatureFlagsCxxInterop { @DoNotStrip @JvmStatic public external fun enablePreallocatedPropsDiffOnInsertAndroid(): Boolean + @DoNotStrip @JvmStatic public external fun enablePremountItemsBetweenFramesAndroid(): Boolean + @DoNotStrip @JvmStatic public external fun enablePreparedTextLayout(): Boolean @DoNotStrip @JvmStatic public external fun enablePropsUpdateReconciliationAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt index 380e9ec4c213..0ac93dfa5e69 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsDefaults.kt @@ -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<<519113f7843bc67a3673ccc83d52d981>> */ /** @@ -119,6 +119,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun enablePreallocatedPropsDiffOnInsertAndroid(): Boolean = false + override fun enablePremountItemsBetweenFramesAndroid(): Boolean = false + override fun enablePreparedTextLayout(): Boolean = true override fun enablePropsUpdateReconciliationAndroid(): Boolean = false diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt index 073cc2b90a73..cf021c42ffe8 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsLocalAccessor.kt @@ -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<<86c35e42db3623cf4a8bc88ccd4c8726>> */ /** @@ -72,6 +72,7 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc private var enableMutationObserverByDefaultCache: Boolean? = null private var enableNativeCSSParsingCache: Boolean? = null private var enablePreallocatedPropsDiffOnInsertAndroidCache: Boolean? = null + private var enablePremountItemsBetweenFramesAndroidCache: Boolean? = null private var enablePreparedTextLayoutCache: Boolean? = null private var enablePropsUpdateReconciliationAndroidCache: Boolean? = null private var enableResizeObserverByDefaultCache: Boolean? = null @@ -601,6 +602,16 @@ internal class ReactNativeFeatureFlagsLocalAccessor : ReactNativeFeatureFlagsAcc return cached } + override fun enablePremountItemsBetweenFramesAndroid(): Boolean { + var cached = enablePremountItemsBetweenFramesAndroidCache + if (cached == null) { + cached = currentProvider.enablePremountItemsBetweenFramesAndroid() + accessedFeatureFlags.add("enablePremountItemsBetweenFramesAndroid") + enablePremountItemsBetweenFramesAndroidCache = cached + } + return cached + } + override fun enablePreparedTextLayout(): Boolean { var cached = enablePreparedTextLayoutCache if (cached == null) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt index a639fd19fa5e..649ac606f44e 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlagsProvider.kt @@ -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<<9fdd2ede21b080cf62bb8710a7060684>> */ /** @@ -119,6 +119,8 @@ public interface ReactNativeFeatureFlagsProvider { @DoNotStrip public fun enablePreallocatedPropsDiffOnInsertAndroid(): Boolean + @DoNotStrip public fun enablePremountItemsBetweenFramesAndroid(): Boolean + @DoNotStrip public fun enablePreparedTextLayout(): Boolean @DoNotStrip public fun enablePropsUpdateReconciliationAndroid(): Boolean diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp index 882566ee95ea..ee3dff6dcadf 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.cpp @@ -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<<1ab2eec7b9c5bdd181ec5fbfd37149ab>> + * @generated SignedSource<<3b896bb2cd426156c8e0ceaf97a63f74>> */ /** @@ -327,6 +327,12 @@ class ReactNativeFeatureFlagsJavaProvider return method(javaProvider_); } + bool enablePremountItemsBetweenFramesAndroid() override { + static const auto method = + getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enablePremountItemsBetweenFramesAndroid"); + return method(javaProvider_); + } + bool enablePreparedTextLayout() override { static const auto method = getReactNativeFeatureFlagsProviderJavaClass()->getMethod("enablePreparedTextLayout"); @@ -859,6 +865,11 @@ bool JReactNativeFeatureFlagsCxxInterop::enablePreallocatedPropsDiffOnInsertAndr return ReactNativeFeatureFlags::enablePreallocatedPropsDiffOnInsertAndroid(); } +bool JReactNativeFeatureFlagsCxxInterop::enablePremountItemsBetweenFramesAndroid( + facebook::jni::alias_ref /*unused*/) { + return ReactNativeFeatureFlags::enablePremountItemsBetweenFramesAndroid(); +} + bool JReactNativeFeatureFlagsCxxInterop::enablePreparedTextLayout( facebook::jni::alias_ref /*unused*/) { return ReactNativeFeatureFlags::enablePreparedTextLayout(); @@ -1274,6 +1285,9 @@ void JReactNativeFeatureFlagsCxxInterop::registerNatives() { makeNativeMethod( "enablePreallocatedPropsDiffOnInsertAndroid", JReactNativeFeatureFlagsCxxInterop::enablePreallocatedPropsDiffOnInsertAndroid), + makeNativeMethod( + "enablePremountItemsBetweenFramesAndroid", + JReactNativeFeatureFlagsCxxInterop::enablePremountItemsBetweenFramesAndroid), makeNativeMethod( "enablePreparedTextLayout", JReactNativeFeatureFlagsCxxInterop::enablePreparedTextLayout), diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h index 8ae17b1dc3b6..68648e068c45 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/featureflags/JReactNativeFeatureFlagsCxxInterop.h @@ -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<<2c8f9205f1de55591ede3845ba2fa877>> + * @generated SignedSource<<3c6f995a3c43d2b779bbdbc4b76676a2>> */ /** @@ -174,6 +174,9 @@ class JReactNativeFeatureFlagsCxxInterop static bool enablePreallocatedPropsDiffOnInsertAndroid( facebook::jni::alias_ref); + static bool enablePremountItemsBetweenFramesAndroid( + facebook::jni::alias_ref); + static bool enablePreparedTextLayout( facebook::jni::alias_ref); diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricUIManagerPreMountTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricUIManagerPreMountTest.kt new file mode 100644 index 000000000000..1047786d621c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricUIManagerPreMountTest.kt @@ -0,0 +1,298 @@ +/* + * 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. + */ + +@file:Suppress("DEPRECATION") + +package com.facebook.react.fabric + +import android.os.Build +import android.os.Looper +import android.view.Choreographer +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactTestHelper +import com.facebook.react.bridge.UIManager +import com.facebook.react.bridge.UIManagerListener +import com.facebook.react.common.SystemClock +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.fabric.mounting.MountItemDispatcher +import com.facebook.react.fabric.mounting.MountingManager +import com.facebook.react.fabric.mounting.mountitems.MountItem +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsDefaults +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests +import com.facebook.react.modules.core.ReactChoreographer +import com.facebook.react.uimanager.ViewManagerRegistry +import com.facebook.testutils.fakes.FakeBatchEventDispatchedListener +import com.facebook.testutils.shadows.ShadowFabricUIManagerBinding +import com.facebook.testutils.shadows.ShadowNativeLoader +import com.facebook.testutils.shadows.ShadowPerformanceTracer +import com.facebook.testutils.shadows.ShadowSoLoader +import kotlin.concurrent.thread +import org.assertj.core.api.Assertions.assertThat +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedStatic +import org.mockito.Mockito.mockStatic +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import org.robolectric.shadows.ShadowDisplay +import org.robolectric.util.ReflectionHelpers + +/** + * Drives FabricUIManager's DISPATCH_UI frame callback as the Choreographer does, with React + * Native's clock under the test's control, to pin where a frame's pre-mount items run. + */ +@OptIn(UnstableReactNativeAPI::class) +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config( + shadows = + [ + ShadowSoLoader::class, + ShadowNativeLoader::class, + ShadowPerformanceTracer::class, + ShadowFabricUIManagerBinding::class, + ], +) +class FabricUIManagerPreMountTest { + private lateinit var systemClock: MockedStatic + private lateinit var reactChoreographer: ReactChoreographer + private var originalReactChoreographer: ReactChoreographer? = null + private var isReactChoreographerOverridden: Boolean = false + private lateinit var reactContext: ReactApplicationContext + private lateinit var underTest: FabricUIManager + private lateinit var dispatcher: MountItemDispatcher + private lateinit var dispatchUiFrameCallback: Choreographer.FrameCallback + private val handledExceptions: MutableList = mutableListOf() + private var nowNanos: Long = FIRST_FRAME_NANOS + private var executedPreMountItems: Int = 0 + + private fun start(preMountBetweenFrames: Boolean) { + ReactNativeFeatureFlagsForTests.setUp() + ReactNativeFeatureFlags.override( + object : ReactNativeFeatureFlagsDefaults() { + override fun enablePremountItemsBetweenFramesAndroid(): Boolean = preMountBetweenFrames + }, + ) + systemClock = mockStatic(SystemClock::class.java) + systemClock.`when` { SystemClock.nanoTime() }.thenAnswer { nowNanos } + reactChoreographer = mock() + originalReactChoreographer = ReactChoreographer.overrideInstanceForTest(reactChoreographer) + isReactChoreographerOverridden = true + reactContext = ReactTestHelper.createCatalystContextForTest() + reactContext.setJSExceptionHandler { exception -> handledExceptions.add(exception) } + underTest = + FabricUIManager( + reactContext, + ViewManagerRegistry(emptyList()), + FakeBatchEventDispatchedListener(), + ) + dispatcher = ReflectionHelpers.getField(underTest, "mMountItemDispatcher") + underTest.onHostResume() + val frameCallback = argumentCaptor() + verify(reactChoreographer) + .postFrameCallback(eq(ReactChoreographer.CallbackType.DISPATCH_UI), frameCallback.capture()) + dispatchUiFrameCallback = frameCallback.firstValue + } + + /** Undoes only what [start] got to, so a failing start reports its own error. */ + @After + fun tearDown() { + if (isReactChoreographerOverridden) { + ReactChoreographer.overrideInstanceForTest(originalReactChoreographer) + } + if (::systemClock.isInitialized) { + systemClock.close() + } + ReactNativeFeatureFlags.dangerouslyReset() + } + + /** Queues items that each take 1 ms and run [onExecute] when they execute. */ + private fun queuePreMountItems(count: Int, onExecute: (index: Int) -> Unit = {}) { + repeat(count) { index -> + dispatcher.addPreAllocateMountItem( + object : MountItem { + override fun execute(mountingManager: MountingManager) { + onExecute(index) + executedPreMountItems++ + nowNanos += MILLISECOND_NANOS + } + + override fun getSurfaceId(): Int = SURFACE_ID + } + ) + } + } + + /** + * Runs the DISPATCH_UI callback of the frame at [frameTimeNanos] and returns how many pre-mount + * items it ran. + */ + private fun runFrame(frameTimeNanos: Long): Int { + nowNanos = frameTimeNanos + val before = executedPreMountItems + dispatchUiFrameCallback.doFrame(frameTimeNanos) + return executedPreMountItems - before + } + + /** Runs everything queued on the UI thread and returns how many pre-mount items it ran. */ + private fun runBetweenFrames(): Int { + val before = executedPreMountItems + shadowOf(Looper.getMainLooper()).idle() + return executedPreMountItems - before + } + + @Test + fun withTheFlagOff_aFramePreMountsInsideItsCallbackForHalfOfASixtyHertzFrame() { + start(preMountBetweenFrames = false) + queuePreMountItems(count = 20) + + assertThat(runFrame(FIRST_FRAME_NANOS)).isEqualTo(9) + assertThat(runBetweenFrames()).isZero() + } + + @Test + fun withTheFlagOn_aFramePreMountsAfterItsCallbackUntilTheNextVsync() { + start(preMountBetweenFrames = true) + queuePreMountItems(count = 20) + + assertThat(runFrame(FIRST_FRAME_NANOS)).isZero() + assertThat(runBetweenFrames()).isEqualTo(17) + } + + private fun preMountAfterAFrameOnAOneHundredTwentyHertzDisplay(): Int { + shadowOf(ShadowDisplay.getDefaultDisplay()).setRefreshRate(120f) + start(preMountBetweenFrames = true) + underTest.initialize() + queuePreMountItems(count = 20) + runFrame(FIRST_FRAME_NANOS) + return runBetweenFrames() + } + + @Test + fun withTheFlagOn_initializeReadsTheDisplaysRefreshRate() { + // At 120 Hz the next vsync is 8.33 ms away: items start at 0 to 8 ms. + assertThat(preMountAfterAFrameOnAOneHundredTwentyHertzDisplay()).isEqualTo(9) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.Q]) + fun withTheFlagOnBeforeAndroid13_initializeReadsTheDisplaysRefreshRate() { + assertThat(preMountAfterAFrameOnAOneHundredTwentyHertzDisplay()).isEqualTo(9) + } + + @Test + fun withTheFlagOn_aFrameAfterOneWhoseDrawSleptPreMountsInsideItsCallback() { + start(preMountBetweenFrames = true) + queuePreMountItems(count = 40) + val secondFrameNanos = FIRST_FRAME_NANOS + SIXTY_HERTZ_FRAME_NANOS + runFrame(FIRST_FRAME_NANOS) + + // The frame's draw sleeps until the next vsync, waiting on the render thread. + nowNanos = secondFrameNanos + + assertThat(runBetweenFrames()).isZero() + assertThat(runFrame(secondFrameNanos)).isEqualTo(9) + } + + @Test + fun withTheFlagOn_pausingCancelsThePreMountingQueuedAfterAFrame() { + start(preMountBetweenFrames = true) + queuePreMountItems(count = 20) + runFrame(FIRST_FRAME_NANOS) + + underTest.onHostPause() + + assertThat(runBetweenFrames()).isZero() + } + + @Test + fun withTheFlagOn_aFrameWhoseMountFailsPreMountsNothingAfterIt() { + start(preMountBetweenFrames = true) + underTest.addUIManagerEventListener(FailingWillMountItemsListener()) + queuePreMountItems(count = 3) + dispatcher.addMountItem( + object : MountItem { + override fun execute(mountingManager: MountingManager) = Unit + + override fun getSurfaceId(): Int = SURFACE_ID + } + ) + + runFrame(FIRST_FRAME_NANOS) + + assertThat(handledExceptions).hasSize(1) + assertThat(runBetweenFrames()).isZero() + assertThat(runFrame(FIRST_FRAME_NANOS + SIXTY_HERTZ_FRAME_NANOS)).isZero() + assertThat(runBetweenFrames()).isZero() + } + + @Test + fun withTheFlagOn_aSliceQueuedBeforeAFrameWhoseMountFailsRunsNothing() { + start(preMountBetweenFrames = true) + queuePreMountItems(count = 20) + runFrame(FIRST_FRAME_NANOS) + underTest.addUIManagerEventListener(FailingWillMountItemsListener()) + dispatcher.addMountItem( + object : MountItem { + override fun execute(mountingManager: MountingManager) = Unit + + override fun getSurfaceId(): Int = SURFACE_ID + } + ) + + // The display runs faster than the interval the slices were queued for, so the next frame + // comes while they are still queued, and its mount fails. + runFrame(FIRST_FRAME_NANOS + 5 * MILLISECOND_NANOS) + + assertThat(handledExceptions).hasSize(1) + assertThat(runBetweenFrames()).isZero() + } + + @Test + fun withTheFlagOn_invalidatingFromAnotherThreadStopsTheSlices() { + start(preMountBetweenFrames = true) + queuePreMountItems(count = 20) { index -> + if (index == 0) { + // React Native destroys an instance on a background thread, here while a slice runs. + thread { underTest.invalidate() }.join() + } + } + runFrame(FIRST_FRAME_NANOS) + + // The slice that was running finishes its 1 ms, and none runs after it. + assertThat(runBetweenFrames()).isEqualTo(2) + } + + private class FailingWillMountItemsListener : UIManagerListener { + override fun willDispatchViewUpdates(uiManager: UIManager) = Unit + + override fun willMountItems(uiManager: UIManager) { + throw IllegalStateException("willMountItems failed") + } + + override fun didMountItems(uiManager: UIManager) = Unit + + override fun didDispatchMountItems(uiManager: UIManager) = Unit + + override fun didScheduleMountItems(uiManager: UIManager) = Unit + } + + private companion object { + const val SURFACE_ID: Int = 1 + const val MILLISECOND_NANOS: Long = 1_000_000L + const val SIXTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 60 + const val FIRST_FRAME_NANOS: Long = 1_000_000_000L + } +} 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..83fd3b032794 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt @@ -0,0 +1,150 @@ +/* + * 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.content.ContextWrapper +import android.hardware.display.DisplayManager +import android.os.Handler +import android.os.Looper +import android.view.Display +import kotlin.concurrent.thread +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +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() + } + + private fun readStartedFrameInterval(): Long? { + val frameInterval = DisplayFrameInterval(RuntimeEnvironment.getApplication()) + frameInterval.start() + frameInterval.stop() + return frameInterval.frameIntervalNanos() + } + + @Test + fun readsTheDefaultDisplaysFrameAtEveryRefreshRate() { + val frameIntervalNanosByRefreshRate = + listOf(1f, 60f, 90f, 120f, 144f, 165f, 185f, 240f).associateWith { refreshRate -> + setRefreshRate(refreshRate) + readStartedFrameInterval() + } + + 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 + fun followsARefreshRateChangeTheDisplayReports() { + setRefreshRate(120f) + val frameInterval = DisplayFrameInterval(RuntimeEnvironment.getApplication()) + frameInterval.start() + + setRefreshRate(60f) + + assertThat(frameInterval.frameIntervalNanos()).isEqualTo(SIXTY_HERTZ_FRAME_NANOS) + } + + @Test + fun readsARefreshRateChangeThatLandsWhileTheListenerRegisters() { + setRefreshRate(120f) + val application = RuntimeEnvironment.getApplication() + val displayManager = spy(application.getSystemService(DisplayManager::class.java)) + doAnswer { invocation -> + // The display drops to 60 Hz before the listener is in place, so no change reaches it. + shadowOf(display).setRefreshRate(60f) + invocation.callRealMethod() + } + .whenever(displayManager) + .registerDisplayListener(any(), anyOrNull()) + val context = + object : ContextWrapper(application) { + override fun getSystemService(name: String): Any? = + if (name == Context.DISPLAY_SERVICE) displayManager else super.getSystemService(name) + } + val frameInterval = DisplayFrameInterval(context) + + frameInterval.start() + + assertThat(frameInterval.frameIntervalNanos()).isEqualTo(SIXTY_HERTZ_FRAME_NANOS) + } + + @Test + fun startedOffTheUiThreadReadsTheRateOnIt() { + setRefreshRate(120f) + val frameInterval = DisplayFrameInterval(RuntimeEnvironment.getApplication()) + + // React Native creates its instance, and initializes the UI manager, on a background thread. + thread { frameInterval.start() }.join() + + assertThat(frameInterval.frameIntervalNanos()).isNull() + shadowOf(Looper.getMainLooper()).idle() + assertThat(frameInterval.frameIntervalNanos()).isEqualTo(ONE_HUNDRED_TWENTY_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 reportsNoFrameWhileTheDisplayReportsNoRefreshRate() { + setRefreshRate(0f) + + assertThat(readStartedFrameInterval()).isNull() + } + + @Test + fun readsTheDisplayOnlyOnceStarted() { + setRefreshRate(120f) + + assertThat(DisplayFrameInterval(RuntimeEnvironment.getApplication()).frameIntervalNanos()) + .isNull() + } + + 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 new file mode 100644 index 000000000000..c43e7f5a2abb --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt @@ -0,0 +1,112 @@ +/* + * 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 val executed: MutableList = mutableListOf() + + @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() + } + + private fun recordingItem(name: String, costNanos: Long): MountItem = + object : MountItem { + override fun execute(mountingManager: MountingManager) { + executed.add(name) + nowNanos += costNanos + } + + override fun getSurfaceId(): Int = SURFACE_ID + } + + private fun queuePreMountItems(count: Int, costNanos: Long) { + repeat(count) { index -> + dispatcher.addPreAllocateMountItem(recordingItem("premount $index", costNanos)) + } + } + + @Test + fun preMountsForHalfOfASixtyHertzFrameFromTheFrameStart() { + queuePreMountItems(count = 20, costNanos = MILLISECOND_NANOS) + nowNanos = FIRST_FRAME_NANOS + + dispatcher.dispatchPreMountItems(FIRST_FRAME_NANOS) + + // Items start at 0, 1, ... 8 ms; 8.33 ms is half of a 60 Hz frame. + assertThat(executed).hasSize(9) + } + + @Test + fun preMountsUntilADeadlineAndReportsWhetherItemsAreLeft() { + queuePreMountItems(count = 6, costNanos = MILLISECOND_NANOS) + nowNanos = FIRST_FRAME_NANOS + + val itemsLeft = dispatcher.dispatchPreMountItemsUntil(FIRST_FRAME_NANOS + 3 * MILLISECOND_NANOS) + + assertThat(executed).hasSize(4) + assertThat(itemsLeft).isTrue() + assertThat(dispatcher.dispatchPreMountItemsUntil(nowNanos + SECOND_NANOS)).isFalse() + assertThat(executed).hasSize(6) + assertThat(dispatcher.hasPreMountItems()).isFalse() + } + + @Test + fun aMountBatchRunsEveryPreMountItemLeftBeforeItself() { + queuePreMountItems(count = 3, costNanos = MILLISECOND_NANOS) + dispatcher.addMountItem(recordingItem("mount", costNanos = 0L)) + + dispatcher.tryDispatchMountItems() + + assertThat(executed).containsExactly("premount 0", "premount 1", "premount 2", "mount") + } + + private companion object { + const val SURFACE_ID: Int = 1 + const val MILLISECOND_NANOS: Long = 1_000_000L + const val SECOND_NANOS: Long = 1_000_000_000L + const val FIRST_FRAME_NANOS: Long = SECOND_NANOS + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/PreMountItemsBetweenFramesTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/PreMountItemsBetweenFramesTest.kt new file mode 100644 index 000000000000..de576fb7c422 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/PreMountItemsBetweenFramesTest.kt @@ -0,0 +1,793 @@ +/* + * 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.Handler +import android.os.Looper +import android.os.MessageQueue +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.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config( + shadows = [ShadowSoLoader::class, ShadowNativeLoader::class, ShadowPerformanceTracer::class] +) +class PreMountItemsBetweenFramesTest { + private lateinit var systemClock: MockedStatic + private lateinit var dispatcher: MountItemDispatcher + private lateinit var preMountItemsBetweenFrames: PreMountItemsBetweenFrames + private val uiThreadHandler: Handler = Handler(Looper.getMainLooper()) + private val handledExceptions: MutableList = mutableListOf() + private var nowNanos: Long = 0L + private var threadCpuNanos: Long = 0L + private var threadCpuClockCanTell: Boolean = true + private var nextVsyncNanos: Long = FIRST_FRAME_NANOS + private var executedItems: Int = 0 + private var mountedAtNanos: Long? = null + private var displayFrameIntervalNanos: Long? = ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS + private var isMountingAllowed: Boolean = true + + @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) + val itemDispatchListener = mock() + doAnswer { + mountedAtNanos = nowNanos + null + } + .whenever(itemDispatchListener) + .pullTransaction(any()) + dispatcher = MountItemDispatcher(mountingManager, itemDispatchListener) + preMountItemsBetweenFrames = + PreMountItemsBetweenFrames( + dispatcher, + { displayFrameIntervalNanos }, + // Debug.threadCpuTimeNanos() reports -1 where the system cannot tell a thread's CPU + // time. + { if (threadCpuClockCanTell) threadCpuNanos else -1L }, + { isMountingAllowed }, + uiThreadHandler, + { exception -> handledExceptions.add(exception) }, + ) + } + + @After + fun tearDown() { + systemClock.close() + } + + /** Queues items that each take [costNanos] and run [onExecute] when they execute. */ + private fun queuePreMountItems( + count: Int, + costNanos: Long = MILLISECOND_NANOS, + onExecute: (index: Int) -> Unit = {}, + ) { + repeat(count) { index -> + dispatcher.addPreAllocateMountItem( + object : MountItem { + override fun execute(mountingManager: MountingManager) { + onExecute(index) + executedItems++ + nowNanos += costNanos + threadCpuNanos += costNanos + } + + override fun getSurfaceId(): Int = SURFACE_ID + } + ) + } + } + + /** + * Runs the frame callback at [frameTimeNanos] and the frame's own work, [frameWorkNanos], and + * returns how many items the frame callback itself executed. The frame starts at [startNanos], + * its frame time unless it starts late. With [renderThreadReadyAtNanos], the frame's draw then + * sleeps until the render thread takes the frame, as it does while the render thread waits on + * SurfaceFlinger for a buffer. + */ + private fun runFrame( + frameTimeNanos: Long, + frameWorkNanos: Long, + renderThreadReadyAtNanos: Long? = null, + startNanos: Long = frameTimeNanos, + ): Int { + nowNanos = startNanos + val before = executedItems + preMountItemsBetweenFrames.preMountBeforeTraversal() + preMountItemsBetweenFrames.scheduleAfterFrame(frameTimeNanos) + val executedInsideTheFrame = executedItems - before + nowNanos += frameWorkNanos + threadCpuNanos += frameWorkNanos + if (renderThreadReadyAtNanos != null) { + nowNanos = maxOf(nowNanos, renderThreadReadyAtNanos) + } + return executedInsideTheFrame + } + + /** Runs everything queued on the UI thread and returns how many items it executed. */ + private fun runBetweenFrames(): Int { + val before = executedItems + val looper = shadowOf(Looper.getMainLooper()) + var tasks = 0 + while (!looper.isIdle) { + tasks++ + check(tasks <= MAX_TASKS_BETWEEN_FRAMES) { "The UI thread never went idle between frames" } + looper.runOneTask() + } + return executedItems - before + } + + /** + * Runs the next frame, then everything queued after it, and returns how many items the frame + * callback executed. As with the Choreographer, the frame starts at the vsync after the last + * frame's start, or once the UI thread is free if that vsync has passed, and its frame time is + * the last vsync at or before its start. + */ + private fun runNextFrame(frameWorkNanos: Long): Int { + val frameIntervalNanos = checkNotNull(displayFrameIntervalNanos) + val startNanos = maxOf(nowNanos, nextVsyncNanos) + val frameTimeNanos = startNanos - (startNanos - nextVsyncNanos) % frameIntervalNanos + nextVsyncNanos = frameTimeNanos + frameIntervalNanos + val executedInsideTheFrame = runFrame(frameTimeNanos, frameWorkNanos, startNanos = startNanos) + runBetweenFrames() + return executedInsideTheFrame + } + + @Test + fun preMountsNothingInsideTheFrame() { + queuePreMountItems(count = 20) + + assertThat(runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L)).isZero() + } + + @Test + fun preMountsBetweenFramesUntilTheNextVsync() { + val itemsByRateAndFrameWork = + listOf(60, 120, 144) + .flatMap { refreshRate -> + listOf(0, 2, 5).map { frameWork -> refreshRate to frameWork } + } + .withIndex() + .associate { (index, rateAndFrameWork) -> + val (refreshRate, frameWorkMilliseconds) = rateAndFrameWork + displayFrameIntervalNanos = SECOND_NANOS / refreshRate + queuePreMountItems(count = 40) + runFrame( + FIRST_FRAME_NANOS + index * SECOND_NANOS, + frameWorkMilliseconds * MILLISECOND_NANOS, + ) + val executed = "$refreshRate Hz after $frameWorkMilliseconds ms" to runBetweenFrames() + dispatcher.dispatchPreMountItemsUntil(Long.MAX_VALUE) + executed + } + + // Items start each millisecond from the end of the frame's work, and none starts past the + // next vsync: 16.67 ms after the frame at 60 Hz, 8.33 ms at 120 Hz, 6.94 ms at 144 Hz. + assertThat(itemsByRateAndFrameWork) + .isEqualTo( + mapOf( + "60 Hz after 0 ms" to 17, + "60 Hz after 2 ms" to 15, + "60 Hz after 5 ms" to 12, + "120 Hz after 0 ms" to 9, + "120 Hz after 2 ms" to 7, + "120 Hz after 5 ms" to 4, + "144 Hz after 0 ms" to 7, + "144 Hz after 2 ms" to 5, + "144 Hz after 5 ms" to 2, + ) + ) + } + + @Test + fun preMountsLongerThanHalfOfASixtyHertzFrameWhenTheFrameLeavesTheTime() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 40) + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 2 * MILLISECOND_NANOS) + + // Before the frame, the budget is half of a 60 Hz frame: 9 items of 1 ms. + assertThat(runBetweenFrames()).isEqualTo(15) + } + + @Test + fun aMessageQueuedDuringASliceRunsBeforeTheNextSlice() { + var itemsBeforeTheMessage = -1 + queuePreMountItems(count = 20) { index -> + if (index == 0) { + uiThreadHandler.post { itemsBeforeTheMessage = executedItems } + } + } + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + runBetweenFrames() + + // A slice ends one millisecond after it starts, and the item running at that moment finishes. + assertThat(itemsBeforeTheMessage).isEqualTo(2) + assertThat(executedItems).isEqualTo(9) + } + + /** Queues a commit's mount item, which records when it executes in [mountedAtNanos]. */ + private fun landCommit() { + dispatcher.addMountItem( + object : MountItem { + override fun execute(mountingManager: MountingManager) { + mountedAtNanos = nowNanos + } + + override fun getSurfaceId(): Int = SURFACE_ID + } + ) + } + + @Test + fun aCommitThatLandsBetweenFramesMountsWithoutWaitingForTheNextFrame() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 20) { index -> + if (index == 2) { + landCommit() + } + } + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + runBetweenFrames() + + // The commit lands during the third item, in the second slice. The next slice mounts it, and + // the batch first runs the 16 items still waiting, as the frame's mount items would. + assertThat(mountedAtNanos).isEqualTo(FIRST_FRAME_NANOS + 20 * MILLISECOND_NANOS) + } + + @Test + fun aCommitThatLandsWithTheLastItemMountsWithoutWaitingForTheNextFrame() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 3) { index -> + if (index == 2) { + landCommit() + } + } + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + runBetweenFrames() + + assertThat(mountedAtNanos).isEqualTo(FIRST_FRAME_NANOS + 3 * MILLISECOND_NANOS) + } + + @Test + fun aCommitToPullWaitsForTheFrameWhileTheSlicesPreMount() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 20) { index -> + if (index == 2) { + dispatcher.addSurfaceIdToPull(SURFACE_ID) + } + } + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + + // The pull model queues the commit's surface, and the next frame pulls it once, however many + // commits land before it. + assertThat(runBetweenFrames()).isEqualTo(17) + assertThat(mountedAtNanos).isNull() + assertThat(dispatcher.hasPendingItems()).isTrue() + } + + @Test + fun mountItemsWaitWithACommitToPullForTheFrame() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 5) { index -> + if (index == 2) { + dispatcher.addSurfaceIdToPull(SURFACE_ID) + landCommit() + } + } + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + + // A dispatch pulls before it mounts, so the frame runs both, and no slice spins while they + // wait. + assertThat(runBetweenFrames()).isEqualTo(5) + assertThat(mountedAtNanos).isNull() + assertThat(dispatcher.hasPendingItems()).isTrue() + } + + @Test + fun workQueuedWhileASliceMountsRunsBeforeTheNextVsync() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 3) { index -> + if (index == 2) { + dispatcher.addMountItem( + object : MountItem { + override fun execute(mountingManager: MountingManager) { + // JavaScript pre-allocates the next views while the UI thread mounts the commit. + queuePreMountItems(count = 5) + } + + override fun getSurfaceId(): Int = SURFACE_ID + } + ) + } + } + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + + assertThat(runBetweenFrames()).isEqualTo(8) + } + + @Test + fun aSliceRunsNothingOnceMountingIsNoLongerAllowed() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 20) { index -> + if (index == 1) { + // A frame's mount fails, or the UI manager is destroyed, while slices are still queued. + isMountingAllowed = false + } + } + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + + assertThat(runBetweenFrames()).isEqualTo(2) + } + + @Test + fun aTraversalScheduledForTheNextFrameDoesNotHoldBackTheSlices() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 20) + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + + // A view that invalidates while it draws, such as a ScrollView during a fling, schedules the + // next frame's traversal, which holds a sync barrier in the queue until that frame. + val barrier = postSyncBarrier() + try { + assertThat(runBetweenFrames()).isEqualTo(17) + } finally { + removeSyncBarrier(barrier) + } + } + + private fun postSyncBarrier(): Int = + MessageQueue::class.java.getMethod("postSyncBarrier").invoke(Looper.getMainLooper().queue) + as Int + + private fun removeSyncBarrier(token: Int) { + MessageQueue::class + .java + .getMethod("removeSyncBarrier", Int::class.javaPrimitiveType) + .invoke(Looper.getMainLooper().queue, token) + } + + @Test + fun aStaleFrameIntervalStillYieldsEveryMillisecond() { + // The display last reported 30 Hz while frames arrive at 120 Hz. + displayFrameIntervalNanos = SECOND_NANOS / 30 + var itemsBeforeTheMessage = -1 + queuePreMountItems(count = 40) { index -> + if (index == 0) { + uiThreadHandler.post { itemsBeforeTheMessage = executedItems } + } + } + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + runBetweenFrames() + + assertThat(itemsBeforeTheMessage).isEqualTo(2) + } + + @Test + fun aDisplayThatReportsNoRateIsTakenAsSixtyHertz() { + displayFrameIntervalNanos = null + queuePreMountItems(count = 40) + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 2 * MILLISECOND_NANOS) + + assertThat(runBetweenFrames()).isEqualTo(15) + } + + @Test + fun aFrameThatRanPastTheNextVsyncPreMountsNothing() { + queuePreMountItems(count = 20) + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 9 * MILLISECOND_NANOS) + + assertThat(runBetweenFrames()).isZero() + } + + @Test + fun aFrameWhoseDrawWaitedOnTheRenderThreadPreMountsBeforeTheNextTraversal() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 60) + val secondFrameNanos = FIRST_FRAME_NANOS + SIXTY_HERTZ_FRAME_NANOS + + runFrame(FIRST_FRAME_NANOS, MILLISECOND_NANOS, renderThreadReadyAtNanos = secondFrameNanos) + + // The draw slept 15.67 ms and left no time after the frame; the next frame spends that wait + // before its traversal, at most half a frame: 9 items of 1 ms start within 8.33 ms. + assertThat(runBetweenFrames()).isZero() + assertThat(runFrame(secondFrameNanos, MILLISECOND_NANOS)).isEqualTo(9) + } + + @Test + fun preMountingBeforeTheTraversalLastsWhileTheFramesKeepWaiting() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 60) + + val itemsBeforeEachTraversal = + (0 until 4).map { frame -> + val frameTimeNanos = FIRST_FRAME_NANOS + frame * SIXTY_HERTZ_FRAME_NANOS + val executed = + runFrame( + frameTimeNanos, + MILLISECOND_NANOS, + renderThreadReadyAtNanos = frameTimeNanos + SIXTY_HERTZ_FRAME_NANOS, + ) + runBetweenFrames() + executed + } + + assertThat(itemsBeforeEachTraversal).containsExactly(0, 9, 9, 9) + } + + @Test + fun aFrameThatNoLongerWaitsLeavesTheNextTraversalAlone() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 60) + val secondFrameNanos = FIRST_FRAME_NANOS + SIXTY_HERTZ_FRAME_NANOS + runFrame(FIRST_FRAME_NANOS, MILLISECOND_NANOS, renderThreadReadyAtNanos = secondFrameNanos) + runBetweenFrames() + + assertThat(runFrame(secondFrameNanos, MILLISECOND_NANOS)).isEqualTo(9) + runBetweenFrames() + + assertThat(runFrame(secondFrameNanos + SIXTY_HERTZ_FRAME_NANOS, MILLISECOND_NANOS)).isZero() + } + + @Test + fun aFrameBusyPastTheNextVsyncLeavesTheNextTraversalAlone() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 60) + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 20 * MILLISECOND_NANOS) + + assertThat(runBetweenFrames()).isZero() + assertThat(runFrame(FIRST_FRAME_NANOS + 2 * SIXTY_HERTZ_FRAME_NANOS, MILLISECOND_NANOS)) + .isZero() + } + + @Test + fun framesBusyPastEveryVsyncPreMountBeforeTheirTraversalFromTheThird() { + val itemsBeforeEachTraversalByRate = + listOf(60, 120, 144).withIndex().associate { (index, refreshRate) -> + val frameIntervalNanos = SECOND_NANOS / refreshRate + displayFrameIntervalNanos = frameIntervalNanos + nextVsyncNanos = FIRST_FRAME_NANOS + index * SECOND_NANOS + queuePreMountItems(count = 100) + // Each frame's own work runs a fifth of a frame past the next vsync. + val executed = List(5) { runNextFrame(frameIntervalNanos * 6 / 5) } + preMountItemsBetweenFrames.cancel() + dispatcher.dispatchPreMountItemsUntil(Long.MAX_VALUE) + "$refreshRate Hz" to executed + } + + // Half a frame is 8.33 ms at 60 Hz, 4.17 ms at 120 Hz and 3.47 ms at 144 Hz, from the start of + // the pre-mounting, however late the frame started. + assertThat(itemsBeforeEachTraversalByRate) + .isEqualTo( + mapOf( + "60 Hz" to listOf(0, 0, 9, 9, 9), + "120 Hz" to listOf(0, 0, 5, 5, 5), + "144 Hz" to listOf(0, 0, 4, 4, 4), + ) + ) + } + + @Test + fun oneFrameBusyPastTheNextVsyncPaysNothingBeforeTheTraversals() { + queuePreMountItems(count = 100) + + val executed = + listOf(20, 2, 20, 2, 2).map { frameWorkMilliseconds -> + runNextFrame(frameWorkMilliseconds * MILLISECOND_NANOS) + } + + // Each busy frame is followed by one that leaves time, so no two frames in a row are busy. + assertThat(executed).containsExactly(0, 0, 0, 0, 0) + } + + @Test + fun preMountingBeforeTheTraversalEndsWithTheFramesBusyPastTheVsync() { + queuePreMountItems(count = 100) + + // Three frames busy for 10 ms at 120 Hz, then frames whose 7.5 ms fit an 8.33 ms frame but + // start late until the UI thread catches up with the vsyncs. + val executed = + (List(3) { 10 * MILLISECOND_NANOS } + List(8) { 7_500 * MICROSECOND_NANOS }).map { + frameWorkNanos -> + runNextFrame(frameWorkNanos) + } + + assertThat(executed).containsExactly(0, 0, 5, 5, 0, 0, 0, 0, 0, 0, 0) + } + + @Test + fun aFrameWithNothingToPreMountEndsARunOfBusyFrames() { + queuePreMountItems(count = 100) + val busyFrameNanos = 10 * MILLISECOND_NANOS + val firstBurst = List(2) { runNextFrame(busyFrameNanos) } + + // A commit's mount runs every item left, and the next frame has nothing to pre-mount. + dispatcher.dispatchPreMountItemsUntil(Long.MAX_VALUE) + runNextFrame(busyFrameNanos) + queuePreMountItems(count = 100) + val secondBurst = List(3) { runNextFrame(busyFrameNanos) } + + assertThat(firstBurst + secondBurst).containsExactly(0, 0, 0, 0, 5) + } + + @Test + fun vsyncsWithNoFrameCallbackEndARunOfBusyFrames() { + queuePreMountItems(count = 100) + val busyFrameNanos = 10 * MILLISECOND_NANOS + val beforeTheGap = List(2) { runNextFrame(busyFrameNanos) } + + // The frame callback stops re-arming, and the next frame comes ten vsyncs later. + nextVsyncNanos += 10 * ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS + val afterTheGap = List(3) { runNextFrame(busyFrameNanos) } + + assertThat(beforeTheGap + afterTheGap).containsExactly(0, 0, 0, 0, 5) + } + + @Test + fun cancellingEndsARunOfBusyFrames() { + queuePreMountItems(count = 100) + val busyFrameNanos = 10 * MILLISECOND_NANOS + val beforeCancelling = List(2) { runNextFrame(busyFrameNanos) } + + preMountItemsBetweenFrames.cancel() + val afterCancelling = List(3) { runNextFrame(busyFrameNanos) } + + assertThat(beforeCancelling + afterCancelling).containsExactly(0, 0, 0, 0, 5) + } + + @Test + fun framesBusyPastEveryVsyncPreMountBeforeTheirTraversalWhereTheThreadClockCannotTell() { + threadCpuClockCanTell = false + queuePreMountItems(count = 100) + + val executed = List(4) { runNextFrame(10 * MILLISECOND_NANOS) } + + assertThat(executed).containsExactly(0, 0, 5, 5) + } + + @Test + fun aFrameThatPreMountedEverythingBeforeItsTraversalStillMeasuresItsSleep() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + val frames = (0 until 3).map { frame -> FIRST_FRAME_NANOS + frame * SIXTY_HERTZ_FRAME_NANOS } + queuePreMountItems(count = 9) + runFrame(frames[0], MILLISECOND_NANOS, renderThreadReadyAtNanos = frames[1]) + runBetweenFrames() + + assertThat(runFrame(frames[1], MILLISECOND_NANOS, renderThreadReadyAtNanos = frames[2])) + .isEqualTo(9) + runBetweenFrames() + queuePreMountItems(count = 9) + + assertThat(runFrame(frames[2], MILLISECOND_NANOS)).isEqualTo(9) + } + + @Test + fun aFrameAfterVsyncsWithNoFrameCallbackLeavesItsTraversalAlone() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + val frames = (0 until 2).map { frame -> FIRST_FRAME_NANOS + frame * SIXTY_HERTZ_FRAME_NANOS } + queuePreMountItems(count = 9) + runFrame(frames[0], MILLISECOND_NANOS, renderThreadReadyAtNanos = frames[1]) + runBetweenFrames() + runFrame( + frames[1], + MILLISECOND_NANOS, + renderThreadReadyAtNanos = frames[1] + SIXTY_HERTZ_FRAME_NANOS, + ) + runBetweenFrames() + + // With nothing left to mount, the frame callback stops re-arming, and the next burst's first + // frame comes ten vsyncs later: the sleep measured before them says nothing about its frame. + queuePreMountItems(count = 9) + + assertThat(runFrame(frames[1] + 11 * SIXTY_HERTZ_FRAME_NANOS, MILLISECOND_NANOS)).isZero() + } + + @Test + fun framesThatKeepWaitingQueueNothingOnceTheItemsRunOut() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + // A real clock moves between any two reads. + systemClock + .`when` { SystemClock.nanoTime() } + .thenAnswer { + nowNanos += MICROSECOND_NANOS + nowNanos + } + val frames = (0 until 3).map { frame -> FIRST_FRAME_NANOS + frame * SIXTY_HERTZ_FRAME_NANOS } + queuePreMountItems(count = 9) + runFrame(frames[0], MILLISECOND_NANOS, renderThreadReadyAtNanos = frames[1]) + runBetweenFrames() + runFrame(frames[1], MILLISECOND_NANOS, renderThreadReadyAtNanos = frames[2]) + runBetweenFrames() + + runFrame( + frames[2], + MILLISECOND_NANOS, + renderThreadReadyAtNanos = frames[2] + SIXTY_HERTZ_FRAME_NANOS, + ) + + assertThat(shadowOf(Looper.getMainLooper()).isIdle).isTrue() + } + + @Test + fun aThreadClockThatCannotTellLeavesTheNextTraversalAlone() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 60) + threadCpuClockCanTell = false + val secondFrameNanos = FIRST_FRAME_NANOS + SIXTY_HERTZ_FRAME_NANOS + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 20 * MILLISECOND_NANOS) + runBetweenFrames() + + assertThat(runFrame(secondFrameNanos + SIXTY_HERTZ_FRAME_NANOS, MILLISECOND_NANOS)).isZero() + } + + @Test + fun cancellingDropsTheTimeBeforeTheNextTraversal() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 60) + val secondFrameNanos = FIRST_FRAME_NANOS + SIXTY_HERTZ_FRAME_NANOS + runFrame(FIRST_FRAME_NANOS, MILLISECOND_NANOS, renderThreadReadyAtNanos = secondFrameNanos) + runBetweenFrames() + + preMountItemsBetweenFrames.cancel() + + assertThat(runFrame(secondFrameNanos, MILLISECOND_NANOS)).isZero() + } + + @Test + fun beforeTheTraversalPreMountingTakesAtMostHalfAFrame() { + queuePreMountItems(count = 60) + val nextFrameNanos = FIRST_FRAME_NANOS + 3 * ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS + runFrame(FIRST_FRAME_NANOS, MILLISECOND_NANOS, renderThreadReadyAtNanos = nextFrameNanos) + runBetweenFrames() + + // Half of a 120 Hz frame is 4.17 ms: items start at 0 to 4 ms, though the draw slept 24 ms. + assertThat(runFrame(nextFrameNanos, MILLISECOND_NANOS)).isEqualTo(5) + } + + @Test + fun beforeTheTraversalPreMountingStopsAtHalfOfAThirtyHertzFrame() { + // The display last reported 24 Hz, where half a frame is 20.83 ms. + displayFrameIntervalNanos = SECOND_NANOS / 24 + queuePreMountItems(count = 60) + val nextFrameNanos = FIRST_FRAME_NANOS + SECOND_NANOS / 24 + runFrame(FIRST_FRAME_NANOS, MILLISECOND_NANOS, renderThreadReadyAtNanos = nextFrameNanos) + runBetweenFrames() + + // Half of a 30 Hz frame is 16.67 ms: items start at 0 to 16 ms. + assertThat(runFrame(nextFrameNanos, MILLISECOND_NANOS)).isEqualTo(17) + } + + @Test + fun aFrameThatLeftTimeBeforeTheNextVsyncLeavesTheNextTraversalAlone() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 60) + + // The UI thread is off the CPU for 3 ms after the callbacks, preempted or waiting on a binder + // call rather than on the render thread, and the frame still leaves time before the next vsync. + runFrame( + FIRST_FRAME_NANOS, + frameWorkNanos = 0L, + renderThreadReadyAtNanos = FIRST_FRAME_NANOS + 3 * MILLISECOND_NANOS, + ) + runBetweenFrames() + + assertThat(runFrame(FIRST_FRAME_NANOS + SIXTY_HERTZ_FRAME_NANOS, MILLISECOND_NANOS)).isZero() + } + + @Test + fun cancellingStopsEverySliceStillQueued() { + queuePreMountItems(count = 20) + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + shadowOf(Looper.getMainLooper()).runOneTask() + val executedBeforeCancelling = executedItems + + preMountItemsBetweenFrames.cancel() + + assertThat(runBetweenFrames()).isZero() + assertThat(executedBeforeCancelling).isEqualTo(2) + } + + @Test + fun aFrameWithItemsLeftAsksForTheNextFrameInterval() { + var requests = 0 + preMountItemsBetweenFrames = + PreMountItemsBetweenFrames( + dispatcher, + object : PreMountItemsBetweenFrames.FrameIntervalSource { + override fun frameIntervalNanos(): Long = SIXTY_HERTZ_FRAME_NANOS + + override fun requestNextFrameInterval() { + requests++ + } + }, + { threadCpuNanos }, + { isMountingAllowed }, + uiThreadHandler, + { exception -> handledExceptions.add(exception) }, + ) + queuePreMountItems(count = 1) + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + runBetweenFrames() + runFrame(FIRST_FRAME_NANOS + SIXTY_HERTZ_FRAME_NANOS, frameWorkNanos = 0L) + + // The frame after the last item asks the source for nothing. + assertThat(requests).isEqualTo(1) + } + + @Test + fun queuesNothingWithoutItemsToPreMount() { + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + + assertThat(shadowOf(Looper.getMainLooper()).isIdle).isTrue() + } + + @Test + fun aFailingItemReachesTheExceptionHandlerAndEndsTheSlices() { + val failure = IllegalStateException("pre-mount failed") + queuePreMountItems(count = 20) { index -> + if (index == 3) { + throw failure + } + } + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 0L) + runBetweenFrames() + + assertThat(handledExceptions).containsExactly(failure) + assertThat(executedItems).isEqualTo(3) + } + + private companion object { + const val SURFACE_ID: Int = 1 + const val MICROSECOND_NANOS: Long = 1_000L + const val MILLISECOND_NANOS: Long = 1_000_000L + const val SECOND_NANOS: Long = 1_000_000_000L + const val FIRST_FRAME_NANOS: Long = SECOND_NANOS + const val SIXTY_HERTZ_FRAME_NANOS: Long = SECOND_NANOS / 60 + const val ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS: Long = SECOND_NANOS / 120 + const val MAX_TASKS_BETWEEN_FRAMES: Int = 1_000 + } +} diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/VsyncFrameIntervalTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/VsyncFrameIntervalTest.kt new file mode 100644 index 000000000000..ded3b77b8f50 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/VsyncFrameIntervalTest.kt @@ -0,0 +1,130 @@ +/* + * 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.Choreographer +import com.facebook.react.common.SystemClock +import java.time.Duration +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.doReturn +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.LooperMode + +@RunWith(RobolectricTestRunner::class) +@LooperMode(LooperMode.Mode.PAUSED) +class VsyncFrameIntervalTest { + private lateinit var systemClock: MockedStatic + private var nowNanos: Long = FRAME_TIME_NANOS + + @Before + fun setUp() { + systemClock = mockStatic(SystemClock::class.java) + systemClock.`when` { SystemClock.nanoTime() }.thenAnswer { nowNanos } + } + + @After + fun tearDown() { + systemClock.close() + } + + private fun frameDataPresentingAt( + vararg expectedPresentationTimesNanos: Long + ): Choreographer.FrameData { + val timelines = + expectedPresentationTimesNanos + .map { presentationTimeNanos -> + mock { + on { expectedPresentationTimeNanos } doReturn presentationTimeNanos + } + } + .toTypedArray() + return mock { + on { frameTimelines } doReturn timelines + on { frameTimeNanos } doReturn FRAME_TIME_NANOS + } + } + + @Test + fun aVsyncsFrameTimelinesAreSpacedByTheFrameInterval() { + assertThat( + VsyncFrameInterval.computeTimelineSpacingNanos( + FRAME_TIME_NANOS, + FRAME_TIME_NANOS + SIXTY_HERTZ_FRAME_NANOS, + ) + ) + .isEqualTo(SIXTY_HERTZ_FRAME_NANOS) + assertThat(VsyncFrameInterval.computeTimelineSpacingNanos(1L, 1L)).isZero() + } + + @Test + fun aVsyncsTimelineSpacingAnswersInsteadOfTheDisplaysInterval() { + // The display reports its 120 Hz mode while the app's frames run at 60 Hz. + val frameInterval = VsyncFrameInterval { ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS } + + frameInterval.onVsync(frameDataPresentingAt(0L, SIXTY_HERTZ_FRAME_NANOS)) + + assertThat(frameInterval.frameIntervalNanos()).isEqualTo(SIXTY_HERTZ_FRAME_NANOS) + } + + @Test + fun aSpacingMoreThanTwoFramesOldLeavesTheDisplaysInterval() { + val frameInterval = VsyncFrameInterval { ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS } + frameInterval.onVsync(frameDataPresentingAt(0L, SIXTY_HERTZ_FRAME_NANOS)) + + // The next burst starts after the frame callback stopped, when the rate may have changed. + nowNanos = FRAME_TIME_NANOS + 3 * SIXTY_HERTZ_FRAME_NANOS + + assertThat(frameInterval.frameIntervalNanos()).isEqualTo(ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS) + } + + @Test + fun aVsyncWithOneTimelineLeavesTheDisplaysInterval() { + val frameInterval = VsyncFrameInterval { ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS } + + frameInterval.onVsync(frameDataPresentingAt(0L)) + + assertThat(frameInterval.frameIntervalNanos()).isEqualTo(ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS) + } + + @Test + fun cancellingDropsTheTimelineSpacing() { + val frameInterval = VsyncFrameInterval { ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS } + frameInterval.onVsync(frameDataPresentingAt(0L, SIXTY_HERTZ_FRAME_NANOS)) + + frameInterval.cancel() + + assertThat(frameInterval.frameIntervalNanos()).isEqualTo(ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS) + } + + @Test + fun aRequestedVsyncWithOneTimelineLeavesTheDisplaysInterval() { + val frameInterval = VsyncFrameInterval { ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS } + + frameInterval.requestNextFrameInterval() + // Robolectric's Android 13 vsync carries a single frame timeline. + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(VSYNC_WAIT_MILLISECONDS)) + + assertThat(frameInterval.frameIntervalNanos()).isEqualTo(ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS) + } + + private companion object { + const val FRAME_TIME_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 VSYNC_WAIT_MILLISECONDS: Long = 50L + } +} diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp index c2457ce7afc5..2afc08466507 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp @@ -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<<7542508e6a3b0dd942557748186527b3>> + * @generated SignedSource<> */ /** @@ -218,6 +218,10 @@ bool ReactNativeFeatureFlags::enablePreallocatedPropsDiffOnInsertAndroid() { return getAccessor().enablePreallocatedPropsDiffOnInsertAndroid(); } +bool ReactNativeFeatureFlags::enablePremountItemsBetweenFramesAndroid() { + return getAccessor().enablePremountItemsBetweenFramesAndroid(); +} + bool ReactNativeFeatureFlags::enablePreparedTextLayout() { return getAccessor().enablePreparedTextLayout(); } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index 9668de64aaee..e8bbd1edea02 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h @@ -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<> */ /** @@ -281,6 +281,11 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool enablePreallocatedPropsDiffOnInsertAndroid(); + /** + * Pre-mount views on Android in the time each frame leaves free: after the frame, in short slices that stop before the next vsync, and, after a frame that left none, before the next traversal for as long as the last draw slept, or for half a frame once two frames in a row were busy for a whole frame, at most half a frame, instead of before every traversal for half of a 60 Hz frame. + */ + RN_EXPORT static bool enablePremountItemsBetweenFramesAndroid(); + /** * Enables caching text layout artifacts for later reuse */ diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp index 418d452984b9..7a31c3371037 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.cpp @@ -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<<3c7ff59afd1e9e05051e888b51a39ac0>> + * @generated SignedSource<<67c1937ba8e0ddea4cf366fbc2e70a05>> */ /** @@ -893,6 +893,24 @@ bool ReactNativeFeatureFlagsAccessor::enablePreallocatedPropsDiffOnInsertAndroid return flagValue.value(); } +bool ReactNativeFeatureFlagsAccessor::enablePremountItemsBetweenFramesAndroid() { + auto flagValue = enablePremountItemsBetweenFramesAndroid_.load(); + + if (!flagValue.has_value()) { + // This block is not exclusive but it is not necessary. + // If multiple threads try to initialize the feature flag, we would only + // be accessing the provider multiple times but the end state of this + // instance and the returned flag value would be the same. + + markFlagAsAccessed(48, "enablePremountItemsBetweenFramesAndroid"); + + flagValue = currentProvider_->enablePremountItemsBetweenFramesAndroid(); + enablePremountItemsBetweenFramesAndroid_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::enablePreparedTextLayout() { auto flagValue = enablePreparedTextLayout_.load(); @@ -902,7 +920,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePreparedTextLayout() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(48, "enablePreparedTextLayout"); + markFlagAsAccessed(49, "enablePreparedTextLayout"); flagValue = currentProvider_->enablePreparedTextLayout(); enablePreparedTextLayout_ = flagValue; @@ -920,7 +938,7 @@ bool ReactNativeFeatureFlagsAccessor::enablePropsUpdateReconciliationAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(49, "enablePropsUpdateReconciliationAndroid"); + markFlagAsAccessed(50, "enablePropsUpdateReconciliationAndroid"); flagValue = currentProvider_->enablePropsUpdateReconciliationAndroid(); enablePropsUpdateReconciliationAndroid_ = flagValue; @@ -938,7 +956,7 @@ bool ReactNativeFeatureFlagsAccessor::enableResizeObserverByDefault() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(50, "enableResizeObserverByDefault"); + markFlagAsAccessed(51, "enableResizeObserverByDefault"); flagValue = currentProvider_->enableResizeObserverByDefault(); enableResizeObserverByDefault_ = flagValue; @@ -956,7 +974,7 @@ bool ReactNativeFeatureFlagsAccessor::enableSwiftUIBasedFilters() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(51, "enableSwiftUIBasedFilters"); + markFlagAsAccessed(52, "enableSwiftUIBasedFilters"); flagValue = currentProvider_->enableSwiftUIBasedFilters(); enableSwiftUIBasedFilters_ = flagValue; @@ -974,7 +992,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewCulling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(52, "enableViewCulling"); + markFlagAsAccessed(53, "enableViewCulling"); flagValue = currentProvider_->enableViewCulling(); enableViewCulling_ = flagValue; @@ -992,7 +1010,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecycling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(53, "enableViewRecycling"); + markFlagAsAccessed(54, "enableViewRecycling"); flagValue = currentProvider_->enableViewRecycling(); enableViewRecycling_ = flagValue; @@ -1010,7 +1028,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForImage() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(54, "enableViewRecyclingForImage"); + markFlagAsAccessed(55, "enableViewRecyclingForImage"); flagValue = currentProvider_->enableViewRecyclingForImage(); enableViewRecyclingForImage_ = flagValue; @@ -1028,7 +1046,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForScrollView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(55, "enableViewRecyclingForScrollView"); + markFlagAsAccessed(56, "enableViewRecyclingForScrollView"); flagValue = currentProvider_->enableViewRecyclingForScrollView(); enableViewRecyclingForScrollView_ = flagValue; @@ -1046,7 +1064,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForText() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(56, "enableViewRecyclingForText"); + markFlagAsAccessed(57, "enableViewRecyclingForText"); flagValue = currentProvider_->enableViewRecyclingForText(); enableViewRecyclingForText_ = flagValue; @@ -1064,7 +1082,7 @@ bool ReactNativeFeatureFlagsAccessor::enableViewRecyclingForView() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(57, "enableViewRecyclingForView"); + markFlagAsAccessed(58, "enableViewRecyclingForView"); flagValue = currentProvider_->enableViewRecyclingForView(); enableViewRecyclingForView_ = flagValue; @@ -1082,7 +1100,7 @@ bool ReactNativeFeatureFlagsAccessor::enableVirtualViewContainerStateExperimenta // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(58, "enableVirtualViewContainerStateExperimental"); + markFlagAsAccessed(59, "enableVirtualViewContainerStateExperimental"); flagValue = currentProvider_->enableVirtualViewContainerStateExperimental(); enableVirtualViewContainerStateExperimental_ = flagValue; @@ -1100,7 +1118,7 @@ bool ReactNativeFeatureFlagsAccessor::fixBorderlessRippleAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(59, "fixBorderlessRippleAndroid"); + markFlagAsAccessed(60, "fixBorderlessRippleAndroid"); flagValue = currentProvider_->fixBorderlessRippleAndroid(); fixBorderlessRippleAndroid_ = flagValue; @@ -1118,7 +1136,7 @@ bool ReactNativeFeatureFlagsAccessor::fixMappingOfEventPrioritiesBetweenFabricAn // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(60, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); + markFlagAsAccessed(61, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); flagValue = currentProvider_->fixMappingOfEventPrioritiesBetweenFabricAndReact(); fixMappingOfEventPrioritiesBetweenFabricAndReact_ = flagValue; @@ -1136,7 +1154,7 @@ bool ReactNativeFeatureFlagsAccessor::fixTextLayoutOnPixelDensityChange() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(61, "fixTextLayoutOnPixelDensityChange"); + markFlagAsAccessed(62, "fixTextLayoutOnPixelDensityChange"); flagValue = currentProvider_->fixTextLayoutOnPixelDensityChange(); fixTextLayoutOnPixelDensityChange_ = flagValue; @@ -1154,7 +1172,7 @@ bool ReactNativeFeatureFlagsAccessor::fixYogaFlexBasisFitContentInMainAxis() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(62, "fixYogaFlexBasisFitContentInMainAxis"); + markFlagAsAccessed(63, "fixYogaFlexBasisFitContentInMainAxis"); flagValue = currentProvider_->fixYogaFlexBasisFitContentInMainAxis(); fixYogaFlexBasisFitContentInMainAxis_ = flagValue; @@ -1172,7 +1190,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxAssertSingleHostState() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(63, "fuseboxAssertSingleHostState"); + markFlagAsAccessed(64, "fuseboxAssertSingleHostState"); flagValue = currentProvider_->fuseboxAssertSingleHostState(); fuseboxAssertSingleHostState_ = flagValue; @@ -1190,7 +1208,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxEnabledRelease() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(64, "fuseboxEnabledRelease"); + markFlagAsAccessed(65, "fuseboxEnabledRelease"); flagValue = currentProvider_->fuseboxEnabledRelease(); fuseboxEnabledRelease_ = flagValue; @@ -1208,7 +1226,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxFrameRecordingEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(65, "fuseboxFrameRecordingEnabled"); + markFlagAsAccessed(66, "fuseboxFrameRecordingEnabled"); flagValue = currentProvider_->fuseboxFrameRecordingEnabled(); fuseboxFrameRecordingEnabled_ = flagValue; @@ -1226,7 +1244,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxScreenshotCaptureEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(66, "fuseboxScreenshotCaptureEnabled"); + markFlagAsAccessed(67, "fuseboxScreenshotCaptureEnabled"); flagValue = currentProvider_->fuseboxScreenshotCaptureEnabled(); fuseboxScreenshotCaptureEnabled_ = flagValue; @@ -1244,7 +1262,7 @@ bool ReactNativeFeatureFlagsAccessor::fuseboxWebSocketEventsEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(67, "fuseboxWebSocketEventsEnabled"); + markFlagAsAccessed(68, "fuseboxWebSocketEventsEnabled"); flagValue = currentProvider_->fuseboxWebSocketEventsEnabled(); fuseboxWebSocketEventsEnabled_ = flagValue; @@ -1262,7 +1280,7 @@ bool ReactNativeFeatureFlagsAccessor::optimizedAnimatedPropUpdates() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(68, "optimizedAnimatedPropUpdates"); + markFlagAsAccessed(69, "optimizedAnimatedPropUpdates"); flagValue = currentProvider_->optimizedAnimatedPropUpdates(); optimizedAnimatedPropUpdates_ = flagValue; @@ -1280,7 +1298,7 @@ bool ReactNativeFeatureFlagsAccessor::overrideBySynchronousMountPropsAtMountingA // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(69, "overrideBySynchronousMountPropsAtMountingAndroid"); + markFlagAsAccessed(70, "overrideBySynchronousMountPropsAtMountingAndroid"); flagValue = currentProvider_->overrideBySynchronousMountPropsAtMountingAndroid(); overrideBySynchronousMountPropsAtMountingAndroid_ = flagValue; @@ -1298,7 +1316,7 @@ bool ReactNativeFeatureFlagsAccessor::perfIssuesEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(70, "perfIssuesEnabled"); + markFlagAsAccessed(71, "perfIssuesEnabled"); flagValue = currentProvider_->perfIssuesEnabled(); perfIssuesEnabled_ = flagValue; @@ -1316,7 +1334,7 @@ bool ReactNativeFeatureFlagsAccessor::perfMonitorV2Enabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(71, "perfMonitorV2Enabled"); + markFlagAsAccessed(72, "perfMonitorV2Enabled"); flagValue = currentProvider_->perfMonitorV2Enabled(); perfMonitorV2Enabled_ = flagValue; @@ -1334,7 +1352,7 @@ double ReactNativeFeatureFlagsAccessor::preparedTextCacheSize() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(72, "preparedTextCacheSize"); + markFlagAsAccessed(73, "preparedTextCacheSize"); flagValue = currentProvider_->preparedTextCacheSize(); preparedTextCacheSize_ = flagValue; @@ -1352,7 +1370,7 @@ bool ReactNativeFeatureFlagsAccessor::preventShadowTreeCommitExhaustion() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(73, "preventShadowTreeCommitExhaustion"); + markFlagAsAccessed(74, "preventShadowTreeCommitExhaustion"); flagValue = currentProvider_->preventShadowTreeCommitExhaustion(); preventShadowTreeCommitExhaustion_ = flagValue; @@ -1370,7 +1388,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2Android() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(74, "redBoxV2Android"); + markFlagAsAccessed(75, "redBoxV2Android"); flagValue = currentProvider_->redBoxV2Android(); redBoxV2Android_ = flagValue; @@ -1388,7 +1406,7 @@ bool ReactNativeFeatureFlagsAccessor::redBoxV2IOS() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(75, "redBoxV2IOS"); + markFlagAsAccessed(76, "redBoxV2IOS"); flagValue = currentProvider_->redBoxV2IOS(); redBoxV2IOS_ = flagValue; @@ -1406,7 +1424,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldPressibilityUseW3CPointerEventsForHo // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(76, "shouldPressibilityUseW3CPointerEventsForHover"); + markFlagAsAccessed(77, "shouldPressibilityUseW3CPointerEventsForHover"); flagValue = currentProvider_->shouldPressibilityUseW3CPointerEventsForHover(); shouldPressibilityUseW3CPointerEventsForHover_ = flagValue; @@ -1424,7 +1442,7 @@ bool ReactNativeFeatureFlagsAccessor::shouldTriggerResponderTransferOnScrollAndr // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(77, "shouldTriggerResponderTransferOnScrollAndroid"); + markFlagAsAccessed(78, "shouldTriggerResponderTransferOnScrollAndroid"); flagValue = currentProvider_->shouldTriggerResponderTransferOnScrollAndroid(); shouldTriggerResponderTransferOnScrollAndroid_ = flagValue; @@ -1442,7 +1460,7 @@ bool ReactNativeFeatureFlagsAccessor::skipActivityIdentityAssertionOnHostPause() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(78, "skipActivityIdentityAssertionOnHostPause"); + markFlagAsAccessed(79, "skipActivityIdentityAssertionOnHostPause"); flagValue = currentProvider_->skipActivityIdentityAssertionOnHostPause(); skipActivityIdentityAssertionOnHostPause_ = flagValue; @@ -1460,7 +1478,7 @@ bool ReactNativeFeatureFlagsAccessor::skipUnchangedChildrenInDifferentiator() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(79, "skipUnchangedChildrenInDifferentiator"); + markFlagAsAccessed(80, "skipUnchangedChildrenInDifferentiator"); flagValue = currentProvider_->skipUnchangedChildrenInDifferentiator(); skipUnchangedChildrenInDifferentiator_ = flagValue; @@ -1478,7 +1496,7 @@ bool ReactNativeFeatureFlagsAccessor::syncAndroidClipBoundsWithOverflow() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(80, "syncAndroidClipBoundsWithOverflow"); + markFlagAsAccessed(81, "syncAndroidClipBoundsWithOverflow"); flagValue = currentProvider_->syncAndroidClipBoundsWithOverflow(); syncAndroidClipBoundsWithOverflow_ = flagValue; @@ -1496,7 +1514,7 @@ bool ReactNativeFeatureFlagsAccessor::traceTurboModulePromiseRejectionsOnAndroid // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(81, "traceTurboModulePromiseRejectionsOnAndroid"); + markFlagAsAccessed(82, "traceTurboModulePromiseRejectionsOnAndroid"); flagValue = currentProvider_->traceTurboModulePromiseRejectionsOnAndroid(); traceTurboModulePromiseRejectionsOnAndroid_ = flagValue; @@ -1514,7 +1532,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommit( // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(82, "updateRuntimeShadowNodeReferencesOnCommit"); + markFlagAsAccessed(83, "updateRuntimeShadowNodeReferencesOnCommit"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommit(); updateRuntimeShadowNodeReferencesOnCommit_ = flagValue; @@ -1532,7 +1550,7 @@ bool ReactNativeFeatureFlagsAccessor::updateRuntimeShadowNodeReferencesOnCommitT // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(83, "updateRuntimeShadowNodeReferencesOnCommitThread"); + markFlagAsAccessed(84, "updateRuntimeShadowNodeReferencesOnCommitThread"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommitThread(); updateRuntimeShadowNodeReferencesOnCommitThread_ = flagValue; @@ -1550,7 +1568,7 @@ bool ReactNativeFeatureFlagsAccessor::useAlwaysAvailableJSErrorHandling() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(84, "useAlwaysAvailableJSErrorHandling"); + markFlagAsAccessed(85, "useAlwaysAvailableJSErrorHandling"); flagValue = currentProvider_->useAlwaysAvailableJSErrorHandling(); useAlwaysAvailableJSErrorHandling_ = flagValue; @@ -1568,7 +1586,7 @@ bool ReactNativeFeatureFlagsAccessor::useFabricInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(85, "useFabricInterop"); + markFlagAsAccessed(86, "useFabricInterop"); flagValue = currentProvider_->useFabricInterop(); useFabricInterop_ = flagValue; @@ -1586,7 +1604,7 @@ bool ReactNativeFeatureFlagsAccessor::useNativeViewConfigsInBridgelessMode() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(86, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(87, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1604,7 +1622,7 @@ bool ReactNativeFeatureFlagsAccessor::useNestedScrollViewAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(87, "useNestedScrollViewAndroid"); + markFlagAsAccessed(88, "useNestedScrollViewAndroid"); flagValue = currentProvider_->useNestedScrollViewAndroid(); useNestedScrollViewAndroid_ = flagValue; @@ -1622,7 +1640,7 @@ bool ReactNativeFeatureFlagsAccessor::useSharedAnimatedBackend() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(88, "useSharedAnimatedBackend"); + markFlagAsAccessed(89, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1640,7 +1658,7 @@ bool ReactNativeFeatureFlagsAccessor::useTraitHiddenOnAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(89, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(90, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1658,7 +1676,7 @@ bool ReactNativeFeatureFlagsAccessor::useTurboModuleInterop() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(90, "useTurboModuleInterop"); + markFlagAsAccessed(91, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1676,7 +1694,7 @@ bool ReactNativeFeatureFlagsAccessor::useWindowDensityForDisplayMetricsAndroid() // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(91, "useWindowDensityForDisplayMetricsAndroid"); + markFlagAsAccessed(92, "useWindowDensityForDisplayMetricsAndroid"); flagValue = currentProvider_->useWindowDensityForDisplayMetricsAndroid(); useWindowDensityForDisplayMetricsAndroid_ = flagValue; @@ -1694,7 +1712,7 @@ double ReactNativeFeatureFlagsAccessor::viewCullingOutsetRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(92, "viewCullingOutsetRatio"); + markFlagAsAccessed(93, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1712,7 +1730,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionEnabled() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(93, "viewTransitionEnabled"); + markFlagAsAccessed(94, "viewTransitionEnabled"); flagValue = currentProvider_->viewTransitionEnabled(); viewTransitionEnabled_ = flagValue; @@ -1730,7 +1748,7 @@ bool ReactNativeFeatureFlagsAccessor::viewTransitionUseHardwareBitmapAndroid() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(94, "viewTransitionUseHardwareBitmapAndroid"); + markFlagAsAccessed(95, "viewTransitionUseHardwareBitmapAndroid"); flagValue = currentProvider_->viewTransitionUseHardwareBitmapAndroid(); viewTransitionUseHardwareBitmapAndroid_ = flagValue; @@ -1748,7 +1766,7 @@ double ReactNativeFeatureFlagsAccessor::virtualViewPrerenderRatio() { // be accessing the provider multiple times but the end state of this // instance and the returned flag value would be the same. - markFlagAsAccessed(95, "virtualViewPrerenderRatio"); + markFlagAsAccessed(96, "virtualViewPrerenderRatio"); flagValue = currentProvider_->virtualViewPrerenderRatio(); virtualViewPrerenderRatio_ = flagValue; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h index a6ba9a46d70f..1cc7e39697f8 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsAccessor.h @@ -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<<0b7f6d7766d1e038b3909bb7ea634f83>> + * @generated SignedSource<<0527d039a13b350c1224427a20d60dae>> */ /** @@ -82,6 +82,7 @@ class ReactNativeFeatureFlagsAccessor { bool enableMutationObserverByDefault(); bool enableNativeCSSParsing(); bool enablePreallocatedPropsDiffOnInsertAndroid(); + bool enablePremountItemsBetweenFramesAndroid(); bool enablePreparedTextLayout(); bool enablePropsUpdateReconciliationAndroid(); bool enableResizeObserverByDefault(); @@ -141,7 +142,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 96> accessedFeatureFlags_; + std::array, 97> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -191,6 +192,7 @@ class ReactNativeFeatureFlagsAccessor { std::atomic> enableMutationObserverByDefault_; std::atomic> enableNativeCSSParsing_; std::atomic> enablePreallocatedPropsDiffOnInsertAndroid_; + std::atomic> enablePremountItemsBetweenFramesAndroid_; std::atomic> enablePreparedTextLayout_; std::atomic> enablePropsUpdateReconciliationAndroid_; std::atomic> enableResizeObserverByDefault_; diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h index 76bb504681e3..e73416a53977 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDefaults.h @@ -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<<46e48274f8b27b9c6136b13048682252>> + * @generated SignedSource<> */ /** @@ -221,6 +221,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return false; } + bool enablePremountItemsBetweenFramesAndroid() override { + return false; + } + bool enablePreparedTextLayout() override { return true; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index bda4465a9160..ef99e5f65656 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h @@ -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<<583b5df6e0b5482bda7adbe81829f484>> */ /** @@ -479,6 +479,15 @@ class ReactNativeFeatureFlagsDynamicProvider : public ReactNativeFeatureFlagsDef return ReactNativeFeatureFlagsDefaults::enablePreallocatedPropsDiffOnInsertAndroid(); } + bool enablePremountItemsBetweenFramesAndroid() override { + auto value = values_["enablePremountItemsBetweenFramesAndroid"]; + if (!value.isNull()) { + return value.getBool(); + } + + return ReactNativeFeatureFlagsDefaults::enablePremountItemsBetweenFramesAndroid(); + } + bool enablePreparedTextLayout() override { auto value = values_["enablePreparedTextLayout"]; if (!value.isNull()) { diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h index bc1459bef441..536758cb3815 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsProvider.h @@ -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<<7626d25a1450564a30de026870f4d094>> + * @generated SignedSource<<0cd52302c93504cab33e68532ca0fda4>> */ /** @@ -75,6 +75,7 @@ class ReactNativeFeatureFlagsProvider { virtual bool enableMutationObserverByDefault() = 0; virtual bool enableNativeCSSParsing() = 0; virtual bool enablePreallocatedPropsDiffOnInsertAndroid() = 0; + virtual bool enablePremountItemsBetweenFramesAndroid() = 0; virtual bool enablePreparedTextLayout() = 0; virtual bool enablePropsUpdateReconciliationAndroid() = 0; virtual bool enableResizeObserverByDefault() = 0; diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp index 9d8c414b2686..467d2eba1497 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.cpp @@ -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<> */ /** @@ -284,6 +284,11 @@ bool NativeReactNativeFeatureFlags::enablePreallocatedPropsDiffOnInsertAndroid( return ReactNativeFeatureFlags::enablePreallocatedPropsDiffOnInsertAndroid(); } +bool NativeReactNativeFeatureFlags::enablePremountItemsBetweenFramesAndroid( + jsi::Runtime& /*runtime*/) { + return ReactNativeFeatureFlags::enablePremountItemsBetweenFramesAndroid(); +} + bool NativeReactNativeFeatureFlags::enablePreparedTextLayout( jsi::Runtime& /*runtime*/) { return ReactNativeFeatureFlags::enablePreparedTextLayout(); diff --git a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h index 4014c7ac6551..2e98402ed12d 100644 --- a/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/nativemodule/featureflags/NativeReactNativeFeatureFlags.h @@ -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<<4d3151d0ce9a022c5b9b74e2a910229d>> + * @generated SignedSource<<1ec0ddae334df3e531301400fadf52f0>> */ /** @@ -134,6 +134,8 @@ class NativeReactNativeFeatureFlags bool enablePreallocatedPropsDiffOnInsertAndroid(jsi::Runtime& runtime); + bool enablePremountItemsBetweenFramesAndroid(jsi::Runtime& runtime); + bool enablePreparedTextLayout(jsi::Runtime& runtime); bool enablePropsUpdateReconciliationAndroid(jsi::Runtime& runtime); diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index ac6c41e0a8e3..8ca40349d065 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -566,6 +566,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + enablePremountItemsBetweenFramesAndroid: { + defaultValue: false, + metadata: { + dateAdded: '2026-10-20', + description: + 'Pre-mount views on Android in the time each frame leaves free: after the frame, in short slices that stop before the next vsync, and, after a frame that left none, before the next traversal for as long as the last draw slept, or for half a frame once two frames in a row were busy for a whole frame, at most half a frame, instead of before every traversal for half of a 60 Hz frame.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, enablePreparedTextLayout: { defaultValue: true, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index c1c6f65c21d1..8d457de36f45 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<<5a79107fda7ef89a1d68594cad01b8e7>> + * @generated SignedSource<<2ceb59c18ae2c4fdc5e8c84b59e8c650>> * @flow strict * @noformat */ @@ -97,6 +97,7 @@ export type ReactNativeFeatureFlags = Readonly<{ enableMutationObserverByDefault: Getter, enableNativeCSSParsing: Getter, enablePreallocatedPropsDiffOnInsertAndroid: Getter, + enablePremountItemsBetweenFramesAndroid: Getter, enablePreparedTextLayout: Getter, enablePropsUpdateReconciliationAndroid: Getter, enableResizeObserverByDefault: Getter, @@ -413,6 +414,10 @@ export const enableNativeCSSParsing: Getter = createNativeFlagGetter('e * When enabled together with `enableAccumulatedUpdatesInRawPropsAndroid`, the Insert of a preallocated view only sends the difference between the props the view was preallocated with and the inserted props, instead of sending all props again on every Insert. */ export const enablePreallocatedPropsDiffOnInsertAndroid: Getter = createNativeFlagGetter('enablePreallocatedPropsDiffOnInsertAndroid', false); +/** + * Pre-mount views on Android in the time each frame leaves free: after the frame, in short slices that stop before the next vsync, and, after a frame that left none, before the next traversal for as long as the last draw slept, or for half a frame once two frames in a row were busy for a whole frame, at most half a frame, instead of before every traversal for half of a 60 Hz frame. + */ +export const enablePremountItemsBetweenFramesAndroid: Getter = createNativeFlagGetter('enablePremountItemsBetweenFramesAndroid', false); /** * Enables caching text layout artifacts for later reuse */ diff --git a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js index 48355705857f..b0107309fdaf 100644 --- a/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/specs/NativeReactNativeFeatureFlags.js @@ -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<<0bea15ac190431b83ffe8088f6e63174>> * @flow strict * @noformat */ @@ -73,6 +73,7 @@ export interface Spec extends TurboModule { readonly enableMutationObserverByDefault?: () => boolean; readonly enableNativeCSSParsing?: () => boolean; readonly enablePreallocatedPropsDiffOnInsertAndroid?: () => boolean; + readonly enablePremountItemsBetweenFramesAndroid?: () => boolean; readonly enablePreparedTextLayout?: () => boolean; readonly enablePropsUpdateReconciliationAndroid?: () => boolean; readonly enableResizeObserverByDefault?: () => boolean;