Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/vscode-extension/assets/react.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
42 changes: 38 additions & 4 deletions packages/vscode-extension/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions packages/vscode-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,18 @@
"description": "Get up-to-date answers related to React Native",
"isSticky": true
}
],
"customEditors": [
{
"viewType": "RadonIDE.reactDevtoolsProfiler",
"displayName": "React Profiler",
"selector": [
{
"filenamePattern": "*.reactprofile"
}
],
"priority": "default"
}
]
},
"scripts": {
Expand Down Expand Up @@ -780,8 +792,10 @@
"prettier": "3.5.3",
"re-resizable": "^6.11.2",
"react": "^19.1.0",
"react-devtools-inline": "^6.1.1",
"react-dom": "^19.1.0",
"react-hook-form": "^7.55.0",
"react-is": "^19.1.0",
"react-scan": "https://github.com/software-mansion-labs/react-scan/releases/download/0.1.3-radon-2/react-scan-0.1.3-radon-2.tgz",
"rollup": "^4.40.0",
"rollup-plugin-license": "^3.6.0",
Expand Down
4 changes: 4 additions & 0 deletions packages/vscode-extension/src/common/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ export interface ProjectEventMap {
replayDataCreated: MultimediaData;
isRecording: boolean;
isProfilingCPU: boolean;
isProfilingReact: boolean;
}

export interface ProjectEventListener<T> {
Expand Down Expand Up @@ -189,6 +190,9 @@ export interface ProjectInterface {
startProfilingCPU(): void;
stopProfilingCPU(): void;

startProfilingReact(): void;
stopProfilingReact(): void;

dispatchTouches(touches: Array<TouchPoint>, type: "Up" | "Move" | "Down"): void;
dispatchKeyPress(keyCode: number, direction: "Up" | "Down"): void;
dispatchWheel(point: TouchPoint, deltaX: number, deltaY: number): void;
Expand Down
2 changes: 2 additions & 0 deletions packages/vscode-extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { IDE } from "./project/ide";
import { registerChat } from "./chat";
import { ProxyDebugSessionAdapterDescriptorFactory } from "./debugging/ProxyDebugAdapter";
import { Connector } from "./connect/Connector";
import { ReactDevtoolsEditorProvider } from "./react-devtools-profiler/ReactDevtoolsEditorProvider";

const OPEN_PANEL_ON_ACTIVATION = "open_panel_on_activation";
const CHAT_ONBOARDING_COMPLETED = "chat_onboarding_completed";
Expand Down Expand Up @@ -147,6 +148,7 @@ export async function activate(context: ExtensionContext) {
{ webviewOptions: { retainContextWhenHidden: true } }
)
);
context.subscriptions.push(ReactDevtoolsEditorProvider.register(context));
context.subscriptions.push(
commands.registerCommand("RNIDE.performBiometricAuthorization", performBiometricAuthorization)
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export function generateWebviewContent(
extensionUri: Uri,
webviewName: string,
webviewDevPath: string,
wsEndpoint?: string
wsEndpoint?: string,
allowUnsafeEval?: boolean
) {
const config = workspace.getConfiguration("RadonIDE");
const useCodeTheme = config.get("themeType") === "vscode";
Expand Down Expand Up @@ -65,11 +66,11 @@ export function generateWebviewContent(
img-src vscode-resource: http: https: data:;
media-src vscode-resource: http: https:;
style-src ${webview.cspSource} 'unsafe-inline';
script-src 'nonce-${nonce}';
script-src 'nonce-${nonce}' ${allowUnsafeEval ? "'unsafe-eval'" : ""};
worker-src 'self' blob:;
${wsEndpoint ? `connect-src ws://${wsEndpoint} ${webview.cspSource};` : ""}
font-src vscode-resource: https:;" />
<link rel="stylesheet" type="text/css" href="theme.css" />
<link rel="stylesheet" type="text/css" href="${webviewName}.css" />
<link rel="stylesheet" type="text/css" href="style.css" />
`
}
</head>
Expand Down
4 changes: 4 additions & 0 deletions packages/vscode-extension/src/project/deviceSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export type AppEvent = {
navigationChanged: { displayName: string; id: string };
fastRefreshStarted: undefined;
fastRefreshComplete: undefined;
isProfilingReact: boolean;
};

export type DeviceSessionDelegate = {
Expand Down Expand Up @@ -154,6 +155,9 @@ export class DeviceSession implements Disposable {
devtools.onEvent("RNIDE_fastRefreshComplete", () => {
this.deviceSessionDelegate.onAppEvent("fastRefreshComplete", undefined);
});
devtools.onEvent("RNIDE_isProfilingReact", (isProfiling) => {
this.deviceSessionDelegate.onAppEvent("isProfilingReact", isProfiling);
});
return devtools;
}

Expand Down
52 changes: 51 additions & 1 deletion packages/vscode-extension/src/project/devtools.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import http from "http";
import { Disposable } from "vscode";
import { commands, Disposable, Uri } from "vscode";
import { WebSocketServer, WebSocket } from "ws";
import { Logger } from "../Logger";
import {
createBridge,
createStore,
FrontendBridge,
prepareProfilingDataExport,
Store,
Wall,
} from "../../third-party/react-devtools/headless";
import path from "path";
import fs from "fs";
import os from "os";

// Define event names as a const array to avoid duplication
Expand All @@ -23,6 +25,7 @@ export const DEVTOOLS_EVENTS = [
"RNIDE_devtoolPluginsChanged",
"RNIDE_rendersReported",
"RNIDE_pluginMessage",
"RNIDE_isProfilingReact",
] as const;

// Define the payload types for each event
Expand All @@ -36,6 +39,13 @@ export interface DevtoolsEvents {
RNIDE_devtoolPluginsChanged: [{ plugins: string[] }];
RNIDE_rendersReported: [any];
RNIDE_pluginMessage: [{ scope: string; type: string; data: any }];
RNIDE_isProfilingReact: [boolean];
}

function filePathForProfile() {
const fileName = `profile-${Date.now()}.reactprofile`;
const filePath = path.join(os.tmpdir(), fileName);
return filePath;
}

export class Devtools implements Disposable {
Expand All @@ -44,6 +54,7 @@ export class Devtools implements Disposable {
private socket?: WebSocket;
private startPromise: Promise<void> | undefined;
private listeners: Map<keyof DevtoolsEvents, Array<(...payload: any) => void>> = new Map();
private store: Store | undefined;

public get port() {
return this._port;
Expand Down Expand Up @@ -107,10 +118,14 @@ export class Devtools implements Disposable {

const bridge = createBridge(wall);
const store = createStore(bridge);
this.store = store;

ws.on("close", () => {
this.socket = undefined;
bridge.shutdown();
if (this.store === store) {
this.store = undefined;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we check this.socket === ws in that case as well? Doesn't it break in the exact same scenario (i.e. a new connection is started before the previous one closes?)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the check was just defensive code as we never call starInternal more than once on devtools. But it'd be good to have the same check for this.socket anyways.

});

// Register bridge listeners for ALL custom event types
Expand All @@ -119,6 +134,14 @@ export class Devtools implements Disposable {
this.listeners.get(event)?.forEach((listener) => listener(payload));
});
}

// Register for isProfiling event on the profiler store
store.profilerStore.addListener("isProfiling", () => {
this.listeners
.get("RNIDE_isProfilingReact")
// @ts-ignore - isProfilingBasedOnUserInput exists but types are outdated
?.forEach((listener) => listener(store.profilerStore.isProfilingBasedOnUserInput));
});
});

return new Promise<void>((resolve) => {
Expand All @@ -130,6 +153,33 @@ export class Devtools implements Disposable {
});
}

public async startProfilingReact() {
this.store?.profilerStore.startProfiling();
}

public async stopProfilingReact() {
const { resolve, reject, promise } = Promise.withResolvers<Uri>();
const saveProfileListener = async () => {
const isProcessingData = this.store?.profilerStore.isProcessingData;
if (!isProcessingData) {
this.store?.profilerStore.removeListener("isProcessingData", saveProfileListener);
const profilingData = this.store?.profilerStore.profilingData;
if (profilingData) {
const exportData = prepareProfilingDataExport(profilingData);
const filePath = filePathForProfile();
await fs.promises.writeFile(filePath, JSON.stringify(exportData));
resolve(Uri.file(filePath));
} else {
reject(new Error("No profiling data available"));
}
}
};

this.store?.profilerStore.addListener("isProcessingData", saveProfileListener);
this.store?.profilerStore.stopProfiling();
return promise;
}

public dispose() {
this.server.close();
}
Expand Down
15 changes: 15 additions & 0 deletions packages/vscode-extension/src/project/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,9 @@ export class Project implements Disposable, ProjectInterface, DeviceSessionsMana
}
this.updateProjectState({ status: "running" });
break;
case "isProfilingReact":
this.eventEmitter.emit("isProfilingReact", payload);
break;
}
};
//#endregion
Expand Down Expand Up @@ -291,6 +294,18 @@ export class Project implements Disposable, ProjectInterface, DeviceSessionsMana
}
}

async startProfilingReact() {
await this.deviceSession?.devtools.startProfilingReact();
}

async stopProfilingReact() {
const uri = await this.deviceSession?.devtools.stopProfilingReact();
if (uri) {
// open profile file in vscode using our custom editor
commands.executeCommand("vscode.open", uri);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does VSCode guess the editor using the file extension? Maybe there's some way to explicitely tell the vscode.open command which editor it should open with?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we register custom editor to open .reactprofile files and this works based on that mechanism. The file is saved with .reactprofile extension. This is also good, because if someone saves the file on disk, they can open it later on using in VSCode using our editor.

The only case where it may not work is if some other extension registers an editor that opens .reacprofile files in which case I don't necessarily have anything agains that because it'd be the user's choice to use a different editor over the one that we provide.

There is a command called vscode.openWith that allows you to specify the editor but for the reasons mentioned above I'd rather use just plain open method here and rely on VSCode to use the editor that the user prefers or the one that we provide (in most of the cases)

}
}

onProfilingCPUStarted(event: DebugSessionCustomEvent): void {
this.eventEmitter.emit("isProfilingCPU", true);
}
Expand Down
10 changes: 10 additions & 0 deletions packages/vscode-extension/src/react-devtools-profiler/App.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
html,
body,
#root {
box-sizing: border-box;
height: 100%;
width: 100%;
margin: 0;
padding: 0;
background-color: var(--background);
}
Loading