Skip to content

Commit 11a5432

Browse files
huntiemeta-codesync[bot]
authored andcommitted
Support light/dark mode toggling via Emulation.setEmulatedMedia (#56510)
Summary: Pull Request resolved: #56510 Implements the `Emulation.setEmulatedMedia` CDP method in `jsinspector-modern`, scoped to `prefers-color-scheme` emulation. This allows CDP clients to toggle the app color scheme for debugging and visual verification. **Implementation notes** Adds a new `EmulationAgent` CDP domain agent that validates the `features` param, rejects unsupported media features/types, and delegates to `HostTargetDelegate::onSetEmulatedMedia`. Platform delegates: - **Android**: Calls `AppCompatDelegate.setDefaultNightMode()` on the UI thread via JNI through `ReactHostImpl`. - **iOS**: Sets `overrideUserInterfaceStyle` on the key `UIWindow`. Both platforms trigger their existing `Appearance` change event propagation to JS automatically. Changelog: [General][Added] - **React Native DevTools**: Add support for light/dark mode emulation via `Emulation.setEmulatedMedia` Reviewed By: hoxyq Differential Revision: D101624433 fbshipit-source-id: e795bf22327142bae0a7275ee92f7f778736541f
1 parent bedf33f commit 11a5432

14 files changed

Lines changed: 328 additions & 1 deletion

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import android.content.Context
1212
import android.content.Intent
1313
import android.nfc.NfcAdapter
1414
import android.os.Bundle
15+
import androidx.appcompat.app.AppCompatDelegate
1516
import androidx.core.graphics.createBitmap
1617
import com.facebook.common.logging.FLog
1718
import com.facebook.infer.annotation.Assertions
@@ -448,6 +449,19 @@ public class ReactHostImpl(
448449
InspectorNetworkHelper.loadNetworkResource(url, listener)
449450
}
450451

452+
@DoNotStrip
453+
private fun setEmulatedMedia(colorScheme: String) {
454+
UiThreadUtil.runOnUiThread {
455+
val mode =
456+
when (colorScheme) {
457+
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
458+
"light" -> AppCompatDelegate.MODE_NIGHT_NO
459+
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
460+
}
461+
AppCompatDelegate.setDefaultNightMode(mode)
462+
}
463+
}
464+
451465
@DoNotStrip
452466
private fun captureScreenshot(format: String, quality: Int): String? {
453467
val activity = currentActivity ?: return null

packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,16 @@ std::optional<std::string> JReactHostInspectorTarget::captureScreenshot(
159159
return std::nullopt;
160160
}
161161

162+
bool JReactHostInspectorTarget::onSetEmulatedMedia(
163+
const jsinspector_modern::HostTargetDelegate::SetEmulatedMediaRequest&
164+
request) {
165+
if (auto javaReactHostImplStrong = javaReactHostImpl_->get()) {
166+
javaReactHostImplStrong->setEmulatedMedia(request.colorScheme);
167+
return true;
168+
}
169+
return false;
170+
}
171+
162172
HostTarget* JReactHostInspectorTarget::getInspectorTarget() {
163173
return inspectorTarget_ ? inspectorTarget_.get() : nullptr;
164174
}

packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ struct JReactHostImpl : public jni::JavaClass<JReactHostImpl> {
152152
"captureScreenshot");
153153
return method(self(), jni::make_jstring(format), static_cast<jint>(quality));
154154
}
155+
156+
void setEmulatedMedia(const std::string &colorScheme)
157+
{
158+
static auto method = javaClassStatic()->getMethod<void(jni::local_ref<jni::JString>)>("setEmulatedMedia");
159+
method(self(), jni::make_jstring(colorScheme));
160+
}
155161
};
156162

157163
/**
@@ -284,6 +290,7 @@ class JReactHostInspectorTarget : public jni::HybridClass<JReactHostInspectorTar
284290
jsinspector_modern::ScopedExecutor<jsinspector_modern::NetworkRequestListener> executor) override;
285291
std::optional<std::string> captureScreenshot(
286292
const jsinspector_modern::HostTargetDelegate::PageCaptureScreenshotRequest &request) override;
293+
bool onSetEmulatedMedia(const jsinspector_modern::HostTargetDelegate::SetEmulatedMediaRequest &request) override;
287294
jsinspector_modern::HostTargetTracingDelegate *getTracingDelegate() override;
288295

289296
private:
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#include "EmulationAgent.h"
9+
10+
#include <jsinspector-modern/cdp/CdpJson.h>
11+
12+
namespace facebook::react::jsinspector_modern {
13+
14+
EmulationAgent::EmulationAgent(
15+
FrontendChannel frontendChannel,
16+
HostTargetController& hostTargetController)
17+
: frontendChannel_(std::move(frontendChannel)),
18+
hostTargetController_(hostTargetController) {}
19+
20+
bool EmulationAgent::handleRequest(const cdp::PreparsedRequest& req) {
21+
if (req.method == "Emulation.setEmulatedMedia") {
22+
handleSetEmulatedMedia(req);
23+
return true;
24+
}
25+
26+
return false;
27+
}
28+
29+
void EmulationAgent::handleSetEmulatedMedia(const cdp::PreparsedRequest& req) {
30+
if (req.params.isObject() && req.params.count("media") != 0u &&
31+
!req.params.at("media").empty()) {
32+
frontendChannel_(
33+
cdp::jsonError(
34+
req.id,
35+
cdp::ErrorCode::MethodNotFound,
36+
"Emulation.setEmulatedMedia: media type emulation is not supported"));
37+
return;
38+
}
39+
40+
if (!req.params.isObject() || req.params.count("features") == 0u ||
41+
!req.params.at("features").isArray()) {
42+
frontendChannel_(cdp::jsonResult(req.id));
43+
return;
44+
}
45+
46+
const auto& features = req.params.at("features");
47+
48+
std::string colorSchemeValue;
49+
bool hasColorScheme = false;
50+
51+
for (const auto& feature : features) {
52+
if (!feature.isObject() || feature.count("name") == 0u) {
53+
continue;
54+
}
55+
56+
const auto& name = feature.at("name").asString();
57+
const auto value =
58+
feature.count("value") != 0u ? feature.at("value").asString() : "";
59+
60+
if (name == "prefers-color-scheme") {
61+
hasColorScheme = true;
62+
colorSchemeValue = value;
63+
continue;
64+
}
65+
66+
// Unsupported features are OK if their value is empty (reset).
67+
// DevTools sends all features on every update, with empty values for
68+
// features that aren't being emulated.
69+
if (!value.empty()) {
70+
frontendChannel_(
71+
cdp::jsonError(
72+
req.id,
73+
cdp::ErrorCode::MethodNotFound,
74+
"Emulation.setEmulatedMedia: unsupported media feature '" + name +
75+
"'"));
76+
return;
77+
}
78+
}
79+
80+
if (hasColorScheme && !colorSchemeValue.empty() &&
81+
colorSchemeValue != "light" && colorSchemeValue != "dark") {
82+
frontendChannel_(
83+
cdp::jsonError(
84+
req.id,
85+
cdp::ErrorCode::InvalidParams,
86+
"Emulation.setEmulatedMedia: invalid value '" + colorSchemeValue +
87+
"' for prefers-color-scheme (expected 'light', 'dark', or '')"));
88+
return;
89+
}
90+
91+
if (hasColorScheme) {
92+
bool success = hostTargetController_.getDelegate().onSetEmulatedMedia(
93+
{.colorScheme = colorSchemeValue});
94+
95+
if (!success) {
96+
frontendChannel_(
97+
cdp::jsonError(
98+
req.id,
99+
cdp::ErrorCode::InternalError,
100+
"Emulation.setEmulatedMedia: failed to apply color scheme override"));
101+
return;
102+
}
103+
}
104+
105+
frontendChannel_(cdp::jsonResult(req.id));
106+
}
107+
108+
} // namespace facebook::react::jsinspector_modern
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#pragma once
9+
10+
#include "HostTarget.h"
11+
#include "InspectorInterfaces.h"
12+
13+
#include <jsinspector-modern/cdp/CdpJson.h>
14+
15+
namespace facebook::react::jsinspector_modern {
16+
17+
/**
18+
* Provides an agent for handling CDP's Emulation domain.
19+
* Currently supports Emulation.setEmulatedMedia (prefers-color-scheme only).
20+
*/
21+
class EmulationAgent {
22+
public:
23+
/**
24+
* \param frontendChannel A channel used to send responses to the
25+
* frontend.
26+
* \param hostTargetController An interface to the HostTarget that this agent
27+
* is attached to. The caller is responsible for ensuring that the
28+
* HostTargetDelegate and underlying HostTarget both outlive the agent.
29+
*/
30+
EmulationAgent(FrontendChannel frontendChannel, HostTargetController &hostTargetController);
31+
32+
/**
33+
* Handle a CDP request. The response will be sent over the provided
34+
* \c FrontendChannel synchronously or asynchronously.
35+
* \param req The parsed request.
36+
* \returns true if the request was handled.
37+
*/
38+
bool handleRequest(const cdp::PreparsedRequest &req);
39+
40+
private:
41+
void handleSetEmulatedMedia(const cdp::PreparsedRequest &req);
42+
43+
FrontendChannel frontendChannel_;
44+
HostTargetController &hostTargetController_;
45+
};
46+
47+
} // namespace facebook::react::jsinspector_modern

packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include "InstanceAgent.h"
1010

1111
#ifdef REACT_NATIVE_DEBUGGER_ENABLED
12+
#include "EmulationAgent.h"
1213
#include "InspectorFlags.h"
1314
#include "InspectorInterfaces.h"
1415
#include "NetworkIOAgent.h"
@@ -50,7 +51,8 @@ class HostAgent::Impl final {
5051
sessionState_(sessionState),
5152
networkIOAgent_(NetworkIOAgent(frontendChannel, std::move(executor))),
5253
tracingAgent_(
53-
TracingAgent(frontendChannel, sessionState, targetController)) {}
54+
TracingAgent(frontendChannel, sessionState, targetController)),
55+
emulationAgent_(EmulationAgent(frontendChannel, targetController)) {}
5456

5557
~Impl() {
5658
if (isPausedInDebuggerOverlayVisible_) {
@@ -380,6 +382,11 @@ class HostAgent::Impl final {
380382
return;
381383
}
382384

385+
if (!requestState.isFinishedHandlingRequest &&
386+
emulationAgent_.handleRequest(req)) {
387+
return;
388+
}
389+
383390
if (!requestState.isFinishedHandlingRequest && instanceAgent_ &&
384391
instanceAgent_->handleRequest(req)) {
385392
return;
@@ -509,6 +516,8 @@ class HostAgent::Impl final {
509516
NetworkIOAgent networkIOAgent_;
510517

511518
TracingAgent tracingAgent_;
519+
520+
EmulationAgent emulationAgent_;
512521
};
513522

514523
#else

packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,19 @@ class HostTargetDelegate : public LoadNetworkResourceDelegate {
148148
std::optional<int> quality;
149149
};
150150

151+
struct SetEmulatedMediaRequest {
152+
/**
153+
* The color scheme to emulate: "light", "dark", or "" (reset to system
154+
* default).
155+
*/
156+
std::string colorScheme;
157+
158+
inline bool operator==(const SetEmulatedMediaRequest &rhs) const
159+
{
160+
return colorScheme == rhs.colorScheme;
161+
}
162+
};
163+
151164
virtual ~HostTargetDelegate() override;
152165

153166
/**
@@ -206,6 +219,18 @@ class HostTargetDelegate : public LoadNetworkResourceDelegate {
206219
return std::nullopt;
207220
}
208221

222+
/**
223+
* Called when the debugger requests an emulated media override via
224+
* @cdp Emulation.setEmulatedMedia. Currently only supports the
225+
* prefers-color-scheme media feature.
226+
*
227+
* \returns true if the override was applied successfully.
228+
*/
229+
virtual bool onSetEmulatedMedia(const SetEmulatedMediaRequest & /*request*/)
230+
{
231+
return false;
232+
}
233+
209234
/**
210235
* An optional delegate that will be used by HostTarget to notify about tracing-related events.
211236
*/

packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTHost.mm

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,43 @@ void loadNetworkResource(const RCTInspectorLoadNetworkResourceRequest &params, R
141141
[networkHelper_ loadNetworkResourceWithParams:params executor:executor];
142142
}
143143

144+
#if TARGET_OS_IPHONE
145+
bool onSetEmulatedMedia(const SetEmulatedMediaRequest &request) override
146+
{
147+
RCTAssertMainQueue();
148+
UIWindow *keyWindow = nil;
149+
for (UIScene *scene in RCTSharedApplication().connectedScenes) {
150+
if (scene.activationState == UISceneActivationStateForegroundActive &&
151+
[scene isKindOfClass:[UIWindowScene class]]) {
152+
auto *windowScene = (UIWindowScene *)scene;
153+
for (UIWindow *win in windowScene.windows) {
154+
if (win.isKeyWindow) {
155+
keyWindow = win;
156+
break;
157+
}
158+
}
159+
}
160+
if (keyWindow != nil) {
161+
break;
162+
}
163+
}
164+
165+
if (keyWindow == nil) {
166+
return false;
167+
}
168+
169+
UIUserInterfaceStyle style = UIUserInterfaceStyleUnspecified;
170+
if (request.colorScheme == "dark") {
171+
style = UIUserInterfaceStyleDark;
172+
} else if (request.colorScheme == "light") {
173+
style = UIUserInterfaceStyleLight;
174+
}
175+
176+
keyWindow.overrideUserInterfaceStyle = style;
177+
return true;
178+
}
179+
#endif
180+
144181
#if TARGET_OS_IPHONE && defined(REACT_NATIVE_DEBUGGER_ENABLED)
145182
std::optional<std::string> captureScreenshot(const PageCaptureScreenshotRequest &request) override
146183
{

scripts/cxx-api/api-snapshots/ReactAndroidDebugCxx.api

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2821,6 +2821,7 @@ class facebook::react::JReactHostInspectorTarget : public jni::HybridClass<faceb
28212821
public static constexpr auto kJavaDescriptor;
28222822
public static jni::local_ref<JReactHostInspectorTarget::jhybriddata> initHybrid(jni::alias_ref<JReactHostInspectorTarget::jhybridobject> jobj, jni::alias_ref<facebook::react::JReactHostImpl> reactHost, jni::alias_ref<JExecutor::javaobject> javaExecutor);
28232823
public static void registerNatives();
2824+
public virtual bool onSetEmulatedMedia(const facebook::react::jsinspector_modern::HostTargetDelegate::SetEmulatedMediaRequest&) override;
28242825
public virtual facebook::react::jsinspector_modern::HostTargetMetadata getMetadata() override;
28252826
public virtual facebook::react::jsinspector_modern::HostTargetTracingDelegate* getTracingDelegate() override;
28262827
public virtual std::optional<std::string> captureScreenshot(const facebook::react::jsinspector_modern::HostTargetDelegate::PageCaptureScreenshotRequest&) override;
@@ -7278,6 +7279,7 @@ struct facebook::react::JReactHostImpl : public facebook::jni::JavaClass<faceboo
72787279
public jni::local_ref<jni::JString> captureScreenshot(const std::string& format, int quality) const;
72797280
public static constexpr auto kJavaDescriptor;
72807281
public void loadNetworkResource(const std::string& url, jni::local_ref<InspectorNetworkRequestListener::javaobject> listener) const;
7282+
public void setEmulatedMedia(const std::string& colorScheme);
72817283
public void setPausedInDebuggerMessage(std::optional<std::string> message);
72827284
}
72837285

@@ -10320,6 +10322,11 @@ class facebook::react::jsinspector_modern::ConsoleTaskOrchestrator {
1032010322
public ~ConsoleTaskOrchestrator() = default;
1032110323
}
1032210324

10325+
class facebook::react::jsinspector_modern::EmulationAgent {
10326+
public EmulationAgent(facebook::react::jsinspector_modern::FrontendChannel frontendChannel, facebook::react::jsinspector_modern::HostTargetController& hostTargetController);
10327+
public bool handleRequest(const facebook::react::jsinspector_modern::cdp::PreparsedRequest& req);
10328+
}
10329+
1032310330
class facebook::react::jsinspector_modern::ExecutionContextManager {
1032410331
public int32_t allocateExecutionContextId();
1032510332
}
@@ -10417,6 +10424,7 @@ class facebook::react::jsinspector_modern::HostTargetDelegate : public facebook:
1041710424
public HostTargetDelegate(facebook::react::jsinspector_modern::HostTargetDelegate&&) = delete;
1041810425
public facebook::react::jsinspector_modern::HostTargetDelegate& operator=(const facebook::react::jsinspector_modern::HostTargetDelegate&) = delete;
1041910426
public facebook::react::jsinspector_modern::HostTargetDelegate& operator=(facebook::react::jsinspector_modern::HostTargetDelegate&&) = delete;
10427+
public virtual bool onSetEmulatedMedia(const facebook::react::jsinspector_modern::HostTargetDelegate::SetEmulatedMediaRequest&);
1042010428
public virtual facebook::react::jsinspector_modern::HostTargetMetadata getMetadata() = 0;
1042110429
public virtual facebook::react::jsinspector_modern::HostTargetTracingDelegate* getTracingDelegate();
1042210430
public virtual std::optional<std::string> captureScreenshot(const facebook::react::jsinspector_modern::HostTargetDelegate::PageCaptureScreenshotRequest&);
@@ -10442,6 +10450,11 @@ struct facebook::react::jsinspector_modern::HostTargetDelegate::PageReloadReques
1044210450
public std::optional<std::string> scriptToEvaluateOnLoad;
1044310451
}
1044410452

10453+
struct facebook::react::jsinspector_modern::HostTargetDelegate::SetEmulatedMediaRequest {
10454+
public bool operator==(const facebook::react::jsinspector_modern::HostTargetDelegate::SetEmulatedMediaRequest& rhs) const;
10455+
public std::string colorScheme;
10456+
}
10457+
1044510458
class facebook::react::jsinspector_modern::HostTargetTraceRecording {
1044610459
public HostTargetTraceRecording(facebook::react::jsinspector_modern::HostTarget& hostTarget, facebook::react::jsinspector_modern::tracing::Mode tracingMode, std::set<facebook::react::jsinspector_modern::tracing::Category> enabledCategories, std::optional<facebook::react::HighResDuration> windowSize = std::nullopt);
1044710460
public bool isBackgroundInitiated() const;

0 commit comments

Comments
 (0)