From 5b450523b782b0b81ddd17d917f09636456b4849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20B=C5=82oniarz?= Date: Fri, 16 Oct 2026 02:38:35 -0700 Subject: [PATCH] Pull model: pull each surface's pending transaction at the start of the mount phase (#59029) Summary: With `enableMountingCoordinatorPullModelAndroid`, each commit notification queues a `PullTransactionMountItem` in `MountItemDispatcher`. `MountingCoordinator::pullTransaction` mounts the latest revision, so a surface's first queued item mounts every pending commit and its later items pull nothing. The item's queue position only reflects when the notification arrived. The queued item breaks root view attach. While the root view is detached, the item waits in the surface's attach queue, ahead of the preallocations for the revisions it pulls. https://github.com/react/react-native/issues/58152 handles this by re-queueing the pulled batch on the dispatcher. That puts the batch behind a later pull of the same surface, so the newer revision mounts first. This PR replaces the queued item with the set of surfaces that have a transaction available. A dispatch runs view commands, then pre-mount items, then one `FabricUIManagerBinding.pullAndExecuteTransaction` per pending surface whose root view is attached, then the queued mount items. Pulls share the mount items' "mount" trace, their handling of ignorable mounting exceptions, and `reportMount`. A surface waiting for its root view stays pending. Its attach already runs through `MountingManager.MountItemExecutor`, which now also schedules a frame, and that frame pulls the surface after its preallocations. This removes the https://github.com/react/react-native/issues/58152 re-queue and `PullTransactionMountItem`. With `disableIdleMountItemFrameCallbackRearmAndroid`, an attached pending surface keeps the frame callback armed, like a queued item. When a dispatch has both a pending surface and queued mount items, it now pulls the surface first. On main, an item queued before the surface's first pull notification ran before the pull. Push behaves like main here: its batch is queued at the end of the JS task, behind the items queued during it. An item that targets a view the commit creates, such as an accessibility event or a `SynchronousMountItem`, now finds it. An item that targets a view the commit deletes now misses it, although that view is gone after this frame either way. It logs a soft exception and is dropped, or for a view command, retries once first. With the default flags, view commands still run before the pulls, as on main. ## Changelog: [ANDROID] [FIXED] - Pull model: fix batches applied out of order when a surface's root view attaches Test Plan: `FabricUIManagerPullModelTest` models each surface's `MountingCoordinator`: a pull mounts every revision committed since the previous pull, as one batch. ``` ./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest \ --tests 'com.facebook.react.fabric.*' ``` | Test | Before | After | | --- | --- | --- | | `pullDeferredByRootAttach_isAppliedBeforeLaterPulls` | fails: revisions mount as 3, 1, 2 | passes | | `onTransactionAvailable_pullsEachPendingSurfaceOnce` | fails: pulls `[1, 2, 1, 3]` | passes | | `mountItemQueuedBeforeCommit_runsAfterThePull` | fails: the item runs before the batch | passes | | `pendingPullOfStoppedSurface_runsOnceAndIsDropped` | fails: the stop clears the queued pull, nothing is pulled | passes | | `viewCommandsInMountItemOrder_runAfterThePull` | fails: the command dispatched before the commit runs before the batch | passes | - The new tests for pre-mount items, `reportMount` and the frame callback with `disableIdleMountItemFrameCallbackRearmAndroid` pass before and after. One of them, a commit notified from the commit thread while a frame pulls another surface, fails if `hasPendingItems` ignores pending surfaces. - `onTransactionAvailable_enqueuesOnePullPerNotification` is now `onTransactionAvailable_pullsEachPendingSurfaceOnce`. `synchronousBatch_waitingForRootAttach_isDeferred` is removed, because a synchronous batch can no longer run while its root view is detached. - All `ReactAndroid` JVM tests pass. - A few one-off checks, not committed, passed on main and with this PR: commits on two surfaces, an item queued after a commit, and a dispatch without a commit. A push-model check also passed, with the pull flag on: batches queued around a root view attach apply in commit order. - RNTester debug on an Android emulator with `enableMountingCoordinatorPullModelAndroid`, `enableAccumulatedUpdatesInRawPropsAndroid` and `enablePreallocatedPropsDiffOnInsertAndroid`: FlatList, Image, Animated and Accessibility examples render and update, including after leaving and reopening the app. There were no crashes or mounting exceptions. Differential Revision: D124966304 Pulled By: bartlomiejbloniarz --- .../react/fabric/FabricUIManager.java | 59 +-- .../fabric/mounting/MountItemDispatcher.kt | 78 +++- .../mountitems/PullTransactionMountItem.kt | 33 -- .../featureflags/ReactNativeFeatureFlags.kt | 4 +- .../jni/react/fabric/FabricMountingManager.h | 2 +- .../react/fabric/FabricUIManagerBinding.cpp | 4 +- .../fabric/FabricUIManagerPullModelTest.kt | 356 ++++++++++++++++-- .../shadows/ShadowFabricUIManagerBinding.kt | 11 + .../featureflags/ReactNativeFeatureFlags.h | 4 +- .../ReactNativePublicFeatureFlags.h | 4 +- .../ReactNativeFeatureFlags.config.js | 2 +- .../featureflags/ReactNativeFeatureFlags.js | 4 +- 12 files changed, 445 insertions(+), 116 deletions(-) delete mode 100644 packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/PullTransactionMountItem.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 3e58ab288c4a..d37e5194d6c3 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 @@ -67,7 +67,6 @@ import com.facebook.react.fabric.mounting.mountitems.MountItem; import com.facebook.react.fabric.mounting.mountitems.MountItemFactory; import com.facebook.react.fabric.mounting.mountitems.PrefetchResourcesMountItem; -import com.facebook.react.fabric.mounting.mountitems.PullTransactionMountItem; import com.facebook.react.fabric.mounting.mountitems.SynchronousMountItem; import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags; import com.facebook.react.internal.featureflags.ReactNativeNewArchitectureFeatureFlags; @@ -237,6 +236,11 @@ public void executeItems(Queue items) { // This executor can be technically accessed before the dispatcher is created, // but if that happens, something is terribly wrong mMountItemDispatcher.dispatchMountItems(items); + // Only called when a surface's root view attaches, before the surface counts as attached, + // so the dispatch above skips its pull. Pull model: the next frame pulls it. + if (mMountItemDispatcher.hasSurfaceIdsToPull()) { + mDispatchUIFrameCallback.schedule(); + } } }; @@ -1016,15 +1020,9 @@ private void scheduleMountItem( if (shouldSchedule) { Assertions.assertNotNull(mountItem, "MountItem is null"); if (synchronous) { - if (mMountingManager.isWaitingForViewAttach(mountItem.getSurfaceId())) { - // Regular mount items are still being deferred into the surface's attach queue. - // Executing this batch inline would run it ahead of the preallocations queued there. - mMountItemDispatcher.addMountItem(mountItem); - } else { - // Pull model: we are already on the UI thread, inside the dispatcher's loop executing - // a PullTransactionMountItem. We don't schedule the item, we execute it directly. - mountItem.execute(mMountingManager); - } + // Pull model: we are already on the UI thread, inside the dispatcher's pull of a surface + // whose root view is attached. We don't schedule the item, we execute it directly. + mountItem.execute(mMountingManager); } else { mMountItemDispatcher.addMountItem(mountItem); if (UiThreadUtil.isOnUiThread()) { @@ -1074,19 +1072,18 @@ public void runGuarded() { /** * Pull model: called from C++ via JNI (usually on the commit thread) to signal that a transaction - * is available for {@code surfaceId}. Enqueues a PullTransactionMountItem so the UI thread pulls - * and applies the transaction itself, preserving mount-item ordering. + * is available for {@code surfaceId}. The next dispatch on the UI thread pulls and applies it + * before the queued mount items. */ @SuppressWarnings("unused") @AnyThread @ThreadConfined(ANY) @VisibleForTesting void onTransactionAvailable(int surfaceId) { - FabricUIManagerBinding binding = mBinding; - if (binding == null) { + if (mBinding == null) { return; } - mMountItemDispatcher.addMountItem(new PullTransactionMountItem(surfaceId, binding)); + mMountItemDispatcher.addSurfaceIdToPull(surfaceId); if (UiThreadUtil.isOnUiThread()) { mMountItemDispatcher.tryDispatchMountItems(); } @@ -1551,16 +1548,14 @@ public void didMountItems(@Nullable List mountItems) { listener.didMountItems(FabricUIManager.this); } - if (mountItems == null || mountItems.isEmpty()) { - return; - } - - // Collect surface IDs for all the mount items - for (MountItem mountItem : mountItems) { - if (mountItem != null - && mountItem.getSurfaceId() != View.NO_ID - && !mSurfaceIdsWithPendingMountNotification.contains(mountItem.getSurfaceId())) { - mSurfaceIdsWithPendingMountNotification.add(mountItem.getSurfaceId()); + if (mountItems != null) { + // Collect surface IDs for all the mount items + for (MountItem mountItem : mountItems) { + if (mountItem != null + && mountItem.getSurfaceId() != View.NO_ID + && !mSurfaceIdsWithPendingMountNotification.contains(mountItem.getSurfaceId())) { + mSurfaceIdsWithPendingMountNotification.add(mountItem.getSurfaceId()); + } } } @@ -1591,6 +1586,20 @@ public void didMountItems(@Nullable List mountItems) { } } + @UiThread + @ThreadConfined(UI) + @Override + public void pullTransaction(int surfaceId) { + FabricUIManagerBinding binding = mBinding; + if (binding == null) { + return; + } + binding.pullAndExecuteTransaction(surfaceId); + if (!mSurfaceIdsWithPendingMountNotification.contains(surfaceId)) { + mSurfaceIdsWithPendingMountNotification.add(surfaceId); + } + } + @Override public void didDispatchMountItems() { for (UIManagerListener listener : mListeners) { 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..f74bb987636f 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 @@ -24,6 +24,7 @@ import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags import com.facebook.react.internal.tracing.PerformanceTracer import com.facebook.systrace.Systrace import java.util.Queue +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue internal class MountItemDispatcher( @@ -33,10 +34,20 @@ internal class MountItemDispatcher( private val viewCommandMountItems: Queue = ConcurrentLinkedQueue() private val mountItems: Queue = ConcurrentLinkedQueue() private val preMountItems: Queue = ConcurrentLinkedQueue() + private val surfaceIdsToPull: MutableSet = ConcurrentHashMap.newKeySet() - /** @return true if any mount items, pre-mount items or view commands are still pending */ + /** + * @return true if any mount items, pre-mount items, view commands or pulls of surfaces with an + * attached root view are still pending. The attach schedules the frame that pulls its surface. + */ fun hasPendingItems(): Boolean = - !viewCommandMountItems.isEmpty() || !mountItems.isEmpty() || !preMountItems.isEmpty() + !viewCommandMountItems.isEmpty() || + !mountItems.isEmpty() || + !preMountItems.isEmpty() || + surfaceIdsToPull.any { !mountingManager.isWaitingForViewAttach(it) } + + /** Pull model: @return true if any surface has a transaction waiting to be pulled. */ + fun hasSurfaceIdsToPull(): Boolean = surfaceIdsToPull.isNotEmpty() // 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. @@ -69,6 +80,12 @@ internal class MountItemDispatcher( notifyItemsQueued() } + /** Pull model: a pull mounts the surface's latest revision, so it is pending at most once. */ + fun addSurfaceIdToPull(surfaceId: Int) { + surfaceIdsToPull.add(surfaceId) + notifyItemsQueued() + } + fun addPreAllocateMountItem(mountItem: MountItem) { // We do this check only for PreAllocateViewMountItem - and not DispatchMountItem or regular // MountItem - because PreAllocateViewMountItem are not batched, and is relatively more @@ -147,9 +164,10 @@ internal class MountItemDispatcher( * Executes view commands, pre mount items and mount items in the respective order: * 1. View commands. * 2. Pre mount items. - * 3. Regular mount items. + * 3. Pulls of surfaces with an available transaction and an attached root view (pull model). + * 4. Regular mount items. * - * Does nothing if `viewCommandMountItemsToDispatch` and `mountItemsToDispatch` are empty. + * Does nothing if there are no view commands, mount items or surfaces to pull. * Nothing should call this directly except for `tryDispatchMountItems`. */ @UiThread @@ -160,8 +178,13 @@ internal class MountItemDispatcher( val viewCommandMountItemsToDispatch = getAndResetViewCommandMountItems() val mountItemsToDispatch = getAndResetMountItems() + val attachedSurfaceIdsToPull = getAttachedSurfaceIdsToPull() - if (mountItemsToDispatch == null && viewCommandMountItemsToDispatch == null) { + if ( + mountItemsToDispatch == null && + viewCommandMountItemsToDispatch == null && + attachedSurfaceIdsToPull == null + ) { return } @@ -256,7 +279,8 @@ internal class MountItemDispatcher( Systrace.endSection(Systrace.TRACE_TAG_REACT) } - mountItemsToDispatch?.let { items -> + if (mountItemsToDispatch != null || attachedSurfaceIdsToPull != null) { + val items = mountItemsToDispatch.orEmpty() Systrace.beginSection( Systrace.TRACE_TAG_REACT, "MountItemDispatcher::mountViews mountItems to execute", @@ -269,6 +293,16 @@ internal class MountItemDispatcher( { -> val batchedExecutionStartTime = SystemClock.uptimeMillis() + for (surfaceId in attachedSurfaceIdsToPull.orEmpty()) { + surfaceIdsToPull.remove(surfaceId) + try { + itemDispatchListener.pullTransaction(surfaceId) + } catch (e: Throwable) { + FLog.e(TAG, "dispatchMountItems: caught exception pulling surface $surfaceId", e) + logOrRethrowMountingException(e, surfaceId) + } + } + for (mountItem in items) { if (ReactNativeFeatureFlags.enableFabricLogs()) { printMountItem(mountItem, "dispatchMountItems: Executing mountItem") @@ -298,15 +332,7 @@ internal class MountItemDispatcher( } } - if (mountItem.getSurfaceId() != View.NO_ID) { - mountingManager.getSurfaceManager(mountItem.getSurfaceId())?.printSurfaceState() - } - - if (ReactIgnorableMountingException.isIgnorable(e)) { - ReactSoftExceptionLogger.logSoftException(TAG, e) - } else { - throw e - } + logOrRethrowMountingException(e, mountItem.getSurfaceId()) } } batchedExecutionTime += SystemClock.uptimeMillis() - batchedExecutionStartTime @@ -375,6 +401,18 @@ internal class MountItemDispatcher( Systrace.endSection(Systrace.TRACE_TAG_REACT) } + private fun logOrRethrowMountingException(e: Throwable, surfaceId: Int) { + if (surfaceId != View.NO_ID) { + mountingManager.getSurfaceManager(surfaceId)?.printSurfaceState() + } + + if (ReactIgnorableMountingException.isIgnorable(e)) { + ReactSoftExceptionLogger.logSoftException(TAG, e) + } else { + throw e + } + } + private fun executeOrEnqueue(item: MountItem) { if (mountingManager.isWaitingForViewAttach(item.getSurfaceId())) { if (ReactNativeFeatureFlags.enableFabricLogs()) { @@ -408,6 +446,13 @@ internal class MountItemDispatcher( @UiThread private fun getAndResetPreMountItems(): List? = drainConcurrentItemQueue(preMountItems) + /** A surface waiting for its root view keeps its pull until the frame after the attach. */ + @ThreadConfined(UI) + @UiThread + private fun getAttachedSurfaceIdsToPull(): List? = + if (surfaceIdsToPull.isEmpty()) null + else surfaceIdsToPull.filterNot(mountingManager::isWaitingForViewAttach).ifEmpty { null } + interface ItemDispatchListener { fun willMountItems(mountItems: List?) @@ -415,6 +460,9 @@ internal class MountItemDispatcher( fun didDispatchMountItems() + /** Pull model: pulls the surface's latest transaction and mounts it synchronously. */ + fun pullTransaction(surfaceId: Int) + /** Called (from any thread) whenever new items are queued into the dispatcher. */ fun onItemsQueued() } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/PullTransactionMountItem.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/PullTransactionMountItem.kt deleted file mode 100644 index 68563cdf4ec0..000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/mountitems/PullTransactionMountItem.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * 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.mountitems - -import com.facebook.proguard.annotations.DoNotStripAny -import com.facebook.react.fabric.FabricUIManagerBinding -import com.facebook.react.fabric.mounting.MountingManager - -/** - * Pull model mount item. Enqueued (on the commit thread) when C++ notifies that a transaction is - * available for a surface. When it executes on the UI thread it asks C++ to pull the surface's - * pending transaction and apply it synchronously, so the diff + batch construction happens on the - * UI thread instead of the commit thread (matching iOS). - */ -@DoNotStripAny -internal class PullTransactionMountItem( - private val surfaceId: Int, - private val binding: FabricUIManagerBinding, -) : MountItem { - - override fun execute(mountingManager: MountingManager) { - binding.pullAndExecuteTransaction(surfaceId) - } - - override fun getSurfaceId(): Int = surfaceId - - override fun toString(): String = "PullTransactionMountItem [surfaceId: $surfaceId]" -} 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..bf411089ec3b 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<> */ /** @@ -289,7 +289,7 @@ public object ReactNativeFeatureFlags { public fun enableModuleArgumentNSNullConversionIOS(): Boolean = accessor.enableModuleArgumentNSNullConversionIOS() /** - * When enabled, Android mounts transactions with the pull model (like iOS): the commit thread no longer pulls and builds the mount batch in schedulerShouldRenderTransactions. Instead the UI thread pulls the transaction itself via a PullTransactionMountItem enqueued in the MountItemDispatcher, builds the IntBufferBatchMountItem, and applies it synchronously. Requires `enableAccumulatedUpdatesInRawPropsAndroid` to be enabled as well, since a single pull may collapse several commits into one diff and therefore needs complete accumulated rawProps; when used together with Props 2.0, also enable `enableExclusivePropsUpdateAndroid` and `enablePropsUpdateReconciliationAndroid`. + * When enabled, Android mounts transactions with the pull model (like iOS): the commit thread no longer pulls and builds the mount batch in schedulerShouldRenderTransactions. Instead the UI thread pulls the transaction itself at its next mount dispatch, builds the IntBufferBatchMountItem, and applies it synchronously. Requires `enableAccumulatedUpdatesInRawPropsAndroid` to be enabled as well, since a single pull may collapse several commits into one diff and therefore needs complete accumulated rawProps; when used together with Props 2.0, also enable `enableExclusivePropsUpdateAndroid` and `enablePropsUpdateReconciliationAndroid`. */ @JvmStatic public fun enableMountingCoordinatorPullModelAndroid(): Boolean = accessor.enableMountingCoordinatorPullModelAndroid() diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.h b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.h index 8ee60a346224..21795ba98ce3 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.h @@ -72,7 +72,7 @@ class FabricMountingManager final { /* * Pull model: notify Java that a transaction is available for `surfaceId` so - * the UI thread can pull it via a PullTransactionMountItem. + * the UI thread pulls it at its next mount dispatch. */ void onTransactionAvailable(SurfaceId surfaceId); diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp index e5afef67d2f3..1899ca0a444f 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp @@ -730,8 +730,8 @@ void FabricUIManagerBinding::schedulerShouldRenderTransactions( if (ReactNativeFeatureFlags::enableMountingCoordinatorPullModelAndroid()) { // Pull model: do NOT pull the transaction or build the batch here (on the // commit thread). Just notify Java that a transaction is available; the UI - // thread will pull it via a PullTransactionMountItem and call back into - // `pullAndExecuteTransaction`. + // thread calls back into `pullAndExecuteTransaction` at its next mount + // dispatch. mountingManager->onTransactionAvailable( mountingCoordinator->getSurfaceId()); } else if (ReactNativeFeatureFlags:: diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricUIManagerPullModelTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricUIManagerPullModelTest.kt index f3bea15d3c84..e0796289dfc3 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricUIManagerPullModelTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/fabric/FabricUIManagerPullModelTest.kt @@ -9,14 +9,22 @@ package com.facebook.react.fabric +import android.os.Looper import com.facebook.react.ReactRootView 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.annotations.UnstableReactNativeAPI +import com.facebook.react.fabric.mounting.MountItemDispatcher import com.facebook.react.fabric.mounting.MountingManager +import com.facebook.react.fabric.mounting.mountitems.DispatchCommandMountItem 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.GuardedFrameCallback import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewManagerRegistry import com.facebook.testutils.fakes.FakeBatchEventDispatchedListener @@ -29,20 +37,23 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.util.ReflectionHelpers /** - * Tests for the pull-model notification path: [FabricUIManager.onTransactionAvailable] enqueues a - * PullTransactionMountItem that asks the (shadowed, JNI-backed) [FabricUIManagerBinding] to pull - * and apply the surface's pending transaction. + * Tests for the pull-model notification path: [FabricUIManager.onTransactionAvailable] marks the + * surface as having a transaction, and the next dispatch asks the (shadowed, JNI-backed) + * [FabricUIManagerBinding] to pull and apply it. * - * The Robolectric test thread is the UI thread, so a notification posted from it dispatches the - * queued mount items synchronously; notifications posted from a background thread stay queued until - * the next UI-thread dispatch (in production, the dispatcher's frame callback). + * The Robolectric test thread is the UI thread, so a notification posted from it dispatches + * synchronously; notifications posted from a background thread wait for the next UI-thread dispatch + * (in production, the dispatcher's frame callback). */ +@OptIn(UnstableReactNativeAPI::class) @RunWith(RobolectricTestRunner::class) @Config( shadows = @@ -59,15 +70,12 @@ class FabricUIManagerPullModelTest { private lateinit var underTest: FabricUIManager private lateinit var binding: FabricUIManagerBinding private lateinit var shadowBinding: ShadowFabricUIManagerBinding + private var didMountItemsCount = 0 @Before fun setup() { ReactNativeFeatureFlagsForTests.setUp() - ReactNativeFeatureFlags.override( - object : ReactNativeFeatureFlagsDefaults() { - override fun enableMountingCoordinatorPullModelAndroid(): Boolean = true - }, - ) + overrideFeatureFlags() reactContext = ReactTestHelper.createCatalystContextForTest() underTest = FabricUIManager( @@ -78,6 +86,21 @@ class FabricUIManagerPullModelTest { binding = FabricUIManagerBinding() shadowBinding = Shadow.extract(binding) underTest.setBinding(binding) + underTest.addUIManagerEventListener( + object : UIManagerListener { + override fun willDispatchViewUpdates(uiManager: UIManager) = Unit + + override fun willMountItems(uiManager: UIManager) = Unit + + override fun didMountItems(uiManager: UIManager) { + didMountItemsCount++ + } + + override fun didDispatchMountItems(uiManager: UIManager) = Unit + + override fun didScheduleMountItems(uiManager: UIManager) = Unit + } + ) } @After @@ -85,6 +108,50 @@ class FabricUIManagerPullModelTest { ReactNativeFeatureFlags.dangerouslyReset() } + private fun overrideFeatureFlags( + disableIdleMountItemFrameCallbackRearm: Boolean = false, + disableEarlyViewCommandExecution: Boolean = false, + ) { + ReactNativeFeatureFlags.dangerouslyReset() + ReactNativeFeatureFlags.override( + object : ReactNativeFeatureFlagsDefaults() { + override fun enableMountingCoordinatorPullModelAndroid(): Boolean = true + + override fun disableIdleMountItemFrameCallbackRearmAndroid(): Boolean = + disableIdleMountItemFrameCallbackRearm + + override fun disableEarlyViewCommandExecution(): Boolean = + disableEarlyViewCommandExecution + }, + ) + } + + /** + * Runs [block] with the frame callback that disarms at idle. The test drives its frames; the + * mocked Choreographer only records that one is scheduled. + */ + private fun withIdleFrameCallback(block: (frameCallback: GuardedFrameCallback) -> Unit) { + overrideFeatureFlags(disableIdleMountItemFrameCallbackRearm = true) + val originalChoreographer = ReactChoreographer.overrideInstanceForTest(mock()) + try { + val frameCallback: GuardedFrameCallback = + ReflectionHelpers.getField(underTest, "mDispatchUIFrameCallback") + ReflectionHelpers.setField(frameCallback, "mShouldSchedule", true) + block(frameCallback) + } finally { + ReactChoreographer.overrideInstanceForTest(originalChoreographer) + } + } + + private fun isFrameScheduled(frameCallback: GuardedFrameCallback): Boolean = + ReflectionHelpers.getField(frameCallback, "mIsScheduled") + + private val mountingManager: MountingManager + get() = ReflectionHelpers.getField(underTest, "mMountingManager") + + private val dispatcher: MountItemDispatcher + get() = ReflectionHelpers.getField(underTest, "mMountItemDispatcher") + private fun runOnBackgroundThread(block: () -> Unit) { var error: Throwable? = null val thread = Thread { @@ -121,6 +188,60 @@ class FabricUIManagerPullModelTest { method.invoke(underTest, mountItem, 0, 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0, true) } + private fun recordingMountItem(surfaceId: Int, name: String, log: MutableList) = + object : MountItem { + override fun execute(mountingManager: MountingManager) { + log.add(name) + } + + override fun getSurfaceId(): Int = surfaceId + } + + private fun recordingCommand(surfaceId: Int, name: String, log: MutableList) = + object : DispatchCommandMountItem() { + override fun execute(mountingManager: MountingManager) { + log.add(name) + } + + override fun getSurfaceId(): Int = surfaceId + } + + /** + * Stands in for a surface's MountingCoordinator: a commit notifies from a background (commit) + * thread, and a pull mounts every revision committed since the previous pull as one synchronous + * batch named "->", or mounts nothing when there is no new revision. + */ + private inner class PulledSurface(val surfaceId: Int, val log: MutableList) { + private var committedRevision = 0 + private var pulledRevision = 0 + + fun commit() { + committedRevision++ + runOnBackgroundThread { underTest.onTransactionAvailable(surfaceId) } + } + + fun commitOnUiThread() { + committedRevision++ + underTest.onTransactionAvailable(surfaceId) + } + + fun pull() { + if (committedRevision == pulledRevision) { + return + } + val batch = "$pulledRevision->$committedRevision" + pulledRevision = committedRevision + scheduleSynchronously(recordingMountItem(surfaceId, batch, log)) + } + } + + /** The revisions that the "->" batches mounted, in mount order. */ + private fun mountedRevisions(batches: List): List = + batches.flatMap { batch -> + val (from, to) = batch.split("->").map(String::toInt) + (from + 1..to).toList() + } + @Test fun onTransactionAvailable_onUiThread_pullsSynchronously() { underTest.onTransactionAvailable(1) @@ -129,47 +250,220 @@ class FabricUIManagerPullModelTest { } @Test - fun onTransactionAvailable_enqueuesOnePullPerNotification() { - // Mirrors iOS: one transaction block per commit, in notification order. Redundant pulls - // (revisions already consumed by an earlier accumulating pull) no-op in C++, not here. + fun onTransactionAvailable_pullsEachPendingSurfaceOnce() { + // A pull mounts every revision committed since the previous one, so further notifications for + // a surface that is already pending add nothing. runOnBackgroundThread { underTest.onTransactionAvailable(1) underTest.onTransactionAvailable(2) underTest.onTransactionAvailable(1) } - // Notifications from a background (commit) thread are queued, not dispatched. + // Notifications from a background (commit) thread are not dispatched. assertThat(shadowBinding.pulledSurfaceIds).isEmpty() - // The next UI-thread dispatch drains them in order. + // The next UI-thread dispatch pulls every pending surface. underTest.onTransactionAvailable(3) - assertThat(shadowBinding.pulledSurfaceIds).containsExactly(1, 2, 1, 3) + assertThat(shadowBinding.pulledSurfaceIds).containsExactlyInAnyOrder(1, 2, 3) } @Test - fun synchronousBatch_waitingForRootAttach_isDeferred() { + fun pullDeferredByRootAttach_isAppliedBeforeLaterPulls() { val surfaceId = 12 val themedReactContext = ThemedReactContext(reactContext, reactContext, "TestModule", surfaceId) - val mountingManager = ReflectionHelpers.getField(underTest, "mMountingManager") mountingManager.startSurface(surfaceId, themedReactContext, null) + val appliedBatches = mutableListOf() + val surface = PulledSurface(surfaceId, appliedBatches) + shadowBinding.onPull = { surface.pull() } - var executionCount = 0 - val mountItem = - object : MountItem { - override fun execute(mountingManager: MountingManager) { - executionCount++ - } + surface.commit() + dispatcher.tryDispatchMountItems() // the root view is not attached, so nothing mounts yet + surface.commit() + mountingManager.attachRootView(surfaceId, ReactRootView(reactContext), themedReactContext) + surface.commit() + dispatcher.tryDispatchMountItems() + + assertThat(mountedRevisions(appliedBatches)).containsExactly(1, 2, 3) + } + + @Test + fun preallocationQueuedBeforeDispatch_runsBeforeThePull() { + val log = mutableListOf() + val surface = PulledSurface(11, log) + shadowBinding.onPull = { surface.pull() } + + surface.commit() + runOnBackgroundThread { + dispatcher.addPreAllocateMountItem(recordingMountItem(11, "preallocate", log)) + } + dispatcher.tryDispatchMountItems() + + assertThat(log).containsExactly("preallocate", "0->1") + } - override fun getSurfaceId(): Int = surfaceId + @Test + fun mountItemQueuedBeforeCommit_runsAfterThePull() { + // Pending pulls run before every queued mount item, whatever their notification order. + val log = mutableListOf() + val surface = PulledSurface(11, log) + shadowBinding.onPull = { surface.pull() } + + runOnBackgroundThread { dispatcher.addMountItem(recordingMountItem(11, "event", log)) } + surface.commit() + dispatcher.tryDispatchMountItems() + + assertThat(log).containsExactly("0->1", "event") + } + + @Test + fun pendingPullOfStoppedSurface_runsOnceAndIsDropped() { + // The C++ pull of a stopped surface finds no surface and mounts nothing. + val surfaceId = 12 + val themedReactContext = ThemedReactContext(reactContext, reactContext, "TestModule", surfaceId) + mountingManager.startSurface(surfaceId, themedReactContext, null) + runOnBackgroundThread { underTest.onTransactionAvailable(surfaceId) } + dispatcher.tryDispatchMountItems() // waits for the root view + + mountingManager.stopSurface(surfaceId) + dispatcher.tryDispatchMountItems() + dispatcher.tryDispatchMountItems() + + assertThat(shadowBinding.pulledSurfaceIds).containsExactly(surfaceId) + } + + @Test + fun pull_runsMountHooks() { + val surface = PulledSurface(11, mutableListOf()) + shadowBinding.onPull = { surface.pull() } + + surface.commit() + dispatcher.tryDispatchMountItems() + shadowOf(Looper.getMainLooper()).idle() + + assertThat(didMountItemsCount).isEqualTo(1) + assertThat(shadowBinding.reportedMountSurfaceIds).containsExactly(11) + } + + @Test + fun pullWaitingForRootAttach_isMountedByTheFrameAfterTheAttach() { + // With the idle frame callback, nothing may keep frames running while the root is detached, so + // the attach must schedule the frame that mounts the surface. + withIdleFrameCallback { frameCallback -> + val surfaceId = 12 + val themedReactContext = + ThemedReactContext(reactContext, reactContext, "TestModule", surfaceId) + mountingManager.startSurface(surfaceId, themedReactContext, null) + val log = mutableListOf() + val surface = PulledSurface(surfaceId, log) + shadowBinding.onPull = { surface.pull() } + + surface.commit() + runOnBackgroundThread { + dispatcher.addPreAllocateMountItem(recordingMountItem(surfaceId, "preallocate", log)) + } + frameCallback.doFrame(System.nanoTime()) + + assertThat(log).isEmpty() + assertThat(isFrameScheduled(frameCallback)).isFalse() + + mountingManager.attachRootView(surfaceId, ReactRootView(reactContext), themedReactContext) + + assertThat(isFrameScheduled(frameCallback)).isTrue() + + frameCallback.doFrame(System.nanoTime()) + + assertThat(log).containsExactly("preallocate", "0->1") + } + } + + @Test + fun rootAttachWithoutPendingPull_doesNotScheduleFrame() { + withIdleFrameCallback { frameCallback -> + val surfaceId = 12 + val themedReactContext = + ThemedReactContext(reactContext, reactContext, "TestModule", surfaceId) + mountingManager.startSurface(surfaceId, themedReactContext, null) + + mountingManager.attachRootView(surfaceId, ReactRootView(reactContext), themedReactContext) + + assertThat(isFrameScheduled(frameCallback)).isFalse() + } + } + + @Test + fun pendingPull_keepsTheIdleFrameCallbackArmed() { + withIdleFrameCallback { frameCallback -> + val firstLog = mutableListOf() + val secondLog = mutableListOf() + val surfaces = mapOf(11 to PulledSurface(11, firstLog), 12 to PulledSurface(12, secondLog)) + shadowBinding.onPull = { surfaceId -> + surfaces.getValue(surfaceId).pull() + if (surfaceId == 11) { + // The commit thread notifies while the frame mounts surface 11. + surfaces.getValue(12).commit() } + } - scheduleSynchronously(mountItem) + surfaces.getValue(11).commit() + frameCallback.doFrame(System.nanoTime()) - assertThat(executionCount).isZero() + assertThat(secondLog).isEmpty() + assertThat(isFrameScheduled(frameCallback)).isTrue() - mountingManager.attachRootView(surfaceId, ReactRootView(reactContext), themedReactContext) - underTest.onTransactionAvailable(surfaceId) + frameCallback.doFrame(System.nanoTime()) + + assertThat(firstLog).containsExactly("0->1") + assertThat(secondLog).containsExactly("0->1") + assertThat(isFrameScheduled(frameCallback)).isFalse() + } + } + + @Test + fun commitFromUiThreadDuringPull_isMountedByTheNextFrame() { + // The nested dispatch returns early, so the pending pull alone must schedule the next frame. + withIdleFrameCallback { frameCallback -> + val log = mutableListOf() + val surface = PulledSurface(11, log) + var committedDuringPull = false + shadowBinding.onPull = { + surface.pull() + if (!committedDuringPull) { + committedDuringPull = true + surface.commitOnUiThread() + } + } + + surface.commit() + frameCallback.doFrame(System.nanoTime()) + + assertThat(log).containsExactly("0->1") + assertThat(isFrameScheduled(frameCallback)).isTrue() + + frameCallback.doFrame(System.nanoTime()) + + assertThat(log).containsExactly("0->1", "1->2") + assertThat(isFrameScheduled(frameCallback)).isFalse() + } + } + + @Test + fun viewCommandsInMountItemOrder_runAfterThePull() { + // With disableEarlyViewCommandExecution, view commands go through the mount item queue. A + // command dispatched before a commit's notification runs after that commit mounts. + overrideFeatureFlags(disableEarlyViewCommandExecution = true) + val log = mutableListOf() + val surface = PulledSurface(11, log) + shadowBinding.onPull = { surface.pull() } + + runOnBackgroundThread { + dispatcher.addViewCommandMountItem(recordingCommand(11, "commandBeforeCommit", log)) + } + surface.commit() + runOnBackgroundThread { + dispatcher.addViewCommandMountItem(recordingCommand(11, "commandAfterCommit", log)) + } + dispatcher.tryDispatchMountItems() - assertThat(executionCount).isEqualTo(1) + assertThat(log).containsExactly("0->1", "commandBeforeCommit", "commandAfterCommit") } } diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/shadows/ShadowFabricUIManagerBinding.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/shadows/ShadowFabricUIManagerBinding.kt index a9fbbf95e9f8..ffade439c192 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/shadows/ShadowFabricUIManagerBinding.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/testutils/shadows/ShadowFabricUIManagerBinding.kt @@ -18,9 +18,20 @@ import org.robolectric.annotation.Implements @Implements(className = "com.facebook.react.fabric.FabricUIManagerBinding") class ShadowFabricUIManagerBinding { val pulledSurfaceIds: MutableList = mutableListOf() + val reportedMountSurfaceIds: MutableList = mutableListOf() + /** Stands in for the C++ pull and mount. */ + var onPull: (surfaceId: Int) -> Unit = {} @Implementation fun pullAndExecuteTransaction(surfaceId: Int) { pulledSurfaceIds.add(surfaceId) + onPull(surfaceId) } + + @Implementation + fun reportMount(surfaceId: Int) { + reportedMountSurfaceIds.add(surfaceId) + } + + @Implementation fun drainPreallocateViewsQueue() = Unit } diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativeFeatureFlags.h index cd4d40533d5f..e6cf6332eaec 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<<7610401931a7e91428feb2e5f54e16cb>> */ /** @@ -257,7 +257,7 @@ class ReactNativeFeatureFlags { RN_EXPORT static bool enableModuleArgumentNSNullConversionIOS(); /** - * When enabled, Android mounts transactions with the pull model (like iOS): the commit thread no longer pulls and builds the mount batch in schedulerShouldRenderTransactions. Instead the UI thread pulls the transaction itself via a PullTransactionMountItem enqueued in the MountItemDispatcher, builds the IntBufferBatchMountItem, and applies it synchronously. Requires `enableAccumulatedUpdatesInRawPropsAndroid` to be enabled as well, since a single pull may collapse several commits into one diff and therefore needs complete accumulated rawProps; when used together with Props 2.0, also enable `enableExclusivePropsUpdateAndroid` and `enablePropsUpdateReconciliationAndroid`. + * When enabled, Android mounts transactions with the pull model (like iOS): the commit thread no longer pulls and builds the mount batch in schedulerShouldRenderTransactions. Instead the UI thread pulls the transaction itself at its next mount dispatch, builds the IntBufferBatchMountItem, and applies it synchronously. Requires `enableAccumulatedUpdatesInRawPropsAndroid` to be enabled as well, since a single pull may collapse several commits into one diff and therefore needs complete accumulated rawProps; when used together with Props 2.0, also enable `enableExclusivePropsUpdateAndroid` and `enablePropsUpdateReconciliationAndroid`. */ RN_EXPORT static bool enableMountingCoordinatorPullModelAndroid(); diff --git a/packages/react-native/ReactCommon/react/featureflags/ReactNativePublicFeatureFlags.h b/packages/react-native/ReactCommon/react/featureflags/ReactNativePublicFeatureFlags.h index 542d15a59baa..7f688dfe3552 100644 --- a/packages/react-native/ReactCommon/react/featureflags/ReactNativePublicFeatureFlags.h +++ b/packages/react-native/ReactCommon/react/featureflags/ReactNativePublicFeatureFlags.h @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<571d4f35aacf5086c088bdc8dd1abd33>> */ /** @@ -48,7 +48,7 @@ class ReactNativeFeatureFlags_DO_NOT_USE { RN_EXPORT static bool enableExclusivePropsUpdateAndroid(); /** - * When enabled, Android mounts transactions with the pull model (like iOS): the commit thread no longer pulls and builds the mount batch in schedulerShouldRenderTransactions. Instead the UI thread pulls the transaction itself via a PullTransactionMountItem enqueued in the MountItemDispatcher, builds the IntBufferBatchMountItem, and applies it synchronously. Requires `enableAccumulatedUpdatesInRawPropsAndroid` to be enabled as well, since a single pull may collapse several commits into one diff and therefore needs complete accumulated rawProps; when used together with Props 2.0, also enable `enableExclusivePropsUpdateAndroid` and `enablePropsUpdateReconciliationAndroid`. + * When enabled, Android mounts transactions with the pull model (like iOS): the commit thread no longer pulls and builds the mount batch in schedulerShouldRenderTransactions. Instead the UI thread pulls the transaction itself at its next mount dispatch, builds the IntBufferBatchMountItem, and applies it synchronously. Requires `enableAccumulatedUpdatesInRawPropsAndroid` to be enabled as well, since a single pull may collapse several commits into one diff and therefore needs complete accumulated rawProps; when used together with Props 2.0, also enable `enableExclusivePropsUpdateAndroid` and `enablePropsUpdateReconciliationAndroid`. */ RN_EXPORT static bool enableMountingCoordinatorPullModelAndroid(); diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 0a76242a2c75..2d288250f025 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -517,7 +517,7 @@ const definitions: FeatureFlagDefinitions = { metadata: { dateAdded: '2026-07-14', description: - 'When enabled, Android mounts transactions with the pull model (like iOS): the commit thread no longer pulls and builds the mount batch in schedulerShouldRenderTransactions. Instead the UI thread pulls the transaction itself via a PullTransactionMountItem enqueued in the MountItemDispatcher, builds the IntBufferBatchMountItem, and applies it synchronously. Requires `enableAccumulatedUpdatesInRawPropsAndroid` to be enabled as well, since a single pull may collapse several commits into one diff and therefore needs complete accumulated rawProps; when used together with Props 2.0, also enable `enableExclusivePropsUpdateAndroid` and `enablePropsUpdateReconciliationAndroid`.', + 'When enabled, Android mounts transactions with the pull model (like iOS): the commit thread no longer pulls and builds the mount batch in schedulerShouldRenderTransactions. Instead the UI thread pulls the transaction itself at its next mount dispatch, builds the IntBufferBatchMountItem, and applies it synchronously. Requires `enableAccumulatedUpdatesInRawPropsAndroid` to be enabled as well, since a single pull may collapse several commits into one diff and therefore needs complete accumulated rawProps; when used together with Props 2.0, also enable `enableExclusivePropsUpdateAndroid` and `enablePropsUpdateReconciliationAndroid`.', 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 1f474567697e..6c47d40dce64 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<<4eb26b4190d023d26a50f54b6e3ea5eb>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -398,7 +398,7 @@ export const enableLayoutAnimationsOnIOS: Getter = createNativeFlagGett */ export const enableModuleArgumentNSNullConversionIOS: Getter = createNativeFlagGetter('enableModuleArgumentNSNullConversionIOS', false); /** - * When enabled, Android mounts transactions with the pull model (like iOS): the commit thread no longer pulls and builds the mount batch in schedulerShouldRenderTransactions. Instead the UI thread pulls the transaction itself via a PullTransactionMountItem enqueued in the MountItemDispatcher, builds the IntBufferBatchMountItem, and applies it synchronously. Requires `enableAccumulatedUpdatesInRawPropsAndroid` to be enabled as well, since a single pull may collapse several commits into one diff and therefore needs complete accumulated rawProps; when used together with Props 2.0, also enable `enableExclusivePropsUpdateAndroid` and `enablePropsUpdateReconciliationAndroid`. + * When enabled, Android mounts transactions with the pull model (like iOS): the commit thread no longer pulls and builds the mount batch in schedulerShouldRenderTransactions. Instead the UI thread pulls the transaction itself at its next mount dispatch, builds the IntBufferBatchMountItem, and applies it synchronously. Requires `enableAccumulatedUpdatesInRawPropsAndroid` to be enabled as well, since a single pull may collapse several commits into one diff and therefore needs complete accumulated rawProps; when used together with Props 2.0, also enable `enableExclusivePropsUpdateAndroid` and `enablePropsUpdateReconciliationAndroid`. */ export const enableMountingCoordinatorPullModelAndroid: Getter = createNativeFlagGetter('enableMountingCoordinatorPullModelAndroid', false); /**