diff --git a/packages/react-native/React/Base/RCTBridge.mm b/packages/react-native/React/Base/RCTBridge.mm index 5ffcb25d887..033bbed6317 100644 --- a/packages/react-native/React/Base/RCTBridge.mm +++ b/packages/react-native/React/Base/RCTBridge.mm @@ -199,6 +199,11 @@ class RCTBridgeHostTargetDelegate : public facebook::react::jsinspector_modern:: [bridge_ reload]; } + void onSetPausedInDebuggerMessage(const OverlaySetPausedInDebuggerMessageRequest &) override + { + // TODO(moti): Implement this + } + private: __weak RCTBridge *bridge_; }; diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 9bc39f340f7..7cca0cbf311 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -1175,6 +1175,7 @@ public class com/facebook/react/bridge/ReactInstanceManagerInspectorTarget : jav public abstract interface class com/facebook/react/bridge/ReactInstanceManagerInspectorTarget$TargetDelegate { public abstract fun onReload ()V + public abstract fun onSetPausedInDebuggerMessage (Ljava/lang/String;)V } public class com/facebook/react/bridge/ReactMarker { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactInstanceManager.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactInstanceManager.java index 7eed8007bfb..6a1dab3ddff 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactInstanceManager.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/ReactInstanceManager.java @@ -1494,6 +1494,11 @@ public class ReactInstanceManager { public void onReload() { UiThreadUtil.runOnUiThread(() -> mDevSupportManager.handleReloadJS()); } + + @Override + public void onSetPausedInDebuggerMessage(@Nullable String message) { + // TODO(moti): Implement this + } }); } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactInstanceManagerInspectorTarget.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactInstanceManagerInspectorTarget.java index bc3f8502f12..05b5e499624 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactInstanceManagerInspectorTarget.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/bridge/ReactInstanceManagerInspectorTarget.java @@ -10,11 +10,14 @@ package com.facebook.react.bridge; import com.facebook.jni.HybridData; import com.facebook.proguard.annotations.DoNotStripAny; import java.util.concurrent.Executor; +import javax.annotation.Nullable; @DoNotStripAny public class ReactInstanceManagerInspectorTarget implements AutoCloseable { public interface TargetDelegate { public void onReload(); + + public void onSetPausedInDebuggerMessage(@Nullable String message); } private final HybridData mHybridData; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.java index 8c3a378dc6f..9afc9f616f1 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.java @@ -26,6 +26,7 @@ import com.facebook.infer.annotation.Assertions; import com.facebook.infer.annotation.Nullsafe; import com.facebook.infer.annotation.ThreadConfined; import com.facebook.infer.annotation.ThreadSafe; +import com.facebook.proguard.annotations.DoNotStrip; import com.facebook.react.JSEngineResolutionAlgorithm; import com.facebook.react.MemoryPressureRouter; import com.facebook.react.ReactHost; @@ -466,6 +467,11 @@ public class ReactHostImpl implements ReactHost { .continueWithTask(Task::getResult); } + @DoNotStrip + private void setPausedInDebuggerMessage(@Nullable String message) { + // TODO(moti): Implement this + } + /** * Entrypoint to destroy the ReactInstance. If the ReactInstance is reloading, will wait until * reload is finished, before destroying. diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/ReactInstanceManagerInspectorTarget.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/jni/ReactInstanceManagerInspectorTarget.cpp index aba0a4335ad..7b8ee8039c4 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/jni/ReactInstanceManagerInspectorTarget.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/ReactInstanceManagerInspectorTarget.cpp @@ -20,6 +20,14 @@ void ReactInstanceManagerInspectorTarget::TargetDelegate::onReload() const { method(self()); } +void ReactInstanceManagerInspectorTarget::TargetDelegate:: + onSetPausedInDebuggerMessage( + const OverlaySetPausedInDebuggerMessageRequest& request) const { + auto method = javaClassStatic()->getMethod)>( + "onSetPausedInDebuggerMessage"); + method(self(), request.message ? make_jstring(*request.message) : nullptr); +} + ReactInstanceManagerInspectorTarget::ReactInstanceManagerInspectorTarget( jni::alias_ref jobj, jni::alias_ref executor, @@ -82,6 +90,11 @@ void ReactInstanceManagerInspectorTarget::onReload( delegate_->onReload(); } +void ReactInstanceManagerInspectorTarget::onSetPausedInDebuggerMessage( + const OverlaySetPausedInDebuggerMessageRequest& request) { + delegate_->onSetPausedInDebuggerMessage(request); +} + HostTarget* ReactInstanceManagerInspectorTarget::getInspectorTarget() { return inspectorTarget_.get(); } diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/jni/ReactInstanceManagerInspectorTarget.h b/packages/react-native/ReactAndroid/src/main/jni/react/jni/ReactInstanceManagerInspectorTarget.h index 64be75bc65e..7b5791859f8 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/jni/ReactInstanceManagerInspectorTarget.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/jni/ReactInstanceManagerInspectorTarget.h @@ -22,6 +22,8 @@ class ReactInstanceManagerInspectorTarget "Lcom/facebook/react/bridge/ReactInstanceManagerInspectorTarget$TargetDelegate;"; void onReload() const; + void onSetPausedInDebuggerMessage( + const OverlaySetPausedInDebuggerMessageRequest& request) const; }; public: @@ -44,9 +46,13 @@ class ReactInstanceManagerInspectorTarget static void registerNatives(); - void onReload(const PageReloadRequest& request) override; jsinspector_modern::HostTarget* getInspectorTarget(); + // HostTargetDelegate methods + void onReload(const PageReloadRequest& request) override; + void onSetPausedInDebuggerMessage( + const OverlaySetPausedInDebuggerMessageRequest&) override; + private: friend HybridBase; diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.cpp b/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.cpp index 89fd8ce02bb..247b4002f4d 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.cpp +++ b/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.cpp @@ -73,6 +73,11 @@ void JReactHostInspectorTarget::onReload(const PageReloadRequest& request) { javaReactHostImpl_->reload("CDP Page.reload"); } +void JReactHostInspectorTarget::onSetPausedInDebuggerMessage( + const OverlaySetPausedInDebuggerMessageRequest& request) { + javaReactHostImpl_->setPausedInDebuggerMessage(request.message); +} + HostTarget* JReactHostInspectorTarget::getInspectorTarget() { return inspectorTarget_ ? inspectorTarget_.get() : nullptr; } diff --git a/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.h b/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.h index d4fc38364d1..84efd9ca91d 100644 --- a/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.h +++ b/packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.h @@ -29,6 +29,13 @@ struct JReactHostImpl : public jni::JavaClass { "reload"); return method(self(), reason); } + + void setPausedInDebuggerMessage(std::optional message) { + static auto method = + javaClassStatic()->getMethod)>( + "setPausedInDebuggerMessage"); + method(self(), message ? jni::make_jstring(*message) : nullptr); + } }; class JReactHostInspectorTarget @@ -48,6 +55,8 @@ class JReactHostInspectorTarget static void registerNatives(); void onReload(const PageReloadRequest& request) override; + void onSetPausedInDebuggerMessage( + const OverlaySetPausedInDebuggerMessageRequest&) override; jsinspector_modern::HostTarget* getInspectorTarget(); diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp index 6ccb8100a13..18121901830 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp @@ -105,6 +105,22 @@ void HostAgent::handleRequest(const cdp::PreparsedRequest& req) { : std::nullopt, }); + shouldSendOKResponse = true; + isFinishedHandlingRequest = true; + } else if (req.method == "Overlay.setPausedInDebuggerMessage") { + auto message = req.params.isObject() && req.params.count("message") + ? std::optional(req.params.at("message").asString()) + : std::nullopt; + if (!isPausedInDebuggerOverlayVisible_ && message.has_value()) { + targetController_.incrementPauseOverlayCounter(); + } else if (isPausedInDebuggerOverlayVisible_ && !message.has_value()) { + targetController_.decrementPauseOverlayCounter(); + } + isPausedInDebuggerOverlayVisible_ = message.has_value(); + targetController_.getDelegate().onSetPausedInDebuggerMessage({ + .message = message, + }); + shouldSendOKResponse = true; isFinishedHandlingRequest = true; } else if (req.method == "FuseboxClient.setClientMetadata") { @@ -154,6 +170,20 @@ void HostAgent::handleRequest(const cdp::PreparsedRequest& req) { req.method + " not implemented yet")); } +HostAgent::~HostAgent() { + if (isPausedInDebuggerOverlayVisible_) { + // In case of a non-graceful shutdown of the session, ensure we clean up + // the "paused on debugger" overlay if we've previously asked the + // integrator to display it. + isPausedInDebuggerOverlayVisible_ = false; + if (!targetController_.decrementPauseOverlayCounter()) { + targetController_.getDelegate().onSetPausedInDebuggerMessage({ + .message = std::nullopt, + }); + } + } +} + void HostAgent::sendFuseboxNotice() { static constexpr auto kFuseboxNotice = ANSI_COLOR_BG_YELLOW "Welcome to the new React Native debugger (codename " ANSI_WEIGHT_BOLD diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h index 363339fac6d..3424f79bd9b 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostAgent.h @@ -46,6 +46,13 @@ class HostAgent final { HostTarget::SessionMetadata sessionMetadata, SessionState& sessionState); + HostAgent(const HostAgent&) = delete; + HostAgent(HostAgent&&) = delete; + HostAgent& operator=(const HostAgent&) = delete; + HostAgent& operator=(HostAgent&&) = delete; + + ~HostAgent(); + /** * Handle a CDP request. The response will be sent over the provided * \c FrontendChannel synchronously or asynchronously. @@ -90,6 +97,7 @@ class HostAgent final { const HostTarget::SessionMetadata sessionMetadata_; std::shared_ptr instanceAgent_; FuseboxClientType fuseboxClientType_{FuseboxClientType::Unknown}; + bool isPausedInDebuggerOverlayVisible_{false}; /** * A shared reference to the session's state. This is only safe to access diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp index 2bbb8d78e74..d880dc6fd27 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.cpp @@ -162,4 +162,16 @@ bool HostTargetController::hasInstance() const { return target_.hasInstance(); } +void HostTargetController::incrementPauseOverlayCounter() { + ++pauseOverlayCounter_; +} + +bool HostTargetController::decrementPauseOverlayCounter() { + assert(pauseOverlayCounter_ > 0 && "Pause overlay counter underflow"); + if (--pauseOverlayCounter_ == 0) { + return false; + } + return true; +} + } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h index cb04ee5f3bb..da0f95a1e4b 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h @@ -67,6 +67,21 @@ class HostTargetDelegate { } }; + struct OverlaySetPausedInDebuggerMessageRequest { + /** + * The message to display in the overlay. If nullopt, hide the overlay. + */ + std::optional message; + + /** + * Equality operator, useful for unit tests + */ + inline bool operator==( + const OverlaySetPausedInDebuggerMessageRequest& rhs) const { + return message == rhs.message; + } + }; + virtual ~HostTargetDelegate(); /** @@ -75,6 +90,19 @@ class HostTargetDelegate { * ILocalConnection::sendMessage was called). */ virtual void onReload(const PageReloadRequest& request) = 0; + + /** + * Called when the debugger requests that the "paused in debugger" overlay be + * shown or hidden. If the message is nullopt, hide the overlay, otherwise + * show it with the given message. This is called on the inspector thread. + * + * If this method is called with a non-null message, it's guaranteed to + * eventually be called again with a null message. In all other respects, + * the timing and payload of these messages are fully controlled by the + * client. + */ + virtual void onSetPausedInDebuggerMessage( + const OverlaySetPausedInDebuggerMessageRequest& request) = 0; }; /** @@ -89,8 +117,28 @@ class HostTargetController final { bool hasInstance() const; + /** + * Increments the target's pause overlay counter. The counter represents the + * exact number of Agents that have (concurrently) requested the pause + * overlay to be shown. It's the caller's responsibility to only call this + * when the pause overlay's requested state transitions from hidden to + * visible. + */ + void incrementPauseOverlayCounter(); + + /** + * Decrements the target's pause overlay counter. The counter represents the + * exact number of Agents that have (concurrently) requested the pause + * overlay to be shown. It's the caller's responsibility to only call this + * when the pause overlay's requested state transitions from hidden to + * visible. + * \returns false if the counter has reached 0, otherwise true. + */ + bool decrementPauseOverlayCounter(); + private: HostTarget& target_; + size_t pauseOverlayCounter_{0}; }; /** diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp b/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp index 7b6b43f2536..2f76ace49f3 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/HostTargetTest.cpp @@ -42,13 +42,21 @@ class HostTargetTest : public Test { void connect() { ASSERT_FALSE(toPage_) << "Can only connect once in a HostTargetTest."; - toPage_ = page_->connect( + auto conn = makeConnection(); + toPage_ = std::move(conn.first); + } + + std::pair, MockRemoteConnection&> + makeConnection() { + size_t connectionIndex = remoteConnections_.objectsVended(); + auto toPage = page_->connect( remoteConnections_.make_unique(), {.integrationName = "HostTargetTest"}); // We'll always get an onDisconnect call when we tear // down the test. Expect it in order to satisfy the strict mock. - EXPECT_CALL(*remoteConnections_[0], onDisconnect()); + EXPECT_CALL(*remoteConnections_[connectionIndex], onDisconnect()); + return {std::move(toPage), *remoteConnections_[connectionIndex]}; } MockHostTargetDelegate hostTargetDelegate_; @@ -206,6 +214,108 @@ TEST_F(HostTargetProtocolTest, PageReloadMethod) { })"); } +TEST_F(HostTargetProtocolTest, OverlaySetPausedInDebuggerMessageMethod) { + InSequence s; + + EXPECT_CALL( + hostTargetDelegate_, + onSetPausedInDebuggerMessage( + Eq(HostTargetDelegate::OverlaySetPausedInDebuggerMessageRequest{ + .message = std::nullopt}))) + .RetiresOnSaturation(); + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "result": {} + })"))) + .RetiresOnSaturation(); + toPage_->sendMessage(R"({ + "id": 1, + "method": "Overlay.setPausedInDebuggerMessage" + })"); + + EXPECT_CALL( + hostTargetDelegate_, + onSetPausedInDebuggerMessage( + Eq(HostTargetDelegate::OverlaySetPausedInDebuggerMessageRequest{ + .message = "Paused in debugger"}))) + .RetiresOnSaturation(); + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 2, + "result": {} + })"))) + .RetiresOnSaturation(); + toPage_->sendMessage(R"({ + "id": 2, + "method": "Overlay.setPausedInDebuggerMessage", + "params": { + "message": "Paused in debugger" + } + })"); + + // A cleanup message is sent automatically when we destroy the session. + EXPECT_CALL( + hostTargetDelegate_, + onSetPausedInDebuggerMessage( + Eq(HostTargetDelegate::OverlaySetPausedInDebuggerMessageRequest{ + .message = std::nullopt}))) + .RetiresOnSaturation(); +} + +TEST_F(HostTargetProtocolTest, OverlaySetPausedInDebuggerMultipleClients) { + auto [toPage2, fromPage2] = makeConnection(); + + InSequence s; + + EXPECT_CALL( + hostTargetDelegate_, + onSetPausedInDebuggerMessage( + Eq(HostTargetDelegate::OverlaySetPausedInDebuggerMessageRequest{ + .message = "Paused in debugger - client 1"}))) + .RetiresOnSaturation(); + EXPECT_CALL(fromPage(), onMessage(JsonEq(R"({ + "id": 1, + "result": {} + })"))) + .RetiresOnSaturation(); + toPage_->sendMessage(R"({ + "id": 1, + "method": "Overlay.setPausedInDebuggerMessage", + "params": { + "message": "Paused in debugger - client 1" + } + })"); + + EXPECT_CALL( + hostTargetDelegate_, + onSetPausedInDebuggerMessage( + Eq(HostTargetDelegate::OverlaySetPausedInDebuggerMessageRequest{ + .message = "Paused in debugger - client 2"}))) + .RetiresOnSaturation(); + EXPECT_CALL(fromPage2, onMessage(JsonEq(R"({ + "id": 1, + "result": {} + })"))) + .RetiresOnSaturation(); + toPage2->sendMessage(R"({ + "id": 1, + "method": "Overlay.setPausedInDebuggerMessage", + "params": { + "message": "Paused in debugger - client 2" + } + })"); + + toPage2.reset(); + + // The cleanup message is sent exactly once. + EXPECT_CALL( + hostTargetDelegate_, + onSetPausedInDebuggerMessage( + Eq(HostTargetDelegate::OverlaySetPausedInDebuggerMessageRequest{ + .message = std::nullopt}))) + .Times(1) + .RetiresOnSaturation(); +} + TEST_F(HostTargetProtocolTest, RegisterUnregisterInstanceWithoutEvents) { auto& instanceTarget = page_->registerInstance(instanceTargetDelegate_); diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h index 7e74771f0a5..23b442e429d 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/InspectorMocks.h @@ -119,6 +119,11 @@ class MockHostTargetDelegate : public HostTargetDelegate { public: // HostTargetDelegate methods MOCK_METHOD(void, onReload, (const PageReloadRequest& request), (override)); + MOCK_METHOD( + void, + onSetPausedInDebuggerMessage, + (const OverlaySetPausedInDebuggerMessageRequest& request), + (override)); }; class MockInstanceTargetDelegate : public InstanceTargetDelegate {}; diff --git a/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.h b/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.h index 8f31fbe5738..c7756139821 100644 --- a/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.h +++ b/packages/react-native/ReactCommon/jsinspector-modern/tests/JsiIntegrationTest.h @@ -183,6 +183,9 @@ class JsiIntegrationPortableTest : public ::testing::Test, (void)request; reload(); } + + void onSetPausedInDebuggerMessage( + const OverlaySetPausedInDebuggerMessageRequest&) override {} }; } // namespace facebook::react::jsinspector_modern diff --git a/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTHost.mm b/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTHost.mm index f3219f2e080..8bb6996afd1 100644 --- a/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTHost.mm +++ b/packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTHost.mm @@ -38,6 +38,11 @@ class RCTHostHostTargetDelegate : public facebook::react::jsinspector_modern::Ho [static_cast>(host_) didReceiveReloadCommand]; } + void onSetPausedInDebuggerMessage(const OverlaySetPausedInDebuggerMessageRequest &) override + { + // TODO(moti): Implement this + } + private: __weak RCTHost *host_; };