Skip to content

Commit 65c391f

Browse files
feat: add profile diff command (#39)
1 parent 5c5ace6 commit 65c391f

File tree

5 files changed

+610
-1
lines changed

5 files changed

+610
-1
lines changed

.changeset/profile-diff.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"agent-react-devtools": minor
3+
---
4+
5+
Add `profile diff <before.json> <after.json>` command
6+
7+
- Compares two profiling exports side by side
8+
- Shows regressed, improved, new, and removed components
9+
- Aggregates by display name, computes avg/max duration deltas
10+
- Configurable threshold filters noise from insignificant changes (default 5%, adjust with `--threshold`)
11+
- No daemon required - works purely on exported JSON files
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { extractStats, diffProfiles } from '../profile-diff.js';
3+
import type { ProfilingDataExport } from '../types.js';
4+
5+
function makeExport(overrides?: Partial<ProfilingDataExport>): ProfilingDataExport {
6+
return {
7+
version: 5,
8+
dataForRoots: [{
9+
commitData: [{
10+
changeDescriptions: null,
11+
duration: 10,
12+
effectDuration: null,
13+
fiberActualDurations: [[1, 8], [2, 3]],
14+
fiberSelfDurations: [[1, 5], [2, 3]],
15+
passiveEffectDuration: null,
16+
priorityLevel: null,
17+
timestamp: 1000,
18+
updaters: null,
19+
}],
20+
displayName: 'Root',
21+
initialTreeBaseDurations: [[1, 5], [2, 3]],
22+
operations: [[]],
23+
rootID: 100,
24+
snapshots: [
25+
[1, { id: 1, children: [2], displayName: 'App', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }],
26+
[2, { id: 2, children: [], displayName: 'Header', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }],
27+
],
28+
}],
29+
...overrides,
30+
};
31+
}
32+
33+
describe('extractStats', () => {
34+
it('extracts per-component stats from export data', () => {
35+
const data = makeExport();
36+
const stats = extractStats(data);
37+
38+
expect(stats.size).toBe(2);
39+
40+
const app = stats.get('App');
41+
expect(app).toBeDefined();
42+
expect(app!.renderCount).toBe(1);
43+
expect(app!.avgDuration).toBe(8);
44+
expect(app!.avgSelfDuration).toBe(5);
45+
46+
const header = stats.get('Header');
47+
expect(header).toBeDefined();
48+
expect(header!.renderCount).toBe(1);
49+
expect(header!.avgDuration).toBe(3);
50+
});
51+
52+
it('aggregates across multiple commits', () => {
53+
const data = makeExport({
54+
dataForRoots: [{
55+
commitData: [
56+
{
57+
changeDescriptions: null,
58+
duration: 10,
59+
effectDuration: null,
60+
fiberActualDurations: [[1, 8]],
61+
fiberSelfDurations: [[1, 5]],
62+
passiveEffectDuration: null,
63+
priorityLevel: null,
64+
timestamp: 1000,
65+
updaters: null,
66+
},
67+
{
68+
changeDescriptions: null,
69+
duration: 6,
70+
effectDuration: null,
71+
fiberActualDurations: [[1, 4]],
72+
fiberSelfDurations: [[1, 2]],
73+
passiveEffectDuration: null,
74+
priorityLevel: null,
75+
timestamp: 2000,
76+
updaters: null,
77+
},
78+
],
79+
displayName: 'Root',
80+
initialTreeBaseDurations: [],
81+
operations: [[], []],
82+
rootID: 100,
83+
snapshots: [
84+
[1, { id: 1, children: [], displayName: 'App', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }],
85+
],
86+
}],
87+
});
88+
89+
const stats = extractStats(data);
90+
const app = stats.get('App')!;
91+
expect(app.renderCount).toBe(2);
92+
expect(app.avgDuration).toBe(6); // (8+4)/2
93+
expect(app.maxDuration).toBe(8);
94+
expect(app.totalDuration).toBe(12);
95+
});
96+
});
97+
98+
describe('diffProfiles', () => {
99+
it('detects regressed components', () => {
100+
const before = makeExport({
101+
dataForRoots: [{
102+
commitData: [{
103+
changeDescriptions: null, duration: 10, effectDuration: null,
104+
fiberActualDurations: [[1, 5]], fiberSelfDurations: [[1, 5]],
105+
passiveEffectDuration: null, priorityLevel: null, timestamp: 1000, updaters: null,
106+
}],
107+
displayName: 'Root', initialTreeBaseDurations: [], operations: [[]], rootID: 100,
108+
snapshots: [[1, { id: 1, children: [], displayName: 'App', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }]],
109+
}],
110+
});
111+
112+
const after = makeExport({
113+
dataForRoots: [{
114+
commitData: [{
115+
changeDescriptions: null, duration: 20, effectDuration: null,
116+
fiberActualDurations: [[1, 15]], fiberSelfDurations: [[1, 15]],
117+
passiveEffectDuration: null, priorityLevel: null, timestamp: 1000, updaters: null,
118+
}],
119+
displayName: 'Root', initialTreeBaseDurations: [], operations: [[]], rootID: 100,
120+
snapshots: [[1, { id: 1, children: [], displayName: 'App', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }]],
121+
}],
122+
});
123+
124+
const diff = diffProfiles(before, after);
125+
expect(diff.regressed).toHaveLength(1);
126+
expect(diff.regressed[0].displayName).toBe('App');
127+
expect(diff.regressed[0].avgDurationDelta).toBe(10);
128+
expect(diff.regressed[0].avgDurationDeltaPct).toBe(200);
129+
});
130+
131+
it('detects improved components', () => {
132+
const before = makeExport({
133+
dataForRoots: [{
134+
commitData: [{
135+
changeDescriptions: null, duration: 20, effectDuration: null,
136+
fiberActualDurations: [[1, 20]], fiberSelfDurations: [[1, 20]],
137+
passiveEffectDuration: null, priorityLevel: null, timestamp: 1000, updaters: null,
138+
}],
139+
displayName: 'Root', initialTreeBaseDurations: [], operations: [[]], rootID: 100,
140+
snapshots: [[1, { id: 1, children: [], displayName: 'App', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }]],
141+
}],
142+
});
143+
144+
const after = makeExport({
145+
dataForRoots: [{
146+
commitData: [{
147+
changeDescriptions: null, duration: 5, effectDuration: null,
148+
fiberActualDurations: [[1, 5]], fiberSelfDurations: [[1, 5]],
149+
passiveEffectDuration: null, priorityLevel: null, timestamp: 1000, updaters: null,
150+
}],
151+
displayName: 'Root', initialTreeBaseDurations: [], operations: [[]], rootID: 100,
152+
snapshots: [[1, { id: 1, children: [], displayName: 'App', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }]],
153+
}],
154+
});
155+
156+
const diff = diffProfiles(before, after);
157+
expect(diff.improved).toHaveLength(1);
158+
expect(diff.improved[0].displayName).toBe('App');
159+
expect(diff.improved[0].avgDurationDeltaPct).toBe(-75);
160+
});
161+
162+
it('detects new components', () => {
163+
const before = makeExport({
164+
dataForRoots: [{
165+
commitData: [{
166+
changeDescriptions: null, duration: 10, effectDuration: null,
167+
fiberActualDurations: [[1, 10]], fiberSelfDurations: [[1, 10]],
168+
passiveEffectDuration: null, priorityLevel: null, timestamp: 1000, updaters: null,
169+
}],
170+
displayName: 'Root', initialTreeBaseDurations: [], operations: [[]], rootID: 100,
171+
snapshots: [[1, { id: 1, children: [], displayName: 'App', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }]],
172+
}],
173+
});
174+
175+
const after = makeExport({
176+
dataForRoots: [{
177+
commitData: [{
178+
changeDescriptions: null, duration: 15, effectDuration: null,
179+
fiberActualDurations: [[1, 10], [2, 5]], fiberSelfDurations: [[1, 10], [2, 5]],
180+
passiveEffectDuration: null, priorityLevel: null, timestamp: 1000, updaters: null,
181+
}],
182+
displayName: 'Root', initialTreeBaseDurations: [], operations: [[]], rootID: 100,
183+
snapshots: [
184+
[1, { id: 1, children: [2], displayName: 'App', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }],
185+
[2, { id: 2, children: [], displayName: 'NewFeature', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }],
186+
],
187+
}],
188+
});
189+
190+
const diff = diffProfiles(before, after);
191+
expect(diff.added).toHaveLength(1);
192+
expect(diff.added[0].displayName).toBe('NewFeature');
193+
});
194+
195+
it('detects removed components', () => {
196+
const before = makeExport();
197+
const after = makeExport({
198+
dataForRoots: [{
199+
commitData: [{
200+
changeDescriptions: null, duration: 8, effectDuration: null,
201+
fiberActualDurations: [[1, 8]], fiberSelfDurations: [[1, 5]],
202+
passiveEffectDuration: null, priorityLevel: null, timestamp: 1000, updaters: null,
203+
}],
204+
displayName: 'Root', initialTreeBaseDurations: [], operations: [[]], rootID: 100,
205+
snapshots: [[1, { id: 1, children: [], displayName: 'App', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }]],
206+
}],
207+
});
208+
209+
const diff = diffProfiles(before, after);
210+
expect(diff.removed).toHaveLength(1);
211+
expect(diff.removed[0].displayName).toBe('Header');
212+
});
213+
214+
it('ignores changes within 5% threshold', () => {
215+
const before = makeExport({
216+
dataForRoots: [{
217+
commitData: [{
218+
changeDescriptions: null, duration: 10, effectDuration: null,
219+
fiberActualDurations: [[1, 10]], fiberSelfDurations: [[1, 10]],
220+
passiveEffectDuration: null, priorityLevel: null, timestamp: 1000, updaters: null,
221+
}],
222+
displayName: 'Root', initialTreeBaseDurations: [], operations: [[]], rootID: 100,
223+
snapshots: [[1, { id: 1, children: [], displayName: 'App', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }]],
224+
}],
225+
});
226+
227+
const after = makeExport({
228+
dataForRoots: [{
229+
commitData: [{
230+
changeDescriptions: null, duration: 10.3, effectDuration: null,
231+
fiberActualDurations: [[1, 10.3]], fiberSelfDurations: [[1, 10.3]],
232+
passiveEffectDuration: null, priorityLevel: null, timestamp: 1000, updaters: null,
233+
}],
234+
displayName: 'Root', initialTreeBaseDurations: [], operations: [[]], rootID: 100,
235+
snapshots: [[1, { id: 1, children: [], displayName: 'App', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }]],
236+
}],
237+
});
238+
239+
const diff = diffProfiles(before, after);
240+
expect(diff.regressed).toHaveLength(0);
241+
expect(diff.improved).toHaveLength(0);
242+
});
243+
244+
it('computes correct summary totals', () => {
245+
const before = makeExport();
246+
const after = makeExport({
247+
dataForRoots: [{
248+
commitData: [
249+
{
250+
changeDescriptions: null, duration: 5, effectDuration: null,
251+
fiberActualDurations: [[1, 5]], fiberSelfDurations: [[1, 5]],
252+
passiveEffectDuration: null, priorityLevel: null, timestamp: 1000, updaters: null,
253+
},
254+
{
255+
changeDescriptions: null, duration: 3, effectDuration: null,
256+
fiberActualDurations: [[1, 3]], fiberSelfDurations: [[1, 3]],
257+
passiveEffectDuration: null, priorityLevel: null, timestamp: 2000, updaters: null,
258+
},
259+
],
260+
displayName: 'Root', initialTreeBaseDurations: [], operations: [[], []], rootID: 100,
261+
snapshots: [[1, { id: 1, children: [], displayName: 'App', hocDisplayNames: null, key: null, type: 5, compiledWithForget: false }]],
262+
}],
263+
});
264+
265+
const diff = diffProfiles(before, after);
266+
expect(diff.summary.totalCommitsBefore).toBe(1);
267+
expect(diff.summary.totalCommitsAfter).toBe(2);
268+
expect(diff.summary.totalDurationBefore).toBe(10);
269+
expect(diff.summary.totalDurationAfter).toBe(8);
270+
});
271+
});

packages/agent-react-devtools/src/cli.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
formatRerenders,
1818
formatTimeline,
1919
formatCommitDetail,
20+
formatProfileDiff,
2021
} from './formatters.js';
2122
import { writeFileSync } from 'node:fs';
2223
import { resolve } from 'node:path';
@@ -51,7 +52,8 @@ Profiling:
5152
profile rerenders [--limit N] Most re-rendered components
5253
profile timeline [--limit N] Commit timeline
5354
profile commit <N | #N> [--limit N] Detail for specific commit
54-
profile export <file> Export as React DevTools JSON`;
55+
profile export <file> Export as React DevTools JSON
56+
profile diff <before.json> <after.json> [--limit N] [--threshold N] Compare two exports`;
5557
}
5658

5759
function parseArgs(argv: string[]): {
@@ -124,6 +126,36 @@ async function main(): Promise<void> {
124126
return;
125127
}
126128

129+
// ── Profile diff (no daemon needed) ──
130+
if (cmd0 === 'profile' && cmd1 === 'diff') {
131+
const fileA = command[2];
132+
const fileB = command[3];
133+
if (!fileA || !fileB) {
134+
console.error('Usage: devtools profile diff <before.json> <after.json> [--limit N] [--threshold N]');
135+
process.exit(1);
136+
}
137+
const { loadExportFile, diffProfiles } = await import('./profile-diff.js');
138+
let before: ReturnType<typeof loadExportFile>;
139+
let after: ReturnType<typeof loadExportFile>;
140+
try {
141+
before = loadExportFile(resolve(fileA));
142+
} catch (e) {
143+
console.error(`Error reading ${fileA}: ${(e as Error).message}`);
144+
process.exit(1);
145+
}
146+
try {
147+
after = loadExportFile(resolve(fileB));
148+
} catch (e) {
149+
console.error(`Error reading ${fileB}: ${(e as Error).message}`);
150+
process.exit(1);
151+
}
152+
const limit = parseNumericFlag(flags, 'limit');
153+
const threshold = parseNumericFlag(flags, 'threshold');
154+
const diff = diffProfiles(before, after, threshold);
155+
console.log(formatProfileDiff(diff, limit));
156+
return;
157+
}
158+
127159
// ── Daemon management ──
128160
if (cmd0 === 'start') {
129161
const port = parseNumericFlag(flags, 'port');

0 commit comments

Comments
 (0)