Skip to content

Commit 626a21a

Browse files
authored
feat: add render profiling and performance analysis
* feat: add render profiling and performance analysis 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. * chore: add changeset for profiler * fix: wait for all renderer profiling data before resolving * feat: add profile commit command for per-commit details
1 parent 137f62f commit 626a21a

9 files changed

Lines changed: 1119 additions & 7 deletions

File tree

.changeset/profiler.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"agent-react-devtools": minor
3+
---
4+
5+
**Profiler**
6+
7+
Start and stop profiling sessions to capture render performance data from connected React apps.
8+
9+
- **Render reports** — Per-component render duration and count
10+
- **Slowest components** — Ranked by self render time
11+
- **Most re-rendered** — Ranked by render count
12+
- **Commit timeline** — Chronological view of React commits with durations
13+
- **Commit details** — Per-component breakdown for a specific commit, sorted by self time
14+
15+
CLI commands: `profile start`, `profile stop`, `profile report`, `profile slow`, `profile rerenders`, `profile timeline`, `profile commit`.

packages/agent-react-devtools/src/__tests__/formatters.test.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@ import {
55
formatSearchResults,
66
formatCount,
77
formatStatus,
8+
formatProfileReport,
9+
formatSlowest,
10+
formatRerenders,
11+
formatTimeline,
812
} from '../formatters.js';
913
import type { TreeNode } from '../component-tree.js';
10-
import type { InspectedElement, StatusInfo } from '../types.js';
14+
import type { InspectedElement, StatusInfo, ComponentRenderReport } from '../types.js';
15+
import type { TimelineEntry } from '../profiler.js';
1116

1217
describe('formatTree', () => {
1318
it('should format empty tree', () => {
@@ -116,3 +121,69 @@ describe('formatStatus', () => {
116121
expect(result).toContain('47 components');
117122
});
118123
});
124+
125+
describe('formatProfileReport', () => {
126+
it('should format a render report', () => {
127+
const report: ComponentRenderReport = {
128+
id: 5,
129+
displayName: 'UserProfile',
130+
renderCount: 12,
131+
totalDuration: 540,
132+
avgDuration: 45,
133+
maxDuration: 120,
134+
causes: ['props-changed', 'state-changed'],
135+
};
136+
137+
const result = formatProfileReport(report, '@c5');
138+
expect(result).toContain('@c5 "UserProfile"');
139+
expect(result).toContain('renders:12');
140+
expect(result).toContain('avg:45.0ms');
141+
expect(result).toContain('max:120.0ms');
142+
expect(result).toContain('props-changed');
143+
});
144+
});
145+
146+
describe('formatSlowest', () => {
147+
it('should format empty data', () => {
148+
expect(formatSlowest([])).toContain('No profiling data');
149+
});
150+
151+
it('should format slowest components', () => {
152+
const reports: ComponentRenderReport[] = [
153+
{ id: 1, displayName: 'SlowComp', renderCount: 5, totalDuration: 250, avgDuration: 50, maxDuration: 100, causes: ['props-changed'] },
154+
{ id: 2, displayName: 'FastComp', renderCount: 10, totalDuration: 100, avgDuration: 10, maxDuration: 20, causes: ['state-changed'] },
155+
];
156+
157+
const result = formatSlowest(reports);
158+
expect(result).toContain('Slowest');
159+
expect(result).toContain('SlowComp');
160+
expect(result).toContain('FastComp');
161+
});
162+
});
163+
164+
describe('formatRerenders', () => {
165+
it('should format rerender data', () => {
166+
const reports: ComponentRenderReport[] = [
167+
{ id: 1, displayName: 'Chatty', renderCount: 50, totalDuration: 100, avgDuration: 2, maxDuration: 5, causes: ['parent-rendered'] },
168+
];
169+
170+
const result = formatRerenders(reports);
171+
expect(result).toContain('50 renders');
172+
expect(result).toContain('parent-rendered');
173+
});
174+
});
175+
176+
describe('formatTimeline', () => {
177+
it('should format timeline entries', () => {
178+
const entries: TimelineEntry[] = [
179+
{ index: 0, timestamp: 1000, duration: 12.5, componentCount: 5 },
180+
{ index: 1, timestamp: 2000, duration: 8.3, componentCount: 3 },
181+
];
182+
183+
const result = formatTimeline(entries);
184+
expect(result).toContain('#0');
185+
expect(result).toContain('12.5ms');
186+
expect(result).toContain('#1');
187+
expect(result).toContain('8.3ms');
188+
});
189+
});
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { Profiler } from '../profiler.js';
3+
import { ComponentTree } from '../component-tree.js';
4+
5+
/**
6+
* Operations encoding reference (protocol v2):
7+
* [rendererID, rootFiberID, stringTableSize, ...stringTable, ...ops]
8+
*
9+
* String table: for each string, [length, ...charCodes]. String ID 0 = null.
10+
*
11+
* TREE_OPERATION_ADD (1):
12+
* 1, id, elementType, parentId, ownerID, displayNameStringID, keyStringID
13+
*/
14+
15+
/** Build a string table and return [tableData, stringIdMap] */
16+
function buildStringTable(strings: string[]): [number[], Map<string, number>] {
17+
const idMap = new Map<string, number>();
18+
const data: number[] = [];
19+
for (const s of strings) {
20+
const id = idMap.size + 1; // 0 is reserved for null
21+
if (!idMap.has(s)) {
22+
idMap.set(s, id);
23+
data.push(s.length, ...Array.from(s).map((c) => c.charCodeAt(0)));
24+
}
25+
}
26+
return [data, idMap];
27+
}
28+
29+
/** Build a complete operations array with string table */
30+
function buildOps(
31+
rendererID: number,
32+
rootID: number,
33+
strings: string[],
34+
opsFn: (strId: (s: string) => number) => number[],
35+
): number[] {
36+
const [tableData, idMap] = buildStringTable(strings);
37+
const strId = (s: string) => idMap.get(s) || 0;
38+
const ops = opsFn(strId);
39+
return [rendererID, rootID, tableData.length, ...tableData, ...ops];
40+
}
41+
42+
function addOp(
43+
id: number,
44+
elementType: number,
45+
parentId: number,
46+
displayNameStrId: number,
47+
keyStrId: number = 0,
48+
): number[] {
49+
return [1, id, elementType, parentId, 0, displayNameStrId, keyStrId];
50+
}
51+
52+
describe('Profiler', () => {
53+
let profiler: Profiler;
54+
let tree: ComponentTree;
55+
56+
beforeEach(() => {
57+
profiler = new Profiler();
58+
tree = new ComponentTree();
59+
60+
// Set up a basic tree (FUNCTION=5 in protocol v2)
61+
const ops = buildOps(1, 100, ['App', 'Header', 'Content'], (s) => [
62+
...addOp(1, 5, 0, s('App')),
63+
...addOp(2, 5, 1, s('Header')),
64+
...addOp(3, 5, 1, s('Content')),
65+
]);
66+
tree.applyOperations(ops);
67+
});
68+
69+
it('should track active state', () => {
70+
expect(profiler.isActive()).toBe(false);
71+
profiler.start('test');
72+
expect(profiler.isActive()).toBe(true);
73+
profiler.stop();
74+
expect(profiler.isActive()).toBe(false);
75+
});
76+
77+
it('should return null when stopping without starting', () => {
78+
expect(profiler.stop()).toBeNull();
79+
});
80+
81+
it('should process flat profiling data', () => {
82+
profiler.start('test');
83+
84+
profiler.processProfilingData({
85+
commitData: [
86+
{
87+
timestamp: 1000,
88+
duration: 15,
89+
fiberActualDurations: [1, 10, 2, 3, 3, 2],
90+
fiberSelfDurations: [1, 5, 2, 3, 3, 2],
91+
},
92+
{
93+
timestamp: 2000,
94+
duration: 8,
95+
fiberActualDurations: [1, 5, 3, 3],
96+
fiberSelfDurations: [1, 2, 3, 3],
97+
},
98+
],
99+
});
100+
101+
const summary = profiler.stop();
102+
expect(summary).not.toBeNull();
103+
expect(summary!.commitCount).toBe(2);
104+
expect(summary!.componentRenderCounts.length).toBeGreaterThan(0);
105+
});
106+
107+
it('should generate render reports', () => {
108+
profiler.start('test');
109+
110+
profiler.processProfilingData({
111+
commitData: [
112+
{
113+
timestamp: 1000,
114+
duration: 15,
115+
fiberActualDurations: [1, 10, 2, 3],
116+
fiberSelfDurations: [1, 5, 2, 3],
117+
changeDescriptions: [
118+
[1, { props: ['theme'], isFirstMount: false }],
119+
[2, { isFirstMount: true }],
120+
],
121+
},
122+
{
123+
timestamp: 2000,
124+
duration: 8,
125+
fiberActualDurations: [1, 20],
126+
fiberSelfDurations: [1, 15],
127+
changeDescriptions: [
128+
[1, { didHooksChange: true, isFirstMount: false }],
129+
],
130+
},
131+
],
132+
});
133+
134+
const report = profiler.getReport(1, tree);
135+
expect(report).not.toBeNull();
136+
expect(report!.displayName).toBe('App');
137+
expect(report!.renderCount).toBe(2);
138+
expect(report!.totalDuration).toBe(30);
139+
expect(report!.avgDuration).toBe(15);
140+
expect(report!.maxDuration).toBe(20);
141+
expect(report!.causes).toContain('props-changed');
142+
expect(report!.causes).toContain('hooks-changed');
143+
});
144+
145+
it('should find slowest components', () => {
146+
profiler.start('test');
147+
148+
profiler.processProfilingData({
149+
commitData: [
150+
{
151+
timestamp: 1000,
152+
duration: 15,
153+
fiberActualDurations: [1, 50, 2, 5, 3, 30],
154+
fiberSelfDurations: [1, 15, 2, 5, 3, 30],
155+
},
156+
],
157+
});
158+
159+
const slowest = profiler.getSlowest(tree, 2);
160+
expect(slowest).toHaveLength(2);
161+
expect(slowest[0].displayName).toBe('App');
162+
expect(slowest[1].displayName).toBe('Content');
163+
});
164+
165+
it('should find most rerenders', () => {
166+
profiler.start('test');
167+
168+
profiler.processProfilingData({
169+
commitData: [
170+
{
171+
timestamp: 1000,
172+
duration: 5,
173+
fiberActualDurations: [1, 1, 2, 1, 3, 1],
174+
fiberSelfDurations: [1, 1, 2, 1, 3, 1],
175+
},
176+
{
177+
timestamp: 2000,
178+
duration: 5,
179+
fiberActualDurations: [2, 1, 3, 1],
180+
fiberSelfDurations: [2, 1, 3, 1],
181+
},
182+
{
183+
timestamp: 3000,
184+
duration: 5,
185+
fiberActualDurations: [3, 1],
186+
fiberSelfDurations: [3, 1],
187+
},
188+
],
189+
});
190+
191+
const rerenders = profiler.getMostRerenders(tree, 3);
192+
expect(rerenders[0].displayName).toBe('Content');
193+
expect(rerenders[0].renderCount).toBe(3);
194+
});
195+
196+
it('should process dataForRoots nested format', () => {
197+
profiler.start('test');
198+
199+
profiler.processProfilingData({
200+
dataForRoots: [
201+
{
202+
commitData: [
203+
{
204+
timestamp: 1000,
205+
duration: 12,
206+
fiberActualDurations: [1, 8, 2, 4],
207+
fiberSelfDurations: [1, 4, 2, 4],
208+
changeDescriptions: [
209+
[1, { props: ['count'], isFirstMount: false }],
210+
[2, { state: ['value'], isFirstMount: false }],
211+
],
212+
},
213+
],
214+
},
215+
],
216+
});
217+
218+
const summary = profiler.stop();
219+
expect(summary).not.toBeNull();
220+
expect(summary!.commitCount).toBe(1);
221+
222+
// Verify the state change was captured correctly
223+
profiler.start('test2');
224+
profiler.processProfilingData({
225+
dataForRoots: [
226+
{
227+
commitData: [
228+
{
229+
timestamp: 2000,
230+
duration: 5,
231+
fiberActualDurations: [2, 3],
232+
fiberSelfDurations: [2, 3],
233+
changeDescriptions: [
234+
[2, { state: ['value', 'count'], isFirstMount: false }],
235+
],
236+
},
237+
],
238+
},
239+
],
240+
});
241+
242+
const report = profiler.getReport(2, tree);
243+
expect(report).not.toBeNull();
244+
expect(report!.causes).toContain('state-changed');
245+
});
246+
247+
it('should generate timeline', () => {
248+
profiler.start('test');
249+
250+
profiler.processProfilingData({
251+
commitData: [
252+
{ timestamp: 1000, duration: 10, fiberActualDurations: [1, 5], fiberSelfDurations: [] },
253+
{ timestamp: 2000, duration: 20, fiberActualDurations: [1, 10, 2, 5], fiberSelfDurations: [] },
254+
],
255+
});
256+
257+
const timeline = profiler.getTimeline();
258+
expect(timeline).toHaveLength(2);
259+
expect(timeline[0].duration).toBe(10);
260+
expect(timeline[0].componentCount).toBe(1);
261+
expect(timeline[1].duration).toBe(20);
262+
expect(timeline[1].componentCount).toBe(2);
263+
});
264+
});

0 commit comments

Comments
 (0)