Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1227,6 +1227,21 @@ public void receiveEvent(
return;
}

// If a previous event for this tag was queued for the UI thread but has not yet been
// dispatched, route this one through the same path so that JS observes events in receive
// order. Synchronous dispatches keep their fast path.
if (!experimentalIsSynchronous && mMountingManager.hasPendingEvents(surfaceId, reactTag)) {
mMountingManager.enqueuePendingEvent(
surfaceId,
reactTag,
eventName,
canCoalesceEvent,
params,
eventCategory,
eventTimestamp);
return;
}

if (experimentalIsSynchronous) {
UiThreadUtil.assertOnUiThread();
// add() returns true only if there are no equivalent events already in the set
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,11 @@ internal class MountingManager(
)
}

@AnyThread
@ThreadConfined(ThreadConfined.ANY)
fun hasPendingEvents(surfaceId: Int, reactTag: Int): Boolean =
getSurfaceMountingManager(surfaceId, reactTag)?.hasPendingEvents(reactTag) ?: false

private fun getSurfaceMountingManager(surfaceId: Int, reactTag: Int): SurfaceMountingManager? =
if (surfaceId == ViewUtil.NO_SURFACE_ID) getSurfaceManagerForView(reactTag)
else getSurfaceManager(surfaceId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import java.util.ArrayDeque
import java.util.LinkedList
import java.util.Queue
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import kotlin.concurrent.Volatile

/** Returns true if the collection contains [key]. */
Expand Down Expand Up @@ -1151,19 +1152,35 @@ internal constructor(

val viewEvent =
PendingViewEvent(eventName, params, eventCategory, canCoalesceEvent, eventTimestamp)
// Mark that an enqueue is in flight before posting; readers on other threads must observe this
// before they decide to dispatch directly, so events stay in receive order.
viewState.pendingEventOps.incrementAndGet()
UiThreadUtil.runOnUiThread {
val eventEmitter = viewState.eventEmitter
if (eventEmitter != null) {
viewEvent.dispatch(eventEmitter)
} else {
val queue =
viewState.pendingEventQueue
?: LinkedList<PendingViewEvent>().also { viewState.pendingEventQueue = it }
queue.add(viewEvent)
try {
val eventEmitter = viewState.eventEmitter
if (eventEmitter != null) {
viewEvent.dispatch(eventEmitter)
} else {
val queue =
viewState.pendingEventQueue
?: LinkedList<PendingViewEvent>().also { viewState.pendingEventQueue = it }
queue.add(viewEvent)
}
} finally {
viewState.pendingEventOps.decrementAndGet()
}
}
}

/**
* Returns true if an enqueuePendingEvent call for [reactTag] has been posted to the UI thread but
* has not yet executed. Callers should route subsequent events through [enqueuePendingEvent] in
* that case to preserve receive order.
*/
@AnyThread
internal fun hasPendingEvents(reactTag: Int): Boolean =
(tagToViewState[reactTag]?.pendingEventOps?.get() ?: 0) > 0

public fun markActiveTouchForTag(reactTag: Int): Unit {
viewsWithActiveTouches.add(reactTag)
}
Expand Down Expand Up @@ -1192,6 +1209,10 @@ internal constructor(

@ThreadConfined(ThreadConfined.UI) var pendingEventQueue: Queue<PendingViewEvent>? = null

// Tracks enqueuePendingEvent operations posted to the UI thread but not yet executed. Read from
// any thread to detect in-flight events that must not be bypassed by a direct dispatch.
val pendingEventOps: AtomicInteger = AtomicInteger(0)

override fun toString(): String {
val isLayoutOnly = viewManager == null
return "ViewState [$reactTag] - isRoot: $isRoot - props: $currentProps - viewManager: $viewManager - isLayoutOnly: $isLayoutOnly"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* 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 com.facebook.react.ReactRootView
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.bridge.ReactTestHelper
import com.facebook.react.fabric.mounting.MountingManager
import com.facebook.react.fabric.mounting.MountingManager.MountItemExecutor
import com.facebook.react.fabric.mounting.SurfaceMountingManager
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlagsForTests
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewManager
import com.facebook.react.uimanager.ViewManagerRegistry
import com.facebook.react.uimanager.events.EventCategoryDef
import com.facebook.react.views.view.ReactViewManager
import com.facebook.testutils.shadows.ShadowSoLoader
import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
import org.robolectric.annotation.LooperMode

/**
* Regression tests for issue #54636: events emitted before [SurfaceMountingManager.updateEventEmitter]
* has run must reach JS in receive order, even if a later event happens to find the emitter ready.
*
* The fix relies on [SurfaceMountingManager.hasPendingEvents] reflecting that an enqueue lambda
* has been posted to the UI thread but not yet executed, so callers can route subsequent events
* through the same queue path instead of dispatching them directly.
*/
@RunWith(RobolectricTestRunner::class)
@LooperMode(LooperMode.Mode.PAUSED)
@Config(shadows = [ShadowSoLoader::class])
class SurfaceMountingManagerEventOrderingTest {
private lateinit var mountingManager: MountingManager
private lateinit var themedReactContext: ThemedReactContext
private val surfaceId = 1
private val reactTag = 42

@Before
fun setUp() {
ReactNativeFeatureFlagsForTests.setUp()
val reactContext = ReactTestHelper.createCatalystContextForTest()
themedReactContext = ThemedReactContext(reactContext, reactContext, null, -1)
mountingManager =
MountingManager(
ViewManagerRegistry(listOf<ViewManager<*, *>>(ReactViewManager())),
MountItemExecutor {},
)
}

private fun startSurfaceWithView(): SurfaceMountingManager {
val rootView = ReactRootView(themedReactContext)
mountingManager.startSurface(surfaceId, themedReactContext, rootView)
val smm = mountingManager.getSurfaceManagerEnforced(surfaceId, "test")
smm.preallocateView("RCTView", reactTag, JavaOnlyMap.of(), null, true)
return smm
}

/**
* After enqueuePendingEvent posts its UI-thread lambda, hasPendingEvents must return true so
* that any concurrent direct-dispatch path knows to also queue rather than overtake.
*/
@Test
fun hasPendingEvents_isTrue_whileEnqueueLambdaIsInFlight() {
val smm = startSurfaceWithView()

assertThat(smm.hasPendingEvents(reactTag)).isFalse()

smm.enqueuePendingEvent(
reactTag, "topChange", false, null, EventCategoryDef.UNSPECIFIED, 0L)

assertThat(smm.hasPendingEvents(reactTag)).isTrue()

shadowOf(Looper.getMainLooper()).idle()

assertThat(smm.hasPendingEvents(reactTag)).isFalse()
}

/**
* MountingManager.hasPendingEvents must mirror the SurfaceMountingManager state so callers above
* the surface layer (e.g. FabricUIManager.receiveEvent) can consult it.
*/
@Test
fun mountingManager_hasPendingEvents_mirrorsSurfaceMountingManager() {
startSurfaceWithView()

assertThat(mountingManager.hasPendingEvents(surfaceId, reactTag)).isFalse()

mountingManager.enqueuePendingEvent(
surfaceId, reactTag, "topChange", false, null, EventCategoryDef.UNSPECIFIED, 0L)

assertThat(mountingManager.hasPendingEvents(surfaceId, reactTag)).isTrue()

shadowOf(Looper.getMainLooper()).idle()

assertThat(mountingManager.hasPendingEvents(surfaceId, reactTag)).isFalse()
}

/**
* The counter must match the number of in-flight enqueue lambdas, so that interleaved enqueue
* + direct-route-to-enqueue calls are all accounted for and only fall back to false once the UI
* thread has fully drained them.
*/
@Test
fun hasPendingEvents_remainsTrue_acrossMultipleEnqueues() {
val smm = startSurfaceWithView()

smm.enqueuePendingEvent(
reactTag, "topChange", false, null, EventCategoryDef.UNSPECIFIED, 0L)
smm.enqueuePendingEvent(
reactTag, "topChange", false, null, EventCategoryDef.UNSPECIFIED, 1L)
smm.enqueuePendingEvent(
reactTag, "topChange", false, null, EventCategoryDef.UNSPECIFIED, 2L)

assertThat(smm.hasPendingEvents(reactTag)).isTrue()

shadowOf(Looper.getMainLooper()).idle()

assertThat(smm.hasPendingEvents(reactTag)).isFalse()
}

/** Tags with no enqueue activity must not report pending events. */
@Test
fun hasPendingEvents_isFalse_forUnrelatedTag() {
val smm = startSurfaceWithView()
val otherTag = reactTag + 1
smm.preallocateView("RCTView", otherTag, JavaOnlyMap.of(), null, true)

smm.enqueuePendingEvent(
reactTag, "topChange", false, null, EventCategoryDef.UNSPECIFIED, 0L)

assertThat(smm.hasPendingEvents(reactTag)).isTrue()
assertThat(smm.hasPendingEvents(otherTag)).isFalse()

shadowOf(Looper.getMainLooper()).idle()
}

/** Calling enqueuePendingEvent for a tag without view state must not falsely flag it. */
@Test
fun hasPendingEvents_isFalse_forUnknownTag() {
startSurfaceWithView()
val unknownTag = reactTag + 99

mountingManager.enqueuePendingEvent(
surfaceId, unknownTag, "topChange", false, null, EventCategoryDef.UNSPECIFIED, 0L)

assertThat(mountingManager.hasPendingEvents(surfaceId, unknownTag)).isFalse()
}
}