From 422e8fdd81b381a2992a933f7e77b18d073733cb Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 15:49:26 +0300 Subject: [PATCH 1/9] Pre-mount views between frames on Android behind a flag With enablePremountItemsBetweenFramesAndroid on, the DISPATCH_UI frame callback no longer pre-mounts views before the frame's traversal for half of a 60 Hz frame. PreMountItemsBetweenFrames queues that work for after the frame and runs it in slices of at most 1 ms posted to the UI thread's looper, stopping at the next vsync: the frame's time plus the default display's frame interval (DisplayFrameInterval), or a 60 Hz frame while the display reports no rate. A frame, an input event or any other message waits for one slice at most, so a stale frame interval delays a frame by one slice and one item at most. A commit still runs every pre-mount item left before its mount items, and a failing slice stops mounting the way a failing frame callback does. --- .../react/fabric/FabricUIManager.java | 38 ++- .../fabric/mounting/DisplayFrameInterval.kt | 55 ++++ .../fabric/mounting/MountItemDispatcher.kt | 21 +- .../mounting/PreMountItemsBetweenFrames.kt | 91 ++++++ .../featureflags/ReactNativeFeatureFlags.kt | 8 +- .../ReactNativeFeatureFlagsCxxAccessor.kt | 12 +- .../ReactNativeFeatureFlagsCxxInterop.kt | 4 +- .../ReactNativeFeatureFlagsDefaults.kt | 4 +- .../ReactNativeFeatureFlagsLocalAccessor.kt | 13 +- .../ReactNativeFeatureFlagsProvider.kt | 4 +- .../JReactNativeFeatureFlagsCxxInterop.cpp | 16 +- .../JReactNativeFeatureFlagsCxxInterop.h | 5 +- .../mounting/DisplayFrameIntervalTest.kt | 103 +++++++ .../mounting/MountItemDispatcherTest.kt | 112 ++++++++ .../PreMountItemsBetweenFramesTest.kt | 265 ++++++++++++++++++ .../featureflags/ReactNativeFeatureFlags.cpp | 6 +- .../featureflags/ReactNativeFeatureFlags.h | 7 +- .../ReactNativeFeatureFlagsAccessor.cpp | 114 ++++---- .../ReactNativeFeatureFlagsAccessor.h | 6 +- .../ReactNativeFeatureFlagsDefaults.h | 6 +- .../ReactNativeFeatureFlagsDynamicProvider.h | 11 +- .../ReactNativeFeatureFlagsProvider.h | 3 +- .../NativeReactNativeFeatureFlags.cpp | 7 +- .../NativeReactNativeFeatureFlags.h | 4 +- .../ReactNativeFeatureFlags.config.js | 11 + .../featureflags/ReactNativeFeatureFlags.js | 7 +- .../specs/NativeReactNativeFeatureFlags.js | 3 +- 27 files changed, 861 insertions(+), 75 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt create mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/PreMountItemsBetweenFrames.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/MountItemDispatcherTest.kt create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/PreMountItemsBetweenFramesTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index ed0cbd42874c..34d92d5791a7 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -58,8 +58,10 @@ import com.facebook.react.fabric.internal.interop.InteropUIBlockListener; import com.facebook.react.fabric.interop.UIBlock; import com.facebook.react.fabric.interop.UIBlockViewResolver; +import com.facebook.react.fabric.mounting.DisplayFrameInterval; import com.facebook.react.fabric.mounting.MountItemDispatcher; import com.facebook.react.fabric.mounting.MountingManager; +import com.facebook.react.fabric.mounting.PreMountItemsBetweenFrames; import com.facebook.react.fabric.mounting.SurfaceMountingManager; import com.facebook.react.fabric.mounting.mountitems.BatchMountItem; import com.facebook.react.fabric.mounting.mountitems.BatchedAnimatedPropsMountItem; @@ -183,6 +185,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(); @@ -254,6 +258,13 @@ public FabricUIManager( mMountingManager = new MountingManager(viewManagerRegistry, mMountItemExecutor); mMountItemDispatcher = new MountItemDispatcher(mMountingManager, new MountItemDispatchListener()); + mDisplayFrameInterval = new DisplayFrameInterval(reactContext); + mPreMountItemsBetweenFrames = + new PreMountItemsBetweenFrames( + mMountItemDispatcher, + mDisplayFrameInterval, + UiThreadUtil.getUiThreadHandler(), + mDispatchUIFrameCallback::handlePreMountException); mEventDispatcher = new FabricEventDispatcher(reactContext, new FabricEventEmitter(this)); mBatchEventDispatchedListener = batchEventDispatchedListener; mReactApplicationContext.addLifecycleEventListener(this); @@ -459,6 +470,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); @@ -494,6 +508,7 @@ public void invalidate() { mEventDispatcher.removeBatchEventDispatchedListener(mBatchEventDispatchedListener); mEventDispatcher.invalidate(); + mDisplayFrameInterval.stop(); mReactApplicationContext.unregisterComponentCallbacks(mViewManagerRegistry); mViewManagerRegistry.invalidate(); @@ -1676,10 +1691,19 @@ void resume() { void pause() { ReactChoreographer.getInstance() .removeFrameCallback(ReactChoreographer.CallbackType.DISPATCH_UI, this); + mPreMountItemsBetweenFrames.cancel(); mShouldSchedule = false; mIsScheduled = false; } + @UiThread + @ThreadConfined(UI) + void handlePreMountException(Exception exception) { + FLog.e(TAG, "Exception thrown when pre-mounting between frames", exception); + mIsMountingEnabled = false; + mReactApplicationContext.getExceptionHandler().handleException(exception); + } + @Override @UiThread @ThreadConfined(UI) @@ -1719,12 +1743,14 @@ 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); + // Pre-mount items run either here, for half of a 60 Hz frame, or after this frame's + // traversal, until the next vsync. Whatever is left when view commands or mount items + // arrive runs before them in tryDispatchMountItems; otherwise it waits for a later frame. + if (ReactNativeFeatureFlags.enablePremountItemsBetweenFramesAndroid()) { + mPreMountItemsBetweenFrames.scheduleAfterFrame(frameTimeNanos); + } else { + mMountItemDispatcher.dispatchPreMountItems(frameTimeNanos); + } mMountItemDispatcher.tryDispatchMountItems(); } catch (Exception ex) { FLog.e(TAG, "Exception thrown when executing UIFrameGuarded", ex); 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..7692e8f11b9c --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/DisplayFrameInterval.kt @@ -0,0 +1,55 @@ +/* + * 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() { + // From Android 16 a listener gets refresh-rate changes only once the process has read the rate. + frameIntervalNanos = readFrameIntervalNanos() + displayManager?.registerDisplayListener(this, UiThreadUtil.getUiThreadHandler()) + } + + 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/MountItemDispatcher.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt index 72efd7c42058..80b7ff34e77e 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 @@ -340,6 +340,23 @@ internal class MountItemDispatcher( dispatchPreMountItemsImpl(deadline) } + 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()) { + return false + } + dispatchPreMountItemsImpl(deadlineNanos) + return !preMountItems.isEmpty() + } + private fun dispatchPreMountItemsImpl(deadline: Long) { Systrace.beginSection(Systrace.TRACE_TAG_REACT, "MountItemDispatcher::premountViews") @@ -355,7 +372,7 @@ internal class MountItemDispatcher( try { while (true) { - if (System.nanoTime() > deadline) { + if (SystemClock.nanoTime() > deadline) { break } 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..a3dc409f8683 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/PreMountItemsBetweenFrames.kt @@ -0,0 +1,91 @@ +/* + * 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 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 between frames: once a frame's callbacks have run, it executes pre-mount items + * in slices posted to the UI thread's looper, so the next frame, an input event or any other + * message waits for one slice at most, 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. + */ +internal class PreMountItemsBetweenFrames( + private val dispatcher: MountItemDispatcher, + private val frameIntervalSource: FrameIntervalSource, + private val uiThreadHandler: Handler, + exceptionHandler: JSExceptionHandler, +) { + private var nextFrameNanos: Long = 0L + private var isSliceQueued: Boolean = false + private val slice: Runnable = + object : GuardedRunnable(exceptionHandler) { + override fun runGuarded() { + isSliceQueued = false + runSlice() + } + } + + /** Queues pre-mounting after the frame that started at [frameTimeNanos], until the next one. */ + @UiThread + @ThreadConfined(UI) + fun scheduleAfterFrame(frameTimeNanos: Long) { + nextFrameNanos = + frameTimeNanos + (frameIntervalSource.frameIntervalNanos() ?: SIXTY_HERTZ_FRAME_NANOS) + if (dispatcher.hasPreMountItems()) { + queueSlice() + } + } + + @UiThread + @ThreadConfined(UI) + fun cancel() { + uiThreadHandler.removeCallbacks(slice) + isSliceQueued = false + } + + private fun runSlice() { + val sliceStartNanos = SystemClock.nanoTime() + if (sliceStartNanos >= nextFrameNanos) { + return + } + val itemsLeft = + dispatcher.dispatchPreMountItemsUntil(minOf(nextFrameNanos, sliceStartNanos + SLICE_NANOS)) + if (itemsLeft && SystemClock.nanoTime() < nextFrameNanos) { + queueSlice() + } + } + + private fun queueSlice() { + if (!isSliceQueued) { + isSliceQueued = true + uiThreadHandler.post(slice) + } + } + + fun interface FrameIntervalSource { + /** The display's current frame interval, or null while the display reports no refresh rate. */ + fun frameIntervalNanos(): Long? + } + + 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 display's reported frame interval is longer than the one it runs at. + */ + private const val SLICE_NANOS: Long = 1_000_000L + + private const val SIXTY_HERTZ_FRAME_NANOS: Long = 1_000_000_000L / 60 + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/internal/featureflags/ReactNativeFeatureFlags.kt index 0c879d6415f9..1ecb73f2a056 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<> */ /** @@ -312,6 +312,12 @@ public object ReactNativeFeatureFlags { @JvmStatic public fun enablePreallocatedPropsDiffOnInsertAndroid(): Boolean = accessor.enablePreallocatedPropsDiffOnInsertAndroid() + /** + * Pre-mount views on Android after each frame is drawn, in short slices that stop before the next vsync, instead of before the frame is drawn 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 28329721fab8..1857b35d465f 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<<71d0554dc45df7d5365f81b7ec8c3d5b>> + * @generated SignedSource<> */ /** @@ -67,6 +67,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 @@ -538,6 +539,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 6f83f1cc3dbd..f2ed7acdb6c1 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<<312cec99bd429d22e25f4e82d7fc4ee7>> + * @generated SignedSource<> */ /** @@ -122,6 +122,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 f4998cb8b416..1917a78e78a0 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<<669f16e131d4a5ddc026ffecc6893575>> + * @generated SignedSource<<95a84edf16347b2ade3be059c61f1a1e>> */ /** @@ -117,6 +117,8 @@ public open class ReactNativeFeatureFlagsDefaults : ReactNativeFeatureFlagsProvi override fun enablePreallocatedPropsDiffOnInsertAndroid(): Boolean = false + override fun enablePremountItemsBetweenFramesAndroid(): Boolean = false + override fun enablePreparedTextLayout(): Boolean = false 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 536ee78905d7..3a0ae2a92778 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<<3dcf9c930ec0b62368bd3d0c5a94ad57>> */ /** @@ -71,6 +71,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 @@ -589,6 +590,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 0143013e3ccd..b38a361951c4 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<<18267103ef4f3eb22f23a15b808528c2>> + * @generated SignedSource<> */ /** @@ -117,6 +117,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 526398c18c11..440834558ae4 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<<568d6e4175fb51e09bc3d4441ffa9741>> + * @generated SignedSource<> */ /** @@ -321,6 +321,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"); @@ -842,6 +848,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(); @@ -1249,6 +1260,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 5f3fdda386cd..01c8f1794301 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<<2e0f936c003aafc456ab3f960ca4b2f3>> + * @generated SignedSource<<5b9c659a809f066fdd6c2f11e8d555a9>> */ /** @@ -171,6 +171,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/mounting/DisplayFrameIntervalTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt new file mode 100644 index 000000000000..53151e7886d6 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/DisplayFrameIntervalTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.fabric.mounting + +import android.os.Looper +import android.view.Display +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowDisplay +import org.robolectric.shadows.ShadowDisplayManager + +@RunWith(RobolectricTestRunner::class) +class DisplayFrameIntervalTest { + private val display: Display = ShadowDisplay.getDefaultDisplay() + + private fun setRefreshRate(refreshRate: Float) { + shadowOf(display).setRefreshRate(refreshRate) + ShadowDisplayManager.changeDisplay(Display.DEFAULT_DISPLAY, "") + shadowOf(Looper.getMainLooper()).idle() + } + + 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 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..3dea66501966 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/mounting/PreMountItemsBetweenFramesTest.kt @@ -0,0 +1,265 @@ +/* + * 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 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.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 executedItems: Int = 0 + private var displayFrameIntervalNanos: Long? = ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS + + @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()) + preMountItemsBetweenFrames = + PreMountItemsBetweenFrames( + dispatcher, + { displayFrameIntervalNanos }, + 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 + } + + 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. + */ + private fun runFrame(frameTimeNanos: Long, frameWorkNanos: Long): Int { + nowNanos = frameTimeNanos + preMountItemsBetweenFrames.scheduleAfterFrame(frameTimeNanos) + val executedInsideTheFrame = executedItems + nowNanos += frameWorkNanos + return executedInsideTheFrame + } + + /** Runs everything queued on the UI thread and returns how many items it executed. */ + private fun runBetweenFrames(): Int { + val before = executedItems + shadowOf(Looper.getMainLooper()).idle() + return executedItems - before + } + + @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) + } + + @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 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 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 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 + } +} diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.cpp index adc3531518ef..9d4459f1775a 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<> + * @generated SignedSource<<3c8e409cd51fb6ba668d412a732be3f9>> */ /** @@ -214,6 +214,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 cd4d40533d5f..33c5e304866d 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<<62dbb584d6aaf595314636a81346e3e5>> + * @generated SignedSource<<35accad3c60bf332b67e61529aab9d61>> */ /** @@ -276,6 +276,11 @@ class ReactNativeFeatureFlags { */ RN_EXPORT static bool enablePreallocatedPropsDiffOnInsertAndroid(); + /** + * Pre-mount views on Android after each frame is drawn, in short slices that stop before the next vsync, instead of before the frame is drawn 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 93ef1cc62ace..4bd795a8b0b7 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<> + * @generated SignedSource<> */ /** @@ -875,6 +875,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(47, "enablePremountItemsBetweenFramesAndroid"); + + flagValue = currentProvider_->enablePremountItemsBetweenFramesAndroid(); + enablePremountItemsBetweenFramesAndroid_ = flagValue; + } + + return flagValue.value(); +} + bool ReactNativeFeatureFlagsAccessor::enablePreparedTextLayout() { auto flagValue = enablePreparedTextLayout_.load(); @@ -884,7 +902,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(47, "enablePreparedTextLayout"); + markFlagAsAccessed(48, "enablePreparedTextLayout"); flagValue = currentProvider_->enablePreparedTextLayout(); enablePreparedTextLayout_ = flagValue; @@ -902,7 +920,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(48, "enablePropsUpdateReconciliationAndroid"); + markFlagAsAccessed(49, "enablePropsUpdateReconciliationAndroid"); flagValue = currentProvider_->enablePropsUpdateReconciliationAndroid(); enablePropsUpdateReconciliationAndroid_ = flagValue; @@ -920,7 +938,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(49, "enableResizeObserverByDefault"); + markFlagAsAccessed(50, "enableResizeObserverByDefault"); flagValue = currentProvider_->enableResizeObserverByDefault(); enableResizeObserverByDefault_ = flagValue; @@ -938,7 +956,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(50, "enableSwiftUIBasedFilters"); + markFlagAsAccessed(51, "enableSwiftUIBasedFilters"); flagValue = currentProvider_->enableSwiftUIBasedFilters(); enableSwiftUIBasedFilters_ = flagValue; @@ -956,7 +974,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(51, "enableViewCulling"); + markFlagAsAccessed(52, "enableViewCulling"); flagValue = currentProvider_->enableViewCulling(); enableViewCulling_ = flagValue; @@ -974,7 +992,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(52, "enableViewRecycling"); + markFlagAsAccessed(53, "enableViewRecycling"); flagValue = currentProvider_->enableViewRecycling(); enableViewRecycling_ = flagValue; @@ -992,7 +1010,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(53, "enableViewRecyclingForImage"); + markFlagAsAccessed(54, "enableViewRecyclingForImage"); flagValue = currentProvider_->enableViewRecyclingForImage(); enableViewRecyclingForImage_ = flagValue; @@ -1010,7 +1028,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(54, "enableViewRecyclingForScrollView"); + markFlagAsAccessed(55, "enableViewRecyclingForScrollView"); flagValue = currentProvider_->enableViewRecyclingForScrollView(); enableViewRecyclingForScrollView_ = flagValue; @@ -1028,7 +1046,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(55, "enableViewRecyclingForText"); + markFlagAsAccessed(56, "enableViewRecyclingForText"); flagValue = currentProvider_->enableViewRecyclingForText(); enableViewRecyclingForText_ = flagValue; @@ -1046,7 +1064,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(56, "enableViewRecyclingForView"); + markFlagAsAccessed(57, "enableViewRecyclingForView"); flagValue = currentProvider_->enableViewRecyclingForView(); enableViewRecyclingForView_ = flagValue; @@ -1064,7 +1082,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(57, "enableVirtualViewContainerStateExperimental"); + markFlagAsAccessed(58, "enableVirtualViewContainerStateExperimental"); flagValue = currentProvider_->enableVirtualViewContainerStateExperimental(); enableVirtualViewContainerStateExperimental_ = flagValue; @@ -1082,7 +1100,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(58, "fixBorderlessRippleAndroid"); + markFlagAsAccessed(59, "fixBorderlessRippleAndroid"); flagValue = currentProvider_->fixBorderlessRippleAndroid(); fixBorderlessRippleAndroid_ = flagValue; @@ -1100,7 +1118,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(59, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); + markFlagAsAccessed(60, "fixMappingOfEventPrioritiesBetweenFabricAndReact"); flagValue = currentProvider_->fixMappingOfEventPrioritiesBetweenFabricAndReact(); fixMappingOfEventPrioritiesBetweenFabricAndReact_ = flagValue; @@ -1118,7 +1136,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(60, "fixTextLayoutOnPixelDensityChange"); + markFlagAsAccessed(61, "fixTextLayoutOnPixelDensityChange"); flagValue = currentProvider_->fixTextLayoutOnPixelDensityChange(); fixTextLayoutOnPixelDensityChange_ = flagValue; @@ -1136,7 +1154,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(61, "fixYogaFlexBasisFitContentInMainAxis"); + markFlagAsAccessed(62, "fixYogaFlexBasisFitContentInMainAxis"); flagValue = currentProvider_->fixYogaFlexBasisFitContentInMainAxis(); fixYogaFlexBasisFitContentInMainAxis_ = flagValue; @@ -1154,7 +1172,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(62, "fuseboxAssertSingleHostState"); + markFlagAsAccessed(63, "fuseboxAssertSingleHostState"); flagValue = currentProvider_->fuseboxAssertSingleHostState(); fuseboxAssertSingleHostState_ = flagValue; @@ -1172,7 +1190,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(63, "fuseboxEnabledRelease"); + markFlagAsAccessed(64, "fuseboxEnabledRelease"); flagValue = currentProvider_->fuseboxEnabledRelease(); fuseboxEnabledRelease_ = flagValue; @@ -1190,7 +1208,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(64, "fuseboxFrameRecordingEnabled"); + markFlagAsAccessed(65, "fuseboxFrameRecordingEnabled"); flagValue = currentProvider_->fuseboxFrameRecordingEnabled(); fuseboxFrameRecordingEnabled_ = flagValue; @@ -1208,7 +1226,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(65, "fuseboxScreenshotCaptureEnabled"); + markFlagAsAccessed(66, "fuseboxScreenshotCaptureEnabled"); flagValue = currentProvider_->fuseboxScreenshotCaptureEnabled(); fuseboxScreenshotCaptureEnabled_ = flagValue; @@ -1226,7 +1244,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(66, "fuseboxWebSocketEventsEnabled"); + markFlagAsAccessed(67, "fuseboxWebSocketEventsEnabled"); flagValue = currentProvider_->fuseboxWebSocketEventsEnabled(); fuseboxWebSocketEventsEnabled_ = flagValue; @@ -1244,7 +1262,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(67, "optimizedAnimatedPropUpdates"); + markFlagAsAccessed(68, "optimizedAnimatedPropUpdates"); flagValue = currentProvider_->optimizedAnimatedPropUpdates(); optimizedAnimatedPropUpdates_ = flagValue; @@ -1262,7 +1280,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(68, "overrideBySynchronousMountPropsAtMountingAndroid"); + markFlagAsAccessed(69, "overrideBySynchronousMountPropsAtMountingAndroid"); flagValue = currentProvider_->overrideBySynchronousMountPropsAtMountingAndroid(); overrideBySynchronousMountPropsAtMountingAndroid_ = flagValue; @@ -1280,7 +1298,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(69, "perfIssuesEnabled"); + markFlagAsAccessed(70, "perfIssuesEnabled"); flagValue = currentProvider_->perfIssuesEnabled(); perfIssuesEnabled_ = flagValue; @@ -1298,7 +1316,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(70, "perfMonitorV2Enabled"); + markFlagAsAccessed(71, "perfMonitorV2Enabled"); flagValue = currentProvider_->perfMonitorV2Enabled(); perfMonitorV2Enabled_ = flagValue; @@ -1316,7 +1334,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(71, "preparedTextCacheSize"); + markFlagAsAccessed(72, "preparedTextCacheSize"); flagValue = currentProvider_->preparedTextCacheSize(); preparedTextCacheSize_ = flagValue; @@ -1334,7 +1352,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(72, "preventShadowTreeCommitExhaustion"); + markFlagAsAccessed(73, "preventShadowTreeCommitExhaustion"); flagValue = currentProvider_->preventShadowTreeCommitExhaustion(); preventShadowTreeCommitExhaustion_ = flagValue; @@ -1352,7 +1370,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(73, "redBoxV2Android"); + markFlagAsAccessed(74, "redBoxV2Android"); flagValue = currentProvider_->redBoxV2Android(); redBoxV2Android_ = flagValue; @@ -1370,7 +1388,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(74, "redBoxV2IOS"); + markFlagAsAccessed(75, "redBoxV2IOS"); flagValue = currentProvider_->redBoxV2IOS(); redBoxV2IOS_ = flagValue; @@ -1388,7 +1406,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(75, "shouldPressibilityUseW3CPointerEventsForHover"); + markFlagAsAccessed(76, "shouldPressibilityUseW3CPointerEventsForHover"); flagValue = currentProvider_->shouldPressibilityUseW3CPointerEventsForHover(); shouldPressibilityUseW3CPointerEventsForHover_ = flagValue; @@ -1406,7 +1424,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(76, "shouldTriggerResponderTransferOnScrollAndroid"); + markFlagAsAccessed(77, "shouldTriggerResponderTransferOnScrollAndroid"); flagValue = currentProvider_->shouldTriggerResponderTransferOnScrollAndroid(); shouldTriggerResponderTransferOnScrollAndroid_ = flagValue; @@ -1424,7 +1442,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(77, "skipActivityIdentityAssertionOnHostPause"); + markFlagAsAccessed(78, "skipActivityIdentityAssertionOnHostPause"); flagValue = currentProvider_->skipActivityIdentityAssertionOnHostPause(); skipActivityIdentityAssertionOnHostPause_ = flagValue; @@ -1442,7 +1460,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(78, "syncAndroidClipBoundsWithOverflow"); + markFlagAsAccessed(79, "syncAndroidClipBoundsWithOverflow"); flagValue = currentProvider_->syncAndroidClipBoundsWithOverflow(); syncAndroidClipBoundsWithOverflow_ = flagValue; @@ -1460,7 +1478,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(79, "traceTurboModulePromiseRejectionsOnAndroid"); + markFlagAsAccessed(80, "traceTurboModulePromiseRejectionsOnAndroid"); flagValue = currentProvider_->traceTurboModulePromiseRejectionsOnAndroid(); traceTurboModulePromiseRejectionsOnAndroid_ = flagValue; @@ -1478,7 +1496,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(80, "updateRuntimeShadowNodeReferencesOnCommit"); + markFlagAsAccessed(81, "updateRuntimeShadowNodeReferencesOnCommit"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommit(); updateRuntimeShadowNodeReferencesOnCommit_ = flagValue; @@ -1496,7 +1514,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(81, "updateRuntimeShadowNodeReferencesOnCommitThread"); + markFlagAsAccessed(82, "updateRuntimeShadowNodeReferencesOnCommitThread"); flagValue = currentProvider_->updateRuntimeShadowNodeReferencesOnCommitThread(); updateRuntimeShadowNodeReferencesOnCommitThread_ = flagValue; @@ -1514,7 +1532,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(82, "useAlwaysAvailableJSErrorHandling"); + markFlagAsAccessed(83, "useAlwaysAvailableJSErrorHandling"); flagValue = currentProvider_->useAlwaysAvailableJSErrorHandling(); useAlwaysAvailableJSErrorHandling_ = flagValue; @@ -1532,7 +1550,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(83, "useFabricInterop"); + markFlagAsAccessed(84, "useFabricInterop"); flagValue = currentProvider_->useFabricInterop(); useFabricInterop_ = flagValue; @@ -1550,7 +1568,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(84, "useNativeViewConfigsInBridgelessMode"); + markFlagAsAccessed(85, "useNativeViewConfigsInBridgelessMode"); flagValue = currentProvider_->useNativeViewConfigsInBridgelessMode(); useNativeViewConfigsInBridgelessMode_ = flagValue; @@ -1568,7 +1586,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(85, "useNestedScrollViewAndroid"); + markFlagAsAccessed(86, "useNestedScrollViewAndroid"); flagValue = currentProvider_->useNestedScrollViewAndroid(); useNestedScrollViewAndroid_ = flagValue; @@ -1586,7 +1604,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(86, "useSharedAnimatedBackend"); + markFlagAsAccessed(87, "useSharedAnimatedBackend"); flagValue = currentProvider_->useSharedAnimatedBackend(); useSharedAnimatedBackend_ = flagValue; @@ -1604,7 +1622,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(87, "useTraitHiddenOnAndroid"); + markFlagAsAccessed(88, "useTraitHiddenOnAndroid"); flagValue = currentProvider_->useTraitHiddenOnAndroid(); useTraitHiddenOnAndroid_ = flagValue; @@ -1622,7 +1640,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(88, "useTurboModuleInterop"); + markFlagAsAccessed(89, "useTurboModuleInterop"); flagValue = currentProvider_->useTurboModuleInterop(); useTurboModuleInterop_ = flagValue; @@ -1640,7 +1658,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(89, "useWindowDensityForDisplayMetricsAndroid"); + markFlagAsAccessed(90, "useWindowDensityForDisplayMetricsAndroid"); flagValue = currentProvider_->useWindowDensityForDisplayMetricsAndroid(); useWindowDensityForDisplayMetricsAndroid_ = flagValue; @@ -1658,7 +1676,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(90, "viewCullingOutsetRatio"); + markFlagAsAccessed(91, "viewCullingOutsetRatio"); flagValue = currentProvider_->viewCullingOutsetRatio(); viewCullingOutsetRatio_ = flagValue; @@ -1676,7 +1694,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(91, "viewTransitionEnabled"); + markFlagAsAccessed(92, "viewTransitionEnabled"); flagValue = currentProvider_->viewTransitionEnabled(); viewTransitionEnabled_ = flagValue; @@ -1694,7 +1712,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(92, "viewTransitionUseHardwareBitmapAndroid"); + markFlagAsAccessed(93, "viewTransitionUseHardwareBitmapAndroid"); flagValue = currentProvider_->viewTransitionUseHardwareBitmapAndroid(); viewTransitionUseHardwareBitmapAndroid_ = flagValue; @@ -1712,7 +1730,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(93, "virtualViewPrerenderRatio"); + markFlagAsAccessed(94, "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 5c05c3bc5427..4364d34218a6 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<<074e6ad1d773f289f93ce1d16656ec6e>> + * @generated SignedSource<> */ /** @@ -81,6 +81,7 @@ class ReactNativeFeatureFlagsAccessor { bool enableMutationObserverByDefault(); bool enableNativeCSSParsing(); bool enablePreallocatedPropsDiffOnInsertAndroid(); + bool enablePremountItemsBetweenFramesAndroid(); bool enablePreparedTextLayout(); bool enablePropsUpdateReconciliationAndroid(); bool enableResizeObserverByDefault(); @@ -139,7 +140,7 @@ class ReactNativeFeatureFlagsAccessor { std::unique_ptr currentProvider_; bool wasOverridden_; - std::array, 94> accessedFeatureFlags_; + std::array, 95> accessedFeatureFlags_; std::atomic> commonTestFlag_; std::atomic> cdpInteractionMetricsEnabled_; @@ -188,6 +189,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 82208e3861b9..736bf1b14253 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<<52db1adfed5fd99e4ca145b50dc413a0>> + * @generated SignedSource<<52634a7357d91f84f8b8a8aeb6122db6>> */ /** @@ -217,6 +217,10 @@ class ReactNativeFeatureFlagsDefaults : public ReactNativeFeatureFlagsProvider { return false; } + bool enablePremountItemsBetweenFramesAndroid() override { + return false; + } + bool enablePreparedTextLayout() override { return false; } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlagsDynamicProvider.h index 4d40ac107fc4..c8171c8d0fc1 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<<8d27c5f3feb4c8000922012882701b7e>> + * @generated SignedSource<<898fe36d2d24a97af05a9a47b792ab79>> */ /** @@ -470,6 +470,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 3ba8405c14ff..f27ce59bcff9 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<<7e5839223cfce81a42708f876c4c77ee>> + * @generated SignedSource<<93470a8ef906b896173c6363c0ed21cb>> */ /** @@ -74,6 +74,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 f2d4210f8d23..537c84f6133b 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<<506be59dc9b8987088bbdbebf0792499>> */ /** @@ -279,6 +279,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 4aafb51f00fc..314c1771c1b4 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<<84b944e162fc53799326c584a5edd17d>> + * @generated SignedSource<> */ /** @@ -132,6 +132,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 2c0786728b99..d5c42b81d17f 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -555,6 +555,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + enablePremountItemsBetweenFramesAndroid: { + defaultValue: false, + metadata: { + dateAdded: '2026-10-20', + description: + 'Pre-mount views on Android after each frame is drawn, in short slices that stop before the next vsync, instead of before the frame is drawn for half of a 60 Hz frame.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, enablePreparedTextLayout: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index c190a2488b08..40b3deec9575 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<> + * @generated SignedSource<<6ea3d949b27ee02705ec859642be72ab>> * @flow strict * @noformat */ @@ -96,6 +96,7 @@ export type ReactNativeFeatureFlags = Readonly<{ enableMutationObserverByDefault: Getter, enableNativeCSSParsing: Getter, enablePreallocatedPropsDiffOnInsertAndroid: Getter, + enablePremountItemsBetweenFramesAndroid: Getter, enablePreparedTextLayout: Getter, enablePropsUpdateReconciliationAndroid: Getter, enableResizeObserverByDefault: Getter, @@ -407,6 +408,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 after each frame is drawn, in short slices that stop before the next vsync, instead of before the frame is drawn 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 673dc0ed3bf8..39d4ff8d86cf 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<<40cc7b87d7658fb5aa3d33f468eff3fc>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -72,6 +72,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; From 6cefb5154d907d499c8feb3a3b909e9a3129738e Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 16:36:48 +0300 Subject: [PATCH 2/9] Queue between-frames pre-mounting after the frame's mount items With enablePremountItemsBetweenFramesAndroid on, the DISPATCH_UI callback queued its pre-mount slice before tryDispatchMountItems. A frame whose dispatch failed before the batch drained the pre-mount queue, such as a UIManagerListener throwing in willMountItems, still pre-mounted after mounting had stopped. The callback now runs the frame's mount items first and queues what is left after them, so a failed frame leaves nothing to pre-mount, as with the flag off. FabricUIManagerPreMountTest drives the real frame callback with React Native's clock under the test's control: with the flag off, nine 1 ms items run inside the frame and none after it; with it on, none inside and 17 before the next 60 Hz vsync; pausing cancels a queued slice; and a frame whose mount fails pre-mounts nothing after it. --- .../react/fabric/FabricUIManager.java | 10 +- .../fabric/FabricUIManagerPreMountTest.kt | 214 ++++++++++++++++++ 2 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricUIManagerPreMountTest.kt diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index 34d92d5791a7..0773bbb0f69c 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 @@ -1743,15 +1743,17 @@ public void doFrameGuarded(long frameTimeNanos) { } try { - // Pre-mount items run either here, for half of a 60 Hz frame, or after this frame's - // traversal, until the next vsync. Whatever is left when view commands or mount items - // arrive runs before them in tryDispatchMountItems; otherwise it waits for a later frame. + // View commands and mount items run every pending pre-mount item before themselves. What + // is left runs either here, for half of a 60 Hz frame, or after this frame's traversal + // until the next vsync, queued only once this frame's mount items have run, so a frame + // that fails to mount leaves nothing to pre-mount after it. if (ReactNativeFeatureFlags.enablePremountItemsBetweenFramesAndroid()) { + mMountItemDispatcher.tryDispatchMountItems(); mPreMountItemsBetweenFrames.scheduleAfterFrame(frameTimeNanos); } else { mMountItemDispatcher.dispatchPreMountItems(frameTimeNanos); + mMountItemDispatcher.tryDispatchMountItems(); } - mMountItemDispatcher.tryDispatchMountItems(); } catch (Exception ex) { FLog.e(TAG, "Exception thrown when executing UIFrameGuarded", ex); mIsMountingEnabled = false; 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..56ae4da98ea2 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricUIManagerPreMountTest.kt @@ -0,0 +1,214 @@ +/* + * 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.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 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.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 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) + 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 + } + + @After + fun tearDown() { + ReactChoreographer.overrideInstanceForTest(originalReactChoreographer) + systemClock.close() + ReactNativeFeatureFlags.dangerouslyReset() + } + + private fun queuePreMountItems(count: Int) { + repeat(count) { + dispatcher.addPreAllocateMountItem( + object : MountItem { + override fun execute(mountingManager: MountingManager) { + 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) + } + + @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() + } + + 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 + } +} From 0c168a1a5d746d9fd6d7f2499bb69d2a038ab83b Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 16:42:24 +0300 Subject: [PATCH 3/9] Post between-frames pre-mount slices as asynchronous messages A view that invalidates while it draws, such as a ScrollView during a fling, schedules the next frame's traversal from inside the current one, and its sync barrier stays in the main queue until that frame. Slices posted as ordinary messages waited behind it, so during a fling only the first slice after each frame ran: on a Galaxy S23 and a Mi MIX 3, RNTester pre-mounted about 1 ms per frame while a fling mounted 2400 views, against 6.6 to 7.7 ms with the flag off. Slices are now asynchronous, as the Choreographer's own vsync messages are, and still stop at the next vsync. PreMountItemsBetweenFramesTest posts the barrier ViewRootImpl.scheduleTraversals posts once a frame has queued its slice; with ordinary messages only the first slice runs (2 items instead of 17). --- .../mounting/PreMountItemsBetweenFrames.kt | 8 +++++- .../PreMountItemsBetweenFramesTest.kt | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) 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 index a3dc409f8683..28216b2ba30b 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -70,7 +71,12 @@ internal class PreMountItemsBetweenFrames( private fun queueSlice() { if (!isSliceQueued) { isSliceQueued = true - uiThreadHandler.post(slice) + // 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 } + ) } } 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 index 3dea66501966..dc54f0dbb379 100644 --- 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 @@ -9,6 +9,7 @@ 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 @@ -182,6 +183,33 @@ class PreMountItemsBetweenFramesTest { assertThat(executedItems).isEqualTo(9) } + @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. From 7d3ae18340fd12c22d5bf4a6404c44c5c4c92e62 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 17:43:50 +0300 Subject: [PATCH 4/9] Mount a commit that lands while views pre-mount between frames With enablePremountItemsBetweenFramesAndroid on, the frame callback dispatches its mount items before it queues the slices, so a commit whose mount items arrived while the slices ran waited for the next frame callback. With the flag off, the frame's pre-mount loop runs before tryDispatchMountItems, and a commit that lands during it mounts in that frame. A slice now dispatches the mount items it finds waiting, and the slice that runs out of pre-mount items queues one more when a commit landed during it. The scheduler test's looper stops after 1000 tasks, so a slice that never stops fails the test instead of hanging it. --- .../fabric/mounting/MountItemDispatcher.kt | 6 +- .../mounting/PreMountItemsBetweenFrames.kt | 10 +++- .../PreMountItemsBetweenFramesTest.kt | 55 ++++++++++++++++++- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.kt index 80b7ff34e77e..0370ae63dc19 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 @@ -35,8 +35,10 @@ internal class MountItemDispatcher( private val preMountItems: Queue = ConcurrentLinkedQueue() /** @return true if any mount items, pre-mount items or view commands are still pending */ - fun hasPendingItems(): Boolean = - !viewCommandMountItems.isEmpty() || !mountItems.isEmpty() || !preMountItems.isEmpty() + fun hasPendingItems(): Boolean = hasMountItems() || hasPreMountItems() + + /** @return true if any mount items or view commands are waiting to be dispatched */ + 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. 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 index 28216b2ba30b..8127bb0bd179 100644 --- 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 @@ -20,7 +20,9 @@ import com.facebook.react.common.SystemClock * Pre-mounts views between frames: once a frame's callbacks have run, it executes pre-mount items * in slices posted to the UI thread's looper, so the next frame, an input event or any other * message waits for one slice at most, 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 time the frame left idle instead of delaying the frame's own work. A commit whose mount items + * arrive meanwhile is mounted by the next slice rather than at the next frame, as it would be once + * a frame's pre-mount budget ends. */ internal class PreMountItemsBetweenFrames( private val dispatcher: MountItemDispatcher, @@ -57,13 +59,17 @@ internal class PreMountItemsBetweenFrames( } private fun runSlice() { + if (dispatcher.hasMountItems()) { + dispatcher.tryDispatchMountItems() + return + } val sliceStartNanos = SystemClock.nanoTime() if (sliceStartNanos >= nextFrameNanos) { return } val itemsLeft = dispatcher.dispatchPreMountItemsUntil(minOf(nextFrameNanos, sliceStartNanos + SLICE_NANOS)) - if (itemsLeft && SystemClock.nanoTime() < nextFrameNanos) { + if ((itemsLeft || dispatcher.hasMountItems()) && SystemClock.nanoTime() < nextFrameNanos) { queueSlice() } } 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 index dc54f0dbb379..22b7b2eb6e3f 100644 --- 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 @@ -44,6 +44,7 @@ class PreMountItemsBetweenFramesTest { private val handledExceptions: MutableList = mutableListOf() private var nowNanos: Long = 0L private var executedItems: Int = 0 + private var mountedAtNanos: Long? = null private var displayFrameIntervalNanos: Long? = ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS @Before @@ -105,7 +106,13 @@ class PreMountItemsBetweenFramesTest { /** Runs everything queued on the UI thread and returns how many items it executed. */ private fun runBetweenFrames(): Int { val before = executedItems - shadowOf(Looper.getMainLooper()).idle() + 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 } @@ -183,6 +190,51 @@ class PreMountItemsBetweenFramesTest { 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 aTraversalScheduledForTheNextFrameDoesNotHoldBackTheSlices() { displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS @@ -289,5 +341,6 @@ class PreMountItemsBetweenFramesTest { 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 } } From beb6a37437eb4c110bf1682571b041658ce24e3c Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 18:28:46 +0300 Subject: [PATCH 5/9] Pre-mount before the traversal while frames wait on the render thread 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, so the frames leave no time after them and pre-mounting between frames stalls until the commit's mount batch. With the flag off, the frame callback pre-mounts before the traversal, and that sleep absorbs it. The first slice after a frame measures how long the UI thread slept since the frame's callbacks ran, from the thread's CPU time, and the next frame pre-mounts before its traversal for that sleep plus what it pre-mounted there, less one slice and at most half a frame. A frame that sleeps less than half a slice ends it, and a frame busy on the CPU never starts it. --- .../react/fabric/FabricUIManager.java | 10 +- .../mounting/PreMountItemsBetweenFrames.kt | 76 +++++++++-- .../fabric/FabricUIManagerPreMountTest.kt | 14 ++ .../PreMountItemsBetweenFramesTest.kt | 122 +++++++++++++++++- 4 files changed, 207 insertions(+), 15 deletions(-) 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 0773bbb0f69c..17f6e875d0ed 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; @@ -263,6 +264,7 @@ public FabricUIManager( new PreMountItemsBetweenFrames( mMountItemDispatcher, mDisplayFrameInterval, + Debug::threadCpuTimeNanos, UiThreadUtil.getUiThreadHandler(), mDispatchUIFrameCallback::handlePreMountException); mEventDispatcher = new FabricEventDispatcher(reactContext, new FabricEventEmitter(this)); @@ -1744,10 +1746,12 @@ public void doFrameGuarded(long frameTimeNanos) { try { // View commands and mount items run every pending pre-mount item before themselves. What - // is left runs either here, for half of a 60 Hz frame, or after this frame's traversal - // until the next vsync, queued only once this frame's mount items have run, so a frame - // that fails to mount leaves nothing to pre-mount after it. + // is left runs either here, for half of a 60 Hz frame, or here for as long as the last + // frame's draw slept and after this frame's traversal until the next vsync, queued only + // once this frame's mount items have run, so a frame that fails to mount leaves nothing to + // pre-mount after it. if (ReactNativeFeatureFlags.enablePremountItemsBetweenFramesAndroid()) { + mPreMountItemsBetweenFrames.preMountBeforeTraversal(); mMountItemDispatcher.tryDispatchMountItems(); mPreMountItemsBetweenFrames.scheduleAfterFrame(frameTimeNanos); } else { 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 index 8127bb0bd179..2e5391522c81 100644 --- 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 @@ -17,21 +17,34 @@ import com.facebook.react.bridge.JSExceptionHandler import com.facebook.react.common.SystemClock /** - * Pre-mounts views between frames: once a frame's callbacks have run, it executes pre-mount items - * in slices posted to the UI thread's looper, so the next frame, an input event or any other - * message waits for one slice at most, 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. A commit whose mount items - * arrive meanwhile is mounted by the next slice rather than at the next frame, as it would be once - * a frame's pre-mount budget ends. + * 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, so the next frame, an input event or + * any other message waits for one slice at most, 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. A + * commit whose mount items arrive meanwhile is mounted by the next slice rather than at the next + * frame, as it would be once a frame's pre-mount budget ends. + * + * 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 after it. A + * frame then pre-mounts before its traversal for as long as the last frame's UI thread slept after + * its callbacks, less one slice and at most half a frame: the work shortens that sleep instead of + * delaying the frame, and a frame that no longer sleeps ends it. */ internal class PreMountItemsBetweenFrames( private val dispatcher: MountItemDispatcher, private val frameIntervalSource: FrameIntervalSource, + private val threadCpuClock: ThreadCpuClock, private val uiThreadHandler: Handler, exceptionHandler: JSExceptionHandler, ) { private var nextFrameNanos: Long = 0L + private var halfFrameNanos: Long = SIXTY_HERTZ_FRAME_NANOS / 2 private var isSliceQueued: Boolean = false + private var isFrameSleepUnmeasured: Boolean = false + private var callbacksEndNanos: Long = 0L + private var callbacksEndThreadCpuNanos: Long = 0L + private var beforeTraversalBudgetNanos: Long = 0L + private var preMountedBeforeTraversalNanos: Long = 0L private val slice: Runnable = object : GuardedRunnable(exceptionHandler) { override fun runGuarded() { @@ -40,13 +53,32 @@ internal class PreMountItemsBetweenFrames( } } + /** Pre-mounts before this frame's traversal for the budget the last frame's sleep left it. */ + @UiThread + @ThreadConfined(UI) + fun preMountBeforeTraversal() { + val budgetNanos = beforeTraversalBudgetNanos + beforeTraversalBudgetNanos = 0L + preMountedBeforeTraversalNanos = 0L + if (budgetNanos <= 0L) { + return + } + val startNanos = SystemClock.nanoTime() + dispatcher.dispatchPreMountItemsUntil(startNanos + budgetNanos) + 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) { - nextFrameNanos = - frameTimeNanos + (frameIntervalSource.frameIntervalNanos() ?: SIXTY_HERTZ_FRAME_NANOS) - if (dispatcher.hasPreMountItems()) { + val frameIntervalNanos = frameIntervalSource.frameIntervalNanos() ?: SIXTY_HERTZ_FRAME_NANOS + nextFrameNanos = frameTimeNanos + frameIntervalNanos + halfFrameNanos = frameIntervalNanos / 2 + if (dispatcher.hasPreMountItems() || preMountedBeforeTraversalNanos > 0L) { + callbacksEndNanos = SystemClock.nanoTime() + callbacksEndThreadCpuNanos = threadCpuClock.threadCpuTimeNanos() + isFrameSleepUnmeasured = true queueSlice() } } @@ -56,9 +88,30 @@ internal class PreMountItemsBetweenFrames( fun cancel() { uiThreadHandler.removeCallbacks(slice) isSliceQueued = false + isFrameSleepUnmeasured = false + beforeTraversalBudgetNanos = 0L + } + + /** + * The next frame's budget before its traversal: how long the UI thread slept between this frame's + * callbacks and its end, plus what it pre-mounted before its traversal, which that sleep + * absorbed, less one slice. A frame that slept less than half a slice gets none. + */ + private fun measureBeforeTraversalBudget(): Long { + val asleepNanos = + (SystemClock.nanoTime() - callbacksEndNanos) - + (threadCpuClock.threadCpuTimeNanos() - callbacksEndThreadCpuNanos) + if (asleepNanos < SLICE_NANOS / 2) { + return 0L + } + return minOf(halfFrameNanos, asleepNanos + preMountedBeforeTraversalNanos - SLICE_NANOS) } private fun runSlice() { + if (isFrameSleepUnmeasured) { + isFrameSleepUnmeasured = false + beforeTraversalBudgetNanos = measureBeforeTraversalBudget() + } if (dispatcher.hasMountItems()) { dispatcher.tryDispatchMountItems() return @@ -91,6 +144,11 @@ internal class PreMountItemsBetweenFrames( fun frameIntervalNanos(): Long? } + fun interface ThreadCpuClock { + /** The CPU time the calling thread has run for, which stops while it sleeps. */ + fun threadCpuTimeNanos(): Long + } + private companion object { /** * How long a slice pre-mounts before it yields to the looper. It bounds how late the next frame 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 index 56ae4da98ea2..b31f192f537e 100644 --- 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 @@ -159,6 +159,20 @@ class FabricUIManagerPreMountTest { assertThat(runBetweenFrames()).isEqualTo(17) } + @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) 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 index 22b7b2eb6e3f..55f7724cfe21 100644 --- 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 @@ -43,6 +43,7 @@ class PreMountItemsBetweenFramesTest { private val uiThreadHandler: Handler = Handler(Looper.getMainLooper()) private val handledExceptions: MutableList = mutableListOf() private var nowNanos: Long = 0L + private var threadCpuNanos: Long = 0L private var executedItems: Int = 0 private var mountedAtNanos: Long? = null private var displayFrameIntervalNanos: Long? = ONE_HUNDRED_TWENTY_HERTZ_FRAME_NANOS @@ -60,6 +61,7 @@ class PreMountItemsBetweenFramesTest { PreMountItemsBetweenFrames( dispatcher, { displayFrameIntervalNanos }, + { threadCpuNanos }, uiThreadHandler, { exception -> handledExceptions.add(exception) }, ) @@ -83,6 +85,7 @@ class PreMountItemsBetweenFramesTest { onExecute(index) executedItems++ nowNanos += costNanos + threadCpuNanos += costNanos } override fun getSurfaceId(): Int = SURFACE_ID @@ -93,13 +96,25 @@ class PreMountItemsBetweenFramesTest { /** * Runs the frame callback at [frameTimeNanos] and the frame's own work, [frameWorkNanos], and - * returns how many items the frame callback itself executed. + * returns how many items the frame callback itself executed. 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): Int { + private fun runFrame( + frameTimeNanos: Long, + frameWorkNanos: Long, + renderThreadReadyAtNanos: Long? = null, + ): Int { nowNanos = frameTimeNanos + val before = executedItems + preMountItemsBetweenFrames.preMountBeforeTraversal() preMountItemsBetweenFrames.scheduleAfterFrame(frameTimeNanos) - val executedInsideTheFrame = executedItems + val executedInsideTheFrame = executedItems - before nowNanos += frameWorkNanos + threadCpuNanos += frameWorkNanos + if (renderThreadReadyAtNanos != null) { + nowNanos = maxOf(nowNanos, renderThreadReadyAtNanos) + } return executedInsideTheFrame } @@ -298,6 +313,107 @@ class PreMountItemsBetweenFramesTest { 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 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 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 cancellingStopsEverySliceStillQueued() { queuePreMountItems(count = 20) From d3e05d852544dfecbe28fab238603ceb017e83c9 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 19:32:40 +0300 Subject: [PATCH 6/9] Pre-mount nothing before the traversal when the thread clock cannot tell Debug.threadCpuTimeNanos() reports -1 where the system cannot tell a thread's CPU time. The frame's sleep would then read as its whole wall time, so a frame busy on the CPU would hand the next one time before its traversal; a negative reading now leaves the next traversal alone. --- .../fabric/mounting/PreMountItemsBetweenFrames.kt | 12 +++++++++--- .../mounting/PreMountItemsBetweenFramesTest.kt | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) 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 index 2e5391522c81..6f07e0761a2a 100644 --- 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 @@ -98,9 +98,12 @@ internal class PreMountItemsBetweenFrames( * absorbed, less one slice. A frame that slept less than half a slice gets none. */ private fun measureBeforeTraversalBudget(): Long { + val threadCpuNanos = threadCpuClock.threadCpuTimeNanos() + if (threadCpuNanos < 0L || callbacksEndThreadCpuNanos < 0L) { + return 0L + } val asleepNanos = - (SystemClock.nanoTime() - callbacksEndNanos) - - (threadCpuClock.threadCpuTimeNanos() - callbacksEndThreadCpuNanos) + (SystemClock.nanoTime() - callbacksEndNanos) - (threadCpuNanos - callbacksEndThreadCpuNanos) if (asleepNanos < SLICE_NANOS / 2) { return 0L } @@ -145,7 +148,10 @@ internal class PreMountItemsBetweenFrames( } fun interface ThreadCpuClock { - /** The CPU time the calling thread has run for, which stops while it sleeps. */ + /** + * 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 } 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 index 55f7724cfe21..dce6ed684143 100644 --- 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 @@ -390,6 +390,21 @@ class PreMountItemsBetweenFramesTest { assertThat(runFrame(frames[2], MILLISECOND_NANOS)).isEqualTo(9) } + @Test + fun aThreadClockThatCannotTellLeavesTheNextTraversalAlone() { + displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS + queuePreMountItems(count = 60) + // Debug.threadCpuTimeNanos() reports -1 where the system cannot tell a thread's CPU time. + threadCpuNanos = -1L + val secondFrameNanos = FIRST_FRAME_NANOS + SIXTY_HERTZ_FRAME_NANOS + + runFrame(FIRST_FRAME_NANOS, frameWorkNanos = 20 * MILLISECOND_NANOS) + threadCpuNanos = -1L + runBetweenFrames() + + assertThat(runFrame(secondFrameNanos + SIXTY_HERTZ_FRAME_NANOS, MILLISECOND_NANOS)).isZero() + } + @Test fun cancellingDropsTheTimeBeforeTheNextTraversal() { displayFrameIntervalNanos = SIXTY_HERTZ_FRAME_NANOS From 6700008316d41b04cf3377d46663cd38fcfab442 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 20:04:14 +0300 Subject: [PATCH 7/9] Queue no measuring slice once the pre-mount items run out With time left before the traversal and no pre-mount items, the frame read the empty call's few microseconds as pre-mounting and queued a slice to measure its sleep, so frames that kept waiting on the render thread after a burst queued one slice each for as long as they waited. --- .../mounting/PreMountItemsBetweenFrames.kt | 2 +- .../PreMountItemsBetweenFramesTest.kt | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) 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 index 6f07e0761a2a..42699b5fcba6 100644 --- 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 @@ -60,7 +60,7 @@ internal class PreMountItemsBetweenFrames( val budgetNanos = beforeTraversalBudgetNanos beforeTraversalBudgetNanos = 0L preMountedBeforeTraversalNanos = 0L - if (budgetNanos <= 0L) { + if (budgetNanos <= 0L || !dispatcher.hasPreMountItems()) { return } val startNanos = SystemClock.nanoTime() 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 index dce6ed684143..7670f506a152 100644 --- 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 @@ -390,6 +390,32 @@ class PreMountItemsBetweenFramesTest { assertThat(runFrame(frames[2], MILLISECOND_NANOS)).isEqualTo(9) } + @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 @@ -467,6 +493,7 @@ class PreMountItemsBetweenFramesTest { 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 From 8c23b279ff3b2d63a24e892ab19360319ffb0f12 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 20:27:22 +0300 Subject: [PATCH 8/9] Describe the pre-mount before the traversal in the flag's description The description named only the slices after each frame. A frame that follows one whose draw slept also pre-mounts before its traversal, so the description now says so, as PreMountItemsBetweenFrames does. --- .../react/internal/featureflags/ReactNativeFeatureFlags.kt | 4 ++-- .../ReactCommon/react/featureflags/ReactNativeFeatureFlags.h | 4 ++-- .../scripts/featureflags/ReactNativeFeatureFlags.config.js | 2 +- .../src/private/featureflags/ReactNativeFeatureFlags.js | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) 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 1ecb73f2a056..e59d0a7f52b5 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<<6d17652dc2d02644c8b5ad0133d2dae3>> */ /** @@ -313,7 +313,7 @@ public object ReactNativeFeatureFlags { public fun enablePreallocatedPropsDiffOnInsertAndroid(): Boolean = accessor.enablePreallocatedPropsDiffOnInsertAndroid() /** - * Pre-mount views on Android after each frame is drawn, in short slices that stop before the next vsync, instead of before the frame is drawn for half of a 60 Hz frame. + * 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 before its traversal for as long as the previous draw slept, at most half a frame, instead of before every traversal for half of a 60 Hz frame. */ @JvmStatic public fun enablePremountItemsBetweenFramesAndroid(): Boolean = accessor.enablePremountItemsBetweenFramesAndroid() diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index 33c5e304866d..47d4a3eab673 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<<35accad3c60bf332b67e61529aab9d61>> + * @generated SignedSource<<6ffd411c79e6502fee182c3e54e9eb0a>> */ /** @@ -277,7 +277,7 @@ class ReactNativeFeatureFlags { RN_EXPORT static bool enablePreallocatedPropsDiffOnInsertAndroid(); /** - * Pre-mount views on Android after each frame is drawn, in short slices that stop before the next vsync, instead of before the frame is drawn for half of a 60 Hz frame. + * 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 before its traversal for as long as the previous draw slept, at most half a frame, instead of before every traversal for half of a 60 Hz frame. */ RN_EXPORT static bool enablePremountItemsBetweenFramesAndroid(); diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index d5c42b81d17f..db5458f1ddbb 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -560,7 +560,7 @@ const definitions: FeatureFlagDefinitions = { metadata: { dateAdded: '2026-10-20', description: - 'Pre-mount views on Android after each frame is drawn, in short slices that stop before the next vsync, instead of before the frame is drawn for half of a 60 Hz frame.', + '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 before its traversal for as long as the previous draw slept, at most half a frame, instead of before every traversal for half of a 60 Hz frame.', expectedReleaseValue: true, purpose: 'experimentation', }, diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 40b3deec9575..c4a38bea1117 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<<6ea3d949b27ee02705ec859642be72ab>> + * @generated SignedSource<<3f253bd16eaaf577089194b4442ec2c6>> * @flow strict * @noformat */ @@ -409,7 +409,7 @@ export const enableNativeCSSParsing: Getter = createNativeFlagGetter('e */ export const enablePreallocatedPropsDiffOnInsertAndroid: Getter = createNativeFlagGetter('enablePreallocatedPropsDiffOnInsertAndroid', false); /** - * Pre-mount views on Android after each frame is drawn, in short slices that stop before the next vsync, instead of before the frame is drawn for half of a 60 Hz frame. + * 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 before its traversal for as long as the previous draw slept, at most half a frame, instead of before every traversal for half of a 60 Hz frame. */ export const enablePremountItemsBetweenFramesAndroid: Getter = createNativeFlagGetter('enablePremountItemsBetweenFramesAndroid', false); /** From e71fb78b300755c970df487664c5634886de0819 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Tue, 20 Oct 2026 21:03:39 +0300 Subject: [PATCH 9/9] Spend the time before a traversal only in the frame after the one measured With disableIdleMountItemFrameCallbackRearmAndroid on, the frame callback stops re-arming once nothing is left to mount, and the sleep a slice had measured after the last frame waited for the next burst's first frame, which spent it before its traversal however many vsyncs had passed. The measured time now lapses once a frame interval has passed since it was measured, so only the frame right after the measured one spends it. --- .../mounting/PreMountItemsBetweenFrames.kt | 28 ++++++++++++------- .../PreMountItemsBetweenFramesTest.kt | 21 ++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) 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 index 42699b5fcba6..0ab0eaf11925 100644 --- 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 @@ -28,7 +28,8 @@ import com.facebook.react.common.SystemClock * frame's draw until the render thread takes the frame, and the frame leaves no time after it. A * frame then pre-mounts before its traversal for as long as the last frame's UI thread slept after * its callbacks, less one slice and at most half a frame: the work shortens that sleep instead of - * delaying the frame, and a frame that no longer sleeps ends it. + * delaying the frame, and a frame that no longer sleeps ends it. A vsync that passes with no frame + * callback, as while the callback is not re-armed with nothing to mount, ends it too. */ internal class PreMountItemsBetweenFrames( private val dispatcher: MountItemDispatcher, @@ -38,12 +39,13 @@ internal class PreMountItemsBetweenFrames( exceptionHandler: JSExceptionHandler, ) { private var nextFrameNanos: Long = 0L - private var halfFrameNanos: Long = SIXTY_HERTZ_FRAME_NANOS / 2 + private var frameIntervalNanos: Long = SIXTY_HERTZ_FRAME_NANOS private var isSliceQueued: Boolean = false private var isFrameSleepUnmeasured: Boolean = false private var callbacksEndNanos: Long = 0L private var callbacksEndThreadCpuNanos: Long = 0L private var beforeTraversalBudgetNanos: Long = 0L + private var beforeTraversalBudgetMeasuredAtNanos: Long = 0L private var preMountedBeforeTraversalNanos: Long = 0L private val slice: Runnable = object : GuardedRunnable(exceptionHandler) { @@ -53,7 +55,10 @@ internal class PreMountItemsBetweenFrames( } } - /** Pre-mounts before this frame's traversal for the budget the last frame's sleep left it. */ + /** + * Pre-mounts before this frame's traversal for the budget the last frame's sleep left it, unless + * a vsync has passed since that sleep was measured. + */ @UiThread @ThreadConfined(UI) fun preMountBeforeTraversal() { @@ -64,6 +69,9 @@ internal class PreMountItemsBetweenFrames( return } val startNanos = SystemClock.nanoTime() + if (startNanos - beforeTraversalBudgetMeasuredAtNanos > frameIntervalNanos) { + return + } dispatcher.dispatchPreMountItemsUntil(startNanos + budgetNanos) preMountedBeforeTraversalNanos = SystemClock.nanoTime() - startNanos } @@ -72,9 +80,8 @@ internal class PreMountItemsBetweenFrames( @UiThread @ThreadConfined(UI) fun scheduleAfterFrame(frameTimeNanos: Long) { - val frameIntervalNanos = frameIntervalSource.frameIntervalNanos() ?: SIXTY_HERTZ_FRAME_NANOS + frameIntervalNanos = frameIntervalSource.frameIntervalNanos() ?: SIXTY_HERTZ_FRAME_NANOS nextFrameNanos = frameTimeNanos + frameIntervalNanos - halfFrameNanos = frameIntervalNanos / 2 if (dispatcher.hasPreMountItems() || preMountedBeforeTraversalNanos > 0L) { callbacksEndNanos = SystemClock.nanoTime() callbacksEndThreadCpuNanos = threadCpuClock.threadCpuTimeNanos() @@ -97,23 +104,24 @@ internal class PreMountItemsBetweenFrames( * callbacks and its end, plus what it pre-mounted before its traversal, which that sleep * absorbed, less one slice. A frame that slept less than half a slice gets none. */ - private fun measureBeforeTraversalBudget(): Long { + private fun measureBeforeTraversalBudget(nowNanos: Long): Long { val threadCpuNanos = threadCpuClock.threadCpuTimeNanos() if (threadCpuNanos < 0L || callbacksEndThreadCpuNanos < 0L) { return 0L } - val asleepNanos = - (SystemClock.nanoTime() - callbacksEndNanos) - (threadCpuNanos - callbacksEndThreadCpuNanos) + val asleepNanos = (nowNanos - callbacksEndNanos) - (threadCpuNanos - callbacksEndThreadCpuNanos) if (asleepNanos < SLICE_NANOS / 2) { return 0L } - return minOf(halfFrameNanos, asleepNanos + preMountedBeforeTraversalNanos - SLICE_NANOS) + return minOf(frameIntervalNanos / 2, asleepNanos + preMountedBeforeTraversalNanos - SLICE_NANOS) } private fun runSlice() { if (isFrameSleepUnmeasured) { isFrameSleepUnmeasured = false - beforeTraversalBudgetNanos = measureBeforeTraversalBudget() + beforeTraversalBudgetMeasuredAtNanos = SystemClock.nanoTime() + beforeTraversalBudgetNanos = + measureBeforeTraversalBudget(beforeTraversalBudgetMeasuredAtNanos) } if (dispatcher.hasMountItems()) { dispatcher.tryDispatchMountItems() 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 index 7670f506a152..912858308659 100644 --- 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 @@ -390,6 +390,27 @@ class PreMountItemsBetweenFramesTest { 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