Skip to content

Commit 26f32a0

Browse files
authored
Add react devtools profiler support (#1117)
This PR adds a new tool – React Profiler React Profiler integration allows controlling (starting/stopping) of the profile via React Devtools and also to open the profiles in a standalone editor panel. https://github.com/user-attachments/assets/b525808b-6eb1-4fa4-95ac-de052121e7de Below is an overview of the changes made in this PR: - We use devtools-hedless package to access react devtools' profiler store to control the profiling - When profiling is finished, we use the export method exposed via devtools-headless package to dump the profile into a file and save under '.reactprofile' extension - We're implementing a new editor provider that can open '.reactprofile' files. The editor uses the devtools-inline package to present the UI and uses the profile import method from devtools-headless package to process the profile data. - We're adding buttons to the tools menu to initiate the profiling and use the CPU profiling style indicator when the profiler is running. - We're updating vite configuration – for some reason after adding a new input, vite in release mode started splitting CSS into more chunks. Before it was just main chunk and theme chunk. Instead of adding more dependencies I decided it will be easier to force it to not do chunking at all and use single style.css for all the webviews. - We are adding additional options to the webview CSP because devtools use JS's eval and also boot up a worker thread using blob URL. ### How Has This Been Tested: 1. Open IDE panel and wait for app to load 2. Click "Start React Profiler" 3. Observe a profiler indicator appear (similar as with the CPU profiling) 4. Do something with the app 5. Click stop 6. The profiler will stop (indicator is gone) and a profile will be automatically opened in a new editor tab 7. Run everything in release mode (DEV=false in weviewContentGenerator) – check other panels like network to make sure vite changes didn't mess something up
1 parent ad6ab94 commit 26f32a0

File tree

18 files changed

+421
-57
lines changed

18 files changed

+421
-57
lines changed
Lines changed: 8 additions & 0 deletions
Loading

packages/vscode-extension/package-lock.json

Lines changed: 38 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/vscode-extension/package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,18 @@
680680
"description": "Get up-to-date answers related to React Native",
681681
"isSticky": true
682682
}
683+
],
684+
"customEditors": [
685+
{
686+
"viewType": "RadonIDE.reactDevtoolsProfiler",
687+
"displayName": "React Profiler",
688+
"selector": [
689+
{
690+
"filenamePattern": "*.reactprofile"
691+
}
692+
],
693+
"priority": "default"
694+
}
683695
]
684696
},
685697
"scripts": {
@@ -780,8 +792,10 @@
780792
"prettier": "3.5.3",
781793
"re-resizable": "^6.11.2",
782794
"react": "^19.1.0",
795+
"react-devtools-inline": "^6.1.1",
783796
"react-dom": "^19.1.0",
784797
"react-hook-form": "^7.55.0",
798+
"react-is": "^19.1.0",
785799
"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",
786800
"rollup": "^4.40.0",
787801
"rollup-plugin-license": "^3.6.0",

packages/vscode-extension/src/common/Project.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ export interface ProjectEventMap {
139139
replayDataCreated: MultimediaData;
140140
isRecording: boolean;
141141
isProfilingCPU: boolean;
142+
isProfilingReact: boolean;
143+
isSavingReactProfile: boolean;
142144
}
143145

144146
export interface ProjectEventListener<T> {
@@ -189,6 +191,9 @@ export interface ProjectInterface {
189191
startProfilingCPU(): void;
190192
stopProfilingCPU(): void;
191193

194+
startProfilingReact(): void;
195+
stopProfilingReact(): void;
196+
192197
dispatchTouches(touches: Array<TouchPoint>, type: "Up" | "Move" | "Down"): void;
193198
dispatchKeyPress(keyCode: number, direction: "Up" | "Down"): void;
194199
dispatchWheel(point: TouchPoint, deltaX: number, deltaY: number): void;

packages/vscode-extension/src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { IDE } from "./project/ide";
3333
import { registerChat } from "./chat";
3434
import { ProxyDebugSessionAdapterDescriptorFactory } from "./debugging/ProxyDebugAdapter";
3535
import { Connector } from "./connect/Connector";
36+
import { ReactDevtoolsEditorProvider } from "./react-devtools-profiler/ReactDevtoolsEditorProvider";
3637

3738
const OPEN_PANEL_ON_ACTIVATION = "open_panel_on_activation";
3839
const CHAT_ONBOARDING_COMPLETED = "chat_onboarding_completed";
@@ -147,6 +148,7 @@ export async function activate(context: ExtensionContext) {
147148
{ webviewOptions: { retainContextWhenHidden: true } }
148149
)
149150
);
151+
context.subscriptions.push(ReactDevtoolsEditorProvider.register(context));
150152
context.subscriptions.push(
151153
commands.registerCommand("RNIDE.performBiometricAuthorization", performBiometricAuthorization)
152154
);

packages/vscode-extension/src/panels/webviewContentGenerator.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export function generateWebviewContent(
1010
extensionUri: Uri,
1111
webviewName: string,
1212
webviewDevPath: string,
13-
wsEndpoint?: string
13+
wsEndpoint?: string,
14+
allowUnsafeEval?: boolean
1415
) {
1516
const config = workspace.getConfiguration("RadonIDE");
1617
const useCodeTheme = config.get("themeType") === "vscode";
@@ -65,11 +66,11 @@ export function generateWebviewContent(
6566
img-src vscode-resource: http: https: data:;
6667
media-src vscode-resource: http: https:;
6768
style-src ${webview.cspSource} 'unsafe-inline';
68-
script-src 'nonce-${nonce}';
69+
script-src 'nonce-${nonce}' ${allowUnsafeEval ? "'unsafe-eval'" : ""};
70+
worker-src 'self' blob:;
6971
${wsEndpoint ? `connect-src ws://${wsEndpoint} ${webview.cspSource};` : ""}
7072
font-src vscode-resource: https:;" />
71-
<link rel="stylesheet" type="text/css" href="theme.css" />
72-
<link rel="stylesheet" type="text/css" href="${webviewName}.css" />
73+
<link rel="stylesheet" type="text/css" href="style.css" />
7374
`
7475
}
7576
</head>

packages/vscode-extension/src/project/deviceSession.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export type AppEvent = {
6161
navigationChanged: { displayName: string; id: string };
6262
fastRefreshStarted: undefined;
6363
fastRefreshComplete: undefined;
64+
isProfilingReact: boolean;
65+
isSavingReactProfile: boolean;
6466
};
6567

6668
export type DeviceSessionDelegate = {
@@ -158,6 +160,9 @@ export class DeviceSession implements Disposable {
158160
devtools.onEvent("RNIDE_fastRefreshComplete", () => {
159161
this.deviceSessionDelegate.onAppEvent("fastRefreshComplete", undefined);
160162
});
163+
devtools.onEvent("RNIDE_isProfilingReact", (isProfiling) => {
164+
this.deviceSessionDelegate.onAppEvent("isProfilingReact", isProfiling);
165+
});
161166
return devtools;
162167
}
163168

packages/vscode-extension/src/project/devtools.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import http from "http";
2-
import { Disposable } from "vscode";
2+
import { commands, Disposable, Uri } from "vscode";
33
import { WebSocketServer, WebSocket } from "ws";
44
import { Logger } from "../Logger";
55
import {
66
createBridge,
77
createStore,
88
FrontendBridge,
9+
prepareProfilingDataExport,
910
Store,
1011
Wall,
1112
} from "../../third-party/react-devtools/headless";
1213
import path from "path";
14+
import fs from "fs";
1315
import os from "os";
1416

1517
// Define event names as a const array to avoid duplication
@@ -23,6 +25,7 @@ export const DEVTOOLS_EVENTS = [
2325
"RNIDE_devtoolPluginsChanged",
2426
"RNIDE_rendersReported",
2527
"RNIDE_pluginMessage",
28+
"RNIDE_isProfilingReact",
2629
] as const;
2730

2831
// Define the payload types for each event
@@ -36,6 +39,13 @@ export interface DevtoolsEvents {
3639
RNIDE_devtoolPluginsChanged: [{ plugins: string[] }];
3740
RNIDE_rendersReported: [any];
3841
RNIDE_pluginMessage: [{ scope: string; type: string; data: any }];
42+
RNIDE_isProfilingReact: [boolean];
43+
}
44+
45+
function filePathForProfile() {
46+
const fileName = `profile-${Date.now()}.reactprofile`;
47+
const filePath = path.join(os.tmpdir(), fileName);
48+
return filePath;
3949
}
4050

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

4859
public get port() {
4960
return this._port;
@@ -107,10 +118,16 @@ export class Devtools implements Disposable {
107118

108119
const bridge = createBridge(wall);
109120
const store = createStore(bridge);
121+
this.store = store;
110122

111123
ws.on("close", () => {
112-
this.socket = undefined;
124+
if (this.socket === ws) {
125+
this.socket = undefined;
126+
}
113127
bridge.shutdown();
128+
if (this.store === store) {
129+
this.store = undefined;
130+
}
114131
});
115132

116133
// Register bridge listeners for ALL custom event types
@@ -119,6 +136,14 @@ export class Devtools implements Disposable {
119136
this.listeners.get(event)?.forEach((listener) => listener(payload));
120137
});
121138
}
139+
140+
// Register for isProfiling event on the profiler store
141+
store.profilerStore.addListener("isProfiling", () => {
142+
this.listeners
143+
.get("RNIDE_isProfilingReact")
144+
// @ts-ignore - isProfilingBasedOnUserInput exists but types are outdated
145+
?.forEach((listener) => listener(store.profilerStore.isProfilingBasedOnUserInput));
146+
});
122147
});
123148

124149
return new Promise<void>((resolve) => {
@@ -130,6 +155,33 @@ export class Devtools implements Disposable {
130155
});
131156
}
132157

158+
public async startProfilingReact() {
159+
this.store?.profilerStore.startProfiling();
160+
}
161+
162+
public async stopProfilingReact() {
163+
const { resolve, reject, promise } = Promise.withResolvers<Uri>();
164+
const saveProfileListener = async () => {
165+
const isProcessingData = this.store?.profilerStore.isProcessingData;
166+
if (!isProcessingData) {
167+
this.store?.profilerStore.removeListener("isProcessingData", saveProfileListener);
168+
const profilingData = this.store?.profilerStore.profilingData;
169+
if (profilingData) {
170+
const exportData = prepareProfilingDataExport(profilingData);
171+
const filePath = filePathForProfile();
172+
await fs.promises.writeFile(filePath, JSON.stringify(exportData));
173+
resolve(Uri.file(filePath));
174+
} else {
175+
reject(new Error("No profiling data available"));
176+
}
177+
}
178+
};
179+
180+
this.store?.profilerStore.addListener("isProcessingData", saveProfileListener);
181+
this.store?.profilerStore.stopProfiling();
182+
return promise;
183+
}
184+
133185
public dispose() {
134186
this.server.close();
135187
}

packages/vscode-extension/src/project/project.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ export class Project implements Disposable, ProjectInterface, DeviceSessionsMana
211211
}
212212
this.updateProjectState({ status: "running" });
213213
break;
214+
case "isProfilingReact":
215+
this.eventEmitter.emit("isProfilingReact", payload);
216+
break;
217+
case "isSavingReactProfile":
218+
this.eventEmitter.emit("isSavingReactProfile", payload);
219+
break;
214220
}
215221
};
216222
//#endregion
@@ -291,6 +297,23 @@ export class Project implements Disposable, ProjectInterface, DeviceSessionsMana
291297
}
292298
}
293299

300+
async startProfilingReact() {
301+
await this.deviceSession?.devtools.startProfilingReact();
302+
}
303+
304+
async stopProfilingReact() {
305+
try {
306+
this.eventEmitter.emit("isSavingReactProfile", true);
307+
const uri = await this.deviceSession?.devtools.stopProfilingReact();
308+
if (uri) {
309+
// open profile file in vscode using our custom editor
310+
commands.executeCommand("vscode.open", uri);
311+
}
312+
} finally {
313+
this.eventEmitter.emit("isSavingReactProfile", false);
314+
}
315+
}
316+
294317
onProfilingCPUStarted(event: DebugSessionCustomEvent): void {
295318
this.eventEmitter.emit("isProfilingCPU", true);
296319
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
html,
2+
body,
3+
#root {
4+
box-sizing: border-box;
5+
height: 100%;
6+
width: 100%;
7+
margin: 0;
8+
padding: 0;
9+
background-color: var(--background);
10+
}

0 commit comments

Comments
 (0)