feat(react): React introspection, Web Vitals, and SPA primitives (#1257)
* feat(react): first-class React introspection, Web Vitals, and nextjs skill
Add React-general and web-universal features as first-class agent-browser verbs
(react tree/inspect/renders/suspense, vitals, pushstate). Genuinely Next.js-specific
workflows (PPR cookie protocol, /_next/mcp bridge, dev-server endpoints) ship as
a new `nextjs` skill that composes the primitives. No new runtime dependencies -
the React DevTools installHook.js is vendored (MIT) and include_str!'d into the
binary.
New commands:
react tree Full React component tree (depth id parent name)
react inspect <fiberId> Props, hooks, state, source for one fiber
react renders start|stop Fiber profiler with Insts/Mounts/Re-renders/Self/DOM
+ prev->next change details
react suspense Suspense boundaries + classifier (client-hook,
request-api, server-fetch, cache, stream, framework)
+ root-cause grouping + recommendations
vitals [url] LCP/CLS/TTFB/FCP/INP + React hydration phases
pushstate <url> Generic SPA client-side navigation
removeinitscript <id> Remove a script registered via addinitscript
New launch flags:
--init-script <path> Register init scripts before first navigation
(repeatable; env AGENT_BROWSER_INIT_SCRIPTS)
--enable <feature> Built-in init scripts; currently react-devtools
(repeatable; env AGENT_BROWSER_ENABLE)
Other primitives:
network route ... --resource-type <csv> Filter by CDP resource type
cookies set --curl <file> Auto-detects JSON/cURL/Cookie-header
* fixes
* fixes
* fixes
This commit is contained in:
+378
-1
@@ -28,6 +28,7 @@ use super::interaction;
|
||||
use super::network::{self, DomainFilter, EventTracker};
|
||||
use super::policy::{ActionPolicy, ConfirmActions, PolicyResult};
|
||||
use super::providers;
|
||||
use super::react;
|
||||
use super::recording::{self, RecordingState};
|
||||
use super::screenshot::{self, ScreenshotOptions};
|
||||
use super::snapshot::{self, SnapshotOptions};
|
||||
@@ -95,6 +96,10 @@ pub struct RouteEntry {
|
||||
pub url_pattern: String,
|
||||
pub response: Option<RouteResponse>,
|
||||
pub abort: bool,
|
||||
/// When non-empty, only requests whose `resourceType` (as reported by
|
||||
/// CDP Fetch.requestPaused) is in this list are matched. Values are
|
||||
/// compared case-insensitively. Empty means "match any resource type".
|
||||
pub resource_types: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct RouteResponse {
|
||||
@@ -1371,7 +1376,15 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
||||
"upload" => handle_upload(cmd, state).await,
|
||||
"addscript" => handle_addscript(cmd, state).await,
|
||||
"addinitscript" => handle_addinitscript(cmd, state).await,
|
||||
"removeinitscript" => handle_removeinitscript(cmd, state).await,
|
||||
"addstyle" => handle_addstyle(cmd, state).await,
|
||||
"react_tree" => handle_react_tree(cmd, state).await,
|
||||
"react_inspect" => handle_react_inspect(cmd, state).await,
|
||||
"react_renders_start" => handle_react_renders_start(cmd, state).await,
|
||||
"react_renders_stop" => handle_react_renders_stop(cmd, state).await,
|
||||
"react_suspense" => handle_react_suspense(cmd, state).await,
|
||||
"vitals" => handle_vitals(cmd, state).await,
|
||||
"pushstate" => handle_pushstate(cmd, state).await,
|
||||
"clipboard" => handle_clipboard(cmd, state).await,
|
||||
"wheel" => handle_wheel(cmd, state).await,
|
||||
"device" => handle_device(cmd, state).await,
|
||||
@@ -1534,6 +1547,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
state.start_fetch_handler();
|
||||
state.start_dialog_handler();
|
||||
state.update_stream_client().await;
|
||||
apply_launch_init_scripts(state).await;
|
||||
try_auto_restore_state(state).await;
|
||||
try_load_storage_state(state, &storage_state_path).await;
|
||||
return Ok(());
|
||||
@@ -1546,6 +1560,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
state.start_fetch_handler();
|
||||
state.start_dialog_handler();
|
||||
state.update_stream_client().await;
|
||||
apply_launch_init_scripts(state).await;
|
||||
try_auto_restore_state(state).await;
|
||||
try_load_storage_state(state, &storage_state_path).await;
|
||||
return Ok(());
|
||||
@@ -1581,6 +1596,7 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
state.start_dialog_handler();
|
||||
state.update_stream_client().await;
|
||||
write_provider_file(&state.session_id, &p);
|
||||
apply_launch_init_scripts(state).await;
|
||||
try_auto_restore_state(state).await;
|
||||
try_load_storage_state(state, &storage_state_path).await;
|
||||
return Ok(());
|
||||
@@ -1614,11 +1630,59 @@ async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
apply_launch_init_scripts(state).await;
|
||||
try_auto_restore_state(state).await;
|
||||
try_load_storage_state(state, &storage_state_path).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply AGENT_BROWSER_ENABLE (built-in init scripts like `react-devtools`)
|
||||
/// and AGENT_BROWSER_INIT_SCRIPTS (user-provided files) to the browser so the
|
||||
/// scripts are registered before any page JS runs on the next navigation.
|
||||
/// Also evaluates each script on the current page (if any) so the effect is
|
||||
/// immediate for already-loaded pages.
|
||||
async fn apply_launch_init_scripts(state: &DaemonState) {
|
||||
let Some(mgr) = state.browser.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Built-in features via --enable / AGENT_BROWSER_ENABLE.
|
||||
if let Ok(raw) = env::var("AGENT_BROWSER_ENABLE") {
|
||||
for feature in raw
|
||||
.split([',', '\n'])
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
match feature {
|
||||
"react-devtools" | "react" => {
|
||||
let _ = mgr.add_script_to_evaluate(react::INSTALL_HOOK_JS).await;
|
||||
}
|
||||
other => {
|
||||
eprintln!("warning: unknown --enable feature '{}'", other);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User init scripts via --init-script / AGENT_BROWSER_INIT_SCRIPTS.
|
||||
if let Ok(raw) = env::var("AGENT_BROWSER_INIT_SCRIPTS") {
|
||||
for path in raw
|
||||
.split([',', '\n'])
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
match fs::read_to_string(path) {
|
||||
Ok(source) => {
|
||||
let _ = mgr.add_script_to_evaluate(&source).await;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("warning: failed to read --init-script '{}': {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn launch_options_from_env() -> LaunchOptions {
|
||||
let headed = env::var("AGENT_BROWSER_HEADED")
|
||||
.map(|v| v == "1" || v == "true")
|
||||
@@ -1882,6 +1946,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
state.start_dialog_handler();
|
||||
state.update_stream_client().await;
|
||||
load_storage_state_or_rollback(state, &storage_state_owned).await?;
|
||||
apply_launch_init_scripts(state).await;
|
||||
return Ok(json!({ "launched": true }));
|
||||
}
|
||||
|
||||
@@ -1893,6 +1958,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
state.start_dialog_handler();
|
||||
state.update_stream_client().await;
|
||||
load_storage_state_or_rollback(state, &storage_state_owned).await?;
|
||||
apply_launch_init_scripts(state).await;
|
||||
return Ok(json!({ "launched": true }));
|
||||
}
|
||||
|
||||
@@ -1904,6 +1970,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
state.start_dialog_handler();
|
||||
state.update_stream_client().await;
|
||||
load_storage_state_or_rollback(state, &storage_state_owned).await?;
|
||||
apply_launch_init_scripts(state).await;
|
||||
return Ok(json!({ "launched": true }));
|
||||
}
|
||||
|
||||
@@ -1941,6 +2008,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
state.update_stream_client().await;
|
||||
write_provider_file(&state.session_id, provider);
|
||||
load_storage_state_or_rollback(state, &storage_state_owned).await?;
|
||||
apply_launch_init_scripts(state).await;
|
||||
|
||||
if let Some(info) = providers::get_agentcore_info() {
|
||||
return Ok(json!({
|
||||
@@ -2038,6 +2106,8 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
// normal browser traffic.
|
||||
load_storage_state_or_rollback(state, &storage_state_owned).await?;
|
||||
|
||||
apply_launch_init_scripts(state).await;
|
||||
|
||||
Ok(json!({ "launched": true }))
|
||||
}
|
||||
|
||||
@@ -4656,6 +4726,280 @@ async fn handle_addinitscript(cmd: &Value, state: &DaemonState) -> Result<Value,
|
||||
Ok(json!({ "added": true, "identifier": identifier }))
|
||||
}
|
||||
|
||||
async fn handle_removeinitscript(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let identifier = cmd
|
||||
.get("identifier")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing 'identifier' parameter")?;
|
||||
mgr.remove_script_to_evaluate(identifier).await?;
|
||||
Ok(json!({ "removed": true, "identifier": identifier }))
|
||||
}
|
||||
|
||||
// === React / Web primitives ===
|
||||
|
||||
/// Parse a `Runtime.evaluate` result whose expression returned a JSON string.
|
||||
/// Returns a helpful error if parsing fails.
|
||||
fn parse_json_string(value: Value, what: &str) -> Result<Value, String> {
|
||||
let s = value
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("{} returned non-string value", what))?;
|
||||
serde_json::from_str(s).map_err(|e| format!("{} returned invalid JSON: {}", what, e))
|
||||
}
|
||||
|
||||
async fn handle_react_tree(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let result = mgr.evaluate(react::scripts::TREE_SNAPSHOT, None).await?;
|
||||
let nodes_json = parse_json_string(result, "react tree")?;
|
||||
let nodes: Vec<react::TreeNode> = serde_json::from_value(nodes_json)
|
||||
.map_err(|e| format!("Failed to parse tree nodes: {}", e))?;
|
||||
|
||||
let return_json = cmd.get("json").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
if return_json {
|
||||
let nodes_value: Vec<Value> = nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
json!({
|
||||
"id": n.id,
|
||||
"type": n.node_type,
|
||||
"name": n.name,
|
||||
"key": n.key,
|
||||
"parent": n.parent,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "nodes": nodes_value }))
|
||||
} else {
|
||||
Ok(json!({ "tree": react::format_tree(&nodes) }))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_react_inspect(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let fiber_id = cmd
|
||||
.get("fiberId")
|
||||
.and_then(|v| v.as_i64())
|
||||
.ok_or("Missing 'fiberId' parameter (numeric React fiber id)")?;
|
||||
|
||||
let script = react::scripts::TREE_INSPECT.replace("{{ID}}", &fiber_id.to_string());
|
||||
let result = mgr.evaluate(&script, None).await?;
|
||||
let parsed = parse_json_string(result, "react inspect")?;
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
async fn handle_react_renders_start(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
// Install for future navigations, then evaluate immediately so the
|
||||
// current page starts recording without a reload.
|
||||
let identifier = mgr
|
||||
.add_script_to_evaluate(react::scripts::RENDERS_INIT)
|
||||
.await?;
|
||||
mgr.evaluate(react::scripts::RENDERS_INIT, None).await?;
|
||||
let _ = cmd;
|
||||
Ok(json!({
|
||||
"recording": true,
|
||||
"identifier": identifier,
|
||||
"message": "recording renders - interact with the page, then run `react renders stop`"
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_react_renders_stop(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let result = mgr.evaluate(react::scripts::RENDERS_STOP, None).await?;
|
||||
let data_json = parse_json_string(result, "react renders stop")?;
|
||||
let data: react::RendersData = serde_json::from_value(data_json.clone())
|
||||
.map_err(|e| format!("Failed to parse renders data: {}", e))?;
|
||||
|
||||
let return_json = cmd.get("json").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
if return_json {
|
||||
Ok(data_json)
|
||||
} else {
|
||||
Ok(json!({ "report": react::format_renders_report(&data) }))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_react_suspense(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let result = mgr.evaluate(react::scripts::SUSPENSE_WALK, None).await?;
|
||||
let boundaries_json = parse_json_string(result, "react suspense")?;
|
||||
let boundaries: Vec<react::Boundary> = serde_json::from_value(boundaries_json.clone())
|
||||
.map_err(|e| format!("Failed to parse suspense boundaries: {}", e))?;
|
||||
|
||||
let return_json = cmd.get("json").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let only_dynamic = cmd
|
||||
.get("onlyDynamic")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if return_json {
|
||||
// When only-dynamic is set, filter the JSON payload too so callers
|
||||
// get consistent output regardless of format choice.
|
||||
if only_dynamic {
|
||||
let filtered: Vec<&react::Boundary> = boundaries
|
||||
.iter()
|
||||
.filter(|b| {
|
||||
b.parent_id != 0
|
||||
&& (b.is_suspended
|
||||
|| !b.suspended_by.is_empty()
|
||||
|| b.unknown_suspenders.is_some())
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "boundaries": filtered }))
|
||||
} else {
|
||||
Ok(json!({ "boundaries": boundaries_json }))
|
||||
}
|
||||
} else {
|
||||
Ok(json!({ "report": react::format_suspense_report(&boundaries, only_dynamic) }))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_vitals(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||
// Install observers BEFORE the navigation/reload that we want to measure.
|
||||
// The script is idempotent — a no-op if already installed on the current page.
|
||||
{
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let _ = mgr.evaluate(react::scripts::VITALS_INIT, None).await?;
|
||||
}
|
||||
|
||||
// Register as an init script too, so navigations done via `vitals --url`
|
||||
// start observing from the first paint.
|
||||
{
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let _ = mgr
|
||||
.add_script_to_evaluate(react::scripts::VITALS_INIT)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Navigate to the target URL (or reload the current page) to trigger a
|
||||
// full page load the observers can capture.
|
||||
let target = cmd.get("url").and_then(|v| v.as_str()).map(String::from);
|
||||
if let Some(url) = target {
|
||||
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
|
||||
let _ = mgr.navigate(&url, WaitUntil::Load).await?;
|
||||
} else {
|
||||
handle_reload(state).await?;
|
||||
}
|
||||
|
||||
// Give layout shifts and React effects a chance to settle.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(3000)).await;
|
||||
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let url = mgr.get_url().await.unwrap_or_default();
|
||||
let result = mgr.evaluate(react::scripts::VITALS_READ, None).await?;
|
||||
let raw = parse_json_string(result, "vitals")?;
|
||||
|
||||
// The raw payload has { cwv, timing, ttfb }. Merge with URL and process
|
||||
// timing into React hydration phases + per-component durations.
|
||||
let cwv = raw.get("cwv").cloned().unwrap_or(json!({}));
|
||||
let timing = raw
|
||||
.get("timing")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let ttfb = raw.get("ttfb").and_then(|v| v.as_f64());
|
||||
let lcp = cwv.get("lcp").cloned().unwrap_or(Value::Null);
|
||||
let cls_score = cwv.get("cls").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let cls_entries = cwv.get("clsEntries").cloned().unwrap_or(json!([]));
|
||||
let fcp = cwv.get("fcp").and_then(|v| v.as_f64());
|
||||
let inp = cwv.get("inp").and_then(|v| v.as_f64());
|
||||
|
||||
let round = |n: f64| (n * 100.0).round() / 100.0;
|
||||
|
||||
let mut hydration_phases: Vec<Value> = Vec::new();
|
||||
let mut hydration_start = f64::INFINITY;
|
||||
let mut hydration_end = 0.0f64;
|
||||
let mut hydrated_components: Vec<Value> = Vec::new();
|
||||
// React's profiling build emits `console.timeStamp(label, start, end,
|
||||
// track, trackGroup, color)` entries whose `track` / `trackGroup`
|
||||
// fields are literal strings containing the atom glyph (e.g.
|
||||
// "Scheduler ⚛", "Components ⚛"). The comparisons below match those
|
||||
// exact strings — don't "clean up" the glyphs.
|
||||
for e in &timing {
|
||||
let label = e.get("label").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let track = e.get("track").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let track_group = e.get("trackGroup").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let color = e.get("color").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let start = e.get("startTime").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let end = e.get("endTime").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
if end <= start {
|
||||
continue;
|
||||
}
|
||||
if track_group == "Scheduler ⚛" {
|
||||
hydration_phases.push(json!({
|
||||
"label": label,
|
||||
"startTime": round(start),
|
||||
"endTime": round(end),
|
||||
"duration": round(end - start),
|
||||
}));
|
||||
if label == "Hydrated" {
|
||||
if start < hydration_start {
|
||||
hydration_start = start;
|
||||
}
|
||||
if end > hydration_end {
|
||||
hydration_end = end;
|
||||
}
|
||||
}
|
||||
} else if track == "Components ⚛" && color.starts_with("tertiary") {
|
||||
hydrated_components.push(json!({
|
||||
"name": label,
|
||||
"startTime": round(start),
|
||||
"endTime": round(end),
|
||||
"duration": round(end - start),
|
||||
}));
|
||||
}
|
||||
}
|
||||
hydrated_components.sort_by(|a, b| {
|
||||
let da = a.get("duration").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let db = b.get("duration").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
db.partial_cmp(&da).unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
let hydration = if hydration_start.is_finite() && hydration_end > 0.0 {
|
||||
json!({
|
||||
"startTime": round(hydration_start),
|
||||
"endTime": round(hydration_end),
|
||||
"duration": round(hydration_end - hydration_start),
|
||||
})
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
|
||||
let data_value = json!({
|
||||
"url": url,
|
||||
"ttfb": ttfb,
|
||||
"lcp": lcp,
|
||||
"cls": { "score": round(cls_score), "entries": cls_entries },
|
||||
"fcp": fcp,
|
||||
"inp": inp,
|
||||
"hydration": hydration,
|
||||
"phases": hydration_phases,
|
||||
"hydratedComponents": hydrated_components,
|
||||
});
|
||||
|
||||
let return_json = cmd.get("json").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
if return_json {
|
||||
Ok(data_value)
|
||||
} else {
|
||||
let data: react::VitalsData = serde_json::from_value(data_value.clone())
|
||||
.map_err(|e| format!("Failed to parse vitals data: {}", e))?;
|
||||
Ok(json!({ "report": react::format_vitals_report(&data) }))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_pushstate(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let url = cmd
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing 'url' parameter")?;
|
||||
let script = react::scripts::PUSHSTATE.replace(
|
||||
"{{URL}}",
|
||||
&serde_json::to_string(url).unwrap_or_else(|_| "\"\"".to_string()),
|
||||
);
|
||||
let result = mgr.evaluate(&script, None).await?;
|
||||
let after = result.as_str().map(String::from).unwrap_or_default();
|
||||
Ok(json!({ "url": after }))
|
||||
}
|
||||
|
||||
async fn handle_addstyle(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let content = cmd
|
||||
@@ -6579,7 +6923,7 @@ async fn resolve_fetch_paused(
|
||||
|
||||
// Route matching
|
||||
for route in routes {
|
||||
let matches = if route.url_pattern == "*" {
|
||||
let url_matches = if route.url_pattern == "*" {
|
||||
true
|
||||
} else if route.url_pattern.contains('*') {
|
||||
let parts: Vec<&str> = route.url_pattern.split('*').collect();
|
||||
@@ -6592,6 +6936,14 @@ async fn resolve_fetch_paused(
|
||||
paused.url.contains(&route.url_pattern)
|
||||
};
|
||||
|
||||
let resource_type_matches = route.resource_types.is_empty()
|
||||
|| route
|
||||
.resource_types
|
||||
.iter()
|
||||
.any(|rt| rt.eq_ignore_ascii_case(&paused.resource_type));
|
||||
|
||||
let matches = url_matches && resource_type_matches;
|
||||
|
||||
if matches {
|
||||
if route.abort {
|
||||
let _ = client
|
||||
@@ -6726,6 +7078,28 @@ async fn handle_route(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
|
||||
.to_string();
|
||||
let abort = cmd.get("abort").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let resource_types: Vec<String> = cmd
|
||||
.get("resourceType")
|
||||
.or_else(|| cmd.get("resourceTypes"))
|
||||
.and_then(|v| {
|
||||
if let Some(s) = v.as_str() {
|
||||
Some(
|
||||
s.split(',')
|
||||
.map(|p| p.trim().to_string())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
v.as_array().map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|x| x.as_str().map(String::from))
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let response = cmd.get("response").and_then(|v| {
|
||||
if v.is_null() {
|
||||
return None;
|
||||
@@ -6753,6 +7127,7 @@ async fn handle_route(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
|
||||
url_pattern: url_pattern.clone(),
|
||||
response,
|
||||
abort,
|
||||
resource_types,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8457,6 +8832,7 @@ mod tests {
|
||||
url_pattern: "https://example.com/*".to_string(),
|
||||
response: None,
|
||||
abort: true,
|
||||
resource_types: Vec::new(),
|
||||
});
|
||||
}
|
||||
let patterns = build_fetch_patterns(&state).await;
|
||||
@@ -8499,6 +8875,7 @@ mod tests {
|
||||
url_pattern: "*".to_string(),
|
||||
response: None,
|
||||
abort: false,
|
||||
resource_types: Vec::new(),
|
||||
});
|
||||
}
|
||||
{
|
||||
|
||||
@@ -1386,6 +1386,18 @@ impl BrowserManager {
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub async fn remove_script_to_evaluate(&self, identifier: &str) -> Result<(), String> {
|
||||
let session_id = self.active_session_id()?;
|
||||
self.client
|
||||
.send_command(
|
||||
"Page.removeScriptToEvaluateOnNewDocument",
|
||||
Some(json!({ "identifier": identifier })),
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn tab_switch_by_id(&mut self, tab_id: u32) -> Result<Value, String> {
|
||||
let index = self
|
||||
.pages
|
||||
|
||||
@@ -5109,3 +5109,238 @@ async fn e2e_explicit_state_load_restores_cookies() {
|
||||
|
||||
let _ = std::fs::remove_file(&state_path);
|
||||
}
|
||||
|
||||
// === React / Web Vitals primitives ===
|
||||
|
||||
const REACT_FIXTURE_HTML: &str = r#"<!doctype html>
|
||||
<html>
|
||||
<head><title>React fixture</title></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script>
|
||||
const { useState, createElement: h } = React;
|
||||
function Counter({ label }) {
|
||||
const [n, setN] = useState(0);
|
||||
return h("button", { onClick: () => setN(n + 1) }, label + ": " + n);
|
||||
}
|
||||
function App() {
|
||||
return h("div", {}, [
|
||||
h("h1", { key: "t" }, "Hello"),
|
||||
h(Counter, { key: "c1", label: "A" }),
|
||||
h(Counter, { key: "c2", label: "B" }),
|
||||
]);
|
||||
}
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(h(App));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
fn react_fixture_url() -> String {
|
||||
format!(
|
||||
"data:text/html;base64,{}",
|
||||
STANDARD.encode(REACT_FIXTURE_HTML)
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_react_tree_errors_without_hook() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": "https://example.com" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
// Without --enable react-devtools, the hook isn't installed and the
|
||||
// command should error.
|
||||
let resp = execute_command(&json!({ "id": "3", "action": "react_tree" }), &mut state).await;
|
||||
let err = resp
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
err.contains("React DevTools") || err.contains("renderer"),
|
||||
"Expected hook-missing error, got: {:?}",
|
||||
resp
|
||||
);
|
||||
|
||||
let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_react_tree_with_enable_hook() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_ENABLE"]);
|
||||
guard.set("AGENT_BROWSER_ENABLE", "react-devtools");
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": &react_fixture_url() }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
// Give React a moment to boot and register with the hook.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
||||
|
||||
let resp = execute_command(&json!({ "id": "3", "action": "react_tree" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
let tree = get_data(&resp)
|
||||
.get("tree")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(
|
||||
tree.contains("App"),
|
||||
"Expected tree to contain 'App': {}",
|
||||
tree
|
||||
);
|
||||
assert!(
|
||||
tree.contains("Counter"),
|
||||
"Expected tree to contain 'Counter': {}",
|
||||
tree
|
||||
);
|
||||
|
||||
let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_vitals_reports_metrics() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": &react_fixture_url() }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(&json!({ "id": "3", "action": "vitals" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
let report = get_data(&resp)
|
||||
.get("report")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
report.contains("Core Web Vitals"),
|
||||
"Expected vitals report, got: {}",
|
||||
report
|
||||
);
|
||||
assert!(report.contains("TTFB"));
|
||||
assert!(report.contains("CLS"));
|
||||
|
||||
let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_pushstate_changes_url() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": "https://example.com/" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "3", "action": "pushstate", "url": "/newpath" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
let url = get_data(&resp)
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
url.ends_with("/newpath"),
|
||||
"Expected pushstate URL to end with /newpath, got: {}",
|
||||
url
|
||||
);
|
||||
|
||||
let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_removeinitscript_roundtrip() {
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "navigate", "url": "https://example.com" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({
|
||||
"id": "3",
|
||||
"action": "addinitscript",
|
||||
"script": "window.__AB_ROUNDTRIP__ = 1;"
|
||||
}),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
let identifier = get_data(&resp)["identifier"]
|
||||
.as_str()
|
||||
.expect("addinitscript should return an identifier")
|
||||
.to_string();
|
||||
assert!(!identifier.is_empty());
|
||||
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "4", "action": "removeinitscript", "identifier": identifier }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
assert_eq!(get_data(&resp)["removed"], true);
|
||||
|
||||
let _ = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ pub mod policy;
|
||||
#[allow(dead_code)]
|
||||
pub mod providers;
|
||||
#[allow(dead_code)]
|
||||
pub mod react;
|
||||
#[allow(dead_code)]
|
||||
pub mod recording;
|
||||
#[allow(dead_code)]
|
||||
pub mod screenshot;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,31 @@
|
||||
//! React/web introspection primitives.
|
||||
//!
|
||||
//! Scripts and handlers for the `react` subcommands (tree, inspect, renders,
|
||||
//! suspense) plus the universal `vitals` verb and the generic `pushstate`
|
||||
//! SPA-navigation action. These primitives are framework-agnostic: React-side
|
||||
//! commands only require the `__REACT_DEVTOOLS_GLOBAL_HOOK__` to be installed,
|
||||
//! and `vitals` / `pushstate` are pure web-standard APIs.
|
||||
//!
|
||||
//! The React DevTools `installHook.js` is vendored from the React DevTools
|
||||
//! Chrome extension (MIT, facebook/react). It's registered via
|
||||
//! `addScriptToEvaluateOnNewDocument` before any page JS runs when the user
|
||||
//! passes `--enable react-devtools` at launch.
|
||||
|
||||
pub mod scripts;
|
||||
|
||||
mod renders;
|
||||
mod suspense;
|
||||
mod tree;
|
||||
mod vitals;
|
||||
|
||||
pub use renders::{format_renders_report, RendersData};
|
||||
pub use suspense::{format_suspense_report, Boundary};
|
||||
pub use tree::{format_tree, TreeNode};
|
||||
pub use vitals::{format_vitals_report, VitalsData};
|
||||
|
||||
/// React DevTools hook script (MIT, from facebook/react).
|
||||
/// Registered via `addScriptToEvaluateOnNewDocument` to install
|
||||
/// `window.__REACT_DEVTOOLS_GLOBAL_HOOK__` before any page JS runs. React
|
||||
/// detects the hook on boot and registers its renderers against it, which
|
||||
/// enables every `react …` command.
|
||||
pub const INSTALL_HOOK_JS: &str = include_str!("installHook.js");
|
||||
@@ -0,0 +1,169 @@
|
||||
//! React fiber render profiler report formatter.
|
||||
//!
|
||||
//! Default output is the
|
||||
//! full agent-readable report (summary, FPS, component table, per-component
|
||||
//! "change details (prev -> next)"). `--json` emits the raw structured data
|
||||
//! instead.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RendersData {
|
||||
pub elapsed: f64,
|
||||
pub fps: FpsStats,
|
||||
#[serde(rename = "totalRenders")]
|
||||
pub total_renders: i64,
|
||||
#[serde(rename = "totalMounts")]
|
||||
pub total_mounts: i64,
|
||||
#[serde(rename = "totalReRenders")]
|
||||
pub total_re_renders: i64,
|
||||
#[serde(rename = "totalComponents")]
|
||||
pub total_components: i64,
|
||||
pub components: Vec<Component>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct FpsStats {
|
||||
pub avg: i64,
|
||||
pub min: i64,
|
||||
pub max: i64,
|
||||
pub drops: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Component {
|
||||
pub name: String,
|
||||
pub count: i64,
|
||||
pub mounts: i64,
|
||||
#[serde(rename = "reRenders")]
|
||||
pub re_renders: i64,
|
||||
#[serde(rename = "instanceCount")]
|
||||
pub instance_count: i64,
|
||||
#[serde(rename = "totalTime")]
|
||||
pub total_time: f64,
|
||||
#[serde(rename = "selfTime")]
|
||||
pub self_time: f64,
|
||||
#[serde(rename = "domMutations")]
|
||||
pub dom_mutations: i64,
|
||||
pub changes: Vec<Change>,
|
||||
#[serde(rename = "changeSummary")]
|
||||
pub change_summary: std::collections::HashMap<String, i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Change {
|
||||
#[serde(rename = "type")]
|
||||
pub change_type: String,
|
||||
pub name: Option<String>,
|
||||
pub prev: Option<String>,
|
||||
pub next: Option<String>,
|
||||
}
|
||||
|
||||
pub fn format_renders_report(d: &RendersData) -> String {
|
||||
if d.components.is_empty() {
|
||||
return "(no renders captured)".to_string();
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push(format!("# Render Profile - {}s recording", d.elapsed));
|
||||
lines.push(format!(
|
||||
"# {} renders ({} mounts + {} re-renders) across {} components",
|
||||
d.total_renders, d.total_mounts, d.total_re_renders, d.total_components
|
||||
));
|
||||
lines.push(format!(
|
||||
"# FPS: avg {}, min {}, max {}, drops (<30fps): {}",
|
||||
d.fps.avg, d.fps.min, d.fps.max, d.fps.drops
|
||||
));
|
||||
lines.push(String::new());
|
||||
lines.push("## Components by total render time".to_string());
|
||||
|
||||
let top: Vec<&Component> = d.components.iter().take(50).collect();
|
||||
let name_w = top.iter().map(|c| c.name.len()).max().unwrap_or(9).max(9);
|
||||
|
||||
lines.push(format!(
|
||||
"| {:<name_w$} | Insts | Mounts | Re-renders | Total | Self | DOM | Top change reason |",
|
||||
"Component",
|
||||
name_w = name_w
|
||||
));
|
||||
lines.push(format!(
|
||||
"| {:-<name_w$} | ----- | ------ | ---------- | -------- | -------- | ----- | -------------------------- |",
|
||||
"",
|
||||
name_w = name_w
|
||||
));
|
||||
for c in &top {
|
||||
let total = if c.total_time > 0.0 {
|
||||
format!("{}ms", c.total_time)
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
let self_time = if c.self_time > 0.0 {
|
||||
format!("{}ms", c.self_time)
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
let dom = format!("{}/{}", c.dom_mutations, c.count);
|
||||
let top_change = c
|
||||
.change_summary
|
||||
.iter()
|
||||
.max_by_key(|(_, v)| *v)
|
||||
.map(|(k, _)| k.as_str())
|
||||
.unwrap_or("-");
|
||||
lines.push(format!(
|
||||
"| {:<name_w$} | {:>5} | {:>6} | {:>10} | {:>8} | {:>8} | {:>5} | {:<26} |",
|
||||
c.name,
|
||||
c.instance_count,
|
||||
c.mounts,
|
||||
c.re_renders,
|
||||
total,
|
||||
self_time,
|
||||
dom,
|
||||
top_change,
|
||||
name_w = name_w
|
||||
));
|
||||
}
|
||||
if d.components.len() > 50 {
|
||||
lines.push(format!("... and {} more", d.components.len() - 50));
|
||||
}
|
||||
|
||||
let detailed: Vec<&Component> = d
|
||||
.components
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
c.changes
|
||||
.iter()
|
||||
.any(|ch| ch.change_type != "mount" && ch.change_type != "parent")
|
||||
})
|
||||
.take(15)
|
||||
.collect();
|
||||
if !detailed.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("## Change details (prev -> next)".to_string());
|
||||
for c in &detailed {
|
||||
lines.push(format!(" {}", c.name));
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for ch in &c.changes {
|
||||
if ch.change_type == "mount" || ch.change_type == "parent" {
|
||||
continue;
|
||||
}
|
||||
let name = ch.name.clone().unwrap_or_default();
|
||||
let key = format!("{}:{}", ch.change_type, name);
|
||||
if !seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
let label = match ch.change_type.as_str() {
|
||||
"props" => format!("props.{}", name),
|
||||
"state" => format!("state ({})", name),
|
||||
_ => format!("context ({})", name),
|
||||
};
|
||||
lines.push(format!(
|
||||
" {}: {} -> {}",
|
||||
label,
|
||||
ch.prev.clone().unwrap_or_else(|| "?".into()),
|
||||
ch.next.clone().unwrap_or_else(|| "?".into())
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
//! Browser-side evaluation scripts for React/web introspection.
|
||||
//!
|
||||
//! These are JavaScript strings evaluated in the page context via
|
||||
//! `Runtime.evaluate`. They assume the React DevTools hook is already
|
||||
//! installed (via `--enable react-devtools`) except for `VITALS_INIT` and
|
||||
//! `PUSHSTATE`, which only use standard Web APIs.
|
||||
//!
|
||||
//! Kept as raw strings rather than TS/JS files because the daemon is a single
|
||||
//! Rust binary with no filesystem vendor step at runtime.
|
||||
|
||||
/// Build a no-argument async IIFE page-eval that returns the component tree as
|
||||
/// JSON.
|
||||
pub const TREE_SNAPSHOT: &str = r#"
|
||||
(async () => {
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!hook) throw new Error("React DevTools hook not installed - relaunch with --enable react-devtools");
|
||||
const ri = hook.rendererInterfaces && hook.rendererInterfaces.get && hook.rendererInterfaces.get(1);
|
||||
if (!ri) throw new Error("No React renderer attached - the page has not booted React yet");
|
||||
|
||||
const batches = await new Promise((resolve) => {
|
||||
const out = [];
|
||||
const origEmit = hook.emit;
|
||||
hook.emit = function (event, payload) {
|
||||
if (event === "operations") out.push(Array.from(payload));
|
||||
return origEmit.apply(hook, arguments);
|
||||
};
|
||||
ri.flushInitialOperations();
|
||||
setTimeout(() => {
|
||||
hook.emit = origEmit;
|
||||
resolve(out);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
const nodes = batches.flatMap((ops) => {
|
||||
let i = 2;
|
||||
const strings = [null];
|
||||
const tableEnd = ++i + ops[i - 1];
|
||||
while (i < tableEnd) {
|
||||
const len = ops[i++];
|
||||
strings.push(String.fromCodePoint(...ops.slice(i, i + len)));
|
||||
i += len;
|
||||
}
|
||||
const out = [];
|
||||
while (i < ops.length) {
|
||||
const op = ops[i];
|
||||
if (op === 1) {
|
||||
const id = ops[i + 1];
|
||||
const type = ops[i + 2];
|
||||
i += 3;
|
||||
if (type === 11) {
|
||||
out.push({ id, type, name: null, key: null, parent: 0 });
|
||||
i += 4;
|
||||
} else {
|
||||
out.push({
|
||||
id,
|
||||
type,
|
||||
name: strings[ops[i + 2]] || null,
|
||||
key: strings[ops[i + 3]] || null,
|
||||
parent: ops[i],
|
||||
});
|
||||
i += 5;
|
||||
}
|
||||
} else {
|
||||
i += skip(op, ops, i);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
function skip(op, ops, i) {
|
||||
if (op === 2) return 2 + ops[i + 1];
|
||||
if (op === 3) return 3 + ops[i + 2];
|
||||
if (op === 4) return 3;
|
||||
if (op === 5) return 4;
|
||||
if (op === 6) return 1;
|
||||
if (op === 7) return 3;
|
||||
if (op === 8) return 6 + rects(ops[i + 5]);
|
||||
if (op === 9) return 2 + ops[i + 1];
|
||||
if (op === 10) return 3 + ops[i + 2];
|
||||
if (op === 11) return 3 + rects(ops[i + 2]);
|
||||
if (op === 12) return suspenders(ops, i);
|
||||
if (op === 13) return 2;
|
||||
return 1;
|
||||
}
|
||||
function rects(n) {
|
||||
return n === -1 ? 0 : n * 4;
|
||||
}
|
||||
function suspenders(ops, i) {
|
||||
let j = i + 2;
|
||||
for (let c = 0; c < ops[i + 1]; c++) j += 5 + ops[j + 4];
|
||||
return j - i;
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify(nodes);
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// Template for `inspect` — replace {{ID}} with the numeric fiber id.
|
||||
pub const TREE_INSPECT: &str = r#"
|
||||
(() => {
|
||||
const id = {{ID}};
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
const ri = hook && hook.rendererInterfaces && hook.rendererInterfaces.get && hook.rendererInterfaces.get(1);
|
||||
if (!ri) throw new Error("No React renderer attached");
|
||||
if (!ri.hasElementWithId(id)) throw new Error("element " + id + " not found (page reloaded?)");
|
||||
const result = ri.inspectElement(1, id, null, true);
|
||||
if (!result || result.type !== "full-data") {
|
||||
throw new Error("inspect failed: " + (result && result.type));
|
||||
}
|
||||
const v = result.value;
|
||||
const name = ri.getDisplayNameForElementID(id);
|
||||
const lines = [name + " #" + id];
|
||||
if (v.key != null) lines.push("key: " + JSON.stringify(v.key));
|
||||
section("props", v.props);
|
||||
section("hooks", v.hooks);
|
||||
section("state", v.state);
|
||||
section("context", v.context);
|
||||
if (v.owners && v.owners.length) {
|
||||
lines.push("rendered by: " + v.owners.map((o) => o.displayName).join(" > "));
|
||||
}
|
||||
const source = Array.isArray(v.source)
|
||||
? [v.source[1], v.source[2], v.source[3]]
|
||||
: null;
|
||||
return JSON.stringify({ text: lines.join("\n"), source });
|
||||
|
||||
function section(label, payload) {
|
||||
const data = (payload && payload.data) || payload;
|
||||
if (data == null) return;
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) return;
|
||||
lines.push(label + ":");
|
||||
for (const h of data) lines.push(" " + hookLine(h));
|
||||
} else if (typeof data === "object") {
|
||||
const entries = Object.entries(data);
|
||||
if (entries.length === 0) return;
|
||||
lines.push(label + ":");
|
||||
for (const [k, val] of entries) lines.push(" " + k + ": " + preview(val));
|
||||
}
|
||||
}
|
||||
function hookLine(h) {
|
||||
const idx = h.id != null ? "[" + h.id + "] " : "";
|
||||
const sub = h.subHooks && h.subHooks.length ? " (" + h.subHooks.length + " sub)" : "";
|
||||
return idx + h.name + ": " + preview(h.value) + sub;
|
||||
}
|
||||
function preview(v) {
|
||||
if (v == null) return String(v);
|
||||
if (typeof v !== "object") return JSON.stringify(v);
|
||||
if (v.type === "undefined") return "undefined";
|
||||
if (v.preview_long) return v.preview_long;
|
||||
if (v.preview_short) return v.preview_short;
|
||||
if (Array.isArray(v)) return "[" + v.map(preview).join(", ") + "]";
|
||||
const entries = Object.entries(v).map((e) => e[0] + ": " + preview(e[1]));
|
||||
return "{" + entries.join(", ") + "}";
|
||||
}
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// Fiber profiler init script. Registered via `addScriptToEvaluateOnNewDocument`
|
||||
/// so it survives navigations; also evaluated immediately on the current page
|
||||
/// by `react renders start`.
|
||||
pub const RENDERS_INIT: &str = r#"
|
||||
(() => {
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!hook || window.__AB_RENDERS_ACTIVE__) return;
|
||||
|
||||
const MAX_COMPONENTS = 200;
|
||||
const data = {};
|
||||
const fps = { frames: [], last: 0, rafId: 0 };
|
||||
|
||||
window.__AB_RENDERS__ = data;
|
||||
window.__AB_RENDERS_FPS__ = fps;
|
||||
window.__AB_RENDERS_START__ = performance.now();
|
||||
window.__AB_RENDERS_ACTIVE__ = true;
|
||||
|
||||
function fpsLoop(now) {
|
||||
if (fps.last > 0) fps.frames.push(now - fps.last);
|
||||
fps.last = now;
|
||||
fps.rafId = requestAnimationFrame(fpsLoop);
|
||||
}
|
||||
fps.rafId = requestAnimationFrame(fpsLoop);
|
||||
|
||||
const origOnCommit = hook.onCommitFiberRoot;
|
||||
window.__AB_RENDERS_ORIG_COMMIT__ = origOnCommit;
|
||||
|
||||
hook.onCommitFiberRoot = function (rendererID, root) {
|
||||
try { walkFiber(root.current); } catch {}
|
||||
if (typeof origOnCommit === "function") {
|
||||
return origOnCommit.apply(hook, arguments);
|
||||
}
|
||||
};
|
||||
|
||||
function getName(fiber) {
|
||||
if (!fiber.type || typeof fiber.type === "string") return null;
|
||||
return fiber.type.displayName || fiber.type.name || null;
|
||||
}
|
||||
|
||||
function brief(val) {
|
||||
if (val === undefined) return "undefined";
|
||||
if (val === null) return "null";
|
||||
if (typeof val === "function") return "fn()";
|
||||
if (typeof val === "string") return val.length > 60 ? '"' + val.slice(0, 57) + '..."' : '"' + val + '"';
|
||||
if (typeof val === "number" || typeof val === "boolean") return String(val);
|
||||
if (Array.isArray(val)) return "Array(" + val.length + ")";
|
||||
if (typeof val === "object") {
|
||||
try {
|
||||
const keys = Object.keys(val);
|
||||
return keys.length <= 3 ? "{" + keys.join(", ") + "}" : "{" + keys.slice(0, 3).join(", ") + ", ...}";
|
||||
} catch { return "{...}"; }
|
||||
}
|
||||
return String(val).slice(0, 40);
|
||||
}
|
||||
|
||||
function getChanges(fiber) {
|
||||
const changes = [];
|
||||
const alt = fiber.alternate;
|
||||
if (!alt) { changes.push({ type: "mount" }); return changes; }
|
||||
if (fiber.memoizedProps !== alt.memoizedProps) {
|
||||
const curr = fiber.memoizedProps || {};
|
||||
const prev = alt.memoizedProps || {};
|
||||
const allKeys = new Set([...Object.keys(curr), ...Object.keys(prev)]);
|
||||
for (const k of allKeys) {
|
||||
if (k !== "children" && curr[k] !== prev[k]) {
|
||||
changes.push({ type: "props", name: k, prev: brief(prev[k]), next: brief(curr[k]) });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fiber.memoizedState !== alt.memoizedState) {
|
||||
let curr = fiber.memoizedState;
|
||||
let prev = alt.memoizedState;
|
||||
let hookIdx = 0;
|
||||
while (curr || prev) {
|
||||
if ((curr && curr.memoizedState) !== (prev && prev.memoizedState)) {
|
||||
changes.push({
|
||||
type: "state",
|
||||
name: "hook #" + hookIdx,
|
||||
prev: brief(prev && prev.memoizedState),
|
||||
next: brief(curr && curr.memoizedState),
|
||||
});
|
||||
}
|
||||
curr = curr && curr.next;
|
||||
prev = prev && prev.next;
|
||||
hookIdx++;
|
||||
}
|
||||
}
|
||||
if (fiber.dependencies && fiber.dependencies.firstContext) {
|
||||
let ctx = fiber.dependencies.firstContext;
|
||||
let altCtx = alt.dependencies && alt.dependencies.firstContext;
|
||||
while (ctx) {
|
||||
if (!altCtx || ctx.memoizedValue !== (altCtx && altCtx.memoizedValue)) {
|
||||
const ctxName =
|
||||
(ctx.context && ctx.context.displayName) ||
|
||||
(ctx.context && ctx.context.Provider && ctx.context.Provider.displayName) ||
|
||||
"unknown";
|
||||
changes.push({
|
||||
type: "context",
|
||||
name: ctxName,
|
||||
prev: brief(altCtx && altCtx.memoizedValue),
|
||||
next: brief(ctx.memoizedValue),
|
||||
});
|
||||
}
|
||||
ctx = ctx.next;
|
||||
altCtx = altCtx && altCtx.next;
|
||||
}
|
||||
}
|
||||
if (changes.length === 0) {
|
||||
let parent = fiber.return;
|
||||
while (parent) {
|
||||
const pName = getName(parent);
|
||||
if (pName) {
|
||||
const suffix = !parent.alternate ? " (mount)" : "";
|
||||
changes.push({ type: "parent", name: pName + suffix });
|
||||
break;
|
||||
}
|
||||
parent = parent.return;
|
||||
}
|
||||
if (changes.length === 0) changes.push({ type: "parent", name: "unknown" });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function childrenTime(fiber) {
|
||||
let t = 0;
|
||||
let child = fiber.child;
|
||||
while (child) {
|
||||
if (typeof child.actualDuration === "number") t += child.actualDuration;
|
||||
child = child.sibling;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
function hasDomMutation(fiber) {
|
||||
if (!fiber.alternate) return true;
|
||||
let child = fiber.child;
|
||||
while (child) {
|
||||
if (typeof child.type === "string" && (child.flags & 6) > 0) return true;
|
||||
child = child.sibling;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function walkFiber(fiber) {
|
||||
if (!fiber) return;
|
||||
const tag = fiber.tag;
|
||||
if (tag === 0 || tag === 1 || tag === 2 || tag === 11 || tag === 15) {
|
||||
const didRender =
|
||||
fiber.alternate === null ||
|
||||
fiber.flags > 0 ||
|
||||
fiber.memoizedProps !== (fiber.alternate && fiber.alternate.memoizedProps) ||
|
||||
fiber.memoizedState !== (fiber.alternate && fiber.alternate.memoizedState);
|
||||
if (didRender) {
|
||||
const name = getName(fiber);
|
||||
if (name) {
|
||||
if (!(name in data) && Object.keys(data).length >= MAX_COMPONENTS) {
|
||||
// at cap - skip
|
||||
} else {
|
||||
if (!data[name]) {
|
||||
data[name] = {
|
||||
count: 0, mounts: 0, totalTime: 0, selfTime: 0,
|
||||
domMutations: 0, changes: [], _instances: new Set(),
|
||||
};
|
||||
}
|
||||
data[name].count++;
|
||||
if (!fiber.alternate) data[name].mounts++;
|
||||
if (!data[name]._instances.has(fiber)) {
|
||||
data[name]._instances.add(fiber);
|
||||
if (fiber.alternate) data[name]._instances.add(fiber.alternate);
|
||||
}
|
||||
if (typeof fiber.actualDuration === "number") {
|
||||
data[name].totalTime += fiber.actualDuration;
|
||||
data[name].selfTime += Math.max(0, fiber.actualDuration - childrenTime(fiber));
|
||||
}
|
||||
if (hasDomMutation(fiber)) data[name].domMutations++;
|
||||
const ch = getChanges(fiber);
|
||||
for (const c of ch) {
|
||||
if (data[name].changes.length < 50) data[name].changes.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
walkFiber(fiber.child);
|
||||
walkFiber(fiber.sibling);
|
||||
}
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// Stop script for fiber profiler. Returns the collected profile as JSON.
|
||||
pub const RENDERS_STOP: &str = r#"
|
||||
(() => {
|
||||
const active = window.__AB_RENDERS_ACTIVE__;
|
||||
if (!active) throw new Error("renders recording not active - run `react renders start` first");
|
||||
|
||||
const data = window.__AB_RENDERS__;
|
||||
const startTime = window.__AB_RENDERS_START__;
|
||||
const elapsed = performance.now() - startTime;
|
||||
|
||||
const fpsData = window.__AB_RENDERS_FPS__;
|
||||
let fpsStats = { avg: 0, min: 0, max: 0, drops: 0 };
|
||||
if (fpsData) {
|
||||
cancelAnimationFrame(fpsData.rafId);
|
||||
if (fpsData.frames.length > 0) {
|
||||
const fpsSamples = fpsData.frames.map((dt) => (dt > 0 ? 1000 / dt : 0));
|
||||
const sum = fpsSamples.reduce((a, b) => a + b, 0);
|
||||
fpsStats = {
|
||||
avg: Math.round(sum / fpsSamples.length),
|
||||
min: Math.round(Math.min(...fpsSamples)),
|
||||
max: Math.round(Math.max(...fpsSamples)),
|
||||
drops: fpsSamples.filter((f) => f < 30).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
const orig = window.__AB_RENDERS_ORIG_COMMIT__;
|
||||
if (hook) hook.onCommitFiberRoot = orig || undefined;
|
||||
|
||||
delete window.__AB_RENDERS__;
|
||||
delete window.__AB_RENDERS_START__;
|
||||
delete window.__AB_RENDERS_ACTIVE__;
|
||||
delete window.__AB_RENDERS_ORIG_COMMIT__;
|
||||
delete window.__AB_RENDERS_FPS__;
|
||||
|
||||
if (!data) {
|
||||
return JSON.stringify({
|
||||
elapsed: 0, fps: fpsStats, totalRenders: 0, totalMounts: 0,
|
||||
totalReRenders: 0, totalComponents: 0, components: [],
|
||||
});
|
||||
}
|
||||
|
||||
const round = (n) => Math.round(n * 100) / 100;
|
||||
const components = Object.entries(data)
|
||||
.map(([name, entry]) => {
|
||||
const summary = {};
|
||||
for (const c of entry.changes) {
|
||||
const key = c.type === "props" ? "props." + c.name
|
||||
: c.type === "state" ? "state (" + c.name + ")"
|
||||
: c.type === "context" ? "context (" + c.name + ")"
|
||||
: c.type === "parent" ? "parent (" + c.name + ")"
|
||||
: c.type;
|
||||
summary[key] = (summary[key] || 0) + 1;
|
||||
}
|
||||
return {
|
||||
name,
|
||||
count: entry.count,
|
||||
mounts: entry.mounts,
|
||||
reRenders: entry.count - entry.mounts,
|
||||
instanceCount: entry._instances.size,
|
||||
totalTime: round(entry.totalTime),
|
||||
selfTime: round(entry.selfTime),
|
||||
domMutations: entry.domMutations,
|
||||
changes: entry.changes,
|
||||
changeSummary: summary,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.totalTime - a.totalTime || b.count - a.count);
|
||||
|
||||
return JSON.stringify({
|
||||
elapsed: round(elapsed / 1000),
|
||||
fps: fpsStats,
|
||||
totalRenders: components.reduce((s, c) => s + c.count, 0),
|
||||
totalMounts: components.reduce((s, c) => s + c.mounts, 0),
|
||||
totalReRenders: components.reduce((s, c) => s + c.reRenders, 0),
|
||||
totalComponents: components.length,
|
||||
components,
|
||||
});
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// Suspense boundary walker. Returns boundaries with suspendedBy metadata as JSON.
|
||||
pub const SUSPENSE_WALK: &str = r#"
|
||||
(async () => {
|
||||
const hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
if (!hook) throw new Error("React DevTools hook not installed - relaunch with --enable react-devtools");
|
||||
const ri = hook.rendererInterfaces && hook.rendererInterfaces.get && hook.rendererInterfaces.get(1);
|
||||
if (!ri) throw new Error("No React renderer attached");
|
||||
|
||||
const batches = await new Promise((resolve) => {
|
||||
const out = [];
|
||||
const origEmit = hook.emit;
|
||||
hook.emit = function (event, payload) {
|
||||
if (event === "operations") out.push(payload);
|
||||
return origEmit.apply(this, arguments);
|
||||
};
|
||||
ri.flushInitialOperations();
|
||||
setTimeout(() => {
|
||||
hook.emit = origEmit;
|
||||
resolve(out);
|
||||
}, 50);
|
||||
});
|
||||
|
||||
const boundaryMap = new Map();
|
||||
for (const ops of batches) decodeSuspenseOps(ops, boundaryMap);
|
||||
|
||||
const results = [];
|
||||
for (const b of boundaryMap.values()) {
|
||||
if (b.parentID === 0) continue;
|
||||
const boundary = {
|
||||
id: b.id,
|
||||
parentID: b.parentID,
|
||||
name: b.name,
|
||||
isSuspended: b.isSuspended,
|
||||
environments: b.environments,
|
||||
suspendedBy: [],
|
||||
unknownSuspenders: null,
|
||||
owners: [],
|
||||
jsxSource: null,
|
||||
};
|
||||
if (ri.hasElementWithId(b.id)) {
|
||||
const displayName = ri.getDisplayNameForElementID(b.id);
|
||||
if (displayName) boundary.name = displayName;
|
||||
const result = ri.inspectElement(1, b.id, null, true);
|
||||
if (result && result.type === "full-data") {
|
||||
parseInspection(boundary, result.value);
|
||||
}
|
||||
}
|
||||
results.push(boundary);
|
||||
}
|
||||
return JSON.stringify(results);
|
||||
|
||||
function decodeSuspenseOps(ops, map) {
|
||||
let i = 2;
|
||||
const strings = [null];
|
||||
const tableEnd = ++i + ops[i - 1];
|
||||
while (i < tableEnd) {
|
||||
const len = ops[i++];
|
||||
strings.push(String.fromCodePoint(...ops.slice(i, i + len)));
|
||||
i += len;
|
||||
}
|
||||
while (i < ops.length) {
|
||||
const op = ops[i];
|
||||
if (op === 1) {
|
||||
const type = ops[i + 2];
|
||||
i += 3 + (type === 11 ? 4 : 5);
|
||||
} else if (op === 2) {
|
||||
i += 2 + ops[i + 1];
|
||||
} else if (op === 3) {
|
||||
i += 3 + ops[i + 2];
|
||||
} else if (op === 4) {
|
||||
i += 3;
|
||||
} else if (op === 5) {
|
||||
i += 4;
|
||||
} else if (op === 6) {
|
||||
i++;
|
||||
} else if (op === 7) {
|
||||
i += 3;
|
||||
} else if (op === 8) {
|
||||
const id = ops[i + 1];
|
||||
const parentID = ops[i + 2];
|
||||
const nameStrID = ops[i + 3];
|
||||
const isSuspended = ops[i + 4] === 1;
|
||||
const numRects = ops[i + 5];
|
||||
i += 6;
|
||||
if (numRects !== -1) i += numRects * 4;
|
||||
map.set(id, { id, parentID, name: strings[nameStrID] || null, isSuspended, environments: [] });
|
||||
} else if (op === 9) {
|
||||
i += 2 + ops[i + 1];
|
||||
} else if (op === 10) {
|
||||
i += 3 + ops[i + 2];
|
||||
} else if (op === 11) {
|
||||
const numRects = ops[i + 2];
|
||||
i += 3;
|
||||
if (numRects !== -1) i += numRects * 4;
|
||||
} else if (op === 12) {
|
||||
i++;
|
||||
const changeLen = ops[i++];
|
||||
for (let c = 0; c < changeLen; c++) {
|
||||
const id = ops[i++];
|
||||
i++;
|
||||
i++;
|
||||
const isSuspended = ops[i++] === 1;
|
||||
const envLen = ops[i++];
|
||||
const envs = [];
|
||||
for (let e = 0; e < envLen; e++) {
|
||||
const n = strings[ops[i++]];
|
||||
if (n != null) envs.push(n);
|
||||
}
|
||||
const node = map.get(id);
|
||||
if (node) {
|
||||
node.isSuspended = isSuspended;
|
||||
for (const env of envs) {
|
||||
if (!node.environments.includes(env)) node.environments.push(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (op === 13) {
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseInspection(boundary, data) {
|
||||
const rawSuspendedBy = data.suspendedBy;
|
||||
const rawSuspenders = Array.isArray(rawSuspendedBy)
|
||||
? rawSuspendedBy
|
||||
: rawSuspendedBy && Array.isArray(rawSuspendedBy.data) ? rawSuspendedBy.data : null;
|
||||
if (rawSuspenders) {
|
||||
for (const entry of rawSuspenders) {
|
||||
const awaited = entry && entry.awaited;
|
||||
if (!awaited) continue;
|
||||
const desc = preview(awaited.description) || preview(awaited.value);
|
||||
boundary.suspendedBy.push({
|
||||
name: awaited.name || "unknown",
|
||||
description: desc,
|
||||
duration: awaited.end && awaited.start ? Math.round(awaited.end - awaited.start) : 0,
|
||||
env: awaited.env || (entry && entry.env) || null,
|
||||
ownerName: (awaited.owner && awaited.owner.displayName) || null,
|
||||
ownerStack: parseStack((awaited.owner && awaited.owner.stack) || awaited.stack),
|
||||
awaiterName: (entry && entry.owner && entry.owner.displayName) || null,
|
||||
awaiterStack: parseStack((entry && entry.owner && entry.owner.stack) || (entry && entry.stack)),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (data.unknownSuspenders && data.unknownSuspenders !== 0) {
|
||||
const reasons = {
|
||||
1: "production build (no debug info)",
|
||||
2: "old React version (missing tracking)",
|
||||
3: "thrown Promise (library using throw instead of use())",
|
||||
};
|
||||
boundary.unknownSuspenders = reasons[data.unknownSuspenders] || "unknown reason";
|
||||
}
|
||||
if (Array.isArray(data.owners)) {
|
||||
for (const o of data.owners) {
|
||||
if (o && o.displayName) {
|
||||
const src = Array.isArray(o.stack) && o.stack.length > 0 && Array.isArray(o.stack[0])
|
||||
? [o.stack[0][1] || "(unknown)", o.stack[0][2], o.stack[0][3]]
|
||||
: null;
|
||||
boundary.owners.push({ name: o.displayName, env: o.env || null, source: src });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(data.stack) && data.stack.length > 0) {
|
||||
const frame = data.stack[0];
|
||||
if (Array.isArray(frame) && frame.length >= 4) {
|
||||
boundary.jsxSource = [frame[1] || "(unknown)", frame[2], frame[3]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseStack(raw) {
|
||||
if (!Array.isArray(raw) || raw.length === 0) return null;
|
||||
return raw
|
||||
.filter((f) => Array.isArray(f) && f.length >= 4)
|
||||
.map((f) => [f[0] || "", f[1] || "", f[2] || 0, f[3] || 0]);
|
||||
}
|
||||
|
||||
function preview(v) {
|
||||
if (v == null) return "";
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v !== "object") return String(v);
|
||||
if (typeof v.preview_long === "string") return v.preview_long;
|
||||
if (typeof v.preview_short === "string") return v.preview_short;
|
||||
if (typeof v.value === "string") return v.value;
|
||||
try {
|
||||
const s = JSON.stringify(v);
|
||||
return s.length > 80 ? s.slice(0, 77) + "..." : s;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// Init script for Core Web Vitals + React hydration timing capture. Installs
|
||||
/// PerformanceObservers for LCP/CLS and intercepts `console.timeStamp` to
|
||||
/// capture React's profiling reconciler timings. Idempotent.
|
||||
pub const VITALS_INIT: &str = r#"
|
||||
(() => {
|
||||
if (window.__AB_VITALS_INSTALLED__) return;
|
||||
window.__AB_VITALS_INSTALLED__ = true;
|
||||
|
||||
const cwv = { lcp: null, cls: 0, clsEntries: [], fcp: null, inp: null };
|
||||
window.__AB_VITALS__ = cwv;
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
if (entries.length > 0) {
|
||||
const last = entries[entries.length - 1];
|
||||
cwv.lcp = {
|
||||
startTime: Math.round(last.startTime * 100) / 100,
|
||||
size: last.size,
|
||||
element: last.element && last.element.tagName ? last.element.tagName.toLowerCase() : null,
|
||||
url: last.url || null,
|
||||
};
|
||||
}
|
||||
}).observe({ type: "largest-contentful-paint", buffered: true });
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!entry.hadRecentInput) {
|
||||
cwv.cls += entry.value;
|
||||
cwv.clsEntries.push({
|
||||
value: Math.round(entry.value * 10000) / 10000,
|
||||
startTime: Math.round(entry.startTime * 100) / 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
}).observe({ type: "layout-shift", buffered: true });
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.name === "first-contentful-paint") {
|
||||
cwv.fcp = Math.round(entry.startTime * 100) / 100;
|
||||
}
|
||||
}
|
||||
}).observe({ type: "paint", buffered: true });
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
let worst = cwv.inp || 0;
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration > worst) worst = entry.duration;
|
||||
}
|
||||
if (worst > 0) cwv.inp = Math.round(worst * 100) / 100;
|
||||
}).observe({ type: "event", buffered: true, durationThreshold: 40 });
|
||||
} catch {}
|
||||
|
||||
// React profiling build emits console.timeStamp(label, start, end, track, trackGroup, color)
|
||||
// for reconciler phases and per-component hydration timing. Intercept and collect.
|
||||
const timing = [];
|
||||
window.__AB_REACT_TIMING__ = timing;
|
||||
const orig = console.timeStamp;
|
||||
console.timeStamp = function (label) {
|
||||
const args = arguments;
|
||||
if (typeof label === "string" && args.length >= 3 && typeof args[1] === "number") {
|
||||
timing.push({
|
||||
label,
|
||||
startTime: args[1],
|
||||
endTime: args[2],
|
||||
track: args[3] || "",
|
||||
trackGroup: args[4] || "",
|
||||
color: args[5] || "",
|
||||
});
|
||||
}
|
||||
return orig.apply(console, args);
|
||||
};
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// Read script for vitals — collects observed metrics plus Navigation Timing
|
||||
/// TTFB and any React hydration phases. Returns JSON.
|
||||
pub const VITALS_READ: &str = r#"
|
||||
(() => {
|
||||
const cwv = window.__AB_VITALS__ || {};
|
||||
const timing = window.__AB_REACT_TIMING__ || [];
|
||||
const nav = performance.getEntriesByType("navigation")[0];
|
||||
const ttfb = nav
|
||||
? Math.round((nav.responseStart - nav.requestStart) * 100) / 100
|
||||
: null;
|
||||
return JSON.stringify({ cwv, timing, ttfb });
|
||||
})()
|
||||
"#;
|
||||
|
||||
/// SPA client-side navigation. Tries the framework router first so Next.js
|
||||
/// app/pages router triggers an RSC fetch (pure `history.pushState` would
|
||||
/// be shallow routing and bypass data loading). Falls back to
|
||||
/// `history.pushState` + popstate/navigate events for vanilla pages and
|
||||
/// routers that listen to history events (React Router, TanStack Router,
|
||||
/// Solid Router, Vue Router).
|
||||
pub const PUSHSTATE: &str = r#"
|
||||
((url) => {
|
||||
const before = location.href;
|
||||
const absolute = new URL(url, before).href;
|
||||
if (absolute === before) return before;
|
||||
|
||||
// Next.js pages + app router expose window.next.router with a `push`
|
||||
// method that triggers the RSC fetch and re-render pipeline.
|
||||
const r = typeof window.next === "object" && window.next && window.next.router;
|
||||
if (r && typeof r.push === "function") {
|
||||
try { r.push(url); return location.href; } catch {}
|
||||
}
|
||||
|
||||
history.pushState(null, "", absolute);
|
||||
try { dispatchEvent(new PopStateEvent("popstate", { state: null })); } catch {}
|
||||
try { dispatchEvent(new Event("navigate")); } catch {}
|
||||
return location.href;
|
||||
})({{URL}})
|
||||
"#;
|
||||
@@ -0,0 +1,633 @@
|
||||
//! React Suspense boundary introspection: walker data types, classifier, and
|
||||
//! human-readable report.
|
||||
//!
|
||||
//! The classifier labels and recommendations are React-Suspense-general —
|
||||
//! they describe what kind of thing is making a boundary suspend (`client-hook`,
|
||||
//! `request-api`, `server-fetch`, `cache`, `stream`, `framework`, `unknown`)
|
||||
//! and a high-level direction for fixing it. Framework-specific reasoning
|
||||
//! (e.g. Next.js PPR push vs goto semantics) is left to the caller.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub type StackFrame = (String, String, i64, i64);
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Boundary {
|
||||
pub id: i64,
|
||||
#[serde(rename = "parentID")]
|
||||
pub parent_id: i64,
|
||||
pub name: Option<String>,
|
||||
#[serde(rename = "isSuspended")]
|
||||
pub is_suspended: bool,
|
||||
pub environments: Vec<String>,
|
||||
#[serde(rename = "suspendedBy")]
|
||||
pub suspended_by: Vec<Suspender>,
|
||||
#[serde(rename = "unknownSuspenders")]
|
||||
pub unknown_suspenders: Option<String>,
|
||||
pub owners: Vec<Owner>,
|
||||
#[serde(rename = "jsxSource")]
|
||||
pub jsx_source: Option<(String, i64, i64)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Owner {
|
||||
pub name: String,
|
||||
pub env: Option<String>,
|
||||
pub source: Option<(String, i64, i64)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Suspender {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub duration: i64,
|
||||
pub env: Option<String>,
|
||||
#[serde(rename = "ownerName")]
|
||||
pub owner_name: Option<String>,
|
||||
#[serde(rename = "ownerStack")]
|
||||
pub owner_stack: Option<Vec<StackFrame>>,
|
||||
#[serde(rename = "awaiterName")]
|
||||
pub awaiter_name: Option<String>,
|
||||
#[serde(rename = "awaiterStack")]
|
||||
pub awaiter_stack: Option<Vec<StackFrame>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlockerKind {
|
||||
ClientHook,
|
||||
RequestApi,
|
||||
ServerFetch,
|
||||
Stream,
|
||||
Cache,
|
||||
Framework,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl BlockerKind {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClientHook => "client-hook",
|
||||
Self::RequestApi => "request-api",
|
||||
Self::ServerFetch => "server-fetch",
|
||||
Self::Stream => "stream",
|
||||
Self::Cache => "cache",
|
||||
Self::Framework => "framework",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn weight(self) -> i32 {
|
||||
match self {
|
||||
Self::ClientHook => 7,
|
||||
Self::RequestApi => 6,
|
||||
Self::ServerFetch => 5,
|
||||
Self::Cache => 4,
|
||||
Self::Stream => 3,
|
||||
Self::Unknown => 2,
|
||||
Self::Framework => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn actionability(self) -> i32 {
|
||||
match self {
|
||||
Self::ClientHook => 90,
|
||||
Self::RequestApi => 88,
|
||||
Self::ServerFetch => 82,
|
||||
Self::Cache => 74,
|
||||
Self::Stream => 60,
|
||||
Self::Unknown => 35,
|
||||
Self::Framework => 18,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BoundaryKind {
|
||||
RouteSegment,
|
||||
ExplicitSuspense,
|
||||
Component,
|
||||
}
|
||||
|
||||
impl BoundaryKind {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::RouteSegment => "route-segment",
|
||||
Self::ExplicitSuspense => "explicit-suspense",
|
||||
Self::Component => "component",
|
||||
}
|
||||
}
|
||||
|
||||
fn weight(self) -> i32 {
|
||||
match self {
|
||||
Self::RouteSegment => 3,
|
||||
Self::ExplicitSuspense => 2,
|
||||
Self::Component => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActionableBlocker {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub kind: BlockerKind,
|
||||
pub env: Option<String>,
|
||||
pub description: String,
|
||||
pub owner_name: Option<String>,
|
||||
pub awaiter_name: Option<String>,
|
||||
pub source_frame: Option<StackFrame>,
|
||||
pub owner_frame: Option<StackFrame>,
|
||||
pub awaiter_frame: Option<StackFrame>,
|
||||
pub actionability: i32,
|
||||
pub suggestion: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BoundaryInsight {
|
||||
pub id: i64,
|
||||
pub name: Option<String>,
|
||||
pub boundary_kind: BoundaryKind,
|
||||
pub environments: Vec<String>,
|
||||
pub source: Option<(String, i64, i64)>,
|
||||
pub rendered_by: Vec<Owner>,
|
||||
pub primary_blocker: Option<ActionableBlocker>,
|
||||
pub blockers: Vec<ActionableBlocker>,
|
||||
pub unknown_suspenders: Option<String>,
|
||||
pub actionability: i32,
|
||||
pub recommendation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RootCauseGroup {
|
||||
pub kind: BlockerKind,
|
||||
pub name: String,
|
||||
pub source_frame: Option<StackFrame>,
|
||||
pub boundary_names: Vec<String>,
|
||||
pub count: usize,
|
||||
pub actionability: i32,
|
||||
pub suggestion: String,
|
||||
}
|
||||
|
||||
pub struct AnalysisReport {
|
||||
pub total_boundaries: usize,
|
||||
pub dynamic_hole_count: usize,
|
||||
pub static_count: usize,
|
||||
pub holes: Vec<BoundaryInsight>,
|
||||
pub statics: Vec<StaticBoundarySummary>,
|
||||
pub root_causes: Vec<RootCauseGroup>,
|
||||
pub files_to_read: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StaticBoundarySummary {
|
||||
pub name: Option<String>,
|
||||
pub source: Option<(String, i64, i64)>,
|
||||
pub rendered_by: Vec<Owner>,
|
||||
}
|
||||
|
||||
pub fn format_suspense_report(boundaries: &[Boundary], only_dynamic: bool) -> String {
|
||||
let report = analyze_boundaries(boundaries);
|
||||
format_report(&report, only_dynamic)
|
||||
}
|
||||
|
||||
fn analyze_boundaries(boundaries: &[Boundary]) -> AnalysisReport {
|
||||
let mut holes: Vec<&Boundary> = Vec::new();
|
||||
let mut statics_raw: Vec<&Boundary> = Vec::new();
|
||||
|
||||
for b in boundaries {
|
||||
if b.parent_id == 0 {
|
||||
continue;
|
||||
}
|
||||
let has_blocker = !b.suspended_by.is_empty() || b.unknown_suspenders.is_some();
|
||||
if b.is_suspended || has_blocker {
|
||||
holes.push(b);
|
||||
} else {
|
||||
statics_raw.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
let mut hole_insights: Vec<BoundaryInsight> = holes.iter().map(|b| build_insight(b)).collect();
|
||||
hole_insights.sort_by(|a, b| {
|
||||
b.actionability.cmp(&a.actionability).then_with(|| {
|
||||
b.boundary_kind
|
||||
.weight()
|
||||
.cmp(&a.boundary_kind.weight())
|
||||
.then_with(|| b.blockers.len().cmp(&a.blockers.len()))
|
||||
.then_with(|| {
|
||||
a.name
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.cmp(b.name.as_deref().unwrap_or(""))
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
let static_summaries: Vec<StaticBoundarySummary> = statics_raw
|
||||
.iter()
|
||||
.map(|b| StaticBoundarySummary {
|
||||
name: b.name.clone(),
|
||||
source: b.jsx_source.clone(),
|
||||
rendered_by: b.owners.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let root_causes = build_root_causes(&hole_insights);
|
||||
let files_to_read = collect_files_to_read(&hole_insights, &root_causes);
|
||||
|
||||
AnalysisReport {
|
||||
total_boundaries: hole_insights.len() + static_summaries.len(),
|
||||
dynamic_hole_count: hole_insights.len(),
|
||||
static_count: static_summaries.len(),
|
||||
holes: hole_insights,
|
||||
statics: static_summaries,
|
||||
root_causes,
|
||||
files_to_read,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_insight(b: &Boundary) -> BoundaryInsight {
|
||||
let boundary_kind = infer_boundary_kind(b);
|
||||
let mut blockers: Vec<ActionableBlocker> = b
|
||||
.suspended_by
|
||||
.iter()
|
||||
.map(build_actionable_blocker)
|
||||
.collect();
|
||||
blockers.sort_by(|a, b| {
|
||||
b.actionability.cmp(&a.actionability).then_with(|| {
|
||||
b.kind
|
||||
.weight()
|
||||
.cmp(&a.kind.weight())
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
})
|
||||
});
|
||||
let primary = blockers.first().cloned();
|
||||
let recommendation = recommend_fix(
|
||||
boundary_kind,
|
||||
primary.as_ref(),
|
||||
b.unknown_suspenders.as_deref(),
|
||||
);
|
||||
let primary_action = primary.as_ref().map(|p| p.actionability).unwrap_or(0);
|
||||
let base_action = if boundary_kind == BoundaryKind::RouteSegment {
|
||||
55
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
BoundaryInsight {
|
||||
id: b.id,
|
||||
name: b.name.clone(),
|
||||
boundary_kind,
|
||||
environments: b.environments.clone(),
|
||||
source: b.jsx_source.clone(),
|
||||
rendered_by: b.owners.clone(),
|
||||
primary_blocker: primary,
|
||||
blockers,
|
||||
unknown_suspenders: b.unknown_suspenders.clone(),
|
||||
actionability: primary_action.max(base_action),
|
||||
recommendation,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_actionable_blocker(s: &Suspender) -> ActionableBlocker {
|
||||
let owner_frame = pick_preferred_frame(s.owner_stack.as_deref());
|
||||
let awaiter_frame = pick_preferred_frame(s.awaiter_stack.as_deref());
|
||||
let source_frame = owner_frame.clone().or_else(|| awaiter_frame.clone());
|
||||
let kind = classify_blocker(s, source_frame.as_ref());
|
||||
let suggestion = suggest_blocker_fix(kind);
|
||||
let mut actionability = kind.actionability();
|
||||
if let Some(ref frame) = source_frame {
|
||||
if !is_frameworkish_path(&frame.1) {
|
||||
actionability += 8;
|
||||
}
|
||||
}
|
||||
if s.owner_name.is_some() || s.awaiter_name.is_some() {
|
||||
actionability += 4;
|
||||
}
|
||||
if actionability > 100 {
|
||||
actionability = 100;
|
||||
}
|
||||
let key = build_blocker_key(&s.name, kind, source_frame.as_ref());
|
||||
|
||||
ActionableBlocker {
|
||||
key,
|
||||
name: s.name.clone(),
|
||||
kind,
|
||||
env: s.env.clone(),
|
||||
description: s.description.clone(),
|
||||
owner_name: s.owner_name.clone(),
|
||||
awaiter_name: s.awaiter_name.clone(),
|
||||
source_frame,
|
||||
owner_frame,
|
||||
awaiter_frame,
|
||||
actionability,
|
||||
suggestion,
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_boundary_kind(b: &Boundary) -> BoundaryKind {
|
||||
let owner_names: Vec<&str> = b.owners.iter().map(|o| o.name.as_str()).collect();
|
||||
let name_ends_slash = b.name.as_ref().is_some_and(|n| n.ends_with('/'));
|
||||
if name_ends_slash
|
||||
|| owner_names.contains(&"LoadingBoundary")
|
||||
|| owner_names.contains(&"OuterLayoutRouter")
|
||||
{
|
||||
return BoundaryKind::RouteSegment;
|
||||
}
|
||||
let name_has_suspense = b.name.as_ref().is_some_and(|n| n.contains("Suspense"));
|
||||
if name_has_suspense || owner_names.iter().any(|n| n.contains("Suspense")) {
|
||||
return BoundaryKind::ExplicitSuspense;
|
||||
}
|
||||
BoundaryKind::Component
|
||||
}
|
||||
|
||||
fn classify_blocker(s: &Suspender, source_frame: Option<&StackFrame>) -> BlockerKind {
|
||||
let name = s.name.to_lowercase();
|
||||
match name.as_str() {
|
||||
"usepathname"
|
||||
| "useparams"
|
||||
| "usesearchparams"
|
||||
| "useselectedlayoutsegments"
|
||||
| "useselectedlayoutsegment"
|
||||
| "userouter" => return BlockerKind::ClientHook,
|
||||
"cookies" | "headers" | "connection" | "params" | "searchparams" | "draftmode" => {
|
||||
return BlockerKind::RequestApi
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if name == "rsc stream" {
|
||||
return BlockerKind::Stream;
|
||||
}
|
||||
if name.contains("fetch") {
|
||||
return BlockerKind::ServerFetch;
|
||||
}
|
||||
if name.contains("cache") || s.description.to_lowercase().contains("cache") {
|
||||
return BlockerKind::Cache;
|
||||
}
|
||||
if name.starts_with("use") {
|
||||
return BlockerKind::ClientHook;
|
||||
}
|
||||
if let Some(frame) = source_frame {
|
||||
if is_frameworkish_path(&frame.1) {
|
||||
return BlockerKind::Framework;
|
||||
}
|
||||
}
|
||||
BlockerKind::Unknown
|
||||
}
|
||||
|
||||
fn suggest_blocker_fix(kind: BlockerKind) -> String {
|
||||
match kind {
|
||||
BlockerKind::ClientHook => "Move route hooks behind a smaller client Suspense or provide a real non-null loading fallback for this segment.",
|
||||
BlockerKind::RequestApi => "Push request-bound reads to a smaller server leaf, or cache around them so the parent shell can stay static.",
|
||||
BlockerKind::ServerFetch => "Split static shell content from data widgets, then push the fetch into smaller Suspense leaves or cache it.",
|
||||
BlockerKind::Cache => "This looks cache-related; check whether \"use cache\" or runtime prefetch can eliminate the suspension.",
|
||||
BlockerKind::Stream => "A stream is still pending here; extract static siblings outside the boundary and push the stream consumer deeper.",
|
||||
BlockerKind::Framework => "This currently looks framework-driven; find the nearest user-owned caller above it before changing code.",
|
||||
BlockerKind::Unknown => "Inspect the nearest user-owned owner/awaiter frame and verify whether this suspender really belongs at this boundary.",
|
||||
}.to_string()
|
||||
}
|
||||
|
||||
fn recommend_fix(
|
||||
boundary_kind: BoundaryKind,
|
||||
primary: Option<&ActionableBlocker>,
|
||||
unknown_suspenders: Option<&str>,
|
||||
) -> String {
|
||||
if boundary_kind == BoundaryKind::RouteSegment
|
||||
&& primary.is_some_and(|p| p.kind == BlockerKind::ClientHook)
|
||||
{
|
||||
return "This route segment is suspending on client hooks. Check loading.tsx first; if it is null or visually empty, fix the fallback before chasing deeper push-down work.".to_string();
|
||||
}
|
||||
if let Some(p) = primary {
|
||||
match p.kind {
|
||||
BlockerKind::ClientHook => {
|
||||
return "Push the hook-using client UI behind a smaller local Suspense boundary so the parent shell can prerender.".to_string();
|
||||
}
|
||||
BlockerKind::RequestApi | BlockerKind::ServerFetch => {
|
||||
return "Push the request-bound async work into a smaller leaf or split static siblings out of this boundary.".to_string();
|
||||
}
|
||||
BlockerKind::Cache => {
|
||||
return "Check whether caching or runtime prefetch can move this personalized content into the shell.".to_string();
|
||||
}
|
||||
BlockerKind::Stream => {
|
||||
return "Keep the stream behind Suspense, but extract any static shell content outside the boundary.".to_string();
|
||||
}
|
||||
BlockerKind::Framework => {
|
||||
return "The top blocker still looks framework-heavy. Find the nearest user-owned caller before changing boundary placement.".to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(reason) = unknown_suspenders {
|
||||
return format!(
|
||||
"React could not identify the suspender ({}). Investigate the nearest user-owned owner or awaiter frame.",
|
||||
reason
|
||||
);
|
||||
}
|
||||
"No primary blocker was identified. Inspect the boundary source and owner chain directly."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn pick_preferred_frame(stack: Option<&[StackFrame]>) -> Option<StackFrame> {
|
||||
let s = stack?;
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
s.iter()
|
||||
.find(|f| !is_frameworkish_path(&f.1))
|
||||
.cloned()
|
||||
.or_else(|| s.first().cloned())
|
||||
}
|
||||
|
||||
fn is_frameworkish_path(file: &str) -> bool {
|
||||
file.contains("/node_modules/")
|
||||
}
|
||||
|
||||
fn build_blocker_key(name: &str, kind: BlockerKind, source_frame: Option<&StackFrame>) -> String {
|
||||
match source_frame {
|
||||
None => format!("{}:{}:unknown", kind.label(), name),
|
||||
Some(f) => format!("{}:{}:{}:{}", kind.label(), name, f.1, f.2),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_root_causes(holes: &[BoundaryInsight]) -> Vec<RootCauseGroup> {
|
||||
let mut groups: HashMap<String, RootCauseGroup> = HashMap::new();
|
||||
for hole in holes {
|
||||
let Some(blocker) = &hole.primary_blocker else {
|
||||
continue;
|
||||
};
|
||||
let display_name = hole
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("boundary-{}", hole.id));
|
||||
groups
|
||||
.entry(blocker.key.clone())
|
||||
.and_modify(|existing| {
|
||||
existing.boundary_names.push(display_name.clone());
|
||||
existing.count += 1;
|
||||
if blocker.actionability > existing.actionability {
|
||||
existing.actionability = blocker.actionability;
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| RootCauseGroup {
|
||||
kind: blocker.kind,
|
||||
name: blocker.name.clone(),
|
||||
source_frame: blocker.source_frame.clone(),
|
||||
boundary_names: vec![display_name],
|
||||
count: 1,
|
||||
actionability: blocker.actionability,
|
||||
suggestion: blocker.suggestion.clone(),
|
||||
});
|
||||
}
|
||||
let mut out: Vec<RootCauseGroup> = groups.into_values().collect();
|
||||
out.sort_by(|a, b| {
|
||||
let score_a = (a.count as i32) * a.actionability;
|
||||
let score_b = (b.count as i32) * b.actionability;
|
||||
score_b.cmp(&score_a).then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_files_to_read(holes: &[BoundaryInsight], root_causes: &[RootCauseGroup]) -> Vec<String> {
|
||||
let mut counts: HashMap<String, i32> = HashMap::new();
|
||||
let mut add = |f: Option<&str>| {
|
||||
if let Some(path) = f {
|
||||
if !path.is_empty() {
|
||||
*counts.entry(path.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
for hole in holes {
|
||||
add(hole.source.as_ref().map(|s| s.0.as_str()));
|
||||
if let Some(pb) = &hole.primary_blocker {
|
||||
add(pb.source_frame.as_ref().map(|f| f.1.as_str()));
|
||||
}
|
||||
for owner in &hole.rendered_by {
|
||||
add(owner.source.as_ref().map(|s| s.0.as_str()));
|
||||
}
|
||||
}
|
||||
for cause in root_causes {
|
||||
add(cause.source_frame.as_ref().map(|f| f.1.as_str()));
|
||||
}
|
||||
|
||||
let mut entries: Vec<(String, i32)> = counts.into_iter().collect();
|
||||
entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
entries.into_iter().take(12).map(|(f, _)| f).collect()
|
||||
}
|
||||
|
||||
fn escape_cell(s: &str) -> String {
|
||||
s.replace('|', "\\|")
|
||||
}
|
||||
|
||||
fn format_report(report: &AnalysisReport, only_dynamic: bool) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("# Suspense Boundary Analysis".to_string());
|
||||
if only_dynamic {
|
||||
lines.push(format!(
|
||||
"# {} dynamic holes (static boundaries hidden; pass without --only-dynamic to see them)",
|
||||
report.dynamic_hole_count
|
||||
));
|
||||
} else {
|
||||
lines.push(format!(
|
||||
"# {} boundaries: {} dynamic holes, {} static",
|
||||
report.total_boundaries, report.dynamic_hole_count, report.static_count
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
|
||||
if !report.holes.is_empty() {
|
||||
lines.push("## Summary".to_string());
|
||||
if let Some(top) = report.holes.first() {
|
||||
if let Some(blocker) = &top.primary_blocker {
|
||||
lines.push(format!(
|
||||
"- Top actionable hole: {} - {} ({})",
|
||||
top.name.clone().unwrap_or_else(|| "(unnamed)".into()),
|
||||
blocker.name,
|
||||
blocker.kind.label()
|
||||
));
|
||||
lines.push(format!("- Suggested next step: {}", top.recommendation));
|
||||
}
|
||||
}
|
||||
if let Some(root) = report.root_causes.first() {
|
||||
lines.push(format!(
|
||||
"- Most common root cause: {} ({}) affecting {} boundar{}",
|
||||
root.name,
|
||||
root.kind.label(),
|
||||
root.count,
|
||||
if root.count == 1 { "y" } else { "ies" }
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
|
||||
lines.push("## Quick Reference".to_string());
|
||||
lines.push(
|
||||
"| Boundary | Type | Primary blocker | Source | Suggested next step |".to_string(),
|
||||
);
|
||||
lines.push("| --- | --- | --- | --- | --- |".to_string());
|
||||
for hole in &report.holes {
|
||||
let blocker = &hole.primary_blocker;
|
||||
let source = match blocker.as_ref().and_then(|b| b.source_frame.as_ref()) {
|
||||
Some(f) => format!("{}:{}", f.1, f.2),
|
||||
None => match &hole.source {
|
||||
Some((f, l, _)) => format!("{}:{}", f, l),
|
||||
None => "unknown".to_string(),
|
||||
},
|
||||
};
|
||||
let blocker_text = match blocker {
|
||||
Some(b) => format!("{} ({})", b.name, b.kind.label()),
|
||||
None => "unknown".to_string(),
|
||||
};
|
||||
lines.push(format!(
|
||||
"| {} | {} | {} | {} | {} |",
|
||||
escape_cell(hole.name.as_deref().unwrap_or("(unnamed)")),
|
||||
hole.boundary_kind.label(),
|
||||
escape_cell(&blocker_text),
|
||||
escape_cell(&source),
|
||||
escape_cell(&hole.recommendation),
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
|
||||
if !report.files_to_read.is_empty() {
|
||||
lines.push("## Files to Read".to_string());
|
||||
for file in &report.files_to_read {
|
||||
lines.push(format!("- {}", file));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
if !report.root_causes.is_empty() {
|
||||
lines.push("## Root Causes".to_string());
|
||||
for cause in &report.root_causes {
|
||||
let source = match &cause.source_frame {
|
||||
Some(f) => format!("{}:{}", f.1, f.2),
|
||||
None => "unknown".to_string(),
|
||||
};
|
||||
lines.push(format!(
|
||||
"- {} ({}) at {} - affects {} boundar{}",
|
||||
cause.name,
|
||||
cause.kind.label(),
|
||||
source,
|
||||
cause.count,
|
||||
if cause.count == 1 { "y" } else { "ies" }
|
||||
));
|
||||
lines.push(format!(" next step: {}", cause.suggestion));
|
||||
lines.push(format!(" boundaries: {}", cause.boundary_names.join(", ")));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
}
|
||||
|
||||
if !only_dynamic && !report.statics.is_empty() {
|
||||
lines.push("## Static (not suspended)".to_string());
|
||||
for b in &report.statics {
|
||||
let name = b.name.clone().unwrap_or_else(|| "(unnamed)".into());
|
||||
let src = match &b.source {
|
||||
Some(s) => format!(" at {}:{}:{}", s.0, s.1, s.2),
|
||||
None => String::new(),
|
||||
};
|
||||
lines.push(format!(" {}{}", name, src));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! React component tree snapshot and formatter.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TreeNode {
|
||||
pub id: i64,
|
||||
#[serde(rename = "type")]
|
||||
pub node_type: i64,
|
||||
pub name: Option<String>,
|
||||
pub key: Option<String>,
|
||||
pub parent: i64,
|
||||
}
|
||||
|
||||
const HEADER: &str = "# React component tree\n# Columns: depth id parent name [key=...]\n# Use `react inspect <id>` for props/hooks/state. IDs valid until next navigation.";
|
||||
|
||||
pub fn format_tree(nodes: &[TreeNode]) -> String {
|
||||
use std::collections::HashMap;
|
||||
let mut children: HashMap<i64, Vec<&TreeNode>> = HashMap::new();
|
||||
for n in nodes {
|
||||
children.entry(n.parent).or_default().push(n);
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = vec![HEADER.to_string()];
|
||||
if let Some(roots) = children.get(&0) {
|
||||
for root in roots {
|
||||
walk(root, 0, &children, &mut lines);
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn walk<'a>(
|
||||
node: &'a TreeNode,
|
||||
depth: usize,
|
||||
children: &std::collections::HashMap<i64, Vec<&'a TreeNode>>,
|
||||
lines: &mut Vec<String>,
|
||||
) {
|
||||
let name = node
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| type_name(node.node_type));
|
||||
let key = match &node.key {
|
||||
Some(k) => format!(" key={:?}", k),
|
||||
None => String::new(),
|
||||
};
|
||||
let parent = if node.parent == 0 {
|
||||
"-".to_string()
|
||||
} else {
|
||||
node.parent.to_string()
|
||||
};
|
||||
lines.push(format!("{} {} {} {}{}", depth, node.id, parent, name, key));
|
||||
if let Some(cs) = children.get(&node.id) {
|
||||
for c in cs {
|
||||
walk(c, depth + 1, children, lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn type_name(t: i64) -> String {
|
||||
match t {
|
||||
11 => "Root".to_string(),
|
||||
12 => "Suspense".to_string(),
|
||||
13 => "SuspenseList".to_string(),
|
||||
_ => format!("({})", t),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Core Web Vitals + React hydration timing report.
|
||||
//!
|
||||
//! Universal web-standard metrics (LCP/CLS/TTFB/FCP/INP) via PerformanceObserver
|
||||
//! and Navigation Timing. When the React profiling build is detected (via
|
||||
//! `console.timeStamp` entries), also reports hydration phases and per-component
|
||||
//! hydration timing.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct VitalsData {
|
||||
pub url: String,
|
||||
pub ttfb: Option<f64>,
|
||||
pub lcp: Option<Lcp>,
|
||||
pub cls: Cls,
|
||||
pub fcp: Option<f64>,
|
||||
pub inp: Option<f64>,
|
||||
pub hydration: Option<HydrationRange>,
|
||||
pub phases: Vec<Phase>,
|
||||
#[serde(rename = "hydratedComponents")]
|
||||
pub hydrated_components: Vec<HydratedComponent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Lcp {
|
||||
#[serde(rename = "startTime")]
|
||||
pub start_time: f64,
|
||||
pub size: Option<i64>,
|
||||
pub element: Option<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Cls {
|
||||
pub score: f64,
|
||||
pub entries: Vec<ClsEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ClsEntry {
|
||||
pub value: f64,
|
||||
#[serde(rename = "startTime")]
|
||||
pub start_time: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct HydrationRange {
|
||||
#[serde(rename = "startTime")]
|
||||
pub start_time: f64,
|
||||
#[serde(rename = "endTime")]
|
||||
pub end_time: f64,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Phase {
|
||||
pub label: String,
|
||||
#[serde(rename = "startTime")]
|
||||
pub start_time: f64,
|
||||
#[serde(rename = "endTime")]
|
||||
pub end_time: f64,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct HydratedComponent {
|
||||
pub name: String,
|
||||
#[serde(rename = "startTime")]
|
||||
pub start_time: f64,
|
||||
#[serde(rename = "endTime")]
|
||||
pub end_time: f64,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
pub fn format_vitals_report(d: &VitalsData) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push(format!("# Page Load Profile - {}", d.url));
|
||||
lines.push(String::new());
|
||||
lines.push("## Core Web Vitals".to_string());
|
||||
|
||||
let ttfb_str = match d.ttfb {
|
||||
Some(t) => format!("{}ms", t),
|
||||
None => "-".to_string(),
|
||||
};
|
||||
lines.push(format!(" TTFB {:>10}", ttfb_str));
|
||||
|
||||
match &d.lcp {
|
||||
Some(lcp) => {
|
||||
let label = match (&lcp.element, &lcp.url) {
|
||||
(Some(el), Some(url)) => {
|
||||
let url_trunc: String = url.chars().take(60).collect();
|
||||
format!(" ({}: {})", el, url_trunc)
|
||||
}
|
||||
(Some(el), None) => format!(" ({})", el),
|
||||
_ => String::new(),
|
||||
};
|
||||
lines.push(format!(
|
||||
" LCP {:>10}{}",
|
||||
format!("{}ms", lcp.start_time),
|
||||
label
|
||||
));
|
||||
}
|
||||
None => lines.push(" LCP -".to_string()),
|
||||
}
|
||||
|
||||
lines.push(format!(" CLS {:>10}", d.cls.score));
|
||||
|
||||
if let Some(fcp) = d.fcp {
|
||||
lines.push(format!(" FCP {:>10}", format!("{}ms", fcp)));
|
||||
}
|
||||
if let Some(inp) = d.inp {
|
||||
lines.push(format!(" INP {:>10}", format!("{}ms", inp)));
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
match &d.hydration {
|
||||
Some(h) => lines.push(format!(
|
||||
"## React Hydration - {}ms ({}ms -> {}ms)",
|
||||
h.duration, h.start_time, h.end_time
|
||||
)),
|
||||
None => {
|
||||
lines.push("## React Hydration - no data (requires React profiling build)".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
if !d.phases.is_empty() {
|
||||
for p in &d.phases {
|
||||
lines.push(format!(
|
||||
" {:<28} {:>10} ({} -> {})",
|
||||
p.label,
|
||||
format!("{}ms", p.duration),
|
||||
p.start_time,
|
||||
p.end_time
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
if !d.hydrated_components.is_empty() {
|
||||
lines.push(format!(
|
||||
"## Hydrated components ({} total, sorted by duration)",
|
||||
d.hydrated_components.len()
|
||||
));
|
||||
for c in d.hydrated_components.iter().take(30) {
|
||||
lines.push(format!(
|
||||
" {:<40} {:>10}",
|
||||
c.name,
|
||||
format!("{}ms", c.duration)
|
||||
));
|
||||
}
|
||||
if d.hydrated_components.len() > 30 {
|
||||
lines.push(format!(
|
||||
" ... and {} more",
|
||||
d.hydrated_components.len() - 30
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
Reference in New Issue
Block a user