Skip to content

Commit ed5226a

Browse files
piotrskiclaude
andcommitted
fix: pass through raw React DevTools profiling data for correct export
The profile export was producing broken flame graphs because we discarded the raw profiling payload and reconstructed it with missing/incorrect data (empty operations, null effect durations, wrong base durations). - Store raw per-root data from React DevTools for export passthrough - Capture additional commit fields (effectDuration, priorityLevel, etc.) - Build snapshots from ComponentTree (protocol sends them empty) - Extract export logic into profile-export.ts for clarity - Fix all tree nodes included in initialTreeBaseDurations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 46be331 commit ed5226a

4 files changed

Lines changed: 250 additions & 153 deletions

File tree

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,8 @@ describe('Profiler', () => {
357357
expect(commit.duration).toBe(15);
358358
expect(commit.fiberActualDurations).toEqual(expect.arrayContaining([[1, 10], [2, 3]]));
359359
expect(commit.fiberSelfDurations).toEqual(expect.arrayContaining([[1, 5], [2, 3]]));
360-
expect(commit.effectDuration).toBeNull();
361-
expect(commit.passiveEffectDuration).toBeNull();
360+
expect(commit.effectDuration).toBe(0);
361+
expect(commit.passiveEffectDuration).toBe(0);
362362
expect(commit.priorityLevel).toBeNull();
363363
expect(commit.updaters).toBeNull();
364364
});
@@ -420,7 +420,7 @@ describe('Profiler', () => {
420420
expect(appNode.hocDisplayNames).toBeNull();
421421
});
422422

423-
it('populates initialTreeBaseDurations from first commit', () => {
423+
it('populates initialTreeBaseDurations for all tree nodes', () => {
424424
profiler.start('test');
425425
profiler.processProfilingData({
426426
commitData: [
@@ -441,8 +441,12 @@ describe('Profiler', () => {
441441
profiler.stop(tree);
442442

443443
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]]));
444+
// Should include ALL tree nodes, using latest self duration (0 for unrendered)
445+
expect(root.initialTreeBaseDurations).toEqual(expect.arrayContaining([
446+
[1, 4], // latest from commit 2
447+
[2, 3], // from commit 1 (not in commit 2)
448+
[3, 0], // never rendered — defaults to 0
449+
]));
446450
});
447451

448452
it('produces JSON that can be serialized and parsed', () => {
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import type {
2+
ProfilingSession,
3+
ProfilingCommit,
4+
ComponentType,
5+
ProfilingDataExport,
6+
ProfilingDataForRootExport,
7+
CommitDataExport,
8+
SnapshotNodeExport,
9+
} from './types.js';
10+
import type { ComponentTree } from './component-tree.js';
11+
12+
/**
13+
* Build a React DevTools Profiler export (version 5) from a profiling session.
14+
*
15+
* When raw data from React DevTools is available (collected via
16+
* processProfilingData), it is passed through to ensure full fidelity —
17+
* including initialTreeBaseDurations that DevTools needs for correct
18+
* flame graph rendering. Snapshots are always built from the ComponentTree
19+
* since the protocol sends them empty.
20+
*/
21+
export function buildExportData(
22+
session: ProfilingSession,
23+
tree: ComponentTree,
24+
): ProfilingDataExport | null {
25+
if (session.commits.length === 0) return null;
26+
27+
if (session.rawRoots.length > 0) {
28+
return {
29+
version: 5,
30+
dataForRoots: session.rawRoots.map((raw) => {
31+
const subtreeIds = collectSubtreeIds(raw.rootID, tree);
32+
const snapshots = buildSnapshots(raw.rootID, subtreeIds, tree);
33+
const operations = raw.operations.length > 0
34+
? raw.operations
35+
: session.commits.map(() => []);
36+
37+
return {
38+
commitData: raw.commitData as CommitDataExport[],
39+
displayName: raw.displayName,
40+
initialTreeBaseDurations: raw.initialTreeBaseDurations,
41+
operations,
42+
rootID: raw.rootID,
43+
snapshots,
44+
};
45+
}),
46+
};
47+
}
48+
49+
// Fallback: reconstruct from parsed commits (e.g. if data came via flat format)
50+
return buildFromParsedCommits(session, tree);
51+
}
52+
53+
function buildFromParsedCommits(
54+
session: ProfilingSession,
55+
tree: ComponentTree,
56+
): ProfilingDataExport {
57+
let rootIds = tree.getRootIds();
58+
if (rootIds.length === 0) {
59+
rootIds = [1];
60+
}
61+
62+
const dataForRoots: ProfilingDataForRootExport[] = [];
63+
64+
for (const rootId of rootIds) {
65+
const rootNode = tree.getNode(rootId);
66+
const subtreeIds = collectSubtreeIds(rootId, tree);
67+
const snapshots = buildSnapshots(rootId, subtreeIds, tree);
68+
69+
const baseDurationMap = new Map<number, number>();
70+
for (const nodeId of subtreeIds) {
71+
baseDurationMap.set(nodeId, 0);
72+
}
73+
for (const commit of session.commits) {
74+
for (const [id, duration] of commit.fiberSelfDurations) {
75+
if (baseDurationMap.has(id)) {
76+
baseDurationMap.set(id, duration);
77+
}
78+
}
79+
}
80+
81+
const commitData: CommitDataExport[] = session.commits.map(
82+
(commit) => convertCommit(commit),
83+
);
84+
85+
dataForRoots.push({
86+
commitData,
87+
displayName: rootNode?.displayName || 'Root',
88+
initialTreeBaseDurations: Array.from(baseDurationMap.entries()),
89+
operations: session.commits.map(() => []),
90+
rootID: rootId,
91+
snapshots,
92+
});
93+
}
94+
95+
return {
96+
version: 5,
97+
dataForRoots,
98+
};
99+
}
100+
101+
function convertCommit(commit: ProfilingCommit): CommitDataExport {
102+
const changeDescriptions: Array<[number, {
103+
context: null;
104+
didHooksChange: boolean;
105+
isFirstMount: boolean;
106+
props: string[] | null;
107+
state: string[] | null;
108+
hooks: number[] | null;
109+
}]> = [];
110+
111+
for (const [id, desc] of commit.changeDescriptions) {
112+
changeDescriptions.push([id, {
113+
context: null,
114+
didHooksChange: desc.didHooksChange,
115+
isFirstMount: desc.isFirstMount,
116+
props: desc.props,
117+
state: desc.state,
118+
hooks: desc.hooks,
119+
}]);
120+
}
121+
122+
return {
123+
changeDescriptions: changeDescriptions.length > 0 ? changeDescriptions : null,
124+
duration: commit.duration,
125+
effectDuration: commit.effectDuration ?? 0,
126+
fiberActualDurations: Array.from(commit.fiberActualDurations.entries()),
127+
fiberSelfDurations: Array.from(commit.fiberSelfDurations.entries()),
128+
passiveEffectDuration: commit.passiveEffectDuration ?? 0,
129+
priorityLevel: commit.priorityLevel ?? null,
130+
timestamp: commit.timestamp,
131+
updaters: commit.updaters as CommitDataExport['updaters'],
132+
};
133+
}
134+
135+
function buildSnapshots(
136+
rootId: number,
137+
subtreeIds: number[],
138+
tree: ComponentTree,
139+
): Array<[number, SnapshotNodeExport]> {
140+
const snapshots: Array<[number, SnapshotNodeExport]> = [];
141+
for (const nodeId of subtreeIds) {
142+
const node = tree.getNode(nodeId);
143+
if (!node) continue;
144+
const elementType =
145+
nodeId === rootId && node.type === 'other'
146+
? 11
147+
: componentTypeToElementType(node.type);
148+
snapshots.push([nodeId, {
149+
id: nodeId,
150+
children: node.children,
151+
displayName: node.displayName || null,
152+
hocDisplayNames: null,
153+
key: node.key,
154+
type: elementType,
155+
compiledWithForget: false,
156+
}]);
157+
}
158+
return snapshots;
159+
}
160+
161+
function collectSubtreeIds(rootId: number, tree: ComponentTree): number[] {
162+
const ids: number[] = [];
163+
const visit = (id: number) => {
164+
const node = tree.getNode(id);
165+
if (!node) return;
166+
ids.push(id);
167+
for (const childId of node.children) {
168+
visit(childId);
169+
}
170+
};
171+
visit(rootId);
172+
return ids;
173+
}
174+
175+
function componentTypeToElementType(type: ComponentType): number {
176+
switch (type) {
177+
case 'class': return 1;
178+
case 'context': return 2;
179+
case 'function': return 5;
180+
case 'forwardRef': return 6;
181+
case 'host': return 7;
182+
case 'memo': return 8;
183+
case 'profiler': return 10;
184+
case 'suspense': return 12;
185+
case 'other': return 9;
186+
default: return 9;
187+
}
188+
}

0 commit comments

Comments
 (0)