From 76a6b896d40e8953c0f059e0b5c56f287a8f3514 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Mon, 9 Feb 2026 19:18:00 +0100 Subject: [PATCH 1/4] feat: profiler Profiling sessions with commit accumulation, per-component render reports (count, duration, causes), slowest/most-rerendered rankings, and commit timeline. Includes data collection from React DevTools backend. --- .../src/__tests__/formatters.test.ts | 73 ++++- .../src/__tests__/profiler.test.ts | 264 +++++++++++++++ packages/agent-react-devtools/src/cli.ts | 96 +++++- packages/agent-react-devtools/src/daemon.ts | 60 +++- .../src/devtools-bridge.ts | 78 ++++- .../agent-react-devtools/src/formatters.ts | 80 +++++ packages/agent-react-devtools/src/profiler.ts | 310 ++++++++++++++++++ packages/agent-react-devtools/src/types.ts | 51 ++- 8 files changed, 1005 insertions(+), 7 deletions(-) create mode 100644 packages/agent-react-devtools/src/__tests__/profiler.test.ts create mode 100644 packages/agent-react-devtools/src/profiler.ts diff --git a/packages/agent-react-devtools/src/__tests__/formatters.test.ts b/packages/agent-react-devtools/src/__tests__/formatters.test.ts index 8a0e007..bb95a6d 100644 --- a/packages/agent-react-devtools/src/__tests__/formatters.test.ts +++ b/packages/agent-react-devtools/src/__tests__/formatters.test.ts @@ -5,9 +5,14 @@ import { formatSearchResults, formatCount, formatStatus, + formatProfileReport, + formatSlowest, + formatRerenders, + formatTimeline, } from '../formatters.js'; import type { TreeNode } from '../component-tree.js'; -import type { InspectedElement, StatusInfo } from '../types.js'; +import type { InspectedElement, StatusInfo, ComponentRenderReport } from '../types.js'; +import type { TimelineEntry } from '../profiler.js'; describe('formatTree', () => { it('should format empty tree', () => { @@ -116,3 +121,69 @@ describe('formatStatus', () => { expect(result).toContain('47 components'); }); }); + +describe('formatProfileReport', () => { + it('should format a render report', () => { + const report: ComponentRenderReport = { + id: 5, + displayName: 'UserProfile', + renderCount: 12, + totalDuration: 540, + avgDuration: 45, + maxDuration: 120, + causes: ['props-changed', 'state-changed'], + }; + + const result = formatProfileReport(report, '@c5'); + expect(result).toContain('@c5 "UserProfile"'); + expect(result).toContain('renders:12'); + expect(result).toContain('avg:45.0ms'); + expect(result).toContain('max:120.0ms'); + expect(result).toContain('props-changed'); + }); +}); + +describe('formatSlowest', () => { + it('should format empty data', () => { + expect(formatSlowest([])).toContain('No profiling data'); + }); + + it('should format slowest components', () => { + const reports: ComponentRenderReport[] = [ + { id: 1, displayName: 'SlowComp', renderCount: 5, totalDuration: 250, avgDuration: 50, maxDuration: 100, causes: ['props-changed'] }, + { id: 2, displayName: 'FastComp', renderCount: 10, totalDuration: 100, avgDuration: 10, maxDuration: 20, causes: ['state-changed'] }, + ]; + + const result = formatSlowest(reports); + expect(result).toContain('Slowest'); + expect(result).toContain('SlowComp'); + expect(result).toContain('FastComp'); + }); +}); + +describe('formatRerenders', () => { + it('should format rerender data', () => { + const reports: ComponentRenderReport[] = [ + { id: 1, displayName: 'Chatty', renderCount: 50, totalDuration: 100, avgDuration: 2, maxDuration: 5, causes: ['parent-rendered'] }, + ]; + + const result = formatRerenders(reports); + expect(result).toContain('50 renders'); + expect(result).toContain('parent-rendered'); + }); +}); + +describe('formatTimeline', () => { + it('should format timeline entries', () => { + const entries: TimelineEntry[] = [ + { index: 0, timestamp: 1000, duration: 12.5, componentCount: 5 }, + { index: 1, timestamp: 2000, duration: 8.3, componentCount: 3 }, + ]; + + const result = formatTimeline(entries); + expect(result).toContain('#0'); + expect(result).toContain('12.5ms'); + expect(result).toContain('#1'); + expect(result).toContain('8.3ms'); + }); +}); diff --git a/packages/agent-react-devtools/src/__tests__/profiler.test.ts b/packages/agent-react-devtools/src/__tests__/profiler.test.ts new file mode 100644 index 0000000..d51b837 --- /dev/null +++ b/packages/agent-react-devtools/src/__tests__/profiler.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Profiler } from '../profiler.js'; +import { ComponentTree } from '../component-tree.js'; + +/** + * Operations encoding reference (protocol v2): + * [rendererID, rootFiberID, stringTableSize, ...stringTable, ...ops] + * + * String table: for each string, [length, ...charCodes]. String ID 0 = null. + * + * TREE_OPERATION_ADD (1): + * 1, id, elementType, parentId, ownerID, displayNameStringID, keyStringID + */ + +/** Build a string table and return [tableData, stringIdMap] */ +function buildStringTable(strings: string[]): [number[], Map] { + const idMap = new Map(); + const data: number[] = []; + for (const s of strings) { + const id = idMap.size + 1; // 0 is reserved for null + if (!idMap.has(s)) { + idMap.set(s, id); + data.push(s.length, ...Array.from(s).map((c) => c.charCodeAt(0))); + } + } + return [data, idMap]; +} + +/** Build a complete operations array with string table */ +function buildOps( + rendererID: number, + rootID: number, + strings: string[], + opsFn: (strId: (s: string) => number) => number[], +): number[] { + const [tableData, idMap] = buildStringTable(strings); + const strId = (s: string) => idMap.get(s) || 0; + const ops = opsFn(strId); + return [rendererID, rootID, tableData.length, ...tableData, ...ops]; +} + +function addOp( + id: number, + elementType: number, + parentId: number, + displayNameStrId: number, + keyStrId: number = 0, +): number[] { + return [1, id, elementType, parentId, 0, displayNameStrId, keyStrId]; +} + +describe('Profiler', () => { + let profiler: Profiler; + let tree: ComponentTree; + + beforeEach(() => { + profiler = new Profiler(); + tree = new ComponentTree(); + + // Set up a basic tree (FUNCTION=5 in protocol v2) + const ops = buildOps(1, 100, ['App', 'Header', 'Content'], (s) => [ + ...addOp(1, 5, 0, s('App')), + ...addOp(2, 5, 1, s('Header')), + ...addOp(3, 5, 1, s('Content')), + ]); + tree.applyOperations(ops); + }); + + it('should track active state', () => { + expect(profiler.isActive()).toBe(false); + profiler.start('test'); + expect(profiler.isActive()).toBe(true); + profiler.stop(); + expect(profiler.isActive()).toBe(false); + }); + + it('should return null when stopping without starting', () => { + expect(profiler.stop()).toBeNull(); + }); + + it('should process flat profiling data', () => { + profiler.start('test'); + + profiler.processProfilingData({ + commitData: [ + { + timestamp: 1000, + duration: 15, + fiberActualDurations: [1, 10, 2, 3, 3, 2], + fiberSelfDurations: [1, 5, 2, 3, 3, 2], + }, + { + timestamp: 2000, + duration: 8, + fiberActualDurations: [1, 5, 3, 3], + fiberSelfDurations: [1, 2, 3, 3], + }, + ], + }); + + const summary = profiler.stop(); + expect(summary).not.toBeNull(); + expect(summary!.commitCount).toBe(2); + expect(summary!.componentRenderCounts.length).toBeGreaterThan(0); + }); + + it('should generate render reports', () => { + profiler.start('test'); + + profiler.processProfilingData({ + commitData: [ + { + timestamp: 1000, + duration: 15, + fiberActualDurations: [1, 10, 2, 3], + fiberSelfDurations: [1, 5, 2, 3], + changeDescriptions: [ + [1, { props: ['theme'], isFirstMount: false }], + [2, { isFirstMount: true }], + ], + }, + { + timestamp: 2000, + duration: 8, + fiberActualDurations: [1, 20], + fiberSelfDurations: [1, 15], + changeDescriptions: [ + [1, { didHooksChange: true, isFirstMount: false }], + ], + }, + ], + }); + + const report = profiler.getReport(1, tree); + expect(report).not.toBeNull(); + expect(report!.displayName).toBe('App'); + expect(report!.renderCount).toBe(2); + expect(report!.totalDuration).toBe(30); + expect(report!.avgDuration).toBe(15); + expect(report!.maxDuration).toBe(20); + expect(report!.causes).toContain('props-changed'); + expect(report!.causes).toContain('hooks-changed'); + }); + + it('should find slowest components', () => { + profiler.start('test'); + + profiler.processProfilingData({ + commitData: [ + { + timestamp: 1000, + duration: 15, + fiberActualDurations: [1, 50, 2, 5, 3, 30], + fiberSelfDurations: [1, 15, 2, 5, 3, 30], + }, + ], + }); + + const slowest = profiler.getSlowest(tree, 2); + expect(slowest).toHaveLength(2); + expect(slowest[0].displayName).toBe('App'); + expect(slowest[1].displayName).toBe('Content'); + }); + + it('should find most rerenders', () => { + profiler.start('test'); + + profiler.processProfilingData({ + commitData: [ + { + timestamp: 1000, + duration: 5, + fiberActualDurations: [1, 1, 2, 1, 3, 1], + fiberSelfDurations: [1, 1, 2, 1, 3, 1], + }, + { + timestamp: 2000, + duration: 5, + fiberActualDurations: [2, 1, 3, 1], + fiberSelfDurations: [2, 1, 3, 1], + }, + { + timestamp: 3000, + duration: 5, + fiberActualDurations: [3, 1], + fiberSelfDurations: [3, 1], + }, + ], + }); + + const rerenders = profiler.getMostRerenders(tree, 3); + expect(rerenders[0].displayName).toBe('Content'); + expect(rerenders[0].renderCount).toBe(3); + }); + + it('should process dataForRoots nested format', () => { + profiler.start('test'); + + profiler.processProfilingData({ + dataForRoots: [ + { + commitData: [ + { + timestamp: 1000, + duration: 12, + fiberActualDurations: [1, 8, 2, 4], + fiberSelfDurations: [1, 4, 2, 4], + changeDescriptions: [ + [1, { props: ['count'], isFirstMount: false }], + [2, { state: ['value'], isFirstMount: false }], + ], + }, + ], + }, + ], + }); + + const summary = profiler.stop(); + expect(summary).not.toBeNull(); + expect(summary!.commitCount).toBe(1); + + // Verify the state change was captured correctly + profiler.start('test2'); + profiler.processProfilingData({ + dataForRoots: [ + { + commitData: [ + { + timestamp: 2000, + duration: 5, + fiberActualDurations: [2, 3], + fiberSelfDurations: [2, 3], + changeDescriptions: [ + [2, { state: ['value', 'count'], isFirstMount: false }], + ], + }, + ], + }, + ], + }); + + const report = profiler.getReport(2, tree); + expect(report).not.toBeNull(); + expect(report!.causes).toContain('state-changed'); + }); + + it('should generate timeline', () => { + profiler.start('test'); + + profiler.processProfilingData({ + commitData: [ + { timestamp: 1000, duration: 10, fiberActualDurations: [1, 5], fiberSelfDurations: [] }, + { timestamp: 2000, duration: 20, fiberActualDurations: [1, 10, 2, 5], fiberSelfDurations: [] }, + ], + }); + + const timeline = profiler.getTimeline(); + expect(timeline).toHaveLength(2); + expect(timeline[0].duration).toBe(10); + expect(timeline[0].componentCount).toBe(1); + expect(timeline[1].duration).toBe(20); + expect(timeline[1].componentCount).toBe(2); + }); +}); diff --git a/packages/agent-react-devtools/src/cli.ts b/packages/agent-react-devtools/src/cli.ts index 59741e1..2a19aaf 100644 --- a/packages/agent-react-devtools/src/cli.ts +++ b/packages/agent-react-devtools/src/cli.ts @@ -11,6 +11,11 @@ import { formatSearchResults, formatCount, formatStatus, + formatProfileSummary, + formatProfileReport, + formatSlowest, + formatRerenders, + formatTimeline, } from './formatters.js'; import type { IpcCommand } from './types.js'; @@ -26,7 +31,15 @@ Components: get tree [--depth N] Component hierarchy get component <@c1 | id> Props, state, hooks find [--exact] Search by display name - count Component count by type`; + count Component count by type + +Profiling: + profile start [name] Start profiling session + profile stop Stop profiling, collect data + profile report <@c1 | id> Render report for component + profile slow [--limit N] Slowest components (by avg) + profile rerenders [--limit N] Most re-rendered components + profile timeline [--limit N] Commit timeline`; } function parseArgs(argv: string[]): { @@ -183,6 +196,87 @@ async function main(): Promise { return; } + // ── Profiling ── + if (cmd0 === 'profile' && cmd1 === 'start') { + const name = command[2]; + const resp = await sendCommand({ type: 'profile-start', name }); + if (resp.ok) { + console.log(resp.data); + } else { + console.error(resp.error); + process.exit(1); + } + return; + } + + if (cmd0 === 'profile' && cmd1 === 'stop') { + const resp = await sendCommand({ type: 'profile-stop' }); + if (resp.ok) { + console.log(formatProfileSummary(resp.data as any)); + } else { + console.error(resp.error); + process.exit(1); + } + return; + } + + if (cmd0 === 'profile' && cmd1 === 'report') { + const raw = command[2]; + if (!raw) { + console.error('Usage: devtools profile report <@c1 | id>'); + process.exit(1); + } + const componentId: number | string = raw.startsWith('@') ? raw : parseInt(raw, 10); + if (typeof componentId === 'number' && isNaN(componentId)) { + console.error('Usage: devtools profile report <@c1 | id>'); + process.exit(1); + } + const resp = await sendCommand({ type: 'profile-report', componentId }); + if (resp.ok) { + console.log(formatProfileReport(resp.data as any, resp.label)); + } else { + console.error(resp.error); + process.exit(1); + } + return; + } + + if (cmd0 === 'profile' && cmd1 === 'slow') { + const limit = flags['limit'] ? parseInt(flags['limit'] as string, 10) : undefined; + const resp = await sendCommand({ type: 'profile-slow', limit }); + if (resp.ok) { + console.log(formatSlowest(resp.data as any)); + } else { + console.error(resp.error); + process.exit(1); + } + return; + } + + if (cmd0 === 'profile' && cmd1 === 'rerenders') { + const limit = flags['limit'] ? parseInt(flags['limit'] as string, 10) : undefined; + const resp = await sendCommand({ type: 'profile-rerenders', limit }); + if (resp.ok) { + console.log(formatRerenders(resp.data as any)); + } else { + console.error(resp.error); + process.exit(1); + } + return; + } + + if (cmd0 === 'profile' && cmd1 === 'timeline') { + const limit = flags['limit'] ? parseInt(flags['limit'] as string, 10) : undefined; + const resp = await sendCommand({ type: 'profile-timeline', limit }); + if (resp.ok) { + console.log(formatTimeline(resp.data as any)); + } else { + console.error(resp.error); + process.exit(1); + } + return; + } + console.error(`Unknown command: ${command.join(' ')}`); console.log(usage()); process.exit(1); diff --git a/packages/agent-react-devtools/src/daemon.ts b/packages/agent-react-devtools/src/daemon.ts index 7cdf972..6501d1b 100644 --- a/packages/agent-react-devtools/src/daemon.ts +++ b/packages/agent-react-devtools/src/daemon.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { DevToolsBridge } from './devtools-bridge.js'; import { ComponentTree } from './component-tree.js'; +import { Profiler } from './profiler.js'; import type { IpcCommand, IpcResponse, DaemonInfo, StatusInfo } from './types.js'; const DEFAULT_STATE_DIR = path.join( @@ -24,13 +25,15 @@ class Daemon { private ipcServer: net.Server | null = null; private bridge: DevToolsBridge; private tree: ComponentTree; + private profiler: Profiler; private port: number; private startedAt = Date.now(); constructor(port: number) { this.port = port; this.tree = new ComponentTree(); - this.bridge = new DevToolsBridge(port, this.tree); + this.profiler = new Profiler(); + this.bridge = new DevToolsBridge(port, this.tree, this.profiler); } async start(): Promise { @@ -125,7 +128,7 @@ class Daemon { port: this.port, connectedApps: this.bridge.getConnectedAppCount(), componentCount: this.tree.getComponentCount(), - profilingActive: false, + profilingActive: this.profiler.isActive(), uptime: Date.now() - this.startedAt, } satisfies StatusInfo, }; @@ -162,6 +165,59 @@ class Daemon { data: this.tree.getCountByType(), }; + case 'profile-start': + this.profiler.start(cmd.name); + // Snapshot existing component names so they survive unmounts + for (const id of this.tree.getAllNodeIds()) { + const node = this.tree.getNode(id); + if (node) this.profiler.trackComponent(id, node.displayName); + } + this.bridge.startProfiling(); + return { ok: true, data: 'Profiling started' }; + + case 'profile-stop': { + await this.bridge.stopProfilingAndCollect(); + const session = this.profiler.stop(this.tree); + if (!session) { + return { ok: false, error: 'No active profiling session' }; + } + return { ok: true, data: session }; + } + + case 'profile-report': { + const resolvedCompId = this.tree.resolveId(cmd.componentId); + if (resolvedCompId === undefined) { + return { ok: false, error: `Component ${cmd.componentId} not found` }; + } + const report = this.profiler.getReport(resolvedCompId, this.tree); + if (!report) { + return { + ok: false, + error: `No profiling data for component ${cmd.componentId}`, + }; + } + const compLabel = typeof cmd.componentId === 'string' ? cmd.componentId : undefined; + return { ok: true, data: report, label: compLabel }; + } + + case 'profile-slow': + return { + ok: true, + data: this.profiler.getSlowest(this.tree, cmd.limit), + }; + + case 'profile-rerenders': + return { + ok: true, + data: this.profiler.getMostRerenders(this.tree, cmd.limit), + }; + + case 'profile-timeline': + return { + ok: true, + data: this.profiler.getTimeline(cmd.limit), + }; + default: return { ok: false, error: `Unknown command: ${(cmd as any).type}` }; } diff --git a/packages/agent-react-devtools/src/devtools-bridge.ts b/packages/agent-react-devtools/src/devtools-bridge.ts index 9e46dfc..e5383e7 100644 --- a/packages/agent-react-devtools/src/devtools-bridge.ts +++ b/packages/agent-react-devtools/src/devtools-bridge.ts @@ -1,5 +1,6 @@ import { WebSocketServer, WebSocket } from 'ws'; import type { ComponentTree } from './component-tree.js'; +import type { Profiler } from './profiler.js'; import type { InspectedElement } from './types.js'; /** @@ -23,19 +24,27 @@ interface PendingInspection { timer: ReturnType; } +interface PendingProfilingCollect { + resolve: () => void; + timer: ReturnType; +} + export class DevToolsBridge { private wss: WebSocketServer | null = null; private connections = new Set(); private port: number; private tree: ComponentTree; + private profiler: Profiler; private pendingInspections = new Map(); + private pendingProfilingCollect: PendingProfilingCollect | null = null; private rendererIds = new Set(); /** Track which root fiber IDs belong to each WebSocket connection */ private connectionRoots = new Map>(); - constructor(port: number, tree: ComponentTree) { + constructor(port: number, tree: ComponentTree, profiler: Profiler) { this.port = port; this.tree = tree; + this.profiler = profiler; } async start(): Promise { @@ -115,6 +124,46 @@ export class DevToolsBridge { }); } + startProfiling(): void { + this.sendToAll({ + event: 'startProfiling', + payload: { recordChangeDescriptions: true }, + }); + } + + /** + * Stop profiling and request data from each renderer. + * Returns a promise that resolves when profilingData arrives (or 5s timeout). + */ + stopProfilingAndCollect(): Promise { + this.sendToAll({ + event: 'stopProfiling', + payload: undefined, + }); + + // If no renderers known, resolve immediately + if (this.rendererIds.size === 0) { + return Promise.resolve(); + } + + // Request profiling data from each renderer + for (const rendererID of this.rendererIds) { + this.sendToAll({ + event: 'getProfilingData', + payload: { rendererID }, + }); + } + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pendingProfilingCollect = null; + resolve(); + }, 5000); + + this.pendingProfilingCollect = { resolve, timer }; + }); + } + private handleMessage(ws: WebSocket, msg: DevToolsMessage): void { switch (msg.event) { case 'backendInitialized': @@ -140,6 +189,10 @@ export class DevToolsBridge { this.handleInspectedElement(msg.payload); break; + case 'profilingData': + this.handleProfilingData(msg.payload); + break; + case 'renderer': { const payload = msg.payload as { id: number }; this.rendererIds.add(payload.id); @@ -185,7 +238,14 @@ export class DevToolsBridge { } roots.add(rootFiberId); } - this.tree.applyOperations(operations); + const added = this.tree.applyOperations(operations); + + // Cache display names during profiling so unmounted components are still identifiable + if (this.profiler.isActive()) { + for (const node of added) { + this.profiler.trackComponent(node.id, node.displayName); + } + } } private cleanupConnection(ws: WebSocket): void { @@ -251,6 +311,20 @@ export class DevToolsBridge { pending.resolve(inspected); } + private handleProfilingData(payload: unknown): void { + // React DevTools sends profiling data as a complex nested structure. + // We forward it to the profiler for processing. + this.profiler.processProfilingData(payload); + + // Resolve the pending collect promise now that data has arrived + if (this.pendingProfilingCollect) { + clearTimeout(this.pendingProfilingCollect.timer); + const pending = this.pendingProfilingCollect; + this.pendingProfilingCollect = null; + pending.resolve(); + } + } + private sendTo(ws: WebSocket, msg: DevToolsMessage): void { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); diff --git a/packages/agent-react-devtools/src/formatters.ts b/packages/agent-react-devtools/src/formatters.ts index d23eb67..9d965eb 100644 --- a/packages/agent-react-devtools/src/formatters.ts +++ b/packages/agent-react-devtools/src/formatters.ts @@ -1,8 +1,10 @@ import type { StatusInfo, InspectedElement, + ComponentRenderReport, } from './types.js'; import type { TreeNode } from './component-tree.js'; +import type { ProfileSummary, TimelineEntry } from './profiler.js'; // ── Abbreviations for component types ── const TYPE_ABBREV: Record = { @@ -148,8 +150,86 @@ export function formatStatus(status: StatusInfo): string { return lines.join('\n'); } +export function formatProfileSummary(summary: ProfileSummary): string { + const lines: string[] = []; + const durSec = (summary.duration / 1000).toFixed(1); + lines.push( + `Profile "${summary.name}" (${durSec}s, ${summary.commitCount} commits)`, + ); + + if (summary.componentRenderCounts.length > 0) { + lines.push(''); + lines.push('Top renders:'); + for (const c of summary.componentRenderCounts.slice(0, 10)) { + const name = c.displayName || `#${c.id}`; + lines.push(` ${name} ${c.count} renders`); + } + } + + return lines.join('\n'); +} + +export function formatProfileReport(report: ComponentRenderReport, label?: string): string { + const lines: string[] = []; + const ref = label || `#${report.id}`; + lines.push(`${ref} "${report.displayName}"`); + lines.push( + `renders:${report.renderCount} avg:${report.avgDuration.toFixed(1)}ms max:${report.maxDuration.toFixed(1)}ms total:${report.totalDuration.toFixed(1)}ms`, + ); + if (report.causes.length > 0) { + lines.push(`causes: ${report.causes.join(', ')}`); + } + return lines.join('\n'); +} + +export function formatSlowest(reports: ComponentRenderReport[]): string { + if (reports.length === 0) return 'No profiling data'; + + const lines: string[] = ['Slowest (by avg render time):']; + for (const r of reports) { + const cause = r.causes[0] || '?'; + lines.push( + ` ${pad(r.displayName, 20)} avg:${r.avgDuration.toFixed(1)}ms max:${r.maxDuration.toFixed(1)}ms renders:${r.renderCount} cause:${cause}`, + ); + } + return lines.join('\n'); +} + +export function formatRerenders(reports: ComponentRenderReport[]): string { + if (reports.length === 0) return 'No profiling data'; + + const lines: string[] = ['Most re-renders:']; + for (const r of reports) { + const cause = r.causes[0] || '?'; + lines.push( + ` ${pad(r.displayName, 20)} ${r.renderCount} renders — ${cause}`, + ); + } + return lines.join('\n'); +} + +export function formatTimeline(entries: TimelineEntry[]): string { + if (entries.length === 0) return 'No profiling data'; + + const lines: string[] = ['Commit timeline:']; + for (const e of entries) { + lines.push( + ` #${e.index} ${e.duration.toFixed(1)}ms ${e.componentCount} components`, + ); + } + return lines.join('\n'); +} + // ── Helpers ── +function formatValue(obj: unknown): string { + try { + return JSON.stringify(obj, replacer, 0) || 'undefined'; + } catch { + return String(obj); + } +} + function formatCompactValue(val: unknown): string | undefined { if (val === undefined) return undefined; if (val === null) return 'null'; diff --git a/packages/agent-react-devtools/src/profiler.ts b/packages/agent-react-devtools/src/profiler.ts new file mode 100644 index 0000000..70a5ffa --- /dev/null +++ b/packages/agent-react-devtools/src/profiler.ts @@ -0,0 +1,310 @@ +import type { + ProfilingSession, + ProfilingCommit, + ChangeDescription, + ComponentRenderReport, + RenderCause, +} from './types.js'; +import type { ComponentTree } from './component-tree.js'; + +export interface ProfileSummary { + name: string; + duration: number; + commitCount: number; + componentRenderCounts: { id: number; displayName: string; count: number }[]; +} + +export interface TimelineEntry { + index: number; + timestamp: number; + duration: number; + componentCount: number; +} + +export class Profiler { + private session: ProfilingSession | null = null; + /** Display names captured during profiling (survives unmounts) */ + private displayNames = new Map(); + + isActive(): boolean { + return this.session !== null && this.session.stoppedAt === null; + } + + start(name?: string): void { + this.displayNames.clear(); + this.session = { + name: name || `session-${Date.now()}`, + startedAt: Date.now(), + stoppedAt: null, + commits: [], + }; + } + + /** Cache a component's display name (call during profiling to survive unmounts) */ + trackComponent(id: number, displayName: string): void { + this.displayNames.set(id, displayName); + } + + stop(tree?: ComponentTree): ProfileSummary | null { + if (!this.session) return null; + this.session.stoppedAt = Date.now(); + + const duration = this.session.stoppedAt - this.session.startedAt; + + // Count renders per component + const renderCounts = new Map(); + for (const commit of this.session.commits) { + for (const [id] of commit.fiberActualDurations) { + renderCounts.set(id, (renderCounts.get(id) || 0) + 1); + } + } + + const componentRenderCounts = Array.from(renderCounts.entries()) + .map(([id, count]) => ({ + id, + displayName: tree?.getNode(id)?.displayName || this.displayNames.get(id) || '', + count, + })) + .sort((a, b) => b.count - a.count); + + return { + name: this.session.name, + duration, + commitCount: this.session.commits.length, + componentRenderCounts, + }; + } + + /** + * Process profiling data sent from React DevTools. + * + * The data format varies between React versions. We handle the common + * format where each commit contains: + * - commitTime + * - duration + * - fiberActualDurations: [id, duration, ...] + * - fiberSelfDurations: [id, duration, ...] + * - changeDescriptions: Map + */ + processProfilingData(payload: unknown): void { + if (!this.session || this.session.stoppedAt !== null) return; + + const data = payload as { + dataForRoots?: Array<{ + commitData?: Array<{ + changeDescriptions?: Array<[number, unknown]> | Map; + duration?: number; + fiberActualDurations?: Array<[number, number]> | number[]; + fiberSelfDurations?: Array<[number, number]> | number[]; + timestamp?: number; + }>; + operations?: unknown[]; + }>; + // Alternative flat format + commitData?: Array<{ + changeDescriptions?: Array<[number, unknown]> | Map; + duration?: number; + fiberActualDurations?: Array<[number, number]> | number[]; + fiberSelfDurations?: Array<[number, number]> | number[]; + timestamp?: number; + }>; + }; + + // Handle nested format (dataForRoots) + const roots = data?.dataForRoots; + if (roots) { + for (const root of roots) { + if (root.commitData) { + for (const commitData of root.commitData) { + this.processCommitData(commitData); + } + } + } + return; + } + + // Handle flat format + if (data?.commitData) { + for (const commitData of data.commitData) { + this.processCommitData(commitData); + } + } + } + + private processCommitData(commitData: { + changeDescriptions?: Array<[number, unknown]> | Map; + duration?: number; + fiberActualDurations?: Array<[number, number]> | number[]; + fiberSelfDurations?: Array<[number, number]> | number[]; + timestamp?: number; + }): void { + const commit: ProfilingCommit = { + timestamp: commitData.timestamp || Date.now(), + duration: commitData.duration || 0, + fiberActualDurations: new Map(), + fiberSelfDurations: new Map(), + changeDescriptions: new Map(), + }; + + // Parse fiber durations (can be [id, duration, id, duration, ...] or [[id, duration], ...]) + if (commitData.fiberActualDurations) { + parseDurations(commitData.fiberActualDurations, commit.fiberActualDurations); + } + if (commitData.fiberSelfDurations) { + parseDurations(commitData.fiberSelfDurations, commit.fiberSelfDurations); + } + + // Parse change descriptions + if (commitData.changeDescriptions) { + const entries = + commitData.changeDescriptions instanceof Map + ? commitData.changeDescriptions.entries() + : commitData.changeDescriptions[Symbol.iterator](); + for (const [id, desc] of entries) { + const d = desc as { + didHooksChange?: boolean; + isFirstMount?: boolean; + props?: string[] | null; + state?: string[] | null; + hooks?: number[] | null; + }; + commit.changeDescriptions.set(id as number, { + didHooksChange: d.didHooksChange || false, + isFirstMount: d.isFirstMount || false, + props: d.props || null, + state: d.state || null, + hooks: d.hooks || null, + }); + } + } + + this.session!.commits.push(commit); + } + + getReport( + componentId: number, + tree: ComponentTree, + ): ComponentRenderReport | null { + if (!this.session) return null; + + const node = tree.getNode(componentId); + let renderCount = 0; + let totalDuration = 0; + let maxDuration = 0; + const causeSet = new Set(); + + for (const commit of this.session.commits) { + const duration = commit.fiberActualDurations.get(componentId); + if (duration !== undefined) { + renderCount++; + totalDuration += duration; + if (duration > maxDuration) maxDuration = duration; + + const desc = commit.changeDescriptions.get(componentId); + if (desc) { + for (const cause of describeCauses(desc)) { + causeSet.add(cause); + } + } + } + } + + if (renderCount === 0) return null; + + return { + id: componentId, + displayName: node?.displayName || this.displayNames.get(componentId) || `Component#${componentId}`, + renderCount, + totalDuration, + avgDuration: totalDuration / renderCount, + maxDuration, + causes: Array.from(causeSet), + }; + } + + getSlowest( + tree: ComponentTree, + limit = 10, + ): ComponentRenderReport[] { + return this.getAllReports(tree) + .sort((a, b) => b.avgDuration - a.avgDuration) + .slice(0, limit); + } + + getMostRerenders( + tree: ComponentTree, + limit = 10, + ): ComponentRenderReport[] { + return this.getAllReports(tree) + .sort((a, b) => b.renderCount - a.renderCount) + .slice(0, limit); + } + + getTimeline(limit?: number): TimelineEntry[] { + if (!this.session) return []; + + const entries = this.session.commits.map((commit, index) => ({ + index, + timestamp: commit.timestamp, + duration: commit.duration, + componentCount: commit.fiberActualDurations.size, + })); + + if (limit) return entries.slice(0, limit); + return entries; + } + + private getAllReports(tree: ComponentTree): ComponentRenderReport[] { + if (!this.session) return []; + + // Collect all component IDs that appear in profiling data + const componentIds = new Set(); + for (const commit of this.session.commits) { + for (const id of commit.fiberActualDurations.keys()) { + componentIds.add(id); + } + } + + const reports: ComponentRenderReport[] = []; + for (const id of componentIds) { + const report = this.getReport(id, tree); + if (report) reports.push(report); + } + return reports; + } +} + +function parseDurations( + raw: Array<[number, number]> | number[], + target: Map, +): void { + if (raw.length === 0) return; + + // Check if it's array of tuples or flat array + if (Array.isArray(raw[0])) { + // [[id, duration], ...] + for (const [id, duration] of raw as Array<[number, number]>) { + target.set(id, duration); + } + } else { + // [id, duration, id, duration, ...] + const flat = raw as number[]; + for (let i = 0; i < flat.length; i += 2) { + target.set(flat[i], flat[i + 1]); + } + } +} + +function describeCauses(desc: ChangeDescription): RenderCause[] { + const causes: RenderCause[] = []; + if (desc.isFirstMount) { + causes.push('first-mount'); + return causes; + } + if (desc.props && desc.props.length > 0) causes.push('props-changed'); + if (desc.state && desc.state.length > 0) causes.push('state-changed'); + if (desc.didHooksChange) causes.push('hooks-changed'); + // If no specific cause found, it was likely parent-triggered + if (causes.length === 0) causes.push('parent-rendered'); + return causes; +} diff --git a/packages/agent-react-devtools/src/types.ts b/packages/agent-react-devtools/src/types.ts index 440af02..2822b50 100644 --- a/packages/agent-react-devtools/src/types.ts +++ b/packages/agent-react-devtools/src/types.ts @@ -39,6 +39,49 @@ export interface HookInfo { subHooks?: HookInfo[]; } +// ── Profiling ── + +export interface ProfilingSession { + name: string; + startedAt: number; + stoppedAt: number | null; + commits: ProfilingCommit[]; +} + +export interface ProfilingCommit { + timestamp: number; + duration: number; + fiberActualDurations: Map; + fiberSelfDurations: Map; + changeDescriptions: Map; +} + +export interface ChangeDescription { + didHooksChange: boolean; + isFirstMount: boolean; + props: string[] | null; + state: string[] | null; + hooks: number[] | null; +} + +export interface ComponentRenderReport { + id: number; + displayName: string; + renderCount: number; + totalDuration: number; + avgDuration: number; + maxDuration: number; + causes: RenderCause[]; +} + +export type RenderCause = + | 'props-changed' + | 'state-changed' + | 'hooks-changed' + | 'parent-rendered' + | 'force-update' + | 'first-mount'; + // ── IPC Commands ── export type IpcCommand = @@ -47,7 +90,13 @@ export type IpcCommand = | { type: 'get-tree'; depth?: number } | { type: 'get-component'; id: number | string } | { type: 'find'; name: string; exact?: boolean } - | { type: 'count' }; + | { type: 'count' } + | { type: 'profile-start'; name?: string } + | { type: 'profile-stop' } + | { type: 'profile-report'; componentId: number | string } + | { type: 'profile-slow'; limit?: number } + | { type: 'profile-rerenders'; limit?: number } + | { type: 'profile-timeline'; limit?: number }; export interface IpcResponse { ok: boolean; From 3ba4061ccd4abaa31d323764e999e436f8c2cae7 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Mon, 9 Feb 2026 21:12:16 +0100 Subject: [PATCH 2/4] chore: add changeset for profiler --- .changeset/profiler.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/profiler.md diff --git a/.changeset/profiler.md b/.changeset/profiler.md new file mode 100644 index 0000000..d03328e --- /dev/null +++ b/.changeset/profiler.md @@ -0,0 +1,14 @@ +--- +"agent-react-devtools": minor +--- + +**Profiler** + +Start and stop profiling sessions to capture render performance data from connected React apps. + +- **Render reports** — Per-component render duration and count +- **Slowest components** — Ranked by render time +- **Most re-rendered** — Ranked by render count +- **Commit timeline** — Chronological view of React commits with durations + +CLI commands: `profile start`, `profile stop`, `profile report`, `profile slow`, `profile rerenders`, `profile timeline`. From f27273c03448133d6df44f10c211d60931977b7e Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Mon, 9 Feb 2026 21:40:48 +0100 Subject: [PATCH 3/4] fix: wait for all renderer profiling data before resolving --- .../agent-react-devtools/src/devtools-bridge.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/agent-react-devtools/src/devtools-bridge.ts b/packages/agent-react-devtools/src/devtools-bridge.ts index e5383e7..9061e26 100644 --- a/packages/agent-react-devtools/src/devtools-bridge.ts +++ b/packages/agent-react-devtools/src/devtools-bridge.ts @@ -27,6 +27,7 @@ interface PendingInspection { interface PendingProfilingCollect { resolve: () => void; timer: ReturnType; + remaining: number; } export class DevToolsBridge { @@ -154,13 +155,14 @@ export class DevToolsBridge { }); } + const expected = this.rendererIds.size; return new Promise((resolve) => { const timer = setTimeout(() => { this.pendingProfilingCollect = null; resolve(); }, 5000); - this.pendingProfilingCollect = { resolve, timer }; + this.pendingProfilingCollect = { resolve, timer, remaining: expected }; }); } @@ -316,12 +318,15 @@ export class DevToolsBridge { // We forward it to the profiler for processing. this.profiler.processProfilingData(payload); - // Resolve the pending collect promise now that data has arrived + // Resolve once all expected renderer responses have arrived if (this.pendingProfilingCollect) { - clearTimeout(this.pendingProfilingCollect.timer); - const pending = this.pendingProfilingCollect; - this.pendingProfilingCollect = null; - pending.resolve(); + this.pendingProfilingCollect.remaining--; + if (this.pendingProfilingCollect.remaining <= 0) { + clearTimeout(this.pendingProfilingCollect.timer); + const pending = this.pendingProfilingCollect; + this.pendingProfilingCollect = null; + pending.resolve(); + } } } From 6a072770cfc854a1e46487a8552a07bbe53c9a86 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Mon, 9 Feb 2026 21:51:29 +0100 Subject: [PATCH 4/4] feat: add profile commit command for per-commit details --- .changeset/profiler.md | 5 +- packages/agent-react-devtools/src/cli.ts | 26 ++++++++++- packages/agent-react-devtools/src/daemon.ts | 8 ++++ .../agent-react-devtools/src/formatters.ts | 17 ++++++- packages/agent-react-devtools/src/profiler.ts | 46 +++++++++++++++++++ packages/agent-react-devtools/src/types.ts | 3 +- 6 files changed, 100 insertions(+), 5 deletions(-) diff --git a/.changeset/profiler.md b/.changeset/profiler.md index d03328e..d14475d 100644 --- a/.changeset/profiler.md +++ b/.changeset/profiler.md @@ -7,8 +7,9 @@ Start and stop profiling sessions to capture render performance data from connected React apps. - **Render reports** — Per-component render duration and count -- **Slowest components** — Ranked by render time +- **Slowest components** — Ranked by self render time - **Most re-rendered** — Ranked by render count - **Commit timeline** — Chronological view of React commits with durations +- **Commit details** — Per-component breakdown for a specific commit, sorted by self time -CLI commands: `profile start`, `profile stop`, `profile report`, `profile slow`, `profile rerenders`, `profile timeline`. +CLI commands: `profile start`, `profile stop`, `profile report`, `profile slow`, `profile rerenders`, `profile timeline`, `profile commit`. diff --git a/packages/agent-react-devtools/src/cli.ts b/packages/agent-react-devtools/src/cli.ts index 2a19aaf..1ba1134 100644 --- a/packages/agent-react-devtools/src/cli.ts +++ b/packages/agent-react-devtools/src/cli.ts @@ -16,6 +16,7 @@ import { formatSlowest, formatRerenders, formatTimeline, + formatCommitDetail, } from './formatters.js'; import type { IpcCommand } from './types.js'; @@ -39,7 +40,8 @@ Profiling: profile report <@c1 | id> Render report for component profile slow [--limit N] Slowest components (by avg) profile rerenders [--limit N] Most re-rendered components - profile timeline [--limit N] Commit timeline`; + profile timeline [--limit N] Commit timeline + profile commit [--limit N] Detail for specific commit`; } function parseArgs(argv: string[]): { @@ -265,6 +267,28 @@ async function main(): Promise { return; } + if (cmd0 === 'profile' && cmd1 === 'commit') { + const raw = command[2]; + if (!raw) { + console.error('Usage: devtools profile commit '); + process.exit(1); + } + const index = parseInt(raw.replace(/^#/, ''), 10); + if (isNaN(index)) { + console.error('Usage: devtools profile commit '); + process.exit(1); + } + const limit = flags['limit'] ? parseInt(flags['limit'] as string, 10) : undefined; + const resp = await sendCommand({ type: 'profile-commit', index, limit }); + if (resp.ok) { + console.log(formatCommitDetail(resp.data as any)); + } else { + console.error(resp.error); + process.exit(1); + } + return; + } + if (cmd0 === 'profile' && cmd1 === 'timeline') { const limit = flags['limit'] ? parseInt(flags['limit'] as string, 10) : undefined; const resp = await sendCommand({ type: 'profile-timeline', limit }); diff --git a/packages/agent-react-devtools/src/daemon.ts b/packages/agent-react-devtools/src/daemon.ts index 6501d1b..24fdc78 100644 --- a/packages/agent-react-devtools/src/daemon.ts +++ b/packages/agent-react-devtools/src/daemon.ts @@ -218,6 +218,14 @@ class Daemon { data: this.profiler.getTimeline(cmd.limit), }; + case 'profile-commit': { + const detail = this.profiler.getCommitDetails(cmd.index, this.tree, cmd.limit); + if (!detail) { + return { ok: false, error: `Commit #${cmd.index} not found` }; + } + return { ok: true, data: detail }; + } + default: return { ok: false, error: `Unknown command: ${(cmd as any).type}` }; } diff --git a/packages/agent-react-devtools/src/formatters.ts b/packages/agent-react-devtools/src/formatters.ts index 9d965eb..0de1948 100644 --- a/packages/agent-react-devtools/src/formatters.ts +++ b/packages/agent-react-devtools/src/formatters.ts @@ -4,7 +4,7 @@ import type { ComponentRenderReport, } from './types.js'; import type { TreeNode } from './component-tree.js'; -import type { ProfileSummary, TimelineEntry } from './profiler.js'; +import type { ProfileSummary, TimelineEntry, CommitDetail } from './profiler.js'; // ── Abbreviations for component types ── const TYPE_ABBREV: Record = { @@ -220,6 +220,21 @@ export function formatTimeline(entries: TimelineEntry[]): string { return lines.join('\n'); } +export function formatCommitDetail(detail: CommitDetail): string { + const lines: string[] = []; + lines.push(`Commit #${detail.index} ${detail.duration.toFixed(1)}ms ${detail.totalComponents} components`); + lines.push(''); + for (const c of detail.components) { + const causes = c.causes.length > 0 ? c.causes.join(', ') : '?'; + lines.push(` ${pad(c.displayName, 24)} self:${c.selfDuration.toFixed(1)}ms total:${c.actualDuration.toFixed(1)}ms ${causes}`); + } + const hidden = detail.totalComponents - detail.components.length; + if (hidden > 0) { + lines.push(` ... ${hidden} more (use --limit to show more)`); + } + return lines.join('\n'); +} + // ── Helpers ── function formatValue(obj: unknown): string { diff --git a/packages/agent-react-devtools/src/profiler.ts b/packages/agent-react-devtools/src/profiler.ts index 70a5ffa..25e7440 100644 --- a/packages/agent-react-devtools/src/profiler.ts +++ b/packages/agent-react-devtools/src/profiler.ts @@ -21,6 +21,20 @@ export interface TimelineEntry { componentCount: number; } +export interface CommitDetail { + index: number; + timestamp: number; + duration: number; + components: Array<{ + id: number; + displayName: string; + actualDuration: number; + selfDuration: number; + causes: RenderCause[]; + }>; + totalComponents: number; +} + export class Profiler { private session: ProfilingSession | null = null; /** Display names captured during profiling (survives unmounts) */ @@ -240,6 +254,38 @@ export class Profiler { .slice(0, limit); } + getCommitDetails(index: number, tree: ComponentTree, limit = 10): CommitDetail | null { + if (!this.session) return null; + if (index < 0 || index >= this.session.commits.length) return null; + + const commit = this.session.commits[index]; + const components: CommitDetail['components'] = []; + + for (const [id, actualDuration] of commit.fiberActualDurations) { + const selfDuration = commit.fiberSelfDurations.get(id) || 0; + const desc = commit.changeDescriptions.get(id); + components.push({ + id, + displayName: tree.getNode(id)?.displayName || this.displayNames.get(id) || `Component#${id}`, + actualDuration, + selfDuration, + causes: desc ? describeCauses(desc) : [], + }); + } + + components.sort((a, b) => b.selfDuration - a.selfDuration); + + const totalCount = components.length; + + return { + index, + timestamp: commit.timestamp, + duration: commit.duration, + components: limit > 0 ? components.slice(0, limit) : components, + totalComponents: totalCount, + }; + } + getTimeline(limit?: number): TimelineEntry[] { if (!this.session) return []; diff --git a/packages/agent-react-devtools/src/types.ts b/packages/agent-react-devtools/src/types.ts index 2822b50..f081e2a 100644 --- a/packages/agent-react-devtools/src/types.ts +++ b/packages/agent-react-devtools/src/types.ts @@ -96,7 +96,8 @@ export type IpcCommand = | { type: 'profile-report'; componentId: number | string } | { type: 'profile-slow'; limit?: number } | { type: 'profile-rerenders'; limit?: number } - | { type: 'profile-timeline'; limit?: number }; + | { type: 'profile-timeline'; limit?: number } + | { type: 'profile-commit'; index: number; limit?: number }; export interface IpcResponse { ok: boolean;