Skip to content

Commit

Permalink
Support for launching expo dev plugin tools in separate panels within…
Browse files Browse the repository at this point in the history
… VSCode (#878)

This PR adds support for redux and react query dev tools expo plugins
and makes their UI display as separate panel in VSCode.

Apart from the support for the listed plugins, this change builds an
infrastructure for supporting more expo dev plugins and other types of
plugins in the future.

Key changes that the PR introduces:
1. We are expanding panels configuration in package.json. Apparently
there's no way to define panels dynamically and we need one panel for
every dev tool such that they can be run side-by-side when necessary. To
avoid having too many panels, every panel is hidden behind its own
context variable that we can dynamically set when we detect the user
requested to open this panel
2. We introduce a change to babel transformer that allows us to capture
the use of expo-dev-plugins we currently support. This way we can detect
which plugins the user has installed and only show relevant tools for
the user to open.
3. For the above to work correctly, we are adding a new type of event
that is passed via the devtools channel (same that we use for events
such as "app ready"). We use this event to notify about the plugins that
the app uses.
4. We're adding a UI component to the top menu bar with a "tools"
dropdown. The dropdown displays the list of available tools and a
checkbox next to every tool allowing the user to enable each tool
separately. When tools is enabled, the new panel displaying the tool UI
will launch.
5. Finally, we're implementing a new "ToolPlugin" interface and its
implementation that coordinates all the expo-dev-plugin specific needs.
The interface allows for tools to be listed as available and implements
a persistent storage for saving which tools are enabled. The
expo-dev-plugin implementation of the tool manages the registration of
webview panels, listening to events about the plugins being used by the
app, and controls the context variables that shows/hide the panel.

As a follow up to this change, we will be adding a docs update that
explains how tools can be used. Currently, we only support
epxo-dev-plugin tools, so for the whole setup to work corrently the user
needs to use Expo and have the specific tools configured according to
the documentation here: https://github.com/expo/dev-plugins

### How Has This Been Tested: 
1. I used a sample Expo project and added redux and react query dev
tools following the docs here: https://github.com/expo/dev-plugins
2. I tested enabling/disabling each plugin separately. When plugin is
enabled we expect the panel to open immediately.
3. I tested resetting metro cache which forces metro to reset at which
point it stars at a new port. This is important as the plugin UI is
hosted via metro. In this scenario is it expected for the panel to close
while the app is reloading.

---------

Co-authored-by: filip131311 <[email protected]>
  • Loading branch information
kmagiera and filip131311 authored Jan 14, 2025
1 parent bf16a0c commit d776a85
Show file tree
Hide file tree
Showing 18 changed files with 488 additions and 23 deletions.
3 changes: 3 additions & 0 deletions packages/vscode-extension/assets/mmkv.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/vscode-extension/assets/react-query.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions packages/vscode-extension/assets/redux.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 12 additions & 1 deletion packages/vscode-extension/lib/babel_transformer.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ function transformWrapper({ filename, src, ...rest }) {
isTransforming("node_modules/radon-ide/index.js")
) {
src = `${src};preview = require("__RNIDE_lib__/preview.js").preview;`;
} else if (isTransforming("node_modules/@dev-plugins/react-query/build/index.js")) {
src = `require("__RNIDE_lib__/expo_dev_plugins.js").register("@dev-plugins/react-query");${src}`;
} else if (isTransforming("node_modules/@dev-plugins/react-native-mmkv/build/index.js")) {
src = `require("__RNIDE_lib__/expo_dev_plugins.js").register("@dev-plugins/react-native-mmkv");${src}`;
} else if (isTransforming("node_modules/redux-devtools-expo-dev-plugin/build/index.js")) {
src = `require("__RNIDE_lib__/expo_dev_plugins.js").register("redux-devtools-expo-dev-plugin");${src}`;
} else if (
isTransforming(
"node_modules/react-native/Libraries/Renderer/implementations/ReactFabric-dev.js"
Expand All @@ -98,7 +104,12 @@ function transformWrapper({ filename, src, ...rest }) {
// is experimental as it has some performance implications and may be removed in future versions.
//
const { version } = requireFromAppDir("react-native/package.json");
if (version.startsWith("0.74") || version.startsWith("0.75") || version.startsWith("0.76") || version.startsWith("0.77")) {
if (
version.startsWith("0.74") ||
version.startsWith("0.75") ||
version.startsWith("0.76") ||
version.startsWith("0.77")
) {
const rendererFileName = filename.split(path.sep).pop();
src = `module.exports = require("__RNIDE_lib__/rn-renderer/${rendererFileName}");`;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/vscode-extension/lib/expo_dev_plugins.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function register(pluginName) {
global.__RNIDE_register_expo_dev_plugin && global.__RNIDE_register_expo_dev_plugin(pluginName);
}
4 changes: 4 additions & 0 deletions packages/vscode-extension/lib/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ global.__RNIDE_register_navigation_plugin = function (name, plugin) {
require("__RNIDE_lib__/wrapper.js").registerNavigationPlugin(name, plugin);
};

global.__RNIDE_register_expo_dev_plugin = function (name) {
require("__RNIDE_lib__/wrapper.js").registerExpoDevPlugin(name);
};

AppRegistry.setWrapperComponentProvider((appParameters) => {
return require("__RNIDE_lib__/wrapper.js").AppWrapper;
});
Expand Down
18 changes: 18 additions & 0 deletions packages/vscode-extension/lib/wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export function registerNavigationPlugin(name, plugin) {
navigationPlugins.push({ name, plugin });
}

const expoDevPlugins = new Set();
let expoDevPluginsChanged = undefined;
export function registerExpoDevPlugin(name) {
expoDevPlugins.add(name);
expoDevPluginsChanged?.();
}

let navigationHistory = new Map();

const InternalImports = {
Expand Down Expand Up @@ -367,6 +374,17 @@ export function AppWrapper({ children, initialProps, fabric }) {
appKey,
navigationPlugins: navigationPlugins.map((plugin) => plugin.name),
});
devtoolsAgent._bridge.send("RNIDE_expoDevPluginsChanged", {
plugins: Array.from(expoDevPlugins.values()),
});
expoDevPluginsChanged = () => {
devtoolsAgent._bridge.send("RNIDE_expoDevPluginsChanged", {
plugins: Array.from(expoDevPlugins.values()),
});
};
return () => {
devtoolsPluginsChanged = undefined;
};
}
}, [!!devtoolsAgent && hasLayout]);

Expand Down
43 changes: 42 additions & 1 deletion packages/vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,23 @@
"title": "Radon IDE",
"icon": "assets/logo.svg"
}
],
"panel": [
{
"id": "RadonIDEToolExpoDevPluginReactQuery",
"title": "React Query",
"icon": "assets/react-query.svg"
},
{
"id": "RadonIDEToolExpoDevPluginMMKV",
"title": "MMKV",
"icon": "assets/mmkv.svg"
},
{
"id": "RadonIDEToolExpoDevPluginReduxDevTools",
"title": "Redux",
"icon": "assets/redux.svg"
}
]
},
"views": {
Expand All @@ -175,7 +192,31 @@
"type": "webview",
"id": "RadonIDE.view",
"name": "",
"when": "config.RadonIDE.panelLocation != 'tab' && !RNIDE.sidePanelIsClosed"
"when": "RNIDE.extensionIsActive && config.RadonIDE.panelLocation != 'tab' && !RNIDE.sidePanelIsClosed"
}
],
"RadonIDEToolExpoDevPluginReactQuery": [
{
"type": "webview",
"id": "RNIDE.Tool.ExpoDevPlugin.ReactQuery.view",
"name": "",
"when": "RNIDE.Tool.ExpoDevPlugin.ReactQuery.available && RNIDE.panelIsOpen"
}
],
"RadonIDEToolExpoDevPluginMMKV": [
{
"type": "webview",
"id": "RNIDE.Tool.ExpoDevPlugin.MMKV.view",
"name": "",
"when": "RNIDE.Tool.ExpoDevPlugin.MMKV.available && RNIDE.panelIsOpen"
}
],
"RadonIDEToolExpoDevPluginReduxDevTools": [
{
"type": "webview",
"id": "RNIDE.Tool.ExpoDevPlugin.ReduxDevTools.view",
"name": "",
"when": "RNIDE.Tool.ExpoDevPlugin.ReduxDevTools.available && RNIDE.panelIsOpen"
}
]
},
Expand Down
9 changes: 9 additions & 0 deletions packages/vscode-extension/src/common/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export type DeviceSettings = {
showTouches: boolean;
};

export type ToolsState = {
[key: string]: { enabled: boolean; label: string };
};

export type ProjectState = {
status:
| "starting"
Expand Down Expand Up @@ -113,6 +117,7 @@ export interface ProjectEventMap {
log: { type: string };
projectStateChanged: ProjectState;
deviceSettingsChanged: DeviceSettings;
toolsStateChanged: ToolsState;
licenseActivationChanged: boolean;
navigationChanged: { displayName: string; id: string };
needsNativeRebuild: void;
Expand Down Expand Up @@ -143,6 +148,10 @@ export interface ProjectInterface {
updateDeviceSettings(deviceSettings: DeviceSettings): Promise<void>;
sendBiometricAuthorization(match: boolean): Promise<void>;

getToolsState(): Promise<ToolsState>;
updateToolEnabledState(toolName: keyof ToolsState, enabled: boolean): Promise<void>;
openTool(toolName: keyof ToolsState): Promise<void>;

resumeDebugger(): Promise<void>;
stepOverDebugger(): Promise<void>;
focusBuildOutput(): Promise<void>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {
CancellationToken,
ExtensionContext,
Uri,
WebviewView,
WebviewViewProvider,
WebviewViewResolveContext,
} from "vscode";
import { IDE } from "../../project/ide";
import { ExpoDevPluginToolName } from "./expo-dev-plugins";
import { Logger } from "../../Logger";

function generateWebviewContent(pluginName: ExpoDevPluginToolName, metroPort: number): string {
const iframeURL = `http://localhost:${metroPort}/_expo/plugins/${pluginName}`;
return /*html*/ `
<!DOCTYPE html>
<html style="height: 100%;">
<head/>
<body style="height: 100%; overflow:hidden">
<iframe src="${iframeURL}" style="width: 100%; height: 100%; border: none;"/>
</body>
</html>
`;
}

export class ExpoDevPluginWebviewProvider implements WebviewViewProvider {
constructor(
private readonly context: ExtensionContext,
private readonly pluginName: ExpoDevPluginToolName
) {}
public resolveWebviewView(
webviewView: WebviewView,
context: WebviewViewResolveContext,
token: CancellationToken
): void {
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [Uri.joinPath(this.context.extensionUri, "dist")],
};

const metroPort = IDE.getInstanceIfExists()?.project.metro.port;
if (!metroPort) {
Logger.error(
"Metro port is unknown while expected to be set, the devtools panel cannot be opened."
);
} else {
webviewView.webview.html = generateWebviewContent(this.pluginName, metroPort);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { commands, window } from "vscode";
import { ExpoDevPluginWebviewProvider } from "./ExpoDevPluginWebviewProvider";
import { ToolPlugin, ToolsManager } from "../../project/tools";
import { extensionContext } from "../../utilities/extensionContext";

export type ExpoDevPluginToolName =
| "@dev-plugins/react-query"
| "@dev-plugins/react-native-mmkv"
| "redux-devtools-expo-dev-plugin";

type ExpoDevPluginInfo = {
viewIdPrefix: string;
label: string;
};

// Define the map of plugins using the string union type
const ExpoDevPluginToolMap: Record<ExpoDevPluginToolName, ExpoDevPluginInfo> = {
"@dev-plugins/react-query": {
label: "React Query",
viewIdPrefix: "RNIDE.Tool.ExpoDevPlugin.ReactQuery",
},
"@dev-plugins/react-native-mmkv": {
label: "MMKV",
viewIdPrefix: "RNIDE.Tool.ExpoDevPlugin.MMKV",
},
"redux-devtools-expo-dev-plugin": {
viewIdPrefix: "RNIDE.Tool.ExpoDevPlugin.ReduxDevTools",
label: "Redux DevTools",
},
};

let initialzed = false;
function initializeExpoDevPluginIfNeeded() {
if (initialzed) {
return;
}
initialzed = true;

for (const [name, pluginInfo] of Object.entries(ExpoDevPluginToolMap)) {
extensionContext.subscriptions.push(
window.registerWebviewViewProvider(
`${pluginInfo.viewIdPrefix}.view`,
new ExpoDevPluginWebviewProvider(extensionContext, name as ExpoDevPluginToolName),
{ webviewOptions: { retainContextWhenHidden: true } }
)
);
}
}

export function createExpoDevPluginTools(toolsManager: ToolsManager): ToolPlugin[] {
initializeExpoDevPluginIfNeeded();

const plugins: ToolPlugin[] = [];

function devtoolsListener(event: string, payload: any) {
if (event === "RNIDE_expoDevPluginsChanged") {
// payload.plugins is a list of expo dev plugin names
const availablePlugins = new Set(payload.plugins);
for (const plugin of plugins) {
plugin.available = availablePlugins.has(plugin.id);
}
// notify tools manager that the state of requested plugins has changed
toolsManager.handleStateChange();
}
}
let disposed = false;
function dispose() {
if (!disposed) {
toolsManager.devtools.removeListener(devtoolsListener);
disposed = false;
}
}

for (const [id, pluginInfo] of Object.entries(ExpoDevPluginToolMap)) {
plugins.push({
id: id as ExpoDevPluginToolName,
label: pluginInfo.label,
available: false,
activate() {
commands.executeCommand("setContext", `${pluginInfo.viewIdPrefix}.available`, true);
},
deactivate() {
commands.executeCommand("setContext", `${pluginInfo.viewIdPrefix}.available`, false);
},
openTool() {
commands.executeCommand(`${pluginInfo.viewIdPrefix}.view.focus`);
},
dispose,
});
}

// Listen for events passed via devtools that indicate which plugins are loaded
// by the app.
toolsManager.devtools.addListener(devtoolsListener);

return plugins;
}
22 changes: 21 additions & 1 deletion packages/vscode-extension/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ import {
refreshTokenPeriodically,
} from "../utilities/license";
import { getTelemetryReporter } from "../utilities/telemetry";
import { ToolKey, ToolsManager } from "./tools";
import { UtilsInterface } from "../common/utils";

const DEVICE_SETTINGS_KEY = "device_settings_v4";

const LAST_SELECTED_DEVICE_KEY = "last_selected_device";
const PREVIEW_ZOOM_KEY = "preview_zoom";
const DEEP_LINKS_HISTORY_KEY = "deep_links_history";
Expand All @@ -55,7 +57,8 @@ const MAX_RECORDING_TIME_SEC = 10 * 60; // 10 minutes
export class Project
implements Disposable, MetroDelegate, EventDelegate, DebugSessionDelegate, ProjectInterface
{
private metro: Metro;
public metro: Metro;
public toolsManager: ToolsManager;
private devtools = new Devtools();
private eventEmitter = new EventEmitter();

Expand Down Expand Up @@ -94,8 +97,10 @@ export class Project
replaysEnabled: false,
showTouches: false,
};

this.devtools = new Devtools();
this.metro = new Metro(this.devtools, this);
this.toolsManager = new ToolsManager(this.devtools, this.eventEmitter);
this.start(false, false);
this.trySelectingInitialDevice();
this.deviceManager.addListener("deviceRemoved", this.removeDeviceListener);
Expand Down Expand Up @@ -458,8 +463,11 @@ export class Project
if (restart) {
const oldDevtools = this.devtools;
const oldMetro = this.metro;
const oldToolsManager = this.toolsManager;
this.devtools = new Devtools();
this.metro = new Metro(this.devtools, this);
this.toolsManager = new ToolsManager(this.devtools, this.eventEmitter);
oldToolsManager.dispose();
oldDevtools.dispose();
oldMetro.dispose();
}
Expand Down Expand Up @@ -627,6 +635,18 @@ export class Project
}
}

public async getToolsState() {
return this.toolsManager.getToolsState();
}

public async updateToolEnabledState(toolName: ToolKey, enabled: boolean) {
await this.toolsManager.updateToolEnabledState(toolName, enabled);
}

public async openTool(toolName: ToolKey) {
await this.toolsManager.openTool(toolName);
}

public async renameDevice(deviceInfo: DeviceInfo, newDisplayName: string) {
await this.deviceManager.renameDevice(deviceInfo, newDisplayName);
deviceInfo.displayName = newDisplayName;
Expand Down
Loading

0 comments on commit d776a85

Please sign in to comment.