diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 2f629f2093ae..7735f7f6aff7 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -967,6 +967,7 @@ public abstract class com/facebook/react/bridge/ReactContext : android/content/C public abstract fun getNativeModule (Ljava/lang/String;)Lcom/facebook/react/bridge/NativeModule; public abstract fun getNativeModules ()Ljava/util/Collection; public fun getNativeModulesMessageQueueThread ()Lcom/facebook/react/bridge/queue/MessageQueueThread; + public abstract fun getRuntimeExecutor ()Lcom/facebook/react/bridge/RuntimeExecutor; public fun getScrollEndedListeners ()Lcom/facebook/react/bridge/ScrollEndedListeners; public abstract fun getSourceURL ()Ljava/lang/String; public fun getSystemService (Ljava/lang/String;)Ljava/lang/Object; @@ -4162,6 +4163,7 @@ public final class com/facebook/react/uimanager/ThemedReactContext : com/faceboo public fun getNativeModule (Ljava/lang/String;)Lcom/facebook/react/bridge/NativeModule; public fun getNativeModules ()Ljava/util/Collection; public final fun getReactApplicationContext ()Lcom/facebook/react/bridge/ReactApplicationContext; + public fun getRuntimeExecutor ()Lcom/facebook/react/bridge/RuntimeExecutor; public fun getScrollEndedListeners ()Lcom/facebook/react/bridge/ScrollEndedListeners; public fun getSourceURL ()Ljava/lang/String; public final fun getSurfaceID ()Ljava/lang/String; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/BridgeReactContext.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/BridgeReactContext.java index 1ccb8abad435..71a932c5774c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/BridgeReactContext.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/BridgeReactContext.java @@ -312,4 +312,9 @@ public void registerSegment(int segmentId, String path, Callback callback) { Assertions.assertNotNull(mCatalystInstance).registerSegment(segmentId, path); Assertions.assertNotNull(callback).invoke(); } + + @Override + public @Nullable RuntimeExecutor getRuntimeExecutor() { + return mCatalystInstance == null ? null : mCatalystInstance.getRuntimeExecutor(); + } } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java index e9e89413d451..8bf13e1cf383 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactContext.java @@ -200,6 +200,12 @@ public LifecycleState getLifecycleState() { return mLifecycleState; } + /** + * Returns the {@link RuntimeExecutor} for the underlying JavaScript runtime, or {@code null} if + * the runtime has not been initialized. Works in both bridged and bridgeless modes. + */ + public abstract @Nullable RuntimeExecutor getRuntimeExecutor(); + /** * This allows scroll views to notify NativeAnimatedModule when user-driven scrolling ends. * diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/BridgelessReactContext.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/BridgelessReactContext.kt index 936dc6c99537..bfaf55b140d3 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/BridgelessReactContext.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/BridgelessReactContext.kt @@ -20,6 +20,7 @@ import com.facebook.react.bridge.JavaScriptModuleRegistry import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactSoftExceptionLogger.logSoftException +import com.facebook.react.bridge.RuntimeExecutor import com.facebook.react.bridge.UIManager import com.facebook.react.common.annotations.FrameworkAPI import com.facebook.react.common.annotations.UnstableReactNativeAPI @@ -180,4 +181,6 @@ internal class BridgelessReactContext(context: Context, private val reactHost: R } override fun getJSCallInvokerHolder(): CallInvokerHolder? = reactHost.jsCallInvokerHolder + + override fun getRuntimeExecutor(): RuntimeExecutor? = reactHost.runtimeExecutor } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt index 06cb79b4be32..6625b7eee616 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/uimanager/ThemedReactContext.kt @@ -20,6 +20,7 @@ import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.RuntimeExecutor import com.facebook.react.bridge.ScrollEndedListeners import com.facebook.react.bridge.UIManager import com.facebook.react.common.annotations.internal.LegacyArchitecture @@ -162,6 +163,8 @@ public class ThemedReactContext( override fun getJSCallInvokerHolder(): CallInvokerHolder? = reactApplicationContext.getJSCallInvokerHolder() + override fun getRuntimeExecutor(): RuntimeExecutor? = reactApplicationContext.runtimeExecutor + @Deprecated( "This method is deprecated, please use UIManagerHelper.getUIManager() instead.", ReplaceWith("UIManagerHelper.getUIManager()"), diff --git a/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp b/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp index 8d39ace3f5f7..0c6aeb3f3a51 100644 --- a/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp +++ b/packages/react-native/ReactCommon/react/runtime/tests/cxx/ReactInstanceTest.cpp @@ -26,9 +26,9 @@ namespace facebook::react { class MockTimerRegistry : public PlatformTimerRegistry { public: - MOCK_METHOD2(createTimer, void(uint32_t, double)); - MOCK_METHOD2(createRecurringTimer, void(uint32_t, double)); - MOCK_METHOD1(deleteTimer, void(uint32_t)); + MOCK_METHOD(void, createTimer, (uint32_t, double), (override)); + MOCK_METHOD(void, createRecurringTimer, (uint32_t, double), (override)); + MOCK_METHOD(void, deleteTimer, (uint32_t), (override)); }; class MockMessageQueueThread : public MessageQueueThread { diff --git a/packages/react-native/ReactCommon/react/runtime/tests/cxx/RuntimeExecutorShutdownTest.cpp b/packages/react-native/ReactCommon/react/runtime/tests/cxx/RuntimeExecutorShutdownTest.cpp new file mode 100644 index 000000000000..f68527cc20fd --- /dev/null +++ b/packages/react-native/ReactCommon/react/runtime/tests/cxx/RuntimeExecutorShutdownTest.cpp @@ -0,0 +1,149 @@ +/* + * 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 + +namespace facebook::react { + +namespace { + +class MockTimerRegistry : public PlatformTimerRegistry { + public: + MOCK_METHOD(void, createTimer, (uint32_t, double), (override)); + MOCK_METHOD(void, createRecurringTimer, (uint32_t, double), (override)); + MOCK_METHOD(void, deleteTimer, (uint32_t), (override)); +}; + +class MockMessageQueueThread : public MessageQueueThread { + public: + void runOnQueue(std::function&& func) override { + callbackQueue_.push(std::move(func)); + } + + void runOnQueueSync(std::function&& /*unused*/) override {} + void quitSynchronous() override {} + + void tick() { + while (!callbackQueue_.empty()) { + auto callback = std::move(callbackQueue_.front()); + callbackQueue_.pop(); + callback(); + } + } + + size_t size() const { + return callbackQueue_.size(); + } + + private: + std::queue> callbackQueue_; +}; + +} // namespace + +class RuntimeExecutorShutdownTest : public ::testing::Test { + protected: + void SetUp() override { + auto runtime = + std::make_unique(hermes::makeHermesRuntime()); + messageQueueThread_ = std::make_shared(); + auto mockRegistry = std::make_unique(); + auto timerManager = std::make_shared(std::move(mockRegistry)); + auto onJsError = + [](jsi::Runtime& /*runtime*/, + const JsErrorHandler::ProcessedError& /*error*/) noexcept {}; + + instance_ = std::make_unique( + std::move(runtime), + messageQueueThread_, + timerManager, + std::move(onJsError)); + } + + std::shared_ptr messageQueueThread_; + std::unique_ptr instance_; +}; + +// Calling a held RuntimeExecutor after ReactInstance has been destroyed +// must silently drop the callback. Use a shared_ptr captured into the +// callback to also prove the callback itself is destroyed (not retained +// somewhere indefinitely). +TEST_F( + RuntimeExecutorShutdownTest, + heldExecutorCallAfterShutdownDropsCallback) { + RuntimeExecutor executor = instance_->getBufferedRuntimeExecutor(); + + instance_.reset(); + + auto tracker = std::make_shared(0); + std::weak_ptr weakTracker = tracker; + executor([tracker = std::move(tracker)](jsi::Runtime& /*runtime*/) { + FAIL() << "Callback should never run after shutdown"; + }); + + EXPECT_EQ(messageQueueThread_->size(), 0u); + EXPECT_TRUE(weakTracker.expired()) + << "Rejected callback (and its captures) should be destroyed"; + messageQueueThread_->tick(); +} + +// Repeated post-shutdown invocations must remain memory-safe. This guards +// against future refactors that swap the weak_ptr capture for a raw pointer +// or shared_ptr. +TEST_F(RuntimeExecutorShutdownTest, heldExecutorCallAfterShutdownDoesNotCrash) { + RuntimeExecutor executor = instance_->getBufferedRuntimeExecutor(); + + instance_.reset(); + + for (int i = 0; i < 100; ++i) { + executor([](jsi::Runtime& /*runtime*/) { + FAIL() << "Callback should never run after shutdown"; + }); + } + messageQueueThread_->tick(); +} + +// Work scheduled through the buffered executor sits in the internal buffer +// until flush() (which only happens via loadScript). If the ReactInstance +// is destroyed before flush, the buffered work must be dropped — never +// surfaced on the JS queue and never retained past instance destruction. +// A shared_ptr captured into the callback, watched via weak_ptr from +// outside, proves the buffered queue actually releases its contents. +TEST_F( + RuntimeExecutorShutdownTest, + pendingCallbackShutdownBeforeTickDropsCallback) { + RuntimeExecutor executor = instance_->getBufferedRuntimeExecutor(); + + auto tracker = std::make_shared(0); + std::weak_ptr weakTracker = tracker; + executor([tracker = std::move(tracker)](jsi::Runtime& /*runtime*/) { + FAIL() << "Buffered callback should never run after shutdown"; + }); + + EXPECT_EQ(messageQueueThread_->size(), 0u); + EXPECT_FALSE(weakTracker.expired()) + << "Callback should still be alive in BufferedRuntimeExecutor's queue"; + + instance_.reset(); + + EXPECT_TRUE(weakTracker.expired()) + << "Buffered callback should be destroyed when ReactInstance is"; + messageQueueThread_->tick(); +} + +} // namespace facebook::react