Skip to content

Commit a4f339b

Browse files
feat: add profile export command for React DevTools Profiler import
Export profiling data in React DevTools Profiler JSON format (version 5) so it can be imported into the browser extension's Profiler tab. - New IPC command: profile-export - New CLI command: profile export <file> - Maps internal ProfilingSession to ProfilingDataExport schema - Includes commit data, fiber durations, change descriptions, snapshots Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 05090ca commit a4f339b

6 files changed

Lines changed: 379 additions & 1 deletion

File tree

.changeset/profile-export.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"agent-react-devtools": minor
3+
---
4+
5+
Add `profile export <file>` command
6+
7+
- Exports profiling data in React DevTools Profiler JSON format (version 5)
8+
- Output can be imported into React DevTools via the Profiler tab
9+
- Includes commit data, fiber durations, change descriptions, and component snapshots

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

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,4 +299,175 @@ describe('Profiler', () => {
299299
expect(timeline[1].duration).toBe(20);
300300
expect(timeline[1].componentCount).toBe(2);
301301
});
302+
303+
describe('getExportData', () => {
304+
it('returns null when no session exists', () => {
305+
expect(profiler.getExportData(tree)).toBeNull();
306+
});
307+
308+
it('returns null when session has no commits', () => {
309+
profiler.start('test');
310+
profiler.stop();
311+
expect(profiler.getExportData(tree)).toBeNull();
312+
});
313+
314+
it('exports version 5 format', () => {
315+
profiler.start('test');
316+
profiler.processProfilingData({
317+
commitData: [
318+
{
319+
timestamp: 1000,
320+
duration: 15,
321+
fiberActualDurations: [1, 10, 2, 3, 3, 2],
322+
fiberSelfDurations: [1, 5, 2, 3, 3, 2],
323+
},
324+
],
325+
});
326+
profiler.stop(tree);
327+
328+
const exported = profiler.getExportData(tree);
329+
expect(exported).not.toBeNull();
330+
expect(exported!.version).toBe(5);
331+
expect(exported!.dataForRoots).toHaveLength(1);
332+
});
333+
334+
it('maps commits to CommitDataExport format', () => {
335+
profiler.start('test');
336+
profiler.processProfilingData({
337+
commitData: [
338+
{
339+
timestamp: 1000,
340+
duration: 15,
341+
fiberActualDurations: [1, 10, 2, 3],
342+
fiberSelfDurations: [1, 5, 2, 3],
343+
changeDescriptions: [
344+
[1, { props: ['theme'], isFirstMount: false }],
345+
[2, { isFirstMount: true }],
346+
],
347+
},
348+
],
349+
});
350+
profiler.stop(tree);
351+
352+
const root = profiler.getExportData(tree)!.dataForRoots[0];
353+
expect(root.commitData).toHaveLength(1);
354+
355+
const commit = root.commitData[0];
356+
expect(commit.timestamp).toBe(1000);
357+
expect(commit.duration).toBe(15);
358+
expect(commit.fiberActualDurations).toEqual(expect.arrayContaining([[1, 10], [2, 3]]));
359+
expect(commit.fiberSelfDurations).toEqual(expect.arrayContaining([[1, 5], [2, 3]]));
360+
expect(commit.effectDuration).toBeNull();
361+
expect(commit.passiveEffectDuration).toBeNull();
362+
expect(commit.priorityLevel).toBeNull();
363+
expect(commit.updaters).toBeNull();
364+
});
365+
366+
it('exports change descriptions with context:null field', () => {
367+
profiler.start('test');
368+
profiler.processProfilingData({
369+
commitData: [
370+
{
371+
timestamp: 1000,
372+
duration: 5,
373+
fiberActualDurations: [1, 5],
374+
fiberSelfDurations: [1, 5],
375+
changeDescriptions: [
376+
[1, { props: ['onClick'], state: ['count'], isFirstMount: false, didHooksChange: true }],
377+
],
378+
},
379+
],
380+
});
381+
profiler.stop(tree);
382+
383+
const commit = profiler.getExportData(tree)!.dataForRoots[0].commitData[0];
384+
expect(commit.changeDescriptions).toHaveLength(1);
385+
const [id, desc] = commit.changeDescriptions![0];
386+
expect(id).toBe(1);
387+
expect(desc.context).toBeNull();
388+
expect(desc.props).toEqual(['onClick']);
389+
expect(desc.state).toEqual(['count']);
390+
expect(desc.didHooksChange).toBe(true);
391+
expect(desc.isFirstMount).toBe(false);
392+
});
393+
394+
it('builds snapshots from component tree', () => {
395+
profiler.start('test');
396+
profiler.processProfilingData({
397+
commitData: [
398+
{
399+
timestamp: 1000,
400+
duration: 5,
401+
fiberActualDurations: [1, 5],
402+
fiberSelfDurations: [1, 5],
403+
},
404+
],
405+
});
406+
profiler.stop(tree);
407+
408+
const root = profiler.getExportData(tree)!.dataForRoots[0];
409+
// Should have snapshots for all nodes in the tree
410+
expect(root.snapshots.length).toBeGreaterThanOrEqual(3); // App, Header, Content
411+
412+
// Find the App snapshot
413+
const appSnapshot = root.snapshots.find(([id]) => id === 1);
414+
expect(appSnapshot).toBeDefined();
415+
const [, appNode] = appSnapshot!;
416+
expect(appNode.displayName).toBe('App');
417+
expect(appNode.type).toBe(5); // ElementType for function
418+
expect(appNode.children).toEqual(expect.arrayContaining([2, 3]));
419+
expect(appNode.compiledWithForget).toBe(false);
420+
expect(appNode.hocDisplayNames).toBeNull();
421+
});
422+
423+
it('populates initialTreeBaseDurations from first commit', () => {
424+
profiler.start('test');
425+
profiler.processProfilingData({
426+
commitData: [
427+
{
428+
timestamp: 1000,
429+
duration: 15,
430+
fiberActualDurations: [1, 10, 2, 3],
431+
fiberSelfDurations: [1, 5, 2, 3],
432+
},
433+
{
434+
timestamp: 2000,
435+
duration: 8,
436+
fiberActualDurations: [1, 6],
437+
fiberSelfDurations: [1, 4],
438+
},
439+
],
440+
});
441+
profiler.stop(tree);
442+
443+
const root = profiler.getExportData(tree)!.dataForRoots[0];
444+
// Should use first commit's self durations
445+
expect(root.initialTreeBaseDurations).toEqual(expect.arrayContaining([[1, 5], [2, 3]]));
446+
});
447+
448+
it('produces JSON that can be serialized and parsed', () => {
449+
profiler.start('test');
450+
profiler.processProfilingData({
451+
commitData: [
452+
{
453+
timestamp: 1000,
454+
duration: 15,
455+
fiberActualDurations: [1, 10, 2, 3, 3, 2],
456+
fiberSelfDurations: [1, 5, 2, 3, 3, 2],
457+
changeDescriptions: [
458+
[1, { props: ['x'], isFirstMount: false }],
459+
],
460+
},
461+
],
462+
});
463+
profiler.stop(tree);
464+
465+
const exported = profiler.getExportData(tree)!;
466+
const json = JSON.stringify(exported);
467+
const parsed = JSON.parse(json);
468+
expect(parsed.version).toBe(5);
469+
expect(parsed.dataForRoots).toHaveLength(1);
470+
expect(parsed.dataForRoots[0].commitData).toHaveLength(1);
471+
});
472+
});
302473
});

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
formatTimeline,
1919
formatCommitDetail,
2020
} from './formatters.js';
21+
import { writeFileSync } from 'node:fs';
22+
import { resolve } from 'node:path';
2123
import type { IpcCommand } from './types.js';
2224

2325
function usage(): string {
@@ -48,7 +50,8 @@ Profiling:
4850
profile slow [--limit N] Slowest components (by avg)
4951
profile rerenders [--limit N] Most re-rendered components
5052
profile timeline [--limit N] Commit timeline
51-
profile commit <N | #N> [--limit N] Detail for specific commit`;
53+
profile commit <N | #N> [--limit N] Detail for specific commit
54+
profile export <file> Export as React DevTools JSON`;
5255
}
5356

5457
function parseArgs(argv: string[]): {
@@ -364,6 +367,24 @@ async function main(): Promise<void> {
364367
return;
365368
}
366369

370+
if (cmd0 === 'profile' && cmd1 === 'export') {
371+
const file = command[2];
372+
if (!file) {
373+
console.error('Usage: devtools profile export <file>');
374+
process.exit(1);
375+
}
376+
const resp = await sendCommand({ type: 'profile-export' });
377+
if (resp.ok) {
378+
const outPath = resolve(file);
379+
writeFileSync(outPath, JSON.stringify(resp.data), 'utf-8');
380+
console.log(`Exported to ${outPath}`);
381+
} else {
382+
console.error(resp.error);
383+
process.exit(1);
384+
}
385+
return;
386+
}
387+
367388
console.error(`Unknown command: ${command.join(' ')}`);
368389
console.log(usage());
369390
process.exit(1);

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,14 @@ class Daemon {
255255
return { ok: true, data: detail };
256256
}
257257

258+
case 'profile-export': {
259+
const exportData = this.profiler.getExportData(this.tree);
260+
if (!exportData) {
261+
return { ok: false, error: 'No profiling data to export (run profile start/stop first)' };
262+
}
263+
return { ok: true, data: exportData };
264+
}
265+
258266
case 'wait':
259267
return this.handleWait(cmd, conn);
260268

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

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import type {
55
ComponentRenderReport,
66
RenderCause,
77
ChangedKeys,
8+
ComponentType,
9+
ProfilingDataExport,
10+
ProfilingDataForRootExport,
11+
CommitDataExport,
12+
SnapshotNodeExport,
813
} from './types.js';
914
import type { ComponentTree } from './component-tree.js';
1015

@@ -317,6 +322,106 @@ export class Profiler {
317322
return entries;
318323
}
319324

325+
/**
326+
* Export profiling data in React DevTools Profiler format (version 5).
327+
* The output can be imported into React DevTools via the Profiler tab.
328+
*/
329+
getExportData(tree: ComponentTree): ProfilingDataExport | null {
330+
if (!this.session || this.session.commits.length === 0) return null;
331+
332+
// Group commits by root ID (most apps have one root)
333+
const rootIds = tree.getRootIds();
334+
if (rootIds.length === 0) {
335+
// Fallback: use a synthetic root
336+
rootIds.push(1);
337+
}
338+
339+
const dataForRoots: ProfilingDataForRootExport[] = [];
340+
341+
for (const rootId of rootIds) {
342+
const rootNode = tree.getNode(rootId);
343+
344+
// Build snapshots from current tree
345+
const snapshots: Array<[number, SnapshotNodeExport]> = [];
346+
const allNodeIds = tree.getAllNodeIds();
347+
for (const nodeId of allNodeIds) {
348+
const node = tree.getNode(nodeId);
349+
if (!node) continue;
350+
snapshots.push([nodeId, {
351+
id: nodeId,
352+
children: node.children,
353+
displayName: node.displayName || null,
354+
hocDisplayNames: null,
355+
key: node.key,
356+
type: componentTypeToElementType(node.type),
357+
compiledWithForget: false,
358+
}]);
359+
}
360+
361+
// Build initial tree base durations from first commit
362+
const initialTreeBaseDurations: Array<[number, number]> = [];
363+
if (this.session.commits.length > 0) {
364+
const firstCommit = this.session.commits[0];
365+
for (const [id, duration] of firstCommit.fiberSelfDurations) {
366+
initialTreeBaseDurations.push([id, duration]);
367+
}
368+
}
369+
370+
// Convert commits
371+
const commitData: CommitDataExport[] = this.session.commits.map(
372+
(commit) => this.convertCommit(commit),
373+
);
374+
375+
dataForRoots.push({
376+
commitData,
377+
displayName: rootNode?.displayName || 'Root',
378+
initialTreeBaseDurations,
379+
operations: this.session.commits.map(() => []),
380+
rootID: rootId,
381+
snapshots,
382+
});
383+
}
384+
385+
return {
386+
version: 5,
387+
dataForRoots,
388+
};
389+
}
390+
391+
private convertCommit(commit: ProfilingCommit): CommitDataExport {
392+
const changeDescriptions: Array<[number, {
393+
context: null;
394+
didHooksChange: boolean;
395+
isFirstMount: boolean;
396+
props: string[] | null;
397+
state: string[] | null;
398+
hooks: number[] | null;
399+
}]> = [];
400+
401+
for (const [id, desc] of commit.changeDescriptions) {
402+
changeDescriptions.push([id, {
403+
context: null,
404+
didHooksChange: desc.didHooksChange,
405+
isFirstMount: desc.isFirstMount,
406+
props: desc.props,
407+
state: desc.state,
408+
hooks: desc.hooks,
409+
}]);
410+
}
411+
412+
return {
413+
changeDescriptions: changeDescriptions.length > 0 ? changeDescriptions : null,
414+
duration: commit.duration,
415+
effectDuration: null,
416+
fiberActualDurations: Array.from(commit.fiberActualDurations.entries()),
417+
fiberSelfDurations: Array.from(commit.fiberSelfDurations.entries()),
418+
passiveEffectDuration: null,
419+
priorityLevel: null,
420+
timestamp: commit.timestamp,
421+
updaters: null,
422+
};
423+
}
424+
320425
private getAllReports(tree: ComponentTree): ComponentRenderReport[] {
321426
if (!this.session) return [];
322427

@@ -337,6 +442,21 @@ export class Profiler {
337442
}
338443
}
339444

445+
function componentTypeToElementType(type: ComponentType): number {
446+
switch (type) {
447+
case 'class': return 1;
448+
case 'context': return 2;
449+
case 'function': return 5;
450+
case 'forwardRef': return 6;
451+
case 'host': return 7;
452+
case 'memo': return 8;
453+
case 'profiler': return 10;
454+
case 'suspense': return 12;
455+
case 'other': return 9;
456+
default: return 9;
457+
}
458+
}
459+
340460
function parseDurations(
341461
raw: Array<[number, number]> | number[],
342462
target: Map<number, number>,

0 commit comments

Comments
 (0)