diff --git a/cli/src/commands.rs b/cli/src/commands.rs index ed8ea7c1..fa0b75bf 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -1037,6 +1037,31 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result { + const VALID: &[&str] = &["start", "stop"]; + match rest.first().copied() { + Some("start") => { + Ok(json!({ "id": id, "action": "react_profile_start" })) + } + Some("stop") => { + let mut cmd = json!({ "id": id, "action": "react_profile_stop" }); + if let Some(path) = rest.get(1) { + cmd["path"] = json!(path); + } + Ok(cmd) + } + Some(sub) => Err(ParseError::UnknownSubcommand { + subcommand: sub.to_string(), + valid_options: VALID, + }), + None => Err(ParseError::MissingArguments { + context: "react_profile".to_string(), + usage: "react_profile [path]", + }), + } + } + // === Recording (Playwright native video recording) === "record" => { const VALID: &[&str] = &["start", "stop", "restart"]; @@ -2919,6 +2944,49 @@ mod tests { )); } + // === React Profile Tests === + + #[test] + fn test_react_profile_start() { + let cmd = parse_command(&args("react_profile start"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "react_profile_start"); + } + + #[test] + fn test_react_profile_stop_no_path() { + let cmd = parse_command(&args("react_profile stop"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "react_profile_stop"); + assert!(cmd.get("path").is_none()); + } + + #[test] + fn test_react_profile_stop_with_path() { + let cmd = + parse_command(&args("react_profile stop react.json"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "react_profile_stop"); + assert_eq!(cmd["path"], "react.json"); + } + + #[test] + fn test_react_profile_invalid_subcommand() { + let result = parse_command(&args("react_profile foo"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::UnknownSubcommand { .. } + )); + } + + #[test] + fn test_react_profile_missing_subcommand() { + let result = parse_command(&args("react_profile"), &default_flags()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ParseError::MissingArguments { .. } + )); + } + // === Eval Tests === #[test] diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 706cc582..bbe03aba 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -100,6 +100,8 @@ pub struct DaemonState { pub tracked_requests: Vec, pub request_tracking: bool, pub active_frame_id: Option, + pub react_profiling_active: bool, + pub react_profile_init_script_installed: bool, } impl DaemonState { @@ -131,6 +133,8 @@ impl DaemonState { tracked_requests: Vec::new(), request_tracking: false, active_frame_id: None, + react_profiling_active: false, + react_profile_init_script_installed: false, } } @@ -612,6 +616,8 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { "trace_stop" => handle_trace_stop(cmd, state).await, "profiler_start" => handle_profiler_start(cmd, state).await, "profiler_stop" => handle_profiler_stop(cmd, state).await, + "react_profile_start" => handle_react_profile_start(state).await, + "react_profile_stop" => handle_react_profile_stop(cmd, state).await, "recording_start" => handle_recording_start(cmd, state).await, "recording_stop" => handle_recording_stop(state).await, "recording_restart" => handle_recording_restart(cmd, state).await, @@ -2388,6 +2394,365 @@ async fn handle_profiler_stop(cmd: &Value, state: &mut DaemonState) -> Result { + if (window.__AGENT_REACT_PROFILER__) { + window.__AGENT_REACT_PROFILER__.renders = []; + window.__AGENT_REACT_PROFILER__.active = true; + return { reactDetected: window.__AGENT_REACT_PROFILER__.reactDetected }; + } + + const profiler = { + active: true, + reactDetected: false, + reactVersion: null, + renders: [], + maxRenders: 50000, + _fiberRoots: null, + _observer: null, + _rafPending: false, + }; + window.__AGENT_REACT_PROFILER__ = profiler; + + function getComponentName(fiber) { + if (!fiber || !fiber.type) return null; + if (typeof fiber.type === 'string') return null; + return fiber.type.displayName || fiber.type.name || 'Anonymous'; + } + + function collectRenderData(fiberRoot, rendererID, requireDuration) { + const current = fiberRoot.current; + if (!current) return; + const now = performance.now(); + + function walkFiber(fiber) { + if (!fiber) return; + const isComponent = fiber.tag === 0 || fiber.tag === 1 || fiber.tag === 11 || fiber.tag === 15; + if (isComponent) { + const hasDuration = fiber.actualDuration !== undefined && fiber.actualDuration > 0; + const isUpdated = fiber.alternate !== null; + if ((requireDuration ? hasDuration : isUpdated)) { + const name = getComponentName(fiber); + if (name && profiler.renders.length < profiler.maxRenders) { + profiler.renders.push({ + id: String(rendererID), + phase: fiber.alternate === null ? 'mount' : 'update', + componentName: name, + actualDuration: hasDuration ? fiber.actualDuration : 0, + baseDuration: fiber.selfBaseDuration || fiber.baseDuration || 0, + startTime: fiber.actualStartTime || now, + commitTime: now, + }); + } + } + } + walkFiber(fiber.child); + walkFiber(fiber.sibling); + } + + walkFiber(current); + } + + function patchHook(hook) { + if (!hook || hook.__agentPatched) return; + hook.__agentPatched = true; + + const originalOnCommitFiberRoot = hook.onCommitFiberRoot; + hook.onCommitFiberRoot = function(rendererID, fiberRoot, priorityLevel) { + if (profiler.active) { + try { + profiler.reactDetected = true; + collectRenderData(fiberRoot, rendererID, true); + } catch (e) {} + } + if (originalOnCommitFiberRoot) { + return originalOnCommitFiberRoot.call(this, rendererID, fiberRoot, priorityLevel); + } + }; + + const originalInject = hook.inject; + hook.inject = function(renderer) { + if (renderer && renderer.version) { + profiler.reactVersion = renderer.version; + } + if (originalInject) { + return originalInject.call(this, renderer); + } + return 0; + }; + } + + function installFreshHook() { + const hook = { + renderers: new Map(), + supportsFiber: true, + inject: function(renderer) { + profiler.reactDetected = true; + if (renderer && renderer.version) { + profiler.reactVersion = renderer.version; + } + const id = hook.renderers.size + 1; + hook.renderers.set(id, renderer); + return id; + }, + onScheduleFiberRoot: function() {}, + onCommitFiberRoot: function(rendererID, fiberRoot) { + if (profiler.active) { + try { collectRenderData(fiberRoot, rendererID, true); } catch (e) {} + } + }, + onCommitFiberUnmount: function() {}, + }; + hook.__agentPatched = true; + window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook; + return hook; + } + + function findFiberRoots() { + const roots = []; + const candidates = document.querySelectorAll('[id]'); + candidates.forEach(function(el) { + const keys = Object.keys(el); + for (let i = 0; i < keys.length; i++) { + if (keys[i].startsWith('__reactContainer') || keys[i].startsWith('__reactFiber')) { + const fiber = el[keys[i]]; + if (fiber) { + let node = fiber; + while (node.return) { node = node.return; } + if (node.stateNode && node.stateNode.current) { + roots.push(node.stateNode); + } + } + break; + } + } + }); + return roots; + } + + const existingHook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (existingHook) { + patchHook(existingHook); + if (existingHook.renderers && existingHook.renderers.size > 0) { + profiler.reactDetected = true; + } + } else { + const hook = installFreshHook(); + const fiberRoots = [...new Set(findFiberRoots())]; + if (fiberRoots.length > 0) { + profiler.reactDetected = true; + profiler._fiberRoots = fiberRoots; + let rafPending = false; + const observer = new MutationObserver(function() { + if (!profiler.active || rafPending) return; + rafPending = true; + requestAnimationFrame(function() { + rafPending = false; + if (!profiler.active) return; + for (let i = 0; i < profiler._fiberRoots.length; i++) { + try { + collectRenderData(profiler._fiberRoots[i], 1, false); + } catch (e) {} + } + }); + }); + fiberRoots.forEach(function(root) { + if (root.containerInfo) { + observer.observe(root.containerInfo, { childList: true, subtree: true, characterData: true }); + } + }); + profiler._observer = observer; + } + } + + return { reactDetected: profiler.reactDetected }; +})(); +"#; + +const REACT_COLLECTION_SCRIPT: &str = r#" +(() => { + const profiler = window.__AGENT_REACT_PROFILER__; + if (!profiler) { + return { reactDetected: false, reactVersion: null, renders: [] }; + } + profiler.active = false; + if (profiler._observer) { + profiler._observer.disconnect(); + profiler._observer = null; + } + const data = { + reactDetected: profiler.reactDetected, + reactVersion: profiler.reactVersion, + renders: profiler.renders, + }; + profiler.renders = []; + return data; +})(); +"#; + +async fn handle_react_profile_start(state: &mut DaemonState) -> Result { + if state.react_profiling_active { + return Err("React profiling already active".to_string()); + } + + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + + let result = mgr.evaluate(REACT_INJECTION_SCRIPT, None).await?; + let react_detected = result + .get("reactDetected") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if !state.react_profile_init_script_installed { + let _ = mgr.add_script_to_evaluate(REACT_INJECTION_SCRIPT).await; + state.react_profile_init_script_installed = true; + } + + state.react_profiling_active = true; + Ok(json!({ "started": true, "reactDetected": react_detected })) +} + +async fn handle_react_profile_stop(cmd: &Value, state: &mut DaemonState) -> Result { + if !state.react_profiling_active { + return Err("No React profiling session active".to_string()); + } + + let mgr = state.browser.as_ref().ok_or("Browser not launched")?; + + let raw_data = mgr.evaluate(REACT_COLLECTION_SCRIPT, None).await?; + + state.react_profiling_active = false; + + let react_detected = raw_data + .get("reactDetected") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let react_version = raw_data + .get("reactVersion") + .and_then(|v| v.as_str()) + .map(String::from); + let renders = raw_data + .get("renders") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + // Aggregate per-component stats + let mut component_map: std::collections::HashMap< + String, + (usize, f64, std::collections::HashSet), + > = std::collections::HashMap::new(); + let mut total_duration: f64 = 0.0; + + for render in &renders { + let component_name = render + .get("componentName") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + .to_string(); + let actual_duration = render + .get("actualDuration") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let phase = render + .get("phase") + .and_then(|v| v.as_str()) + .unwrap_or("update") + .to_string(); + + total_duration += actual_duration; + + let entry = component_map + .entry(component_name) + .or_insert_with(|| (0, 0.0, std::collections::HashSet::new())); + entry.0 += 1; + entry.1 += actual_duration; + entry.2.insert(phase); + } + + let mut components: Vec = component_map + .iter() + .map(|(name, (count, total_actual, reasons))| { + let avg = if *count > 0 { + total_actual / *count as f64 + } else { + 0.0 + }; + json!({ + "name": name, + "renderCount": count, + "totalActualDuration": total_actual, + "averageActualDuration": avg, + "reasons": reasons.iter().collect::>(), + }) + }) + .collect(); + + // Sort by average duration descending for slowestComponents + let mut sorted_by_avg = components.clone(); + sorted_by_avg.sort_by(|a, b| { + let avg_b = b.get("averageActualDuration").and_then(|v| v.as_f64()).unwrap_or(0.0); + let avg_a = a.get("averageActualDuration").and_then(|v| v.as_f64()).unwrap_or(0.0); + avg_b.partial_cmp(&avg_a).unwrap_or(std::cmp::Ordering::Equal) + }); + + let slowest: Vec = sorted_by_avg + .iter() + .take(10) + .map(|c| { + json!({ + "name": c.get("name").and_then(|v| v.as_str()).unwrap_or(""), + "avgDuration": c.get("averageActualDuration").and_then(|v| v.as_f64()).unwrap_or(0.0), + }) + }) + .collect(); + + // Sort components alphabetically for consistent output + components.sort_by(|a, b| { + let na = a.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let nb = b.get("name").and_then(|v| v.as_str()).unwrap_or(""); + na.cmp(nb) + }); + + let mut result = json!({ + "reactDetected": react_detected, + "renders": renders, + "components": components, + "summary": { + "totalRenders": renders.len(), + "totalComponents": components.len(), + "slowestComponents": slowest, + "totalDuration": total_duration, + }, + }); + + if let Some(version) = react_version { + result + .as_object_mut() + .unwrap() + .insert("reactVersion".to_string(), Value::String(version)); + } + + // Optionally save to file + let output_path = cmd.get("path").and_then(|v| v.as_str()); + if let Some(path) = output_path { + let dir = std::path::Path::new(path).parent(); + if let Some(dir) = dir { + let _ = std::fs::create_dir_all(dir); + } + let json_str = serde_json::to_string_pretty(&result) + .map_err(|e| format!("Failed to serialize: {}", e))?; + std::fs::write(path, json_str) + .map_err(|e| format!("Failed to write to {}: {}", path, e))?; + result + .as_object_mut() + .unwrap() + .insert("path".to_string(), Value::String(path.to_string())); + } + + Ok(result) +} + async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result { let path = cmd .get("path") @@ -5181,10 +5546,7 @@ mod tests { let _guard = EnvGuard::new(&["AGENT_BROWSER_HEADED"]); _guard.set("AGENT_BROWSER_HEADED", "1"); let opts = launch_options_from_env(); - assert!( - !opts.headless, - "AGENT_BROWSER_HEADED=1 should set headless=false" - ); + assert!(!opts.headless, "AGENT_BROWSER_HEADED=1 should set headless=false"); } #[tokio::test] diff --git a/cli/src/output.rs b/cli/src/output.rs index 8ae07cb9..ef4094b1 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -411,6 +411,23 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou Some("profiler_start") => { println!("{} Profiling started", color::success_indicator()); } + Some("react_profile_start") => { + let detected = data + .get("reactDetected") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if detected { + println!( + "{} React profiling started (React detected)", + color::success_indicator() + ); + } else { + println!( + "{} React profiling started (React not yet detected)", + color::success_indicator() + ); + } + } _ => { if let Some(path) = data.get("path").and_then(|v| v.as_str()) { println!("{} Recording started: {}", color::success_indicator(), path); @@ -488,6 +505,37 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou println!("{} Trace stopped", color::success_indicator()); return; } + // React profile stop without path (inline data) + if action == Some("react_profile_stop") && data.get("path").is_none() { + let detected = data + .get("reactDetected") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let total_renders = data + .get("summary") + .and_then(|s| s.get("totalRenders")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let total_components = data + .get("summary") + .and_then(|s| s.get("totalComponents")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + if detected { + println!( + "{} React profile collected ({} renders, {} components)", + color::success_indicator(), + total_renders, + total_components + ); + } else { + println!( + "{} React profile collected (React not detected on page)", + color::success_indicator() + ); + } + return; + } // Path-based operations (screenshot/pdf/trace/har/download/state/video) if let Some(path) = data.get("path").and_then(|v| v.as_str()) { match action.unwrap_or("") { @@ -538,6 +586,25 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou color::green(path), data.get("eventCount").and_then(|c| c.as_u64()).unwrap_or(0) ), + "react_profile_stop" => { + let total_renders = data + .get("summary") + .and_then(|s| s.get("totalRenders")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let total_components = data + .get("summary") + .and_then(|s| s.get("totalComponents")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + println!( + "{} React profile saved to {} ({} renders, {} components)", + color::success_indicator(), + color::green(path), + total_renders, + total_components + ); + } "har_stop" => println!( "{} HAR saved to {}", color::success_indicator(), @@ -1954,6 +2021,45 @@ The output file can be viewed in: "## } + // === React Profile === + "react_profile" => { + r##" +agent-browser react_profile - Profile React component renders + +Usage: agent-browser react_profile [path] + +Capture React-specific component render data by injecting a hook into +__REACT_DEVTOOLS_GLOBAL_HOOK__. Works fully headless, no extension needed. +Can run simultaneously with the CDP profiler. + +Requirement: The target React app must use a development or profiling build. +Production builds strip the fiber timing data. + +Operations: + start Start React profiling + stop [path] Stop profiling and return/save data + +Global Options: + --json Output as JSON + --session Use specific session + +Examples: + agent-browser react_profile start + agent-browser click "#button" + agent-browser react_profile stop + + # Save to file + agent-browser react_profile stop ./react-profile.json + + # Combine with CDP profiler + agent-browser profiler start + agent-browser react_profile start + agent-browser click "#heavy-component" + agent-browser react_profile stop ./react.json + agent-browser profiler stop ./trace.json +"## + } + // === Record (video) === "record" => { r##" @@ -2384,6 +2490,7 @@ Diff: Debug: trace start|stop [path] Record Playwright trace profiler start|stop [path] Record Chrome DevTools profile + react_profile start|stop React component profiling record start [url] Start video recording (WebM) record stop Stop and save video console [--clear] View console logs diff --git a/skills/agent-browser/references/commands.md b/skills/agent-browser/references/commands.md index e77196cd..a9b8fa82 100644 --- a/skills/agent-browser/references/commands.md +++ b/skills/agent-browser/references/commands.md @@ -249,6 +249,9 @@ agent-browser trace start # Start recording trace agent-browser trace stop trace.zip # Stop and save trace agent-browser profiler start # Start Chrome DevTools profiling agent-browser profiler stop trace.json # Stop and save profile +agent-browser react_profile start # Start React component profiling +agent-browser react_profile stop # Stop and return React profile data +agent-browser react_profile stop out.json # Stop and save React profile to file ``` ## Environment Variables diff --git a/skills/agent-browser/references/profiling.md b/skills/agent-browser/references/profiling.md index bd47eaa0..acd65121 100644 --- a/skills/agent-browser/references/profiling.md +++ b/skills/agent-browser/references/profiling.md @@ -113,8 +113,93 @@ Load the output JSON file in any of these tools: - **Perfetto UI**: https://ui.perfetto.dev/ -- drag and drop the JSON file - **Trace Viewer**: `chrome://tracing` in any Chromium browser +## React Profiling + +Capture React-specific component render data without the React DevTools extension. Works fully headless by injecting a lightweight hook into `__REACT_DEVTOOLS_GLOBAL_HOOK__`. + +**Requirement**: The target React app must use a development build or a profiling build (`react-dom/profiling`). Standard production builds strip the fiber timing data that makes this work. + +### React Profiler Commands + +```bash +# Start React profiling (injects hook into page) +agent-browser react_profile start + +# Perform interactions that trigger React renders +agent-browser click "#add-item" +agent-browser fill "#search" "query" +agent-browser wait 2000 + +# Stop and get results inline +agent-browser react_profile stop + +# Stop and save to file +agent-browser react_profile stop ./react-profile.json +``` + +### Combining with CDP Profiling + +Both profilers can run simultaneously. The CDP profiler captures browser-level trace events (JS execution, layout, paint), while the React profiler captures component-level data (render durations, mount/update phases). + +```bash +agent-browser profiler start +agent-browser react_profile start +agent-browser click "#heavy-component" +agent-browser wait 2000 +agent-browser react_profile stop ./react-profile.json +agent-browser profiler stop ./chrome-trace.json +``` + +### React Profile Output Format + +```json +{ + "reactDetected": true, + "reactVersion": "18.2.0", + "renders": [ + { + "id": "1", + "phase": "update", + "componentName": "TodoList", + "actualDuration": 12.5, + "baseDuration": 8.3, + "startTime": 1500.2, + "commitTime": 1512.7 + } + ], + "components": [ + { + "name": "TodoList", + "renderCount": 5, + "totalActualDuration": 62.5, + "averageActualDuration": 12.5, + "reasons": ["mount", "update"] + } + ], + "summary": { + "totalRenders": 42, + "totalComponents": 8, + "slowestComponents": [ + { "name": "TodoList", "avgDuration": 12.5 }, + { "name": "SearchResults", "avgDuration": 8.1 } + ], + "totalDuration": 156.3 + } +} +``` + +### Key Fields + +- **`actualDuration`** -- time spent rendering the component in this commit (ms) +- **`baseDuration`** -- estimated time for a full re-render of the subtree (ms) +- **`phase`** -- `mount` (first render) or `update` (re-render) +- **`slowestComponents`** -- top 10 components by average render duration +- **`reactDetected`** -- `false` if no React app was found on the page (graceful degradation, no error) + ## Limitations - Only works with Chromium-based browsers (Chrome, Edge). Not supported on Firefox or WebKit. - Trace data accumulates in memory while profiling is active (capped at 5 million events). Stop profiling promptly after the area of interest. - Data collection on stop has a 30-second timeout. If the browser is unresponsive, the stop command may fail. +- React profiling requires a React development or profiling build. Production builds zero out `actualDuration` and related fields. +- React render data is capped at 50,000 entries per session to prevent unbounded memory growth. diff --git a/src/action-policy.ts b/src/action-policy.ts index b9847d2e..47b820d6 100644 --- a/src/action-policy.ts +++ b/src/action-policy.ts @@ -134,6 +134,8 @@ const ACTION_CATEGORIES: Record = { recording_restart: '_internal', profiler_start: '_internal', profiler_stop: '_internal', + react_profile_start: '_internal', + react_profile_stop: '_internal', clipboard: '_internal', viewport: '_internal', useragent: '_internal', diff --git a/src/actions.ts b/src/actions.ts index 681a33e9..df6b8eba 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -78,6 +78,8 @@ import type { TraceStopCommand, ProfilerStartCommand, ProfilerStopCommand, + ReactProfileStartCommand, + ReactProfileStopCommand, HarStopCommand, StorageStateSaveCommand, StateListCommand, @@ -159,6 +161,7 @@ import type { RecordingRestartData, InputEventData, StylesData, + ReactProfileData, } from './types.js'; import { successResponse, errorResponse, parseCommand } from './protocol.js'; import { diffSnapshots, diffScreenshots } from './diff.js'; @@ -461,6 +464,10 @@ async function dispatchAction(command: Command, browser: BrowserManager): Promis return await handleProfilerStart(command, browser); case 'profiler_stop': return await handleProfilerStop(command, browser); + case 'react_profile_start': + return await handleReactProfileStart(command, browser); + case 'react_profile_stop': + return await handleReactProfileStop(command, browser); case 'har_start': return await handleHarStart(command, browser); case 'har_stop': @@ -1753,6 +1760,22 @@ async function handleProfilerStop( return successResponse(command.id, result); } +async function handleReactProfileStart( + command: ReactProfileStartCommand, + browser: BrowserManager +): Promise { + const result = await browser.startReactProfiling(); + return successResponse(command.id, { started: true, reactDetected: result.reactDetected }); +} + +async function handleReactProfileStop( + command: ReactProfileStopCommand, + browser: BrowserManager +): Promise> { + const result = await browser.stopReactProfiling(command.path); + return successResponse(command.id, result); +} + async function handleHarStart( command: Command & { action: 'har_start' }, browser: BrowserManager diff --git a/src/browser.ts b/src/browser.ts index 8c723e2a..e102ea71 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -18,7 +18,13 @@ import path from 'node:path'; import os from 'node:os'; import { existsSync, mkdirSync, rmSync, readFileSync, statSync } from 'node:fs'; import { writeFile, mkdir } from 'node:fs/promises'; -import type { LaunchCommand, TraceEvent } from './types.js'; +import type { + LaunchCommand, + TraceEvent, + ReactProfileData, + ReactProfileRender, + ReactProfileComponent, +} from './types.js'; import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js'; import { safeHeaderMerge } from './state-utils.js'; import { isDomainAllowed, installDomainFilter, parseDomainList } from './domain-filter.js'; @@ -160,6 +166,11 @@ export class BrowserManager { private profileDataHandler: ((params: { value?: TraceEvent[] }) => void) | null = null; private profileCompleteHandler: (() => void) | null = null; + // React profiling state + private static readonly MAX_REACT_PROFILE_RENDERS = 50_000; + private reactProfilingActive: boolean = false; + private reactProfileInitScriptInstalled: boolean = false; + /** * Check if browser is launched */ @@ -2160,6 +2171,322 @@ export class BrowserManager { return { path: outputPath, eventCount }; } + /** + * Check if React profiling is currently active + */ + isReactProfilingActive(): boolean { + return this.reactProfilingActive; + } + + /** + * Start React profiling by injecting a hook into the page. + * Patches __REACT_DEVTOOLS_GLOBAL_HOOK__ to capture component render data. + */ + async startReactProfiling(): Promise<{ reactDetected: boolean }> { + if (this.reactProfilingActive) { + throw new Error('React profiling already active'); + } + + const page = this.getPage(); + const maxRenders = BrowserManager.MAX_REACT_PROFILE_RENDERS; + + const injectionScript = ` + (() => { + if (window.__AGENT_REACT_PROFILER__) { + window.__AGENT_REACT_PROFILER__.renders = []; + window.__AGENT_REACT_PROFILER__.active = true; + return { reactDetected: window.__AGENT_REACT_PROFILER__.reactDetected }; + } + + const profiler = { + active: true, + reactDetected: false, + reactVersion: null, + renders: [], + maxRenders: ${maxRenders}, + _fiberRoots: null, + _observer: null, + _rafPending: false, + }; + window.__AGENT_REACT_PROFILER__ = profiler; + + function getComponentName(fiber) { + if (!fiber || !fiber.type) return null; + if (typeof fiber.type === 'string') return null; + return fiber.type.displayName || fiber.type.name || 'Anonymous'; + } + + // Shared fiber tree walker used by both hook and MutationObserver paths. + // requireDuration=true filters to fibers with actualDuration > 0 (hook path). + // requireDuration=false records all updated components (MutationObserver path). + function collectRenderData(fiberRoot, rendererID, requireDuration) { + const current = fiberRoot.current; + if (!current) return; + const now = performance.now(); + + function walkFiber(fiber) { + if (!fiber) return; + const isComponent = fiber.tag === 0 || fiber.tag === 1 || fiber.tag === 11 || fiber.tag === 15; + if (isComponent) { + const hasDuration = fiber.actualDuration !== undefined && fiber.actualDuration > 0; + const isUpdated = fiber.alternate !== null; + if ((requireDuration ? hasDuration : isUpdated)) { + const name = getComponentName(fiber); + if (name && profiler.renders.length < profiler.maxRenders) { + profiler.renders.push({ + id: String(rendererID), + phase: fiber.alternate === null ? 'mount' : 'update', + componentName: name, + actualDuration: hasDuration ? fiber.actualDuration : 0, + baseDuration: fiber.selfBaseDuration || fiber.baseDuration || 0, + startTime: fiber.actualStartTime || now, + commitTime: now, + }); + } + } + } + walkFiber(fiber.child); + walkFiber(fiber.sibling); + } + + walkFiber(current); + } + + function patchHook(hook) { + if (!hook || hook.__agentPatched) return; + hook.__agentPatched = true; + + const originalOnCommitFiberRoot = hook.onCommitFiberRoot; + hook.onCommitFiberRoot = function(rendererID, fiberRoot, priorityLevel) { + if (profiler.active) { + try { + profiler.reactDetected = true; + collectRenderData(fiberRoot, rendererID, true); + } catch (e) {} + } + if (originalOnCommitFiberRoot) { + return originalOnCommitFiberRoot.call(this, rendererID, fiberRoot, priorityLevel); + } + }; + + const originalInject = hook.inject; + hook.inject = function(renderer) { + if (renderer && renderer.version) { + profiler.reactVersion = renderer.version; + } + if (originalInject) { + return originalInject.call(this, renderer); + } + return 0; + }; + } + + function installFreshHook() { + const hook = { + renderers: new Map(), + supportsFiber: true, + inject: function(renderer) { + profiler.reactDetected = true; + if (renderer && renderer.version) { + profiler.reactVersion = renderer.version; + } + const id = hook.renderers.size + 1; + hook.renderers.set(id, renderer); + return id; + }, + onScheduleFiberRoot: function() {}, + onCommitFiberRoot: function(rendererID, fiberRoot) { + if (profiler.active) { + try { collectRenderData(fiberRoot, rendererID, true); } catch (e) {} + } + }, + onCommitFiberUnmount: function() {}, + }; + hook.__agentPatched = true; + window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook; + return hook; + } + + // Find existing React fiber roots in the DOM (post-load detection). + // Only checks elements with id attributes since React roots are + // conventionally mounted on #root, #app, #__next, etc. + function findFiberRoots() { + const roots = []; + const candidates = document.querySelectorAll('[id]'); + candidates.forEach(function(el) { + const keys = Object.keys(el); + for (let i = 0; i < keys.length; i++) { + if (keys[i].startsWith('__reactContainer') || keys[i].startsWith('__reactFiber')) { + const fiber = el[keys[i]]; + if (fiber) { + let node = fiber; + while (node.return) { node = node.return; } + if (node.stateNode && node.stateNode.current) { + roots.push(node.stateNode); + } + } + break; + } + } + }); + return roots; + } + + const existingHook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__; + if (existingHook) { + patchHook(existingHook); + if (existingHook.renderers && existingHook.renderers.size > 0) { + profiler.reactDetected = true; + } + } else { + const hook = installFreshHook(); + + // Check if React already loaded without a hook (post-load scenario) + const fiberRoots = [...new Set(findFiberRoots())]; + if (fiberRoots.length > 0) { + profiler.reactDetected = true; + // React doesn't call our hook for future commits since it initialized + // without one. Use a MutationObserver to detect DOM changes and + // walk the fiber tree for component names. actualDuration will be 0 + // in this mode since React clears it after commit. + profiler._fiberRoots = fiberRoots; + let rafPending = false; + const observer = new MutationObserver(function() { + if (!profiler.active || rafPending) return; + rafPending = true; + requestAnimationFrame(function() { + rafPending = false; + if (!profiler.active) return; + for (let i = 0; i < profiler._fiberRoots.length; i++) { + try { + collectRenderData(profiler._fiberRoots[i], 1, false); + } catch (e) {} + } + }); + }); + fiberRoots.forEach(function(root) { + if (root.containerInfo) { + observer.observe(root.containerInfo, { childList: true, subtree: true, characterData: true }); + } + }); + profiler._observer = observer; + } + } + + return { reactDetected: profiler.reactDetected }; + })(); + `; + + const result = (await page.evaluate(injectionScript)) as { reactDetected: boolean }; + + if (!this.reactProfileInitScriptInstalled) { + const context = page.context(); + await context.addInitScript(injectionScript); + this.reactProfileInitScriptInstalled = true; + } + + this.reactProfilingActive = true; + return { reactDetected: result.reactDetected }; + } + + /** + * Stop React profiling and collect the results. + */ + async stopReactProfiling(outputPath?: string): Promise { + if (!this.reactProfilingActive) { + throw new Error('No React profiling session active'); + } + + const page = this.getPage(); + + const rawData = (await page.evaluate(` + (() => { + const profiler = window.__AGENT_REACT_PROFILER__; + if (!profiler) { + return { reactDetected: false, reactVersion: null, renders: [] }; + } + profiler.active = false; + if (profiler._observer) { + profiler._observer.disconnect(); + profiler._observer = null; + } + const data = { + reactDetected: profiler.reactDetected, + reactVersion: profiler.reactVersion, + renders: profiler.renders, + }; + profiler.renders = []; + return data; + })(); + `)) as { + reactDetected: boolean; + reactVersion: string | null; + renders: ReactProfileRender[]; + }; + + this.reactProfilingActive = false; + + const componentMap = new Map< + string, + { name: string; renderCount: number; totalActualDuration: number; reasons: Set } + >(); + let totalDuration = 0; + + for (const render of rawData.renders) { + totalDuration += render.actualDuration; + const existing = componentMap.get(render.componentName); + if (existing) { + existing.renderCount++; + existing.totalActualDuration += render.actualDuration; + existing.reasons.add(render.phase); + } else { + componentMap.set(render.componentName, { + name: render.componentName, + renderCount: 1, + totalActualDuration: render.actualDuration, + reasons: new Set([render.phase]), + }); + } + } + + const components: ReactProfileComponent[] = Array.from(componentMap.values()).map((c) => ({ + name: c.name, + renderCount: c.renderCount, + totalActualDuration: c.totalActualDuration, + averageActualDuration: c.totalActualDuration / c.renderCount, + reasons: Array.from(c.reasons), + })); + + const sortedByAvg = [...components].sort( + (a, b) => b.averageActualDuration - a.averageActualDuration + ); + + const result: ReactProfileData = { + reactDetected: rawData.reactDetected, + ...(rawData.reactVersion && { reactVersion: rawData.reactVersion }), + renders: rawData.renders, + components, + summary: { + totalRenders: rawData.renders.length, + totalComponents: components.length, + slowestComponents: sortedByAvg.slice(0, 10).map((c) => ({ + name: c.name, + avgDuration: c.averageActualDuration, + })), + totalDuration, + }, + }; + + if (outputPath) { + const dir = path.dirname(outputPath); + await mkdir(dir, { recursive: true }); + await writeFile(outputPath, JSON.stringify(result, null, 2)); + result.path = outputPath; + } + + return result; + } + /** * Inject a mouse event via CDP */ diff --git a/src/protocol.ts b/src/protocol.ts index a7be39de..31be4d28 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -390,6 +390,15 @@ const profilerStopSchema = baseCommandSchema.extend({ path: z.string().min(1).optional(), }); +const reactProfileStartSchema = baseCommandSchema.extend({ + action: z.literal('react_profile_start'), +}); + +const reactProfileStopSchema = baseCommandSchema.extend({ + action: z.literal('react_profile_stop'), + path: z.string().min(1).optional(), +}); + const harStartSchema = baseCommandSchema.extend({ action: z.literal('har_start'), }); @@ -997,6 +1006,8 @@ const commandSchema = z.discriminatedUnion('action', [ traceStopSchema, profilerStartSchema, profilerStopSchema, + reactProfileStartSchema, + reactProfileStopSchema, harStartSchema, harStopSchema, stateSaveSchema, diff --git a/src/types.ts b/src/types.ts index ed9e1f42..bfa75b12 100644 --- a/src/types.ts +++ b/src/types.ts @@ -612,6 +612,16 @@ export interface ProfilerStopCommand extends BaseCommand { path?: string; } +// React profiling +export interface ReactProfileStartCommand extends BaseCommand { + action: 'react_profile_start'; +} + +export interface ReactProfileStopCommand extends BaseCommand { + action: 'react_profile_stop'; + path?: string; +} + // HAR recording export interface HarStartCommand extends BaseCommand { action: 'har_start'; @@ -963,6 +973,8 @@ export type Command = | TraceStopCommand | ProfilerStartCommand | ProfilerStopCommand + | ReactProfileStartCommand + | ReactProfileStopCommand | HarStartCommand | HarStopCommand | StorageStateSaveCommand @@ -1151,6 +1163,39 @@ export interface EvaluateData { origin?: string; } +// React profiling data +export interface ReactProfileRender { + id: string; + phase: 'mount' | 'update'; + componentName: string; + actualDuration: number; + baseDuration: number; + startTime: number; + commitTime: number; +} + +export interface ReactProfileComponent { + name: string; + renderCount: number; + totalActualDuration: number; + averageActualDuration: number; + reasons: string[]; +} + +export interface ReactProfileData { + reactDetected: boolean; + reactVersion?: string; + renders: ReactProfileRender[]; + components: ReactProfileComponent[]; + summary: { + totalRenders: number; + totalComponents: number; + slowestComponents: Array<{ name: string; avgDuration: number }>; + totalDuration: number; + }; + path?: string; +} + export interface ContentData { html: string; origin?: string;