diff --git a/.changeset/profile-export.md b/.changeset/profile-export.md new file mode 100644 index 0000000..60b1f33 --- /dev/null +++ b/.changeset/profile-export.md @@ -0,0 +1,9 @@ +--- +"agent-react-devtools": minor +--- + +Add `profile export ` command + +- Exports profiling session as a JSON file compatible with the React DevTools Profiler +- Import the file in the browser extension's Profiler tab to visualize flame graphs, ranked charts, and commit timelines +- Includes commit data, fiber durations, change descriptions, effect durations, and component snapshots diff --git a/packages/agent-react-devtools/src/__tests__/profiler.test.ts b/packages/agent-react-devtools/src/__tests__/profiler.test.ts index 4aabfdb..36f2cb4 100644 --- a/packages/agent-react-devtools/src/__tests__/profiler.test.ts +++ b/packages/agent-react-devtools/src/__tests__/profiler.test.ts @@ -299,4 +299,179 @@ describe('Profiler', () => { expect(timeline[1].duration).toBe(20); expect(timeline[1].componentCount).toBe(2); }); + + describe('getExportData', () => { + it('returns null when no session exists', () => { + expect(profiler.getExportData(tree)).toBeNull(); + }); + + it('returns null when session has no commits', () => { + profiler.start('test'); + profiler.stop(); + expect(profiler.getExportData(tree)).toBeNull(); + }); + + it('exports version 5 format', () => { + profiler.start('test'); + profiler.processProfilingData({ + commitData: [ + { + timestamp: 1000, + duration: 15, + fiberActualDurations: [1, 10, 2, 3, 3, 2], + fiberSelfDurations: [1, 5, 2, 3, 3, 2], + }, + ], + }); + profiler.stop(tree); + + const exported = profiler.getExportData(tree); + expect(exported).not.toBeNull(); + expect(exported!.version).toBe(5); + expect(exported!.dataForRoots).toHaveLength(1); + }); + + it('maps commits to CommitDataExport format', () => { + 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 }], + ], + }, + ], + }); + profiler.stop(tree); + + const root = profiler.getExportData(tree)!.dataForRoots[0]; + expect(root.commitData).toHaveLength(1); + + const commit = root.commitData[0]; + expect(commit.timestamp).toBe(1000); + expect(commit.duration).toBe(15); + expect(commit.fiberActualDurations).toEqual(expect.arrayContaining([[1, 10], [2, 3]])); + expect(commit.fiberSelfDurations).toEqual(expect.arrayContaining([[1, 5], [2, 3]])); + expect(commit.effectDuration).toBe(0); + expect(commit.passiveEffectDuration).toBe(0); + expect(commit.priorityLevel).toBeNull(); + expect(commit.updaters).toBeNull(); + }); + + it('exports change descriptions with context:null field', () => { + profiler.start('test'); + profiler.processProfilingData({ + commitData: [ + { + timestamp: 1000, + duration: 5, + fiberActualDurations: [1, 5], + fiberSelfDurations: [1, 5], + changeDescriptions: [ + [1, { props: ['onClick'], state: ['count'], isFirstMount: false, didHooksChange: true }], + ], + }, + ], + }); + profiler.stop(tree); + + const commit = profiler.getExportData(tree)!.dataForRoots[0].commitData[0]; + expect(commit.changeDescriptions).toHaveLength(1); + const [id, desc] = commit.changeDescriptions![0]; + expect(id).toBe(1); + expect(desc.context).toBeNull(); + expect(desc.props).toEqual(['onClick']); + expect(desc.state).toEqual(['count']); + expect(desc.didHooksChange).toBe(true); + expect(desc.isFirstMount).toBe(false); + }); + + it('builds snapshots from component tree', () => { + profiler.start('test'); + profiler.processProfilingData({ + commitData: [ + { + timestamp: 1000, + duration: 5, + fiberActualDurations: [1, 5], + fiberSelfDurations: [1, 5], + }, + ], + }); + profiler.stop(tree); + + const root = profiler.getExportData(tree)!.dataForRoots[0]; + // Should have snapshots for all nodes in the tree + expect(root.snapshots.length).toBeGreaterThanOrEqual(3); // App, Header, Content + + // Find the App snapshot + const appSnapshot = root.snapshots.find(([id]) => id === 1); + expect(appSnapshot).toBeDefined(); + const [, appNode] = appSnapshot!; + expect(appNode.displayName).toBe('App'); + expect(appNode.type).toBe(5); // ElementType for function + expect(appNode.children).toEqual(expect.arrayContaining([2, 3])); + expect(appNode.compiledWithForget).toBe(false); + expect(appNode.hocDisplayNames).toBeNull(); + }); + + it('populates initialTreeBaseDurations for all tree nodes', () => { + profiler.start('test'); + profiler.processProfilingData({ + commitData: [ + { + timestamp: 1000, + duration: 15, + fiberActualDurations: [1, 10, 2, 3], + fiberSelfDurations: [1, 5, 2, 3], + }, + { + timestamp: 2000, + duration: 8, + fiberActualDurations: [1, 6], + fiberSelfDurations: [1, 4], + }, + ], + }); + profiler.stop(tree); + + const root = profiler.getExportData(tree)!.dataForRoots[0]; + // Should include ALL tree nodes, using latest self duration (0 for unrendered) + expect(root.initialTreeBaseDurations).toEqual(expect.arrayContaining([ + [1, 4], // latest from commit 2 + [2, 3], // from commit 1 (not in commit 2) + [3, 0], // never rendered — defaults to 0 + ])); + }); + + it('produces JSON that can be serialized and parsed', () => { + profiler.start('test'); + profiler.processProfilingData({ + commitData: [ + { + timestamp: 1000, + duration: 15, + fiberActualDurations: [1, 10, 2, 3, 3, 2], + fiberSelfDurations: [1, 5, 2, 3, 3, 2], + changeDescriptions: [ + [1, { props: ['x'], isFirstMount: false }], + ], + }, + ], + }); + profiler.stop(tree); + + const exported = profiler.getExportData(tree)!; + const json = JSON.stringify(exported); + const parsed = JSON.parse(json); + expect(parsed.version).toBe(5); + expect(parsed.dataForRoots).toHaveLength(1); + expect(parsed.dataForRoots[0].commitData).toHaveLength(1); + }); + }); }); diff --git a/packages/agent-react-devtools/src/cli.ts b/packages/agent-react-devtools/src/cli.ts index e1ae938..fb2a0fe 100644 --- a/packages/agent-react-devtools/src/cli.ts +++ b/packages/agent-react-devtools/src/cli.ts @@ -18,6 +18,8 @@ import { formatTimeline, formatCommitDetail, } from './formatters.js'; +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import type { IpcCommand } from './types.js'; function usage(): string { @@ -48,7 +50,8 @@ Profiling: profile slow [--limit N] Slowest components (by avg) profile rerenders [--limit N] Most re-rendered components profile timeline [--limit N] Commit timeline - profile commit [--limit N] Detail for specific commit`; + profile commit [--limit N] Detail for specific commit + profile export Export as React DevTools JSON`; } function parseArgs(argv: string[]): { @@ -364,6 +367,24 @@ async function main(): Promise { return; } + if (cmd0 === 'profile' && cmd1 === 'export') { + const file = command[2]; + if (!file) { + console.error('Usage: devtools profile export '); + process.exit(1); + } + const resp = await sendCommand({ type: 'profile-export' }); + if (resp.ok) { + const outPath = resolve(file); + writeFileSync(outPath, JSON.stringify(resp.data), 'utf-8'); + console.log(`Exported to ${outPath}`); + } 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 6aa0802..2002062 100644 --- a/packages/agent-react-devtools/src/daemon.ts +++ b/packages/agent-react-devtools/src/daemon.ts @@ -255,6 +255,14 @@ class Daemon { return { ok: true, data: detail }; } + case 'profile-export': { + const exportData = this.profiler.getExportData(this.tree); + if (!exportData) { + return { ok: false, error: 'No profiling data to export (run profile start/stop first)' }; + } + return { ok: true, data: exportData }; + } + case 'wait': return this.handleWait(cmd, conn); diff --git a/packages/agent-react-devtools/src/profile-export.ts b/packages/agent-react-devtools/src/profile-export.ts new file mode 100644 index 0000000..02ddb23 --- /dev/null +++ b/packages/agent-react-devtools/src/profile-export.ts @@ -0,0 +1,188 @@ +import type { + ProfilingSession, + ProfilingCommit, + ComponentType, + ProfilingDataExport, + ProfilingDataForRootExport, + CommitDataExport, + SnapshotNodeExport, +} from './types.js'; +import type { ComponentTree } from './component-tree.js'; + +/** + * Build a React DevTools Profiler export (version 5) from a profiling session. + * + * When raw data from React DevTools is available (collected via + * processProfilingData), it is passed through to ensure full fidelity — + * including initialTreeBaseDurations that DevTools needs for correct + * flame graph rendering. Snapshots are always built from the ComponentTree + * since the protocol sends them empty. + */ +export function buildExportData( + session: ProfilingSession, + tree: ComponentTree, +): ProfilingDataExport | null { + if (session.commits.length === 0) return null; + + if (session.rawRoots.length > 0) { + return { + version: 5, + dataForRoots: session.rawRoots.map((raw) => { + const subtreeIds = collectSubtreeIds(raw.rootID, tree); + const snapshots = buildSnapshots(raw.rootID, subtreeIds, tree); + const operations = raw.operations.length > 0 + ? raw.operations + : session.commits.map(() => []); + + return { + commitData: raw.commitData as CommitDataExport[], + displayName: raw.displayName, + initialTreeBaseDurations: raw.initialTreeBaseDurations, + operations, + rootID: raw.rootID, + snapshots, + }; + }), + }; + } + + // Fallback: reconstruct from parsed commits (e.g. if data came via flat format) + return buildFromParsedCommits(session, tree); +} + +function buildFromParsedCommits( + session: ProfilingSession, + tree: ComponentTree, +): ProfilingDataExport { + let rootIds = tree.getRootIds(); + if (rootIds.length === 0) { + rootIds = [1]; + } + + const dataForRoots: ProfilingDataForRootExport[] = []; + + for (const rootId of rootIds) { + const rootNode = tree.getNode(rootId); + const subtreeIds = collectSubtreeIds(rootId, tree); + const snapshots = buildSnapshots(rootId, subtreeIds, tree); + + const baseDurationMap = new Map(); + for (const nodeId of subtreeIds) { + baseDurationMap.set(nodeId, 0); + } + for (const commit of session.commits) { + for (const [id, duration] of commit.fiberSelfDurations) { + if (baseDurationMap.has(id)) { + baseDurationMap.set(id, duration); + } + } + } + + const commitData: CommitDataExport[] = session.commits.map( + (commit) => convertCommit(commit), + ); + + dataForRoots.push({ + commitData, + displayName: rootNode?.displayName || 'Root', + initialTreeBaseDurations: Array.from(baseDurationMap.entries()), + operations: session.commits.map(() => []), + rootID: rootId, + snapshots, + }); + } + + return { + version: 5, + dataForRoots, + }; +} + +function convertCommit(commit: ProfilingCommit): CommitDataExport { + const changeDescriptions: Array<[number, { + context: null; + didHooksChange: boolean; + isFirstMount: boolean; + props: string[] | null; + state: string[] | null; + hooks: number[] | null; + }]> = []; + + for (const [id, desc] of commit.changeDescriptions) { + changeDescriptions.push([id, { + context: null, + didHooksChange: desc.didHooksChange, + isFirstMount: desc.isFirstMount, + props: desc.props, + state: desc.state, + hooks: desc.hooks, + }]); + } + + return { + changeDescriptions: changeDescriptions.length > 0 ? changeDescriptions : null, + duration: commit.duration, + effectDuration: commit.effectDuration ?? 0, + fiberActualDurations: Array.from(commit.fiberActualDurations.entries()), + fiberSelfDurations: Array.from(commit.fiberSelfDurations.entries()), + passiveEffectDuration: commit.passiveEffectDuration ?? 0, + priorityLevel: commit.priorityLevel ?? null, + timestamp: commit.timestamp, + updaters: commit.updaters as CommitDataExport['updaters'], + }; +} + +function buildSnapshots( + rootId: number, + subtreeIds: number[], + tree: ComponentTree, +): Array<[number, SnapshotNodeExport]> { + const snapshots: Array<[number, SnapshotNodeExport]> = []; + for (const nodeId of subtreeIds) { + const node = tree.getNode(nodeId); + if (!node) continue; + const elementType = + nodeId === rootId && node.type === 'other' + ? 11 + : componentTypeToElementType(node.type); + snapshots.push([nodeId, { + id: nodeId, + children: node.children, + displayName: node.displayName || null, + hocDisplayNames: null, + key: node.key, + type: elementType, + compiledWithForget: false, + }]); + } + return snapshots; +} + +function collectSubtreeIds(rootId: number, tree: ComponentTree): number[] { + const ids: number[] = []; + const visit = (id: number) => { + const node = tree.getNode(id); + if (!node) return; + ids.push(id); + for (const childId of node.children) { + visit(childId); + } + }; + visit(rootId); + return ids; +} + +function componentTypeToElementType(type: ComponentType): number { + switch (type) { + case 'class': return 1; + case 'context': return 2; + case 'function': return 5; + case 'forwardRef': return 6; + case 'host': return 7; + case 'memo': return 8; + case 'profiler': return 10; + case 'suspense': return 12; + case 'other': return 9; + default: return 9; + } +} diff --git a/packages/agent-react-devtools/src/profiler.ts b/packages/agent-react-devtools/src/profiler.ts index a44c236..3fb9704 100644 --- a/packages/agent-react-devtools/src/profiler.ts +++ b/packages/agent-react-devtools/src/profiler.ts @@ -5,8 +5,10 @@ import type { ComponentRenderReport, RenderCause, ChangedKeys, + ProfilingDataExport, } from './types.js'; import type { ComponentTree } from './component-tree.js'; +import { buildExportData } from './profile-export.js'; export interface ProfileSummary { name: string; @@ -55,6 +57,7 @@ export class Profiler { startedAt: Date.now(), stoppedAt: null, commits: [], + rawRoots: [], }; } @@ -105,36 +108,38 @@ export class Profiler { * - changeDescriptions: Map */ processProfilingData(payload: unknown): void { - if (!this.session || this.session.stoppedAt !== null) return; + if (!this.session) 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[]; + rootID?: number; + commitData?: unknown[]; + operations?: Array; + initialTreeBaseDurations?: Array<[number, number]>; + snapshots?: Array<[number, unknown]>; + displayName?: string; }>; // Alternative flat format - commitData?: Array<{ - changeDescriptions?: Array<[number, unknown]> | Map; - duration?: number; - fiberActualDurations?: Array<[number, number]> | number[]; - fiberSelfDurations?: Array<[number, number]> | number[]; - timestamp?: number; - }>; + commitData?: unknown[]; }; // Handle nested format (dataForRoots) const roots = data?.dataForRoots; if (roots) { for (const root of roots) { + // Store raw root data for export passthrough + this.session.rawRoots.push({ + rootID: root.rootID ?? 1, + commitData: root.commitData ?? [], + initialTreeBaseDurations: root.initialTreeBaseDurations ?? [], + operations: root.operations ?? [], + snapshots: root.snapshots ?? [], + displayName: root.displayName ?? 'Root', + }); + if (root.commitData) { for (const commitData of root.commitData) { - this.processCommitData(commitData); + this.processCommitData(commitData as Record); } } } @@ -144,40 +149,39 @@ export class Profiler { // Handle flat format if (data?.commitData) { for (const commitData of data.commitData) { - this.processCommitData(commitData); + this.processCommitData(commitData as Record); } } } - private processCommitData(commitData: { - changeDescriptions?: Array<[number, unknown]> | Map; - duration?: number; - fiberActualDurations?: Array<[number, number]> | number[]; - fiberSelfDurations?: Array<[number, number]> | number[]; - timestamp?: number; - }): void { + private processCommitData(commitData: Record): void { const commit: ProfilingCommit = { - timestamp: commitData.timestamp || Date.now(), - duration: commitData.duration || 0, + timestamp: (commitData.timestamp as number) || Date.now(), + duration: (commitData.duration as number) || 0, fiberActualDurations: new Map(), fiberSelfDurations: new Map(), changeDescriptions: new Map(), + effectDuration: (commitData.effectDuration as number) ?? null, + passiveEffectDuration: (commitData.passiveEffectDuration as number) ?? null, + priorityLevel: (commitData.priorityLevel as string) ?? null, + updaters: (commitData.updaters as unknown[]) ?? null, }; // Parse fiber durations (can be [id, duration, id, duration, ...] or [[id, duration], ...]) if (commitData.fiberActualDurations) { - parseDurations(commitData.fiberActualDurations, commit.fiberActualDurations); + parseDurations(commitData.fiberActualDurations as Array<[number, number]> | number[], commit.fiberActualDurations); } if (commitData.fiberSelfDurations) { - parseDurations(commitData.fiberSelfDurations, commit.fiberSelfDurations); + parseDurations(commitData.fiberSelfDurations as Array<[number, number]> | number[], commit.fiberSelfDurations); } // Parse change descriptions - if (commitData.changeDescriptions) { + const rawDescs = commitData.changeDescriptions as Array<[number, unknown]> | Map | undefined; + if (rawDescs) { const entries = - commitData.changeDescriptions instanceof Map - ? commitData.changeDescriptions.entries() - : commitData.changeDescriptions[Symbol.iterator](); + rawDescs instanceof Map + ? rawDescs.entries() + : rawDescs[Symbol.iterator](); for (const [id, desc] of entries) { const d = desc as { didHooksChange?: boolean; @@ -317,6 +321,11 @@ export class Profiler { return entries; } + getExportData(tree: ComponentTree): ProfilingDataExport | null { + if (!this.session) return null; + return buildExportData(this.session, tree); + } + private getAllReports(tree: ComponentTree): ComponentRenderReport[] { if (!this.session) return []; diff --git a/packages/agent-react-devtools/src/types.ts b/packages/agent-react-devtools/src/types.ts index 6bc3db2..6e58572 100644 --- a/packages/agent-react-devtools/src/types.ts +++ b/packages/agent-react-devtools/src/types.ts @@ -46,6 +46,8 @@ export interface ProfilingSession { startedAt: number; stoppedAt: number | null; commits: ProfilingCommit[]; + /** Raw per-root data from React DevTools, stored for export passthrough. */ + rawRoots: ProfilingRootRawData[]; } export interface ProfilingCommit { @@ -54,6 +56,20 @@ export interface ProfilingCommit { fiberActualDurations: Map; fiberSelfDurations: Map; changeDescriptions: Map; + effectDuration: number | null; + passiveEffectDuration: number | null; + priorityLevel: string | null; + updaters: unknown[] | null; +} + +/** Raw per-root profiling data from React DevTools, stored for export passthrough. */ +export interface ProfilingRootRawData { + rootID: number; + commitData: unknown[]; + initialTreeBaseDurations: Array<[number, number]>; + operations: Array; + snapshots: Array<[number, unknown]>; + displayName: string; } export interface ChangeDescription { @@ -91,6 +107,54 @@ export type RenderCause = | 'force-update' | 'first-mount'; +// ── React DevTools Profiler Export (version 5) ── + +export interface ProfilingDataExport { + version: 5; + dataForRoots: ProfilingDataForRootExport[]; + timelineData?: unknown[]; +} + +export interface ProfilingDataForRootExport { + commitData: CommitDataExport[]; + displayName: string; + initialTreeBaseDurations: Array<[number, number]>; + operations: Array>; + rootID: number; + snapshots: Array<[number, SnapshotNodeExport]>; +} + +export interface CommitDataExport { + changeDescriptions: Array<[number, ChangeDescriptionExport]> | null; + duration: number; + effectDuration: number | null; + fiberActualDurations: Array<[number, number]>; + fiberSelfDurations: Array<[number, number]>; + passiveEffectDuration: number | null; + priorityLevel: string | null; + timestamp: number; + updaters: Array<{ id: number; displayName: string; type: number }> | null; +} + +export interface ChangeDescriptionExport { + context: null; + didHooksChange: boolean; + isFirstMount: boolean; + props: string[] | null; + state: string[] | null; + hooks: number[] | null; +} + +export interface SnapshotNodeExport { + id: number; + children: number[]; + displayName: string | null; + hocDisplayNames: string[] | null; + key: string | null; + type: number; + compiledWithForget: boolean; +} + // ── Connection Health ── export type ConnectionEventType = 'connected' | 'disconnected' | 'reconnected'; @@ -123,6 +187,7 @@ export type IpcCommand = | { type: 'profile-rerenders'; limit?: number } | { type: 'profile-timeline'; limit?: number } | { type: 'profile-commit'; index: number; limit?: number } + | { type: 'profile-export' } | { type: 'wait'; condition: 'connected'; timeout?: number } | { type: 'wait'; condition: 'component'; name: string; timeout?: number };