Skip to content

Commit fc58072

Browse files
committed
feat: surface changed prop/state/hook keys in profiling output
Add ChangedKeys type carrying specific changed keys (props, state, hooks) alongside cause type strings. This makes profiling output more actionable by showing *which* props/state/hooks changed, not just *that* they changed. - Add ChangedKeys interface and changedKeys field to ComponentRenderReport - Add extractChangedKeys helper and aggregate keys across commits in Profiler - Include changedKeys in CommitDetail components - Add formatChangedKeys helper and display keys in all profiling formatters - Add tests for key aggregation, deduplication, and display
1 parent 02e8cf0 commit fc58072

5 files changed

Lines changed: 173 additions & 19 deletions

File tree

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

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import {
1212
formatRerenders,
1313
formatTimeline,
1414
formatCommitDetail,
15+
formatChangedKeys,
1516
} from '../formatters.js';
1617
import type { TreeNode } from '../component-tree.js';
17-
import type { InspectedElement, StatusInfo, ComponentRenderReport, ConnectionHealth } from '../types.js';
18+
import type { InspectedElement, StatusInfo, ComponentRenderReport, ConnectionHealth, ChangedKeys } from '../types.js';
1819
import type { ProfileSummary, TimelineEntry, CommitDetail } from '../profiler.js';
1920

2021
describe('formatTree', () => {
@@ -252,7 +253,7 @@ describe('formatProfileSummary', () => {
252253
});
253254

254255
describe('formatProfileReport', () => {
255-
it('should format a render report with type tag', () => {
256+
it('should format a render report with changed keys', () => {
256257
const report: ComponentRenderReport = {
257258
id: 5,
258259
displayName: 'UserProfile',
@@ -263,6 +264,7 @@ describe('formatProfileReport', () => {
263264
avgDuration: 45,
264265
maxDuration: 120,
265266
causes: ['props-changed', 'state-changed'],
267+
changedKeys: { props: ['userId', 'theme'], state: ['isEditing'], hooks: [] },
266268
};
267269

268270
const result = formatProfileReport(report);
@@ -271,6 +273,23 @@ describe('formatProfileReport', () => {
271273
expect(result).toContain('avg:45.0ms');
272274
expect(result).toContain('max:120.0ms');
273275
expect(result).toContain('props-changed');
276+
expect(result).toContain('changed: props: userId, theme state: isEditing');
277+
});
278+
279+
it('should omit changed line when keys are empty', () => {
280+
const report: ComponentRenderReport = {
281+
id: 5,
282+
displayName: 'UserProfile',
283+
renderCount: 1,
284+
totalDuration: 10,
285+
avgDuration: 10,
286+
maxDuration: 10,
287+
causes: ['first-mount'],
288+
changedKeys: { props: [], state: [], hooks: [] },
289+
};
290+
291+
const result = formatProfileReport(report, '@c5');
292+
expect(result).not.toContain('changed:');
274293
});
275294

276295
it('should prefer explicit label param over report.label', () => {
@@ -296,31 +315,42 @@ describe('formatSlowest', () => {
296315
expect(formatSlowest([])).toContain('No profiling data');
297316
});
298317

299-
it('should format slowest components with labels and all causes', () => {
318+
it('should format slowest components with labels and changed keys', () => {
300319
const reports: ComponentRenderReport[] = [
301-
{ id: 1, displayName: 'SlowComp', label: '@c1', type: 'function', renderCount: 5, totalDuration: 250, avgDuration: 50, maxDuration: 100, causes: ['props-changed', 'state-changed'] },
302-
{ id: 2, displayName: 'FastComp', label: '@c2', type: 'memo', renderCount: 10, totalDuration: 100, avgDuration: 10, maxDuration: 20, causes: ['state-changed'] },
320+
{ id: 1, displayName: 'SlowComp', label: '@c1', type: 'function', renderCount: 5, totalDuration: 250, avgDuration: 50, maxDuration: 100, causes: ['props-changed', 'state-changed'], changedKeys: { props: ['data'], state: ['count'], hooks: [] } },
321+
{ id: 2, displayName: 'FastComp', label: '@c2', type: 'memo', renderCount: 10, totalDuration: 100, avgDuration: 10, maxDuration: 20, causes: ['state-changed'], changedKeys: { props: [], state: ['count'], hooks: [] } },
303322
];
304323

305324
const result = formatSlowest(reports);
306325
expect(result).toContain('Slowest');
307326
expect(result).toContain('@c1 [fn] SlowComp');
308327
expect(result).toContain('@c2 [memo] FastComp');
309328
expect(result).toContain('causes:props-changed, state-changed');
310-
expect(result).toContain('causes:state-changed');
329+
expect(result).toContain('changed: props: data state: count');
330+
expect(result).toContain('changed: state: count');
311331
});
312332
});
313333

314334
describe('formatRerenders', () => {
315-
it('should format rerender data with labels and all causes', () => {
335+
it('should format rerender data with labels and changed keys', () => {
316336
const reports: ComponentRenderReport[] = [
317-
{ id: 1, displayName: 'Chatty', label: '@c1', type: 'function', renderCount: 50, totalDuration: 100, avgDuration: 2, maxDuration: 5, causes: ['parent-rendered', 'props-changed'] },
337+
{ id: 1, displayName: 'Chatty', label: '@c1', type: 'function', renderCount: 50, totalDuration: 100, avgDuration: 2, maxDuration: 5, causes: ['parent-rendered', 'props-changed'], changedKeys: { props: ['value'], state: [], hooks: [] } },
318338
];
319339

320340
const result = formatRerenders(reports);
321341
expect(result).toContain('50 renders');
322342
expect(result).toContain('@c1 [fn] Chatty');
323343
expect(result).toContain('causes:parent-rendered, props-changed');
344+
expect(result).toContain('changed: props: value');
345+
});
346+
347+
it('should omit changed line when keys are empty', () => {
348+
const reports: ComponentRenderReport[] = [
349+
{ id: 1, displayName: 'Chatty', label: '@c1', type: 'function', renderCount: 50, totalDuration: 100, avgDuration: 2, maxDuration: 5, causes: ['parent-rendered'], changedKeys: { props: [], state: [], hooks: [] } },
350+
];
351+
352+
const result = formatRerenders(reports);
353+
expect(result).not.toContain('changed:');
324354
});
325355
});
326356

@@ -340,14 +370,14 @@ describe('formatTimeline', () => {
340370
});
341371

342372
describe('formatCommitDetail', () => {
343-
it('should format commit detail with labels and types', () => {
373+
it('should format commit detail with labels, types, and changed keys', () => {
344374
const detail: CommitDetail = {
345375
index: 0,
346376
timestamp: 1000,
347377
duration: 15.5,
348378
components: [
349-
{ id: 1, displayName: 'App', label: '@c1', type: 'function', actualDuration: 15.5, selfDuration: 5.2, causes: ['state-changed'] },
350-
{ id: 2, displayName: 'Header', label: '@c2', type: 'memo', actualDuration: 10.3, selfDuration: 10.3, causes: ['props-changed', 'hooks-changed'] },
379+
{ id: 1, displayName: 'App', label: '@c1', type: 'function', actualDuration: 15.5, selfDuration: 5.2, causes: ['state-changed'], changedKeys: { props: [], state: ['count'], hooks: [] } },
380+
{ id: 2, displayName: 'Header', label: '@c2', type: 'memo', actualDuration: 10.3, selfDuration: 10.3, causes: ['props-changed', 'hooks-changed'], changedKeys: { props: ['onClick', 'className'], state: [], hooks: [0] } },
351381
],
352382
totalComponents: 2,
353383
};
@@ -360,8 +390,10 @@ describe('formatCommitDetail', () => {
360390
expect(result).toContain('self:5.2ms');
361391
expect(result).toContain('total:15.5ms');
362392
expect(result).toContain('causes:state-changed');
393+
expect(result).toContain('changed: state: count');
363394
expect(result).toContain('@c2 [memo] Header');
364395
expect(result).toContain('causes:props-changed, hooks-changed');
396+
expect(result).toContain('changed: props: onClick, className hooks: #0');
365397
});
366398

367399
it('should show hidden count', () => {
@@ -370,12 +402,45 @@ describe('formatCommitDetail', () => {
370402
timestamp: 2000,
371403
duration: 10,
372404
components: [
373-
{ id: 1, displayName: 'App', label: '@c1', type: 'function', actualDuration: 10, selfDuration: 10, causes: [] },
405+
{ id: 1, displayName: 'App', label: '@c1', type: 'function', actualDuration: 10, selfDuration: 10, causes: [], changedKeys: { props: [], state: [], hooks: [] } },
374406
],
375407
totalComponents: 5,
376408
};
377409

378410
const result = formatCommitDetail(detail);
379411
expect(result).toContain('... 4 more');
380412
});
413+
414+
it('should omit changed keys when empty', () => {
415+
const detail: CommitDetail = {
416+
index: 0,
417+
timestamp: 1000,
418+
duration: 5,
419+
components: [
420+
{ id: 1, displayName: 'App', label: '@c1', type: 'function', actualDuration: 5, selfDuration: 5, causes: ['first-mount'], changedKeys: { props: [], state: [], hooks: [] } },
421+
],
422+
totalComponents: 1,
423+
};
424+
425+
const result = formatCommitDetail(detail);
426+
expect(result).toContain('App');
427+
expect(result).not.toContain('changed:');
428+
});
429+
});
430+
431+
describe('formatChangedKeys', () => {
432+
it('should format all key categories', () => {
433+
const keys: ChangedKeys = { props: ['onClick', 'className'], state: ['count'], hooks: [0, 3] };
434+
expect(formatChangedKeys(keys)).toBe('props: onClick, className state: count hooks: #0, #3');
435+
});
436+
437+
it('should return empty string when no keys', () => {
438+
const keys: ChangedKeys = { props: [], state: [], hooks: [] };
439+
expect(formatChangedKeys(keys)).toBe('');
440+
});
441+
442+
it('should omit empty categories', () => {
443+
const keys: ChangedKeys = { props: ['theme'], state: [], hooks: [] };
444+
expect(formatChangedKeys(keys)).toBe('props: theme');
445+
});
381446
});

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,44 @@ describe('Profiler', () => {
140140
expect(report!.maxDuration).toBe(20);
141141
expect(report!.causes).toContain('props-changed');
142142
expect(report!.causes).toContain('hooks-changed');
143+
expect(report!.changedKeys!.props).toEqual(['theme']);
144+
expect(report!.changedKeys!.state).toEqual([]);
145+
expect(report!.changedKeys!.hooks).toEqual([]);
146+
});
147+
148+
it('should deduplicate changed keys across commits', () => {
149+
profiler.start('test');
150+
151+
profiler.processProfilingData({
152+
commitData: [
153+
{
154+
timestamp: 1000,
155+
duration: 5,
156+
fiberActualDurations: [1, 5],
157+
fiberSelfDurations: [1, 5],
158+
changeDescriptions: [
159+
[1, { props: ['onClick', 'className'], state: ['count'], isFirstMount: false }],
160+
],
161+
},
162+
{
163+
timestamp: 2000,
164+
duration: 5,
165+
fiberActualDurations: [1, 5],
166+
fiberSelfDurations: [1, 5],
167+
changeDescriptions: [
168+
[1, { props: ['className', 'theme'], state: ['count'], hooks: [0, 2], isFirstMount: false }],
169+
],
170+
},
171+
],
172+
});
173+
174+
const report = profiler.getReport(1, tree);
175+
expect(report).not.toBeNull();
176+
expect(report!.changedKeys!.props).toEqual(expect.arrayContaining(['onClick', 'className', 'theme']));
177+
expect(report!.changedKeys!.props).toHaveLength(3);
178+
expect(report!.changedKeys!.state).toEqual(['count']);
179+
expect(report!.changedKeys!.hooks).toEqual(expect.arrayContaining([0, 2]));
180+
expect(report!.changedKeys!.hooks).toHaveLength(2);
143181
});
144182

145183
it('should find slowest components', () => {

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

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
StatusInfo,
33
InspectedElement,
44
ComponentRenderReport,
5+
ChangedKeys,
56
} from './types.js';
67
import type { TreeNode } from './component-tree.js';
78
import type { ProfileSummary, TimelineEntry, CommitDetail } from './profiler.js';
@@ -197,6 +198,10 @@ export function formatProfileReport(report: ComponentRenderReport, label?: strin
197198
if (report.causes.length > 0) {
198199
lines.push(`causes: ${report.causes.join(', ')}`);
199200
}
201+
const keys = formatChangedKeys(report.changedKeys);
202+
if (keys) {
203+
lines.push(`changed: ${keys}`);
204+
}
200205
return lines.join('\n');
201206
}
202207

@@ -207,9 +212,10 @@ export function formatSlowest(reports: ComponentRenderReport[]): string {
207212
for (const r of reports) {
208213
const ref = formatRef({ label: r.label, type: r.type, name: r.displayName });
209214
const causes = r.causes.length > 0 ? r.causes.join(', ') : '?';
210-
lines.push(
211-
` ${ref} avg:${r.avgDuration.toFixed(1)}ms max:${r.maxDuration.toFixed(1)}ms renders:${r.renderCount} causes:${causes}`,
212-
);
215+
let line = ` ${ref} avg:${r.avgDuration.toFixed(1)}ms max:${r.maxDuration.toFixed(1)}ms renders:${r.renderCount} causes:${causes}`;
216+
const keys = formatChangedKeys(r.changedKeys);
217+
if (keys) line += ` changed: ${keys}`;
218+
lines.push(line);
213219
}
214220
return lines.join('\n');
215221
}
@@ -221,9 +227,10 @@ export function formatRerenders(reports: ComponentRenderReport[]): string {
221227
for (const r of reports) {
222228
const ref = formatRef({ label: r.label, type: r.type, name: r.displayName });
223229
const causes = r.causes.length > 0 ? r.causes.join(', ') : '?';
224-
lines.push(
225-
` ${ref} ${r.renderCount} renders causes:${causes}`,
226-
);
230+
let line = ` ${ref} ${r.renderCount} renders causes:${causes}`;
231+
const keys = formatChangedKeys(r.changedKeys);
232+
if (keys) line += ` changed: ${keys}`;
233+
lines.push(line);
227234
}
228235
return lines.join('\n');
229236
}
@@ -247,7 +254,10 @@ export function formatCommitDetail(detail: CommitDetail): string {
247254
for (const c of detail.components) {
248255
const ref = formatRef({ label: c.label, type: c.type, name: c.displayName });
249256
const causes = c.causes.length > 0 ? c.causes.join(', ') : '?';
250-
lines.push(` ${ref} self:${c.selfDuration.toFixed(1)}ms total:${c.actualDuration.toFixed(1)}ms causes:${causes}`);
257+
let line = ` ${ref} self:${c.selfDuration.toFixed(1)}ms total:${c.actualDuration.toFixed(1)}ms causes:${causes}`;
258+
const keys = formatChangedKeys(c.changedKeys);
259+
if (keys) line += ` changed: ${keys}`;
260+
lines.push(line);
251261
}
252262
const hidden = detail.totalComponents - detail.components.length;
253263
if (hidden > 0) {
@@ -256,6 +266,17 @@ export function formatCommitDetail(detail: CommitDetail): string {
256266
return lines.join('\n');
257267
}
258268

269+
// ── Changed-keys helper ──
270+
271+
export function formatChangedKeys(keys: ChangedKeys | undefined): string {
272+
if (!keys) return '';
273+
const parts: string[] = [];
274+
if (keys.props.length > 0) parts.push(`props: ${keys.props.join(', ')}`);
275+
if (keys.state.length > 0) parts.push(`state: ${keys.state.join(', ')}`);
276+
if (keys.hooks.length > 0) parts.push(`hooks: ${keys.hooks.map((h) => `#${h}`).join(', ')}`);
277+
return parts.join(' ');
278+
}
279+
259280
// ── Helpers ──
260281

261282
function formatCompactValue(val: unknown): string | undefined {

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
ChangeDescription,
55
ComponentRenderReport,
66
RenderCause,
7+
ChangedKeys,
78
} from './types.js';
89
import type { ComponentTree } from './component-tree.js';
910

@@ -33,6 +34,7 @@ export interface CommitDetail {
3334
actualDuration: number;
3435
selfDuration: number;
3536
causes: RenderCause[];
37+
changedKeys?: ChangedKeys;
3638
}>;
3739
totalComponents: number;
3840
}
@@ -208,6 +210,9 @@ export class Profiler {
208210
let totalDuration = 0;
209211
let maxDuration = 0;
210212
const causeSet = new Set<RenderCause>();
213+
const propsSet = new Set<string>();
214+
const stateSet = new Set<string>();
215+
const hooksSet = new Set<number>();
211216

212217
for (const commit of this.session.commits) {
213218
const duration = commit.fiberActualDurations.get(componentId);
@@ -221,6 +226,10 @@ export class Profiler {
221226
for (const cause of describeCauses(desc)) {
222227
causeSet.add(cause);
223228
}
229+
const keys = extractChangedKeys(desc);
230+
for (const p of keys.props) propsSet.add(p);
231+
for (const s of keys.state) stateSet.add(s);
232+
for (const h of keys.hooks) hooksSet.add(h);
224233
}
225234
}
226235
}
@@ -235,6 +244,11 @@ export class Profiler {
235244
avgDuration: totalDuration / renderCount,
236245
maxDuration,
237246
causes: Array.from(causeSet),
247+
changedKeys: {
248+
props: Array.from(propsSet),
249+
state: Array.from(stateSet),
250+
hooks: Array.from(hooksSet),
251+
},
238252
};
239253
}
240254

@@ -272,6 +286,7 @@ export class Profiler {
272286
actualDuration,
273287
selfDuration,
274288
causes: desc ? describeCauses(desc) : [],
289+
changedKeys: desc ? extractChangedKeys(desc) : { props: [], state: [], hooks: [] },
275290
});
276291
}
277292

@@ -356,3 +371,11 @@ function describeCauses(desc: ChangeDescription): RenderCause[] {
356371
if (causes.length === 0) causes.push('parent-rendered');
357372
return causes;
358373
}
374+
375+
function extractChangedKeys(desc: ChangeDescription): ChangedKeys {
376+
return {
377+
props: desc.props ?? [],
378+
state: desc.state ?? [],
379+
hooks: desc.hooks ?? [],
380+
};
381+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ export interface ChangeDescription {
6464
hooks: number[] | null;
6565
}
6666

67+
export interface ChangedKeys {
68+
props: string[];
69+
state: string[];
70+
hooks: number[];
71+
}
72+
6773
export interface ComponentRenderReport {
6874
id: number;
6975
displayName: string;
@@ -74,6 +80,7 @@ export interface ComponentRenderReport {
7480
avgDuration: number;
7581
maxDuration: number;
7682
causes: RenderCause[];
83+
changedKeys?: ChangedKeys;
7784
}
7885

7986
export type RenderCause =

0 commit comments

Comments
 (0)