From 7ea17c8ba60cd0615e18127676c986913277906b Mon Sep 17 00:00:00 2001 From: DevmateUtgenCppGeneralFBOrg Bot Date: Sun, 2 Aug 2026 06:11:45 -0700 Subject: [PATCH] xplat/js/react-native-github/packages/react-native/ReactAndroid/src/main/jni/react/fabric/EventEmitterWrapper.cpp Differential Revision: D113548144 --- .../fabric/test/EventEmitterWrapperTest.cpp | 247 ++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/EventEmitterWrapperTest.cpp diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/EventEmitterWrapperTest.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/EventEmitterWrapperTest.cpp new file mode 100644 index 000000000000..6f8237dc2191 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/jni/react/fabric/test/EventEmitterWrapperTest.cpp @@ -0,0 +1,247 @@ +/* + * 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. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +/* + * Pure-C++ unit tests for `EventEmitterWrapper`, the JNI adapter that bridges + * Java-side event dispatch to the C++ `EventEmitter`. The three public methods + * (`dispatchEvent`, `dispatchUniqueEvent`, `dispatchEventSynchronously`) are + * plain C++ member functions; the only JNI-coupled argument is the + * `NativeMap* payload`, which the wrapper explicitly treats as optional. By + * passing `nullptr` for the payload we exercise the full forwarding logic + * without any attached JavaVM. + * + * Forwarding is observed by wiring the wrapper to a real `EventEmitter` backed + * by a real `EventDispatcher`, and installing an `EventListener` on that + * dispatcher. `EventDispatcher::dispatchEvent`/`dispatchUniqueEvent` invoke the + * listener chain synchronously *before* enqueueing; a listener that returns + * `true` interrupts default dispatch, letting us capture the fully-formed + * `RawEvent` (normalized type, category, uniqueness, timestamp) without needing + * a `jsi::Runtime` or a real event beat to flush the queue. + * + * `EventEmitterWrapper` derives from `jni::HybridClass`, but with the default + * base its C++ part is just a `detail::BaseHybridClass` (a class with a virtual + * destructor and no JNI state), so instances can be constructed directly on the + * stack host-side. + */ +namespace facebook::react { +namespace { + +// Snapshot of the RawEvent that reached the dispatcher's listener chain. +struct DispatchRecord { + bool dispatched{false}; + std::string type; + RawEvent::Category category{RawEvent::Category::Unspecified}; + bool isUnique{false}; + HighResTimeStamp timestamp{HighResTimeStamp::now()}; +}; + +// EventBeat that records synchronous-flush requests. The base `request()` and +// `requestSynchronous()` only flip atomic flags and never dereference the +// `RuntimeScheduler` (that happens in `induce()`, which the interrupt-based +// listener path never triggers), so overriding `requestSynchronous()` to count +// invocations lets us assert that `dispatchEventSynchronously` routes through +// `EventDispatcher::experimental_flushSync`. +class RecordingEventBeat : public EventBeat { + public: + RecordingEventBeat( + std::shared_ptr ownerBox, + RuntimeScheduler& runtimeScheduler, + int& syncFlushCount) + : EventBeat(std::move(ownerBox), runtimeScheduler), + syncFlushCount_(syncFlushCount) {} + + void requestSynchronous() const override { + ++syncFlushCount_; + } + + private: + int& syncFlushCount_; +}; + +} // namespace + +class EventEmitterWrapperTest : public ::testing::Test { + protected: + void SetUp() override { + // A no-op runtime executor is sufficient: it is only invoked when the + // event beat is induced, which never happens because the listener + // interrupts dispatch before anything is enqueued. + runtimeScheduler_ = std::make_unique(RuntimeExecutor{}); + + record_ = std::make_shared(); + + EventQueueProcessor eventProcessor( + EventPipe{}, + EventPipeConclusion{}, + StatePipe{}, + std::weak_ptr{}); + + auto eventBeat = std::make_unique( + std::make_shared(), + *runtimeScheduler_, + syncFlushCount_); + + dispatcher_ = std::make_shared( + eventProcessor, + std::move(eventBeat), + StatePipe{}, + std::weak_ptr{}); + + auto record = record_; + listener_ = + std::make_shared([record](const RawEvent& event) { + record->dispatched = true; + record->type = event.type; + record->category = event.category; + record->isUnique = event.isUnique; + record->timestamp = event.eventStartTimeStamp; + // Interrupt default dispatch so the event is never enqueued/flushed. + return true; + }); + dispatcher_->addListener(listener_); + + emitter_ = std::make_shared( + /*eventTarget=*/nullptr, EventDispatcher::Weak(dispatcher_)); + } + + // Returns the milliseconds-since-steady-clock-epoch encoded in a timestamp + // produced by the wrapper, so tests can assert the millis->HighResTimeStamp + // conversion preserves the value and unit. + static int64_t millisSinceEpoch(HighResTimeStamp timestamp) { + return std::chrono::duration_cast( + timestamp.toChronoSteadyClockTimePoint().time_since_epoch()) + .count(); + } + + std::unique_ptr runtimeScheduler_; + std::shared_ptr record_; + std::shared_ptr dispatcher_; + std::shared_ptr listener_; + SharedEventEmitter emitter_; + int syncFlushCount_{0}; +}; + +/* + * `dispatchEvent` must (a) normalize the raw JS event name to its "top" form, + * (b) forward the integer category verbatim as a `RawEvent::Category`, and + * (c) convert the Java uptime-millis timestamp into a HighResTimeStamp that + * represents the same number of milliseconds. It must NOT force a synchronous + * flush. + * + * Bug this catches: mis-casting the category (e.g. hardcoding a value), or a + * unit error in the timestamp conversion (treating millis as nanos/seconds). + */ +TEST_F( + EventEmitterWrapperTest, + dispatchEventForwardsNormalizedNameCategoryAndTimestamp) { + EventEmitterWrapper wrapper(emitter_); + constexpr jlong kEventTimestampMillis = 1234; + + wrapper.dispatchEvent( + "onScroll", + /*payload=*/nullptr, + static_cast(RawEvent::Category::Continuous), + kEventTimestampMillis); + + EXPECT_TRUE(record_->dispatched); + EXPECT_EQ("topScroll", record_->type); + EXPECT_EQ(RawEvent::Category::Continuous, record_->category); + EXPECT_FALSE(record_->isUnique); + EXPECT_EQ(kEventTimestampMillis, millisSinceEpoch(record_->timestamp)); + // Asynchronous events must not trigger a synchronous flush. + EXPECT_EQ(0, syncFlushCount_); +} + +/* + * `dispatchUniqueEvent` must forward through + * `EventEmitter::dispatchUniqueEvent`, which marks the RawEvent as unique and + * tags it as `Continuous`. Uniqueness is what lets the event queue coalesce + * repeated events (e.g. onLayout) for the same target. + * + * Bug this catches: routing a unique event through the non-unique dispatch path + * would drop the `isUnique` flag and defeat coalescing. + */ +TEST_F(EventEmitterWrapperTest, dispatchUniqueEventMarksEventUnique) { + EventEmitterWrapper wrapper(emitter_); + constexpr jlong kEventTimestampMillis = 5000; + + wrapper.dispatchUniqueEvent( + "onLayout", /*payload=*/nullptr, kEventTimestampMillis); + + EXPECT_TRUE(record_->dispatched); + EXPECT_EQ("topLayout", record_->type); + EXPECT_TRUE(record_->isUnique); + EXPECT_EQ(RawEvent::Category::Continuous, record_->category); + EXPECT_EQ(kEventTimestampMillis, millisSinceEpoch(record_->timestamp)); +} + +/* + * `dispatchEventSynchronously` must (a) force the `Discrete` category + * regardless of the caller, and (b) route through + * `EventEmitter::experimental_flushSync`, which asks the event beat for a + * synchronous flush. This is what makes synchronous events (e.g. controlled + * text input) observe their effects before returning to Java. + * + * Bug this catches: dropping the synchronous flush (making the call behave like + * an ordinary async dispatch) or using the wrong category. + */ +TEST_F( + EventEmitterWrapperTest, + dispatchEventSynchronouslyUsesDiscreteCategoryAndFlushesSync) { + EventEmitterWrapper wrapper(emitter_); + + wrapper.dispatchEventSynchronously( + "onChange", /*params=*/nullptr, /*eventTimestamp=*/42); + + EXPECT_TRUE(record_->dispatched); + EXPECT_EQ("topChange", record_->type); + EXPECT_EQ(RawEvent::Category::Discrete, record_->category); + EXPECT_EQ(1, syncFlushCount_); +} + +/* + * A wrapper can be constructed without a valid `EventEmitter` (the source + * comments call this "marginal, but possible"). In that state every dispatch + * method must black-hole the event: no crash, and nothing is forwarded. + * + * Bug this catches: removing the `eventEmitter != nullptr` guard would + * dereference a null shared_ptr and crash instead of no-op'ing. + */ +TEST_F(EventEmitterWrapperTest, dispatchOnNullEventEmitterIsNoop) { + EventEmitterWrapper wrapper(/*eventEmitter=*/nullptr); + + wrapper.dispatchEvent( + "onScroll", + /*payload=*/nullptr, + static_cast(RawEvent::Category::Discrete), + /*eventTimestamp=*/100); + wrapper.dispatchUniqueEvent( + "onLayout", /*payload=*/nullptr, /*eventTimestamp=*/100); + wrapper.dispatchEventSynchronously( + "onChange", /*params=*/nullptr, /*eventTimestamp=*/100); + + EXPECT_FALSE(record_->dispatched); + EXPECT_EQ(0, syncFlushCount_); +} + +} // namespace facebook::react