Skip to content

Commit 05090ca

Browse files
authored
feat: surface specific changed prop/state/hook keys in profiling output (#32)
* 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 * chore: add changeset for changed keys feature * docs: update profiling output examples with changed keys
1 parent 303f9e4 commit 05090ca

10 files changed

Lines changed: 214 additions & 36 deletions

File tree

.changeset/surface-changed-keys.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"agent-react-devtools": minor
3+
---
4+
5+
Surface specific changed prop/state/hook keys in profiling output
6+
7+
Profiling reports and commit details now show *which* props, state keys, and hooks changed, not just *that* they changed.
8+
9+
- `profile report` and `profile slow` append `changed: props: onClick, className state: count` lines
10+
- `profile rerenders` and `profile commit` include the same detail per component
11+
- Keys are deduplicated across commits in aggregate reports
12+
- Empty keys produce no extra output (backward-compatible)

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ agent-react-devtools profile slow
9797

9898
```
9999
Slowest (by avg render time):
100-
@c5 [fn] TodoList avg:4.2ms max:8.1ms renders:6 causes:props-changed
101-
@c4 [fn] SearchBar avg:2.1ms max:3.4ms renders:12 causes:hooks-changed
100+
@c5 [fn] TodoList avg:4.2ms max:8.1ms renders:6 causes:props-changed changed: props: items, onDelete
101+
@c4 [fn] SearchBar avg:2.1ms max:3.4ms renders:12 causes:hooks-changed changed: hooks: #0
102102
@c2 [fn] Header avg:0.8ms max:1.2ms renders:3 causes:parent-rendered
103103
```
104104

packages/agent-react-devtools/skills/react-devtools/SKILL.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,14 @@ hooks:
8989

9090
```
9191
Slowest (by avg render time):
92-
@c3 [fn] ExpensiveList avg:12.3ms max:18.1ms renders:47 causes:props-changed
93-
@c4 [fn] TodoItem avg:2.1ms max:5.0ms renders:94 causes:parent-rendered, props-changed
92+
@c3 [fn] ExpensiveList avg:12.3ms max:18.1ms renders:47 causes:props-changed changed: props: items, filter
93+
@c4 [fn] TodoItem avg:2.1ms max:5.0ms renders:94 causes:parent-rendered, props-changed changed: props: onToggle
9494
```
9595

9696
Render causes: `props-changed`, `state-changed`, `hooks-changed`, `parent-rendered`, `force-update`, `first-mount`.
9797

98+
When specific changed keys are available, a `changed:` suffix shows exactly which props, state keys, or hooks triggered the render (e.g. `changed: props: onClick, className state: count hooks: #0`).
99+
98100
## Common Patterns
99101

100102
### Wait for the app to connect after a reload

packages/agent-react-devtools/skills/react-devtools/references/commands.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,21 +65,31 @@ Stop profiling and collect data from React. Shows a summary with duration, commi
6565
### `agent-react-devtools profile slow [--limit N]`
6666
Rank components by average render duration (slowest first). Default limit: 10.
6767

68-
Output columns: label, type tag, component name, avg duration, max duration, render count, all causes.
68+
Output columns: label, type tag, component name, avg duration, max duration, render count, all causes, changed keys.
6969

7070
### `agent-react-devtools profile rerenders [--limit N]`
7171
Rank components by render count (most re-renders first). Default limit: 10.
7272

73-
Output columns: label, type tag, component name, render count, all causes.
73+
Output columns: label, type tag, component name, render count, all causes, changed keys.
7474

7575
### `agent-react-devtools profile report <@cN | id>`
76-
Detailed render report for a single component: render count, avg/max/total duration, all render causes.
76+
Detailed render report for a single component: render count, avg/max/total duration, all render causes, changed keys.
7777

7878
### `agent-react-devtools profile timeline [--limit N]`
7979
Chronological list of React commits during the profiling session. Each entry: index, duration, component count.
8080

8181
### `agent-react-devtools profile commit <N | #N> [--limit N]`
82-
Detail for a specific commit by index. Shows per-component self/total duration and render causes.
82+
Detail for a specific commit by index. Shows per-component self/total duration, render causes, and changed keys.
83+
84+
### Changed Keys
85+
86+
When React DevTools reports which specific props, state keys, or hooks triggered a re-render, profiling commands append a `changed:` suffix:
87+
88+
```
89+
changed: props: onClick, className state: count hooks: #0
90+
```
91+
92+
Categories with no changes are omitted. Keys are deduplicated across commits in aggregate reports (`profile slow`, `profile rerenders`, `profile report`).
8393

8494
## Setup
8595

packages/agent-react-devtools/skills/react-devtools/references/profiling-guide.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,15 @@ Once you identify a suspect, get its full render report:
5959
agent-react-devtools profile report @c12
6060
```
6161

62-
This shows all render causes. Common patterns:
62+
This shows all render causes and the specific changed keys (e.g. `changed: props: onClick, className state: count`). Use the changed keys to pinpoint exactly what to stabilize or investigate. Common patterns:
6363

64-
| Cause | Meaning | Typical Fix |
65-
|-------|---------|-------------|
66-
| `parent-rendered` | Parent re-rendered, child has no bailout | Wrap child in `React.memo()` |
67-
| `props-changed` | Received new prop references | Stabilize with `useMemo`/`useCallback` in parent |
68-
| `state-changed` | Component's own state changed | Check if state update is necessary |
69-
| `hooks-changed` | A hook dependency changed | Review hook dependencies |
70-
| `first-mount` | Initial render | Normal — not a problem |
64+
| Cause | Changed keys example | Meaning | Typical Fix |
65+
|-------|---------------------|---------|-------------|
66+
| `parent-rendered` | _(none)_ | Parent re-rendered, child has no bailout | Wrap child in `React.memo()` |
67+
| `props-changed` | `props: onClick, style` | Received new prop references | Stabilize the listed props with `useMemo`/`useCallback` in parent |
68+
| `state-changed` | `state: count, filter` | Component's own state changed | Check if the listed state updates are necessary |
69+
| `hooks-changed` | `hooks: #0, #2` | A hook dependency changed | Review deps of the listed hooks (by index) |
70+
| `first-mount` | _(none)_ | Initial render | Normal — not a problem |
7171

7272
### 5. Inspect the Component
7373

@@ -101,7 +101,7 @@ Compare render counts and durations to confirm improvement.
101101
A parent component re-renders (e.g., from a timer or context change) and all children re-render because none use `React.memo`. Look for high re-render counts with `parent-rendered` cause.
102102

103103
### Unstable prop references
104-
Parent passes `onClick={() => ...}` or `style={{...}}` inline — creates new references every render, defeating `memo()`. The child shows `props-changed` as the cause even though the values are semantically identical.
104+
Parent passes `onClick={() => ...}` or `style={{...}}` inline — creates new references every render, defeating `memo()`. The child shows `props-changed` as the cause even though the values are semantically identical. The `changed:` output tells you exactly which props are the culprits (e.g. `changed: props: onClick, style`).
105105

106106
### Expensive computations without memoization
107107
A component does heavy work (filtering, sorting, formatting) on every render. Shows up as high avg render time. Fix with `useMemo`.

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', () => {

0 commit comments

Comments
 (0)