From 4cae1763ad5bd2548805a604ec767d42ef10f071 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Sat, 4 Apr 2026 02:19:30 +0200 Subject: [PATCH 1/2] feat: improve profile grouping output --- README.md | 10 +- .../src/__tests__/component-tree.test.ts | 15 ++ .../src/__tests__/devtools-bridge.test.ts | 191 ++++++++++++++ .../src/__tests__/formatters.test.ts | 234 ++++++++++++++++++ .../src/__tests__/profiler.test.ts | 51 ++++ packages/agent-react-devtools/src/cli.ts | 4 +- .../src/component-tree.ts | 29 +++ packages/agent-react-devtools/src/daemon.ts | 112 ++++++++- .../src/devtools-bridge.ts | 53 +++- .../agent-react-devtools/src/formatters.ts | 155 ++++++++++-- packages/agent-react-devtools/src/profiler.ts | 63 ++++- .../src/source-metadata.ts | 39 +++ packages/agent-react-devtools/src/types.ts | 19 ++ 13 files changed, 924 insertions(+), 51 deletions(-) create mode 100644 packages/agent-react-devtools/src/__tests__/devtools-bridge.test.ts create mode 100644 packages/agent-react-devtools/src/source-metadata.ts diff --git a/README.md b/README.md index 782a5c7..42287b7 100644 --- a/README.md +++ b/README.md @@ -114,11 +114,15 @@ agent-react-devtools profile slow ``` Slowest (by avg render time): - @c5 [fn] TodoList avg:4.2ms max:8.1ms renders:6 causes:props-changed changed: props: items, onDelete - @c4 [fn] SearchBar avg:2.1ms max:3.4ms renders:12 causes:hooks-changed changed: hooks: #0 - @c2 [fn] Header avg:0.8ms max:1.2ms renders:3 causes:parent-rendered + TodoItem 3 instances src: TodoItem.tsx:14:1 top avg:4.2ms + @c5 [fn] TodoItem avg:4.2ms max:8.1ms renders:6 causes:props-changed in:TodoList > VisibleItems changed: props: item, onDelete + @c8 [fn] TodoItem avg:3.9ms max:7.5ms renders:6 causes:props-changed in:TodoList > ArchivedItems changed: props: item, onDelete + @c9 [fn] TodoItem avg:3.5ms max:6.8ms renders:6 causes:props-changed in:TodoList > SearchResults changed: props: item, onDelete + @c4 [fn] SearchBar avg:2.1ms max:3.4ms renders:12 causes:hooks-changed in:App > Header changed: hooks: #0 ``` +Repeated rows are grouped only when they share the same implementation source. Components with the same display name from different files, or components without source metadata, remain separate rows. + ## Commands ### Daemon diff --git a/packages/agent-react-devtools/src/__tests__/component-tree.test.ts b/packages/agent-react-devtools/src/__tests__/component-tree.test.ts index b29f7ce..3ecd93e 100644 --- a/packages/agent-react-devtools/src/__tests__/component-tree.test.ts +++ b/packages/agent-react-devtools/src/__tests__/component-tree.test.ts @@ -194,6 +194,21 @@ describe('ComponentTree', () => { expect(shallow.map((n) => n.displayName)).toEqual(['App', 'Level1']); }); + it('should build parent path strings', () => { + const ops = buildOps(1, 100, ['App', 'SearchPage', 'FiltersPanel', 'Context.Provider'], (s) => [ + ...addOp(1, 5, 0, s('App')), + ...addOp(2, 5, 1, s('SearchPage')), + ...addOp(3, 5, 2, s('FiltersPanel')), + ...addOp(4, 5, 3, s('Context.Provider')), + ]); + tree.applyOperations(ops); + + expect(tree.getPathString(4)).toBe('App > SearchPage > FiltersPanel'); + expect(tree.getPathString(4, true)).toBe('App > SearchPage > FiltersPanel > Context.Provider'); + expect(tree.getPathString(4, false, 2)).toBe('SearchPage > FiltersPanel'); + expect(tree.getPathString(1)).toBeUndefined(); + }); + describe('subtree extraction (rootId)', () => { it('should get subtree from a specific root', () => { const ops = buildOps(1, 100, ['App', 'Header', 'Nav', 'Logo', 'Footer'], (s) => [ diff --git a/packages/agent-react-devtools/src/__tests__/devtools-bridge.test.ts b/packages/agent-react-devtools/src/__tests__/devtools-bridge.test.ts new file mode 100644 index 0000000..35ac43a --- /dev/null +++ b/packages/agent-react-devtools/src/__tests__/devtools-bridge.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DevToolsBridge } from '../devtools-bridge.js'; +import { ComponentTree } from '../component-tree.js'; +import { Profiler } from '../profiler.js'; + +function buildStringTable(strings: string[]): [number[], Map] { + const idMap = new Map(); + const data: number[] = []; + for (const s of strings) { + const id = idMap.size + 1; + if (!idMap.has(s)) { + idMap.set(s, id); + data.push(s.length, ...Array.from(s).map((c) => c.charCodeAt(0))); + } + } + return [data, idMap]; +} + +function buildOps( + rendererID: number, + rootID: number, + strings: string[], + opsFn: (strId: (s: string) => number) => number[], +): number[] { + const [tableData, idMap] = buildStringTable(strings); + const strId = (s: string) => idMap.get(s) || 0; + const ops = opsFn(strId); + return [rendererID, rootID, tableData.length, ...tableData, ...ops]; +} + +function addOp( + id: number, + elementType: number, + parentId: number, + displayNameStrId: number, +): number[] { + return [1, id, elementType, parentId, 0, displayNameStrId, 0]; +} + +describe('DevToolsBridge', () => { + let tree: ComponentTree; + let bridge: DevToolsBridge; + + beforeEach(() => { + tree = new ComponentTree(); + bridge = new DevToolsBridge(8097, tree, new Profiler()); + + const ops = buildOps(1, 100, ['App'], (s) => [ + ...addOp(1, 5, 0, s('App')), + ]); + tree.applyOperations(ops); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('preserves source metadata from inspected element payloads', async () => { + const pending = new Promise((resolve) => { + (bridge as any).pendingInspections.set(123, { + id: 1, + resolve, + timer: setTimeout(() => {}, 1000), + }); + }); + + (bridge as any).handleInspectedElement({ + type: 'full-data', + id: 1, + responseID: 123, + value: { + id: 1, + displayName: 'App', + type: 5, + key: null, + props: {}, + state: null, + hooks: null, + source: { + fileName: '/src/App.tsx', + lineNumber: 10, + columnNumber: 4, + }, + }, + }); + + await expect(pending).resolves.toMatchObject({ + source: { + fileName: '/src/App.tsx', + lineNumber: 10, + columnNumber: 4, + }, + }); + }); + + it('keeps inspection working when source metadata is missing', async () => { + const pending = new Promise((resolve) => { + (bridge as any).pendingInspections.set(123, { + id: 1, + resolve, + timer: setTimeout(() => {}, 1000), + }); + }); + + (bridge as any).handleInspectedElement({ + type: 'full-data', + id: 1, + responseID: 123, + value: { + id: 1, + displayName: 'App', + type: 5, + key: null, + props: { count: 1 }, + state: null, + hooks: null, + }, + }); + + await expect(pending).resolves.toMatchObject({ + displayName: 'App', + props: { count: 1 }, + source: undefined, + }); + }); + + it('returns cached inspection data without a live connection', async () => { + (bridge as any).inspectionCache.set(1, { + id: 1, + displayName: 'App', + type: 'function', + key: null, + props: { cached: true }, + state: null, + hooks: null, + renderedAt: null, + source: undefined, + }); + + await expect(bridge.inspectElement(1)).resolves.toMatchObject({ + props: { cached: true }, + }); + }); + + it('supports concurrent inspections for the same component id', async () => { + const sent: Array<{ event: string; payload: { requestID: number } }> = []; + (bridge as any).connections.add({}); + (bridge as any).sendToAll = (msg: { event: string; payload: { requestID: number } }) => { + sent.push(msg); + }; + + const first = bridge.inspectElement(1); + const second = bridge.inspectElement(1); + + expect(sent).toHaveLength(2); + expect(sent[0].payload.requestID).not.toBe(sent[1].payload.requestID); + expect((bridge as any).pendingInspections.size).toBe(2); + + (bridge as any).handleInspectedElement({ + type: 'full-data', + id: 1, + responseID: sent[0].payload.requestID, + value: { + id: 1, + displayName: 'App', + type: 5, + key: null, + props: { source: 'first' }, + state: null, + hooks: null, + }, + }); + (bridge as any).handleInspectedElement({ + type: 'full-data', + id: 1, + responseID: sent[1].payload.requestID, + value: { + id: 1, + displayName: 'App', + type: 5, + key: null, + props: { source: 'second' }, + state: null, + hooks: null, + }, + }); + + await expect(first).resolves.toMatchObject({ props: { source: 'first' } }); + await expect(second).resolves.toMatchObject({ props: { source: 'second' } }); + }); +}); diff --git a/packages/agent-react-devtools/src/__tests__/formatters.test.ts b/packages/agent-react-devtools/src/__tests__/formatters.test.ts index a7c952c..60999c5 100644 --- a/packages/agent-react-devtools/src/__tests__/formatters.test.ts +++ b/packages/agent-react-devtools/src/__tests__/formatters.test.ts @@ -427,6 +427,26 @@ describe('formatProfileReport', () => { const result = formatProfileReport(report, '@c99'); expect(result).toContain('@c99 [fn] UserProfile'); }); + + it('should show path and source context when available', () => { + const report: ComponentRenderReport = { + id: 5, + displayName: 'UserProfile', + label: '@c5', + type: 'function', + path: 'App > SettingsPage', + source: { fileName: '/src/components/UserProfile.tsx', lineNumber: 12, columnNumber: 3 }, + renderCount: 2, + totalDuration: 20, + avgDuration: 10, + maxDuration: 12, + causes: ['props-changed'], + }; + + const result = formatProfileReport(report); + expect(result).toContain('path: App > SettingsPage'); + expect(result).toContain('src: UserProfile.tsx:12:3'); + }); }); describe('formatSlowest', () => { @@ -448,6 +468,170 @@ describe('formatSlowest', () => { expect(result).toContain('changed: props: data state: count'); expect(result).toContain('changed: state: count'); }); + + it('should group rows with the same display name and source', () => { + const reports: ComponentRenderReport[] = [ + { + id: 1, + displayName: 'Context.Provider', + label: '@c1', + type: 'other', + path: 'App > SearchPage > FiltersPanel', + source: { fileName: '/src/context/SearchContext.tsx', lineNumber: 12, columnNumber: 1 }, + sourceKey: '/src/context/SearchContext.tsx:12:1', + renderCount: 5, + totalDuration: 25, + avgDuration: 5, + maxDuration: 10, + causes: ['parent-rendered'], + }, + { + id: 2, + displayName: 'Context.Provider', + label: '@c2', + type: 'other', + path: 'App > SearchPage > ResultsPanel', + source: { fileName: '/src/context/SearchContext.tsx', lineNumber: 12, columnNumber: 1 }, + sourceKey: '/src/context/SearchContext.tsx:12:1', + renderCount: 5, + totalDuration: 20, + avgDuration: 4, + maxDuration: 8, + causes: ['parent-rendered'], + }, + ]; + + const result = formatSlowest(reports); + expect(result).toContain('Context.Provider 2 instances src: SearchContext.tsx:12:1'); + expect(result).toContain('@c1 [?] Context.Provider'); + expect(result).toContain('in:App > SearchPage > FiltersPanel'); + expect(result).toContain('@c2 [?] Context.Provider'); + expect(result).toContain('in:App > SearchPage > ResultsPanel'); + }); + + it('should not group rows with the same display name from different sources', () => { + const reports: ComponentRenderReport[] = [ + { + id: 1, + displayName: 'Context.Provider', + label: '@c1', + type: 'other', + source: { fileName: '/src/context/SearchContext.tsx', lineNumber: 12, columnNumber: 1 }, + sourceKey: '/src/context/SearchContext.tsx:12:1', + renderCount: 5, + totalDuration: 25, + avgDuration: 5, + maxDuration: 10, + causes: ['parent-rendered'], + }, + { + id: 2, + displayName: 'Context.Provider', + label: '@c2', + type: 'other', + source: { fileName: '/src/context/ThemeContext.tsx', lineNumber: 9, columnNumber: 1 }, + sourceKey: '/src/context/ThemeContext.tsx:9:1', + renderCount: 5, + totalDuration: 20, + avgDuration: 4, + maxDuration: 8, + causes: ['parent-rendered'], + }, + ]; + + const result = formatSlowest(reports); + expect(result).not.toContain('2 instances'); + expect(result).toContain('@c1 [?] Context.Provider'); + expect(result).toContain('@c2 [?] Context.Provider'); + }); + + it('should not group rows with missing source metadata', () => { + const reports: ComponentRenderReport[] = [ + { + id: 1, + displayName: 'Context.Provider', + label: '@c1', + type: 'other', + renderCount: 5, + totalDuration: 25, + avgDuration: 5, + maxDuration: 10, + causes: ['parent-rendered'], + }, + { + id: 2, + displayName: 'Context.Provider', + label: '@c2', + type: 'other', + renderCount: 5, + totalDuration: 20, + avgDuration: 4, + maxDuration: 8, + causes: ['parent-rendered'], + }, + ]; + + const result = formatSlowest(reports); + expect(result).not.toContain('instances'); + }); + + it('should respect visible limits after grouping', () => { + const reports: ComponentRenderReport[] = [ + { + id: 1, + displayName: 'Context.Provider', + label: '@c1', + type: 'other', + source: { fileName: '/src/context/SearchContext.tsx', lineNumber: 12, columnNumber: 1 }, + sourceKey: '/src/context/SearchContext.tsx:12:1', + renderCount: 5, + totalDuration: 25, + avgDuration: 5, + maxDuration: 10, + causes: ['parent-rendered'], + }, + { + id: 2, + displayName: 'Context.Provider', + label: '@c2', + type: 'other', + source: { fileName: '/src/context/SearchContext.tsx', lineNumber: 12, columnNumber: 1 }, + sourceKey: '/src/context/SearchContext.tsx:12:1', + renderCount: 4, + totalDuration: 16, + avgDuration: 4, + maxDuration: 8, + causes: ['parent-rendered'], + }, + { + id: 3, + displayName: 'SearchResults', + label: '@c3', + type: 'function', + renderCount: 3, + totalDuration: 9, + avgDuration: 3, + maxDuration: 4, + causes: ['props-changed'], + }, + { + id: 4, + displayName: 'Footer', + label: '@c4', + type: 'function', + renderCount: 2, + totalDuration: 4, + avgDuration: 2, + maxDuration: 3, + causes: ['parent-rendered'], + }, + ]; + + const result = formatSlowest(reports, 2); + expect(result).toContain('Context.Provider 2 instances'); + expect(result).toContain('@c3 [fn] SearchResults'); + expect(result).not.toContain('@c4 [fn] Footer'); + }); }); describe('formatRerenders', () => { @@ -471,6 +655,56 @@ describe('formatRerenders', () => { const result = formatRerenders(reports); expect(result).not.toContain('changed:'); }); + + it('should group repeated rerender rows by source identity and keep mixed ordering', () => { + const reports: ComponentRenderReport[] = [ + { + id: 1, + displayName: 'Context.Provider', + label: '@c1', + type: 'other', + path: 'App > FiltersPanel', + source: { fileName: '/src/context/SearchContext.tsx', lineNumber: 12, columnNumber: 1 }, + sourceKey: '/src/context/SearchContext.tsx:12:1', + renderCount: 20, + totalDuration: 40, + avgDuration: 2, + maxDuration: 4, + causes: ['parent-rendered'], + }, + { + id: 9, + displayName: 'SearchResults', + label: '@c9', + type: 'function', + path: 'App > SearchPage', + renderCount: 15, + totalDuration: 60, + avgDuration: 4, + maxDuration: 8, + causes: ['props-changed'], + }, + { + id: 2, + displayName: 'Context.Provider', + label: '@c2', + type: 'other', + path: 'App > ResultsPanel', + source: { fileName: '/src/context/SearchContext.tsx', lineNumber: 12, columnNumber: 1 }, + sourceKey: '/src/context/SearchContext.tsx:12:1', + renderCount: 12, + totalDuration: 20, + avgDuration: 1.7, + maxDuration: 3, + causes: ['parent-rendered'], + }, + ]; + + const result = formatRerenders(reports); + expect(result).toContain('Context.Provider 2 instances src: SearchContext.tsx:12:1'); + expect(result).toContain('@c9 [fn] SearchResults 15 renders'); + expect(result.indexOf('Context.Provider 2 instances')).toBeLessThan(result.indexOf('@c9 [fn] SearchResults')); + }); }); describe('formatTimeline', () => { diff --git a/packages/agent-react-devtools/src/__tests__/profiler.test.ts b/packages/agent-react-devtools/src/__tests__/profiler.test.ts index d092199..0ed7459 100644 --- a/packages/agent-react-devtools/src/__tests__/profiler.test.ts +++ b/packages/agent-react-devtools/src/__tests__/profiler.test.ts @@ -231,6 +231,57 @@ describe('Profiler', () => { expect(rerenders[0].renderCount).toBe(3); }); + it('should retain stored component metadata for offline reports', () => { + profiler.start('test'); + + profiler.processProfilingData({ + commitData: [ + { + timestamp: 1000, + duration: 15, + fiberActualDurations: [1, 10], + fiberSelfDurations: [1, 5], + }, + ], + }); + + profiler.setComponentMetadata(1, { + label: '@c1', + type: 'function', + path: 'AppShell > Screen', + source: { fileName: '/src/App.tsx', lineNumber: 10, columnNumber: 2 }, + sourceKey: '/src/App.tsx:10:2', + }); + + const report = profiler.getReport(1, new ComponentTree()); + expect(report).not.toBeNull(); + expect(report!.label).toBe('@c1'); + expect(report!.type).toBe('function'); + expect(report!.path).toBe('AppShell > Screen'); + expect(report!.sourceKey).toBe('/src/App.tsx:10:2'); + }); + + it('should resolve profiled components by stored label after disconnect', () => { + profiler.start('test'); + profiler.processProfilingData({ + commitData: [ + { + timestamp: 1000, + duration: 15, + fiberActualDurations: [1, 10], + fiberSelfDurations: [1, 5], + }, + ], + }); + + profiler.setComponentMetadata(1, { label: '@c1' }); + + expect(profiler.resolveProfiledComponentId('@c1')).toBe(1); + expect(profiler.resolveProfiledComponentId('1')).toBe(1); + expect(profiler.resolveProfiledComponentId('@c?(id:1)')).toBe(1); + expect(profiler.resolveProfiledComponentId('@c9')).toBeUndefined(); + }); + it('should process dataForRoots nested format', () => { profiler.start('test'); diff --git a/packages/agent-react-devtools/src/cli.ts b/packages/agent-react-devtools/src/cli.ts index 01a4314..4f2ed2b 100644 --- a/packages/agent-react-devtools/src/cli.ts +++ b/packages/agent-react-devtools/src/cli.ts @@ -379,7 +379,7 @@ async function main(): Promise { const limit = parseNumericFlag(flags, 'limit'); const resp = await sendCommand({ type: 'profile-slow', limit }); if (resp.ok) { - console.log(formatSlowest(resp.data as any)); + console.log(formatSlowest(resp.data as any, limit)); } else { console.error(resp.error); process.exit(1); @@ -391,7 +391,7 @@ async function main(): Promise { const limit = parseNumericFlag(flags, 'limit'); const resp = await sendCommand({ type: 'profile-rerenders', limit }); if (resp.ok) { - console.log(formatRerenders(resp.data as any)); + console.log(formatRerenders(resp.data as any, limit)); } else { console.error(resp.error); process.exit(1); diff --git a/packages/agent-react-devtools/src/component-tree.ts b/packages/agent-react-devtools/src/component-tree.ts index 2ae1c42..8867af4 100644 --- a/packages/agent-react-devtools/src/component-tree.ts +++ b/packages/agent-react-devtools/src/component-tree.ts @@ -580,6 +580,35 @@ export class ComponentTree { return this.nodes.has(num) ? num : undefined; } + getPathSegments(id: number, includeSelf = false, maxSegments?: number): string[] { + const segments: string[] = []; + let current = this.nodes.get(id); + if (!current) return segments; + + if (!includeSelf && current.parentId !== null) { + current = this.nodes.get(current.parentId); + } else if (!includeSelf) { + current = undefined; + } + + while (current) { + segments.push(current.displayName); + if (current.parentId === null) break; + current = this.nodes.get(current.parentId); + } + + segments.reverse(); + if (maxSegments !== undefined && segments.length > maxSegments) { + return segments.slice(-maxSegments); + } + return segments; + } + + getPathString(id: number, includeSelf = false, maxSegments?: number): string | undefined { + const segments = this.getPathSegments(id, includeSelf, maxSegments); + return segments.length > 0 ? segments.join(' > ') : undefined; + } + private toTreeNode(node: ComponentNode): TreeNode { // Calculate depth by walking up the tree let depth = 0; diff --git a/packages/agent-react-devtools/src/daemon.ts b/packages/agent-react-devtools/src/daemon.ts index d0aa12d..e548cd5 100644 --- a/packages/agent-react-devtools/src/daemon.ts +++ b/packages/agent-react-devtools/src/daemon.ts @@ -4,7 +4,8 @@ import path from 'node:path'; import { DevToolsBridge } from './devtools-bridge.js'; import { ComponentTree } from './component-tree.js'; import { Profiler } from './profiler.js'; -import type { IpcCommand, IpcResponse, DaemonInfo, StatusInfo } from './types.js'; +import type { IpcCommand, IpcResponse, DaemonInfo, StatusInfo, ProfileComponentMetadata } from './types.js'; +import { getSourceIdentity } from './source-metadata.js'; const DEFAULT_STATE_DIR = path.join( process.env.HOME || process.env.USERPROFILE || '/tmp', @@ -12,6 +13,11 @@ const DEFAULT_STATE_DIR = path.join( ); let STATE_DIR = DEFAULT_STATE_DIR; +const PROFILE_READ_CANDIDATE_MULTIPLIER = 3; +const PROFILE_READ_MIN_CANDIDATES = 20; +const PROFILE_READ_MAX_CANDIDATES = 60; +const PROFILE_READ_ENRICH_CONCURRENCY = 5; +const PROFILE_READ_ENRICH_TIMEOUT_MS = 1000; function getSocketPath(): string { return path.join(STATE_DIR, 'daemon.sock'); @@ -21,11 +27,8 @@ function getDaemonInfoPath(): string { return path.join(STATE_DIR, 'daemon.json'); } -/** - * Enrich profiling result items with label + type from the component tree. - */ function enrichWithLabels( - items: Array<{ id: number; label?: string; type?: string }>, + items: Array<{ id: number; label?: string; type?: string; path?: string }>, tree: ComponentTree, ): void { for (const item of items) { @@ -34,9 +37,78 @@ function enrichWithLabels( const node = tree.getNode(item.id); if (node) item.type = node.type; } + if (!item.path) item.path = tree.getPathString(item.id, false, 3); } } +function collectStaticProfileMetadata( + componentIds: number[], + tree: ComponentTree, +): Map { + const metadata = new Map(); + + for (const id of componentIds) { + metadata.set(id, { + label: tree.getLabel(id), + type: tree.getNode(id)?.type, + path: tree.getPathString(id, false, 3), + }); + } + + return metadata; +} + +async function enrichProfileMetadataOnDemand( + reports: Array<{ id: number }>, + tree: ComponentTree, + bridge: DevToolsBridge, + profiler: Profiler, +): Promise { + const queue = [...new Set(reports.map((report) => report.id))]; + if (queue.length === 0) return; + + const concurrency = Math.min(PROFILE_READ_ENRICH_CONCURRENCY, queue.length); + await Promise.all(Array.from({ length: concurrency }, async () => { + while (queue.length > 0) { + const id = queue.shift(); + if (id === undefined) return; + + const treeMetadata: ProfileComponentMetadata = {}; + const label = tree.getLabel(id); + const type = tree.getNode(id)?.type; + const path = tree.getPathString(id, false, 3); + if (label !== undefined) treeMetadata.label = label; + if (type !== undefined) treeMetadata.type = type; + if (path !== undefined) treeMetadata.path = path; + if (Object.keys(treeMetadata).length > 0) { + profiler.setComponentMetadata(id, treeMetadata); + } + + const inspected = await bridge.inspectElement(id, { + preferCache: true, + timeoutMs: PROFILE_READ_ENRICH_TIMEOUT_MS, + }); + if (!inspected?.source) continue; + + profiler.setComponentMetadata(id, { + source: inspected.source, + sourceKey: getSourceIdentity(inspected.source), + }); + } + })); +} + +function getCandidateLimit(limit?: number): number { + if (limit === undefined) return PROFILE_READ_MAX_CANDIDATES; + return Math.max( + limit, + Math.min( + PROFILE_READ_MAX_CANDIDATES, + Math.max(PROFILE_READ_MIN_CANDIDATES, limit * PROFILE_READ_CANDIDATE_MULTIPLIER), + ), + ); +} + class Daemon { private ipcServer: net.Server | null = null; private bridge: DevToolsBridge; @@ -242,16 +314,27 @@ class Daemon { case 'profile-stop': { await this.bridge.stopProfilingAndCollect(); + // Build stable labels for this snapshot before the app changes again. + this.tree.getTree(); + const metadata = collectStaticProfileMetadata( + this.profiler.getProfiledComponentIds(), + this.tree, + ); const session = this.profiler.stop(this.tree); if (!session) { return { ok: false, error: 'No active profiling session' }; } + for (const [id, item] of metadata) { + this.profiler.setComponentMetadata(id, item); + } enrichWithLabels(session.componentRenderCounts, this.tree); return { ok: true, data: session }; } case 'profile-report': { - const resolvedCompId = this.tree.resolveId(cmd.componentId); + const resolvedCompId = + this.tree.resolveId(cmd.componentId) ?? + this.profiler.resolveProfiledComponentId(cmd.componentId); if (resolvedCompId === undefined) { return { ok: false, error: `Component ${cmd.componentId} not found` }; } @@ -262,20 +345,25 @@ class Daemon { error: `No profiling data for component ${cmd.componentId}`, }; } - enrichWithLabels([report], this.tree); + await enrichProfileMetadataOnDemand([{ id: resolvedCompId }], this.tree, this.bridge, this.profiler); + const refreshedReport = this.profiler.getReport(resolvedCompId, this.tree) || report; const compLabel = typeof cmd.componentId === 'string' ? cmd.componentId : undefined; - return { ok: true, data: report, label: compLabel }; + return { ok: true, data: refreshedReport, label: compLabel }; } case 'profile-slow': { - const slowest = this.profiler.getSlowest(this.tree, cmd.limit); - enrichWithLabels(slowest, this.tree); + const candidateLimit = getCandidateLimit(cmd.limit); + const candidates = this.profiler.getSlowest(this.tree, candidateLimit); + await enrichProfileMetadataOnDemand(candidates, this.tree, this.bridge, this.profiler); + const slowest = this.profiler.getSlowest(this.tree, candidateLimit); return { ok: true, data: slowest }; } case 'profile-rerenders': { - const rerenders = this.profiler.getMostRerenders(this.tree, cmd.limit); - enrichWithLabels(rerenders, this.tree); + const candidateLimit = getCandidateLimit(cmd.limit); + const candidates = this.profiler.getMostRerenders(this.tree, candidateLimit); + await enrichProfileMetadataOnDemand(candidates, this.tree, this.bridge, this.profiler); + const rerenders = this.profiler.getMostRerenders(this.tree, candidateLimit); return { ok: true, data: rerenders }; } diff --git a/packages/agent-react-devtools/src/devtools-bridge.ts b/packages/agent-react-devtools/src/devtools-bridge.ts index 86214bf..db9a5da 100644 --- a/packages/agent-react-devtools/src/devtools-bridge.ts +++ b/packages/agent-react-devtools/src/devtools-bridge.ts @@ -2,6 +2,7 @@ import { WebSocketServer, WebSocket } from 'ws'; import type { ComponentTree } from './component-tree.js'; import type { Profiler } from './profiler.js'; import type { InspectedElement, ConnectionHealth, ConnectionEvent } from './types.js'; +import { normalizeSourceLocation } from './source-metadata.js'; /** * React DevTools protocol bridge. @@ -20,10 +21,16 @@ interface DevToolsMessage { } interface PendingInspection { + id: number; resolve: (value: InspectedElement | null) => void; timer: ReturnType; } +interface InspectOptions { + preferCache?: boolean; + timeoutMs?: number; +} + interface PendingProfilingCollect { resolve: () => void; timer: ReturnType; @@ -37,6 +44,8 @@ export class DevToolsBridge { private tree: ComponentTree; private profiler: Profiler; private pendingInspections = new Map(); + private inspectionCache = new Map(); + private nextInspectionRequestId = 1; private pendingProfilingCollect: PendingProfilingCollect | null = null; private rendererIds = new Set(); /** Track which root fiber IDs belong to each WebSocket connection */ @@ -115,17 +124,22 @@ export class DevToolsBridge { * Request detailed inspection of a specific element. * Sends a request to the React app and waits for the response. */ - inspectElement(id: number): Promise { + inspectElement(id: number, options: InspectOptions = {}): Promise { const node = this.tree.getNode(id); if (!node) return Promise.resolve(null); + const cached = this.inspectionCache.get(id); + if (options.preferCache && cached) return Promise.resolve(cached); + if (this.connections.size === 0) return Promise.resolve(cached || null); return new Promise((resolve) => { + const requestID = this.nextInspectionRequestId++; + const timeoutMs = options.timeoutMs ?? 5000; const timer = setTimeout(() => { - this.pendingInspections.delete(id); + this.pendingInspections.delete(requestID); resolve(null); - }, 5000); + }, timeoutMs); - this.pendingInspections.set(id, { resolve, timer }); + this.pendingInspections.set(requestID, { id, resolve, timer }); this.sendToAll({ event: 'inspectElement', @@ -133,7 +147,7 @@ export class DevToolsBridge { id, rendererID: node.rendererId, forceFullData: true, - requestID: id, + requestID, path: null, }, }); @@ -256,6 +270,11 @@ export class DevToolsBridge { roots.add(rootFiberId); } const added = this.tree.applyOperations(operations); + if (this.inspectionCache.size > 0) { + for (const id of this.inspectionCache.keys()) { + if (!this.tree.getNode(id)) this.inspectionCache.delete(id); + } + } // Cache display names during profiling so unmounted components are still identifiable if (this.profiler.isActive()) { @@ -277,6 +296,14 @@ export class DevToolsBridge { } this.connectionRoots.delete(ws); } + if (this.connections.size === 0) { + this.inspectionCache.clear(); + for (const [requestID, pending] of this.pendingInspections) { + clearTimeout(pending.timer); + pending.resolve(null); + this.pendingInspections.delete(requestID); + } + } this.lastDisconnectAt = Date.now(); this.pushEvent({ type: 'disconnected', timestamp: Date.now() }); this.notifyStateChange(); @@ -286,6 +313,7 @@ export class DevToolsBridge { const data = payload as { type: string; id: number; + responseID?: number; value?: { id: number; displayName: string; @@ -294,25 +322,30 @@ export class DevToolsBridge { props: Record; state: Record | null; hooks: unknown[] | { data: unknown[]; cleaned: unknown[]; unserializable: unknown[] } | null; + source?: unknown; }; }; if (data.type !== 'full-data' && data.type !== 'hydrated-path') { // No data available - const pending = this.pendingInspections.get(data.id); + const pending = data.responseID !== undefined + ? this.pendingInspections.get(data.responseID) + : undefined; if (pending) { clearTimeout(pending.timer); - this.pendingInspections.delete(data.id); + this.pendingInspections.delete(data.responseID!); pending.resolve(null); } return; } - const pending = this.pendingInspections.get(data.id); + const pending = data.responseID !== undefined + ? this.pendingInspections.get(data.responseID) + : undefined; if (!pending || !data.value) return; clearTimeout(pending.timer); - this.pendingInspections.delete(data.id); + this.pendingInspections.delete(data.responseID!); const node = this.tree.getNode(data.id); const inspected: InspectedElement = { @@ -328,8 +361,10 @@ export class DevToolsBridge { ? parseHooks(extractHooksArray(data.value.hooks)) : null, renderedAt: null, + source: normalizeSourceLocation(data.value.source), }; + this.inspectionCache.set(data.id, inspected); pending.resolve(inspected); } diff --git a/packages/agent-react-devtools/src/formatters.ts b/packages/agent-react-devtools/src/formatters.ts index cde2c4d..cde3819 100644 --- a/packages/agent-react-devtools/src/formatters.ts +++ b/packages/agent-react-devtools/src/formatters.ts @@ -7,6 +7,7 @@ import type { import type { TreeNode } from './component-tree.js'; import type { ProfileSummary, TimelineResult, CommitDetail } from './profiler.js'; import type { ProfileDiffResult, DiffEntry } from './profile-diff.js'; +import { formatSourceLocation } from './source-metadata.js'; // ── Abbreviations for component types ── const TYPE_ABBREV: Record = { @@ -306,6 +307,13 @@ export function formatProfileSummary(summary: ProfileSummary): string { export function formatProfileReport(report: ComponentRenderReport, label?: string): string { const lines: string[] = []; lines.push(formatRef({ label: label || report.label || `#${report.id}`, type: report.type, name: report.displayName })); + if (report.path) { + lines.push(`path: ${report.path}`); + } + const src = formatSourceLocation(report.source); + if (src) { + lines.push(`src: ${src}`); + } lines.push( `renders:${report.renderCount} avg:${report.avgDuration.toFixed(1)}ms max:${report.maxDuration.toFixed(1)}ms total:${report.totalDuration.toFixed(1)}ms`, ); @@ -319,32 +327,50 @@ export function formatProfileReport(report: ComponentRenderReport, label?: strin return lines.join('\n'); } -export function formatSlowest(reports: ComponentRenderReport[]): string { +export function formatSlowest(reports: ComponentRenderReport[], visibleLimit?: number): string { if (reports.length === 0) return 'No profiling data'; const lines: string[] = ['Slowest (by avg render time):']; - for (const r of reports) { - const ref = formatRef({ label: r.label, type: r.type, name: r.displayName }); - const causes = r.causes.length > 0 ? r.causes.join(', ') : '?'; - let line = ` ${ref} avg:${r.avgDuration.toFixed(1)}ms max:${r.maxDuration.toFixed(1)}ms renders:${r.renderCount} causes:${causes}`; - const keys = formatChangedKeys(r.changedKeys); - if (keys) line += ` changed: ${keys}`; - lines.push(line); + for (const group of groupReportsBySource(reports, visibleLimit)) { + if (group.type === 'single') { + lines.push(formatSlowReportLine(group.report)); + continue; + } + + const src = formatSourceLocation(group.source); + const top = group.reports[0]; + let header = ` ${group.displayName} ${group.reports.length} instances`; + if (src) header += ` src: ${src}`; + header += ` top avg:${top.avgDuration.toFixed(1)}ms`; + lines.push(header); + + for (const report of group.reports) { + lines.push(formatSlowReportLine(report, true)); + } } return lines.join('\n'); } -export function formatRerenders(reports: ComponentRenderReport[]): string { +export function formatRerenders(reports: ComponentRenderReport[], visibleLimit?: number): string { if (reports.length === 0) return 'No profiling data'; const lines: string[] = ['Most re-renders:']; - for (const r of reports) { - const ref = formatRef({ label: r.label, type: r.type, name: r.displayName }); - const causes = r.causes.length > 0 ? r.causes.join(', ') : '?'; - let line = ` ${ref} ${r.renderCount} renders causes:${causes}`; - const keys = formatChangedKeys(r.changedKeys); - if (keys) line += ` changed: ${keys}`; - lines.push(line); + for (const group of groupReportsBySource(reports, visibleLimit)) { + if (group.type === 'single') { + lines.push(formatRerenderReportLine(group.report)); + continue; + } + + const src = formatSourceLocation(group.source); + const top = group.reports[0]; + let header = ` ${group.displayName} ${group.reports.length} instances`; + if (src) header += ` src: ${src}`; + header += ` top renders:${top.renderCount}`; + lines.push(header); + + for (const report of group.reports) { + lines.push(formatRerenderReportLine(report, true)); + } } return lines.join('\n'); } @@ -408,6 +434,103 @@ export function formatChangedKeys(keys: ChangedKeys | undefined): string { // ── Helpers ── +type ReportGroup = + | { type: 'single'; report: ComponentRenderReport } + | { + type: 'group'; + displayName: string; + source: NonNullable; + reports: ComponentRenderReport[]; + }; + +function formatSlowReportLine(report: ComponentRenderReport, nested = false): string { + const ref = formatRef({ label: report.label, type: report.type, name: report.displayName }); + const causes = report.causes.length > 0 ? report.causes.join(', ') : '?'; + const prefix = nested ? ' ' : ' '; + let line = `${prefix}${ref} avg:${report.avgDuration.toFixed(1)}ms max:${report.maxDuration.toFixed(1)}ms renders:${report.renderCount} causes:${causes}`; + if (report.path) line += ` in:${report.path}`; + const keys = formatChangedKeys(report.changedKeys); + if (keys) line += ` changed: ${keys}`; + return line; +} + +function formatRerenderReportLine(report: ComponentRenderReport, nested = false): string { + const ref = formatRef({ label: report.label, type: report.type, name: report.displayName }); + const causes = report.causes.length > 0 ? report.causes.join(', ') : '?'; + const prefix = nested ? ' ' : ' '; + let line = `${prefix}${ref} ${report.renderCount} renders causes:${causes}`; + if (report.path) line += ` in:${report.path}`; + const keys = formatChangedKeys(report.changedKeys); + if (keys) line += ` changed: ${keys}`; + return line; +} + +function groupReportsBySource(reports: ComponentRenderReport[], visibleLimit?: number): ReportGroup[] { + const buckets = new Map(); + const order: string[] = []; + + for (const report of reports) { + if (!report.sourceKey) continue; + const key = `${report.displayName}::${report.sourceKey}`; + let bucket = buckets.get(key); + if (!bucket) { + bucket = []; + buckets.set(key, bucket); + order.push(key); + } + bucket.push(report); + } + + const groupedKeys = new Set(); + for (const key of order) { + if ((buckets.get(key)?.length || 0) > 1) { + groupedKeys.add(key); + } + } + + const groups: ReportGroup[] = []; + let visibleCount = 0; + for (const report of reports) { + if (visibleLimit !== undefined && visibleCount >= visibleLimit) { + break; + } + + if (!report.sourceKey) { + groups.push({ type: 'single', report }); + visibleCount++; + continue; + } + + const key = `${report.displayName}::${report.sourceKey}`; + if (!groupedKeys.has(key)) { + groups.push({ type: 'single', report }); + visibleCount++; + continue; + } + + if (groups.some((group) => group.type === 'group' && group.reports[0]?.sourceKey === report.sourceKey && group.displayName === report.displayName)) { + continue; + } + + const bucket = buckets.get(key) || [report]; + const source = bucket[0].source; + if (!source) { + groups.push({ type: 'single', report }); + continue; + } + + groups.push({ + type: 'group', + displayName: report.displayName, + source, + reports: bucket, + }); + visibleCount++; + } + + return groups; +} + function formatCompactValue(val: unknown): string | undefined { if (val === undefined) return undefined; if (val === null) return 'null'; diff --git a/packages/agent-react-devtools/src/profiler.ts b/packages/agent-react-devtools/src/profiler.ts index b947fea..355427d 100644 --- a/packages/agent-react-devtools/src/profiler.ts +++ b/packages/agent-react-devtools/src/profiler.ts @@ -6,6 +6,7 @@ import type { RenderCause, ChangedKeys, ProfilingDataExport, + ProfileComponentMetadata, } from './types.js'; import type { ComponentTree } from './component-tree.js'; import { buildExportData } from './profile-export.js'; @@ -64,6 +65,7 @@ export class Profiler { stoppedAt: null, commits: [], rawRoots: [], + componentMetadata: new Map(), }; } @@ -72,6 +74,50 @@ export class Profiler { this.displayNames.set(id, displayName); } + getProfiledComponentIds(): number[] { + if (!this.session) return []; + + const componentIds = new Set(); + for (const commit of this.session.commits) { + for (const id of commit.fiberActualDurations.keys()) { + componentIds.add(id); + } + } + return Array.from(componentIds); + } + + setComponentMetadata(id: number, metadata: ProfileComponentMetadata): void { + if (!this.session) return; + + const current = this.session.componentMetadata.get(id) || {}; + this.session.componentMetadata.set(id, { ...current, ...metadata }); + } + + resolveProfiledComponentId(id: number | string): number | undefined { + if (!this.session) return undefined; + + if (typeof id === 'number') { + return this.session.componentMetadata.has(id) ? id : undefined; + } + + const match = id.match(/^@c\?\(id:(\d+)\)$/); + if (match) { + const parsed = parseInt(match[1], 10); + return this.session.componentMetadata.has(parsed) ? parsed : undefined; + } + + if (id.startsWith('@c')) { + for (const [componentId, metadata] of this.session.componentMetadata) { + if (metadata.label === id) return componentId; + } + return undefined; + } + + const parsed = parseInt(id, 10); + if (isNaN(parsed)) return undefined; + return this.session.componentMetadata.has(parsed) ? parsed : undefined; + } + stop(tree?: ComponentTree): ProfileSummary | null { if (!this.session) return null; this.session.stoppedAt = Date.now(); @@ -246,9 +292,16 @@ export class Profiler { if (renderCount === 0) return null; + const metadata = this.session.componentMetadata.get(componentId); + return { id: componentId, displayName: node?.displayName || this.displayNames.get(componentId) || `Component#${componentId}`, + label: metadata?.label, + type: metadata?.type, + path: metadata?.path, + source: metadata?.source, + sourceKey: metadata?.sourceKey, renderCount, totalDuration, avgDuration: totalDuration / renderCount, @@ -340,16 +393,8 @@ export class Profiler { private getAllReports(tree: ComponentTree): ComponentRenderReport[] { if (!this.session) return []; - // Collect all component IDs that appear in profiling data - const componentIds = new Set(); - for (const commit of this.session.commits) { - for (const id of commit.fiberActualDurations.keys()) { - componentIds.add(id); - } - } - const reports: ComponentRenderReport[] = []; - for (const id of componentIds) { + for (const id of this.getProfiledComponentIds()) { const report = this.getReport(id, tree); if (report) reports.push(report); } diff --git a/packages/agent-react-devtools/src/source-metadata.ts b/packages/agent-react-devtools/src/source-metadata.ts new file mode 100644 index 0000000..f1854e3 --- /dev/null +++ b/packages/agent-react-devtools/src/source-metadata.ts @@ -0,0 +1,39 @@ +import path from 'node:path'; +import type { ComponentSourceLocation } from './types.js'; + +export function normalizeSourceLocation(source: unknown): ComponentSourceLocation | undefined { + if (!source || typeof source !== 'object') return undefined; + + const raw = source as { + fileName?: unknown; + lineNumber?: unknown; + columnNumber?: unknown; + }; + + if (typeof raw.fileName !== 'string' || raw.fileName.length === 0) return undefined; + + return { + fileName: raw.fileName, + lineNumber: typeof raw.lineNumber === 'number' ? raw.lineNumber : null, + columnNumber: typeof raw.columnNumber === 'number' ? raw.columnNumber : null, + }; +} + +export function getSourceIdentity(source: ComponentSourceLocation | undefined): string | undefined { + if (!source) return undefined; + + let key = source.fileName; + if (source.lineNumber !== null) key += `:${source.lineNumber}`; + if (source.columnNumber !== null) key += `:${source.columnNumber}`; + return key; +} + +export function formatSourceLocation(source: ComponentSourceLocation | undefined): string | undefined { + if (!source) return undefined; + + const base = path.basename(source.fileName); + let location = base; + if (source.lineNumber !== null) location += `:${source.lineNumber}`; + if (source.columnNumber !== null) location += `:${source.columnNumber}`; + return location; +} diff --git a/packages/agent-react-devtools/src/types.ts b/packages/agent-react-devtools/src/types.ts index 1ca62fa..a306dd0 100644 --- a/packages/agent-react-devtools/src/types.ts +++ b/packages/agent-react-devtools/src/types.ts @@ -35,6 +35,7 @@ export interface InspectedElement { state: Record | null; hooks: HookInfo[] | null; renderedAt: number | null; + source?: ComponentSourceLocation; } export interface HookInfo { @@ -43,6 +44,20 @@ export interface HookInfo { subHooks?: HookInfo[]; } +export interface ComponentSourceLocation { + fileName: string; + lineNumber: number | null; + columnNumber: number | null; +} + +export interface ProfileComponentMetadata { + label?: string; + type?: ComponentType; + path?: string; + source?: ComponentSourceLocation; + sourceKey?: string; +} + // ── Profiling ── export interface ProfilingSession { @@ -52,6 +67,7 @@ export interface ProfilingSession { commits: ProfilingCommit[]; /** Raw per-root data from React DevTools, stored for export passthrough. */ rawRoots: ProfilingRootRawData[]; + componentMetadata: Map; } export interface ProfilingCommit { @@ -95,6 +111,9 @@ export interface ComponentRenderReport { displayName: string; label?: string; type?: ComponentType; + path?: string; + source?: ComponentSourceLocation; + sourceKey?: string; renderCount: number; totalDuration: number; avgDuration: number; From 6fce3b89c82335f3eb32b3d0a5abcf48d67990e6 Mon Sep 17 00:00:00 2001 From: Piotr Tomczewski Date: Sat, 4 Apr 2026 03:42:02 +0200 Subject: [PATCH 2/2] fix: preserve profile command limit semantics --- packages/agent-react-devtools/src/cli.ts | 18 ++++++++++++++-- packages/agent-react-devtools/src/daemon.ts | 24 ++++++--------------- packages/agent-react-devtools/src/types.ts | 4 ++-- 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/agent-react-devtools/src/cli.ts b/packages/agent-react-devtools/src/cli.ts index 4f2ed2b..918944c 100644 --- a/packages/agent-react-devtools/src/cli.ts +++ b/packages/agent-react-devtools/src/cli.ts @@ -24,6 +24,10 @@ import { writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import type { IpcCommand } from './types.js'; +function getCandidateLimit(limit: number): number { + return Math.max(limit, Math.min(60, Math.max(20, limit * 3))); +} + function usage(): string { return `Usage: devtools [options] @@ -377,7 +381,12 @@ async function main(): Promise { if (cmd0 === 'profile' && cmd1 === 'slow') { const limit = parseNumericFlag(flags, 'limit'); - const resp = await sendCommand({ type: 'profile-slow', limit }); + const visibleLimit = limit ?? 10; + const resp = await sendCommand({ + type: 'profile-slow', + limit, + candidateLimit: getCandidateLimit(visibleLimit), + }); if (resp.ok) { console.log(formatSlowest(resp.data as any, limit)); } else { @@ -389,7 +398,12 @@ async function main(): Promise { if (cmd0 === 'profile' && cmd1 === 'rerenders') { const limit = parseNumericFlag(flags, 'limit'); - const resp = await sendCommand({ type: 'profile-rerenders', limit }); + const visibleLimit = limit ?? 10; + const resp = await sendCommand({ + type: 'profile-rerenders', + limit, + candidateLimit: getCandidateLimit(visibleLimit), + }); if (resp.ok) { console.log(formatRerenders(resp.data as any, limit)); } else { diff --git a/packages/agent-react-devtools/src/daemon.ts b/packages/agent-react-devtools/src/daemon.ts index e548cd5..df2f270 100644 --- a/packages/agent-react-devtools/src/daemon.ts +++ b/packages/agent-react-devtools/src/daemon.ts @@ -13,9 +13,6 @@ const DEFAULT_STATE_DIR = path.join( ); let STATE_DIR = DEFAULT_STATE_DIR; -const PROFILE_READ_CANDIDATE_MULTIPLIER = 3; -const PROFILE_READ_MIN_CANDIDATES = 20; -const PROFILE_READ_MAX_CANDIDATES = 60; const PROFILE_READ_ENRICH_CONCURRENCY = 5; const PROFILE_READ_ENRICH_TIMEOUT_MS = 1000; @@ -98,17 +95,6 @@ async function enrichProfileMetadataOnDemand( })); } -function getCandidateLimit(limit?: number): number { - if (limit === undefined) return PROFILE_READ_MAX_CANDIDATES; - return Math.max( - limit, - Math.min( - PROFILE_READ_MAX_CANDIDATES, - Math.max(PROFILE_READ_MIN_CANDIDATES, limit * PROFILE_READ_CANDIDATE_MULTIPLIER), - ), - ); -} - class Daemon { private ipcServer: net.Server | null = null; private bridge: DevToolsBridge; @@ -352,18 +338,20 @@ class Daemon { } case 'profile-slow': { - const candidateLimit = getCandidateLimit(cmd.limit); + const requestedLimit = cmd.limit; + const candidateLimit = Math.max(requestedLimit ?? 10, cmd.candidateLimit ?? requestedLimit ?? 10); const candidates = this.profiler.getSlowest(this.tree, candidateLimit); await enrichProfileMetadataOnDemand(candidates, this.tree, this.bridge, this.profiler); - const slowest = this.profiler.getSlowest(this.tree, candidateLimit); + const slowest = this.profiler.getSlowest(this.tree, requestedLimit ?? candidateLimit); return { ok: true, data: slowest }; } case 'profile-rerenders': { - const candidateLimit = getCandidateLimit(cmd.limit); + const requestedLimit = cmd.limit; + const candidateLimit = Math.max(requestedLimit ?? 10, cmd.candidateLimit ?? requestedLimit ?? 10); const candidates = this.profiler.getMostRerenders(this.tree, candidateLimit); await enrichProfileMetadataOnDemand(candidates, this.tree, this.bridge, this.profiler); - const rerenders = this.profiler.getMostRerenders(this.tree, candidateLimit); + const rerenders = this.profiler.getMostRerenders(this.tree, requestedLimit ?? candidateLimit); return { ok: true, data: rerenders }; } diff --git a/packages/agent-react-devtools/src/types.ts b/packages/agent-react-devtools/src/types.ts index a306dd0..3751ca3 100644 --- a/packages/agent-react-devtools/src/types.ts +++ b/packages/agent-react-devtools/src/types.ts @@ -206,8 +206,8 @@ export type IpcCommand = | { type: 'profile-start'; name?: string } | { type: 'profile-stop' } | { type: 'profile-report'; componentId: number | string } - | { type: 'profile-slow'; limit?: number } - | { type: 'profile-rerenders'; limit?: number } + | { type: 'profile-slow'; limit?: number; candidateLimit?: number } + | { type: 'profile-rerenders'; limit?: number; candidateLimit?: number } | { type: 'profile-timeline'; limit?: number; offset?: number; sort?: 'duration' | 'timeline' } | { type: 'profile-commit'; index: number; limit?: number } | { type: 'profile-export' }