Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions packages/agent-react-devtools/src/__tests__/component-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => [
Expand Down
191 changes: 191 additions & 0 deletions packages/agent-react-devtools/src/__tests__/devtools-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>] {
const idMap = new Map<string, number>();
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' } });
});
});
Loading
Loading