diff --git a/.changeset/label-based-react-node-lookups.md b/.changeset/label-based-react-node-lookups.md new file mode 100644 index 00000000..733ac32f --- /dev/null +++ b/.changeset/label-based-react-node-lookups.md @@ -0,0 +1,6 @@ +--- +'@rozenite/middleware': patch +'rozenite': patch +--- + +Add stable React component labels so nodes can be looked up by labels like `@c2` across the React agent tools. diff --git a/packages/cli/src/commands/agent/runtime/react/__tests__/store.test.ts b/packages/cli/src/commands/agent/runtime/react/__tests__/store.test.ts new file mode 100644 index 00000000..179f7bd7 --- /dev/null +++ b/packages/cli/src/commands/agent/runtime/react/__tests__/store.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { createReactTreeStore } from '../store.js'; + +const DEVICE_ID = 'device-1'; + +const waitFor = async (predicate: () => boolean): Promise => { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) { + return; + } + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + + expect(predicate()).toBe(true); +}; + +const createStoreWithBridgeStub = ( + sent: Array<{ event: string; payload: unknown }>, +) => { + return createReactTreeStore({ + createBridge: async (options) => ({ + ingest: () => null, + send: (event, payload) => { + sent.push({ event, payload }); + options?.sendMessage?.({ event, payload }); + }, + startProfiling: () => undefined, + stopProfiling: () => undefined, + reloadAndProfile: () => undefined, + getProfilingStatus: () => ({ + supportsProfiling: true, + supportsReloadAndProfile: false, + isProfilingStarted: false, + isProcessingData: false, + hasProfilingData: false, + rootsWithData: 0, + rootsCount: 0, + }), + getProfilingDataSnapshot: () => null, + getCommitData: () => { + throw new Error('No commit data'); + }, + }), + }); +}; + +const createStoreWithTree = () => { + const store = createReactTreeStore(); + store.registerDevice(DEVICE_ID); + store.syncTree(DEVICE_ID, { + roots: [1], + nodes: [ + { + nodeId: 1, + displayName: 'Root', + elementType: 'root', + childIds: [2, 3], + }, + { + nodeId: 2, + displayName: 'App', + elementType: 'function', + parentId: 1, + childIds: [4], + }, + { + nodeId: 3, + displayName: 'Sidebar', + elementType: 'function', + parentId: 1, + childIds: [], + }, + { + nodeId: 4, + displayName: 'Button', + elementType: 'host', + parentId: 2, + childIds: [], + }, + ], + }); + + return store; +}; + +describe('CLI React tree store labels', () => { + it('resolves labels for node lookup while keeping numeric nodeId lookup working', () => { + const store = createStoreWithTree(); + + expect(store.getNode(DEVICE_ID, { id: '@c2' })).toMatchObject({ + nodeId: 2, + label: '@c2', + displayName: 'App', + parentLabel: '@c1', + }); + expect(store.getNode(DEVICE_ID, { nodeId: 2 })).toMatchObject({ + nodeId: 2, + label: '@c2', + displayName: 'App', + }); + }); + + it('resolves labels for children and inspectable requests', async () => { + const sent: Array<{ event: string; payload: unknown }> = []; + const store = createStoreWithBridgeStub(sent); + store.registerDevice(DEVICE_ID, { + sendMessage: () => undefined, + }); + store.syncTree(DEVICE_ID, { + roots: [1], + nodes: [ + { + nodeId: 1, + displayName: 'Root', + elementType: 'root', + childIds: [2], + }, + { + nodeId: 2, + displayName: 'App', + elementType: 'function', + parentId: 1, + rendererId: 7, + childIds: [], + }, + ], + }); + + expect(store.getChildren(DEVICE_ID, { id: '@c1', limit: 3 }).items).toEqual([ + expect.objectContaining({ + nodeId: 2, + label: '@c2', + parentLabel: '@c1', + }), + ]); + + const propsPromise = store.getProps(DEVICE_ID, { id: '@c2' }); + await waitFor(() => sent.length > 0); + + expect(sent.at(-1)).toEqual({ + event: 'inspectElement', + payload: { + forceFullData: true, + id: 2, + path: null, + rendererID: 7, + requestID: 1, + }, + }); + + await store.ingestReactDevToolsMessage(DEVICE_ID, { + event: 'inspectedElement', + payload: { + id: 2, + type: 'full-data', + value: { + props: { title: 'Hello' }, + }, + }, + }); + + await expect(propsPromise).resolves.toMatchObject({ + items: [{ name: 'title', value: 'Hello' }], + }); + }); +}); diff --git a/packages/cli/src/commands/agent/runtime/react/store.ts b/packages/cli/src/commands/agent/runtime/react/store.ts index 064f762f..0b6cfed2 100644 --- a/packages/cli/src/commands/agent/runtime/react/store.ts +++ b/packages/cli/src/commands/agent/runtime/react/store.ts @@ -65,6 +65,8 @@ type ReactChangeDescription = { type DeviceReactTreeState = { rootIds: number[]; nodesById: Map; + labelByNodeId: Map; + nodeIdByLabel: Map; inspectedById: Map; bridge: ReactDevToolsBridge | null; bridgePromise: Promise | null; @@ -180,6 +182,7 @@ const delay = async (ms: number): Promise => { const ensureNodeSummary = (node: ReactNodeRecord): ReactNodeSummary => { return { nodeId: node.nodeId, + label: '@c?', displayName: node.displayName, elementType: node.elementType, ...(node.key !== undefined ? { key: node.key } : {}), @@ -188,12 +191,48 @@ const ensureNodeSummary = (node: ReactNodeRecord): ReactNodeSummary => { }; }; -const getNodeId = (value: unknown): number => { - if (!Number.isInteger(value)) { - throw new Error('"nodeId" must be an integer'); +const ensureNodeSummaryForState = ( + state: DeviceReactTreeState, + node: ReactNodeRecord, +): ReactNodeSummary => { + const parentLabel = node.parentId !== undefined + ? state.labelByNodeId.get(node.parentId) + : undefined; + + return { + ...ensureNodeSummary(node), + label: state.labelByNodeId.get(node.nodeId) ?? '@c?', + ...(parentLabel !== undefined ? { parentLabel } : {}), + }; +}; + +const resolveNodeId = ( + state: DeviceReactTreeState, + value: unknown, + fieldName: string, +): number => { + if (Number.isInteger(value)) { + return Number(value); } - return Number(value); + if (typeof value === 'string') { + const trimmed = value.trim(); + if (/^@c\d+$/.test(trimmed)) { + const nodeId = state.nodeIdByLabel.get(trimmed); + if (nodeId === undefined) { + throw new Error(`Component label "${trimmed}" no longer exists in the current React tree.`); + } + + return nodeId; + } + + const parsed = Number(trimmed); + if (Number.isInteger(parsed)) { + return parsed; + } + } + + throw new Error(`"${fieldName}" must be an integer or component label like "@c12"`); }; const ensureNodeExists = (state: DeviceReactTreeState, nodeId: number): ReactNodeRecord => { @@ -205,6 +244,29 @@ const ensureNodeExists = (state: DeviceReactTreeState, nodeId: number): ReactNod return node; }; +const getRequestedNodeId = ( + state: DeviceReactTreeState, + request: Record, +): number => { + return resolveNodeId( + state, + request.id !== undefined ? request.id : request.nodeId, + request.id !== undefined ? 'id' : 'nodeId', + ); +}; + +const getOptionalNodeId = ( + state: DeviceReactTreeState, + value: unknown, + fieldName: string, +): number | undefined => { + if (value === undefined) { + return undefined; + } + + return resolveNodeId(state, value, fieldName); +}; + const createSerializableSnapshot = ( value: unknown, depth = 3, @@ -439,6 +501,38 @@ const toReactNodeRecord = (node: ReactTreeNodeInput): ReactNodeRecord => { }; }; +const rebuildLabels = (state: DeviceReactTreeState): void => { + state.labelByNodeId.clear(); + state.nodeIdByLabel.clear(); + + const visited = new Set(); + let nextLabelIndex = 1; + + const walk = (nodeId: number): void => { + if (visited.has(nodeId) || !state.nodesById.has(nodeId)) { + return; + } + + visited.add(nodeId); + const label = `@c${nextLabelIndex++}`; + state.labelByNodeId.set(nodeId, label); + state.nodeIdByLabel.set(label, nodeId); + + const node = state.nodesById.get(nodeId); + if (!node) { + return; + } + + for (const childId of node.childIds) { + walk(childId); + } + }; + + for (const rootId of state.rootIds) { + walk(rootId); + } +}; + export const createReactTreeStore = ( options?: { createBridge?: (options?: { @@ -458,6 +552,8 @@ export const createReactTreeStore = ( const created: DeviceReactTreeState = { rootIds: [], nodesById: new Map(), + labelByNodeId: new Map(), + nodeIdByLabel: new Map(), inspectedById: new Map(), bridge: null, bridgePromise: null, @@ -511,6 +607,7 @@ export const createReactTreeStore = ( state.rootIds = rootIds.filter((rootId) => nodesById.has(rootId)); state.nodesById = nodesById; + rebuildLabels(state); // Tree-sync snapshots replace current topology; invalidate stale inspected snapshots. state.inspectedById.clear(); }; @@ -794,7 +891,7 @@ export const createReactTreeStore = ( } const query = rawQuery.trim().toLowerCase(); - const rootId = Number.isInteger(request.rootId) ? Number(request.rootId) : undefined; + const rootId = getOptionalNodeId(state, request.rootId, 'rootId'); const match = normalizeMatch(request.match); const limit = normalizeLimit(request.limit); @@ -833,7 +930,9 @@ export const createReactTreeStore = ( const safeOffset = Math.max(0, Math.min(offset, matched.length)); const end = Math.min(safeOffset + limit, matched.length); - const items = matched.slice(safeOffset, end).map(ensureNodeSummary); + const items = matched + .slice(safeOffset, end) + .map((node) => ensureNodeSummaryForState(state, node)); const hasMore = end < matched.length; const nextCursor = hasMore ? encodeCursor({ @@ -858,15 +957,15 @@ export const createReactTreeStore = ( const getNode = (deviceId: string, rawRequest: unknown): ReactNodeSummary => { const request = getRecord(rawRequest) || {}; const state = getOrCreateState(deviceId); - const nodeId = getNodeId(request.nodeId); + const nodeId = getRequestedNodeId(state, request); const node = ensureNodeExists(state, nodeId); - return ensureNodeSummary(node); + return ensureNodeSummaryForState(state, node); }; const getChildren = (deviceId: string, rawRequest: unknown): ReactGetChildrenResult => { const request = getRecord(rawRequest) || {}; const state = getOrCreateState(deviceId); - const nodeId = getNodeId(request.nodeId); + const nodeId = getRequestedNodeId(state, request); const node = ensureNodeExists(state, nodeId); const limit = normalizeLimit(request.limit); const filtersHash = hashFilters({ nodeId }); @@ -889,7 +988,9 @@ export const createReactTreeStore = ( .filter((child): child is ReactNodeRecord => Boolean(child)); const safeOffset = Math.max(0, Math.min(offset, children.length)); const end = Math.min(safeOffset + limit, children.length); - const items = children.slice(safeOffset, end).map(ensureNodeSummary); + const items = children + .slice(safeOffset, end) + .map((child) => ensureNodeSummaryForState(state, child)); const hasMore = end < children.length; const nextCursor = hasMore ? encodeCursor({ @@ -968,7 +1069,7 @@ export const createReactTreeStore = ( ): Promise => { const request = getRecord(rawRequest) || {}; const state = getOrCreateState(deviceId); - const nodeId = getNodeId(request.nodeId); + const nodeId = getRequestedNodeId(state, request); ensureNodeExists(state, nodeId); const path = kind === 'hooks' ? normalizePath(request.path) : []; diff --git a/packages/cli/src/commands/agent/runtime/react/types.ts b/packages/cli/src/commands/agent/runtime/react/types.ts index 40069575..16ccb32f 100644 --- a/packages/cli/src/commands/agent/runtime/react/types.ts +++ b/packages/cli/src/commands/agent/runtime/react/types.ts @@ -1,13 +1,21 @@ export interface ReactNodeSummary { nodeId: number; + label: string; displayName: string; elementType: string; key?: string; childCount: number; parentId?: number; + parentLabel?: string; } -export interface ReactNodeRecord extends ReactNodeSummary { +export interface ReactNodeRecord { + nodeId: number; + displayName: string; + elementType: string; + key?: string; + childCount: number; + parentId?: number; childIds: number[]; rendererId?: number; } @@ -40,7 +48,7 @@ export interface ReactDevToolsBridgeMessage { export interface ReactSearchNodesRequest { query?: string; - rootId?: number; + rootId?: number | string; match?: 'name' | 'name-or-key'; limit?: number; cursor?: string; @@ -57,10 +65,12 @@ export interface ReactSearchNodesResult { export interface ReactGetNodeRequest { nodeId?: number; + id?: string | number; } export interface ReactGetChildrenRequest { nodeId?: number; + id?: string | number; limit?: number; cursor?: string; } @@ -81,6 +91,7 @@ export interface ReactInspectableEntry { export interface ReactGetInspectableRequest { nodeId?: number; + id?: string | number; limit?: number; cursor?: string; } diff --git a/packages/middleware/src/agent/local-domains.ts b/packages/middleware/src/agent/local-domains.ts index 78c9650c..9f0c19da 100644 --- a/packages/middleware/src/agent/local-domains.ts +++ b/packages/middleware/src/agent/local-domains.ts @@ -647,6 +647,12 @@ export const createReactDomainService = (deps: { payload: unknown; }) => void; }): LocalAgentToolService => { + const nodeIdentifierSchema = { + oneOf: [{ type: 'integer' }, { type: 'string' }], + }; + const nodeIdentifierRequirement = { + anyOf: [{ required: ['id'] }, { required: ['nodeId'] }], + }; const tools: AgentTool[] = [ { name: 'getTree', @@ -677,27 +683,36 @@ export const createReactDomainService = (deps: { }, { name: 'getNode', - description: 'Get a single React node summary by ID.', + description: 'Get a single React node summary by node ID or label.', inputSchema: { type: 'object', properties: { + id: { + ...nodeIdentifierSchema, + description: 'React DevTools node ID or component label.', + }, nodeId: { - type: 'integer', - description: 'React DevTools node ID.', + ...nodeIdentifierSchema, + description: 'React DevTools node ID or component label.', }, }, - required: ['nodeId'], + ...nodeIdentifierRequirement, }, }, { name: 'getChildren', - description: "Get a node's direct children with cursor-based pagination.", + description: + "Get a node's direct children by node ID or label with cursor-based pagination.", inputSchema: { type: 'object', properties: { + id: { + ...nodeIdentifierSchema, + description: 'Parent node ID or component label.', + }, nodeId: { - type: 'integer', - description: 'Parent node ID.', + ...nodeIdentifierSchema, + description: 'Parent node ID or component label.', }, limit: { type: 'integer', @@ -708,7 +723,7 @@ export const createReactDomainService = (deps: { description: 'Opaque cursor returned by the previous page.', }, }, - required: ['nodeId'], + ...nodeIdentifierRequirement, }, }, { @@ -718,9 +733,13 @@ export const createReactDomainService = (deps: { inputSchema: { type: 'object', properties: { + id: { + ...nodeIdentifierSchema, + description: 'Node ID or component label to read props for.', + }, nodeId: { - type: 'integer', - description: 'Node ID to read props for.', + ...nodeIdentifierSchema, + description: 'Node ID or component label to read props for.', }, limit: { type: 'integer', @@ -731,7 +750,7 @@ export const createReactDomainService = (deps: { description: 'Opaque cursor returned by the previous page.', }, }, - required: ['nodeId'], + ...nodeIdentifierRequirement, }, }, { @@ -741,9 +760,13 @@ export const createReactDomainService = (deps: { inputSchema: { type: 'object', properties: { + id: { + ...nodeIdentifierSchema, + description: 'Node ID or component label to read state for.', + }, nodeId: { - type: 'integer', - description: 'Node ID to read state for.', + ...nodeIdentifierSchema, + description: 'Node ID or component label to read state for.', }, limit: { type: 'integer', @@ -754,7 +777,7 @@ export const createReactDomainService = (deps: { description: 'Opaque cursor returned by the previous page.', }, }, - required: ['nodeId'], + ...nodeIdentifierRequirement, }, }, { @@ -764,9 +787,13 @@ export const createReactDomainService = (deps: { inputSchema: { type: 'object', properties: { + id: { + ...nodeIdentifierSchema, + description: 'Node ID or component label to read hooks for.', + }, nodeId: { - type: 'integer', - description: 'Node ID to read hooks for.', + ...nodeIdentifierSchema, + description: 'Node ID or component label to read hooks for.', }, path: { type: 'array', @@ -784,7 +811,7 @@ export const createReactDomainService = (deps: { description: 'Opaque cursor returned by the previous page.', }, }, - required: ['nodeId'], + ...nodeIdentifierRequirement, }, }, { @@ -798,8 +825,9 @@ export const createReactDomainService = (deps: { description: 'Search query. Required and non-empty.', }, rootId: { - type: 'integer', - description: 'Optional root node ID to scope search to a subtree.', + ...nodeIdentifierSchema, + description: + 'Optional root node ID or component label to scope search to a subtree.', }, match: { type: 'string', diff --git a/packages/middleware/src/agent/runtime/react/__tests__/store.test.ts b/packages/middleware/src/agent/runtime/react/__tests__/store.test.ts index 565c86a2..e0ec2a61 100644 --- a/packages/middleware/src/agent/runtime/react/__tests__/store.test.ts +++ b/packages/middleware/src/agent/runtime/react/__tests__/store.test.ts @@ -3,6 +3,50 @@ import { createReactTreeStore } from '../store.js'; const DEVICE_ID = 'device-1'; +const waitFor = async (predicate: () => boolean): Promise => { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) { + return; + } + + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + + expect(predicate()).toBe(true); +}; + +const createStoreWithBridgeStub = ( + sent: Array<{ event: string; payload: unknown }>, +) => { + return createReactTreeStore({ + createBridge: async (options) => ({ + ingest: () => null, + send: (event, payload) => { + sent.push({ event, payload }); + options?.sendMessage?.({ event, payload }); + }, + startProfiling: () => undefined, + stopProfiling: () => undefined, + reloadAndProfile: () => undefined, + getProfilingStatus: () => ({ + supportsProfiling: true, + supportsReloadAndProfile: false, + isProfilingStarted: false, + isProcessingData: false, + hasProfilingData: false, + rootsWithData: 0, + rootsCount: 0, + }), + getProfilingDataSnapshot: () => null, + getCommitData: () => { + throw new Error('No commit data'); + }, + }), + }); +}; + const createStoreWithTree = () => { const store = createReactTreeStore(); store.registerDevice(DEVICE_ID); @@ -145,3 +189,136 @@ describe('React tree store', () => { }); }); +describe('React tree store labels', () => { + it('adds deterministic labels to node summaries', () => { + const store = createStoreWithTree(); + + const result = store.searchNodes(DEVICE_ID, { query: 'o', limit: 10 }); + + expect(result.items).toEqual([ + expect.objectContaining({ + nodeId: 1, + label: '@c1', + displayName: 'Root', + }), + expect.objectContaining({ + nodeId: 4, + label: '@c3', + parentLabel: '@c2', + displayName: 'Button', + }), + ]); + expect(store.getNode(DEVICE_ID, { id: '@c4' })).toMatchObject({ + nodeId: 3, + label: '@c4', + parentLabel: '@c1', + displayName: 'Sidebar', + }); + }); + + it('resolves labels for node lookup', () => { + const store = createStoreWithTree(); + + expect(store.getNode(DEVICE_ID, { id: '@c2' })).toMatchObject({ + nodeId: 2, + label: '@c2', + displayName: 'App', + parentLabel: '@c1', + }); + }); + + it('keeps numeric nodeId lookup working', () => { + const store = createStoreWithTree(); + + expect(store.getNode(DEVICE_ID, { nodeId: 2 })).toMatchObject({ + nodeId: 2, + label: '@c2', + displayName: 'App', + }); + }); + + it('resolves labels for children and inspectable requests', async () => { + const sent: Array<{ event: string; payload: unknown }> = []; + const store = createStoreWithBridgeStub(sent); + store.registerDevice(DEVICE_ID, { + sendMessage: () => undefined, + }); + store.syncTree(DEVICE_ID, { + roots: [1], + nodes: [ + { + nodeId: 1, + displayName: 'Root', + elementType: 'root', + childIds: [2], + }, + { + nodeId: 2, + displayName: 'App', + elementType: 'function', + parentId: 1, + rendererId: 7, + childIds: [], + }, + ], + }); + + expect(store.getChildren(DEVICE_ID, { id: '@c1' }).items).toEqual([ + expect.objectContaining({ + nodeId: 2, + label: '@c2', + parentLabel: '@c1', + }), + ]); + + const propsPromise = store.getProps(DEVICE_ID, { id: '@c2' }); + await waitFor(() => sent.length > 0); + + expect(sent.at(-1)).toEqual({ + event: 'inspectElement', + payload: { + forceFullData: true, + id: 2, + path: null, + rendererID: 7, + requestID: 1, + }, + }); + + await store.ingestReactDevToolsMessage(DEVICE_ID, { + event: 'inspectedElement', + payload: { + id: 2, + type: 'full-data', + value: { + props: { title: 'Hello' }, + }, + }, + }); + + await expect(propsPromise).resolves.toMatchObject({ + items: [{ name: 'title', value: 'Hello' }], + }); + }); + + it('rejects stale labels after a tree sync', () => { + const store = createStoreWithTree(); + expect(store.getNode(DEVICE_ID, { id: '@c4' }).nodeId).toBe(3); + + store.syncTree(DEVICE_ID, { + roots: [1], + nodes: [ + { + nodeId: 1, + displayName: 'Root', + elementType: 'root', + childIds: [], + }, + ], + }); + + expect(() => store.getNode(DEVICE_ID, { id: '@c4' })).toThrow( + 'Component label "@c4" no longer exists in the current React tree.', + ); + }); +}); diff --git a/packages/middleware/src/agent/runtime/react/store.ts b/packages/middleware/src/agent/runtime/react/store.ts index c376825c..d909e063 100644 --- a/packages/middleware/src/agent/runtime/react/store.ts +++ b/packages/middleware/src/agent/runtime/react/store.ts @@ -71,6 +71,8 @@ type ReactChangeDescription = { type DeviceReactTreeState = { rootIds: number[]; nodesById: Map; + labelByNodeId: Map; + nodeIdByLabel: Map; inspectedById: Map; bridge: ReactDevToolsBridge | null; bridgePromise: Promise | null; @@ -208,6 +210,7 @@ const delay = async (ms: number): Promise => { const ensureNodeSummary = (node: ReactNodeRecord): ReactNodeSummary => { return { nodeId: node.nodeId, + label: '@c?', displayName: node.displayName, elementType: node.elementType, ...(node.key !== undefined ? { key: node.key } : {}), @@ -216,7 +219,22 @@ const ensureNodeSummary = (node: ReactNodeRecord): ReactNodeSummary => { }; }; +const ensureNodeSummaryForState = ( + state: DeviceReactTreeState, + node: ReactNodeRecord, +): ReactNodeSummary => { + const parentLabel = + node.parentId !== undefined ? state.labelByNodeId.get(node.parentId) : undefined; + + return { + ...ensureNodeSummary(node), + label: state.labelByNodeId.get(node.nodeId) ?? '@c?', + ...(parentLabel !== undefined ? { parentLabel } : {}), + }; +}; + const ensureTreeNode = ( + state: DeviceReactTreeState, node: ReactNodeRecord, options: { depth: number; @@ -224,18 +242,41 @@ const ensureTreeNode = ( }, ): ReactTreeNode => { return { - ...ensureNodeSummary(node), + ...ensureNodeSummaryForState(state, node), childIds: options.childIds, depth: options.depth, }; }; -const getNodeId = (value: unknown): number => { - if (!Number.isInteger(value)) { - throw new Error('"nodeId" must be an integer'); +const resolveNodeId = ( + state: DeviceReactTreeState, + value: unknown, + fieldName: string, +): number => { + if (Number.isInteger(value)) { + return Number(value); } - return Number(value); + if (typeof value === 'string') { + const trimmed = value.trim(); + if (/^@c\d+$/.test(trimmed)) { + const nodeId = state.nodeIdByLabel.get(trimmed); + if (nodeId === undefined) { + throw new Error( + `Component label "${trimmed}" no longer exists in the current React tree.`, + ); + } + + return nodeId; + } + + const parsed = Number(trimmed); + if (Number.isInteger(parsed)) { + return parsed; + } + } + + throw new Error(`"${fieldName}" must be an integer or component label like "@c12"`); }; const ensureNodeExists = ( @@ -264,6 +305,17 @@ const getOptionalRootId = (value: unknown): number | undefined => { return Number(value); }; +const getRequestedNodeId = ( + state: DeviceReactTreeState, + request: Record, +): number => { + return resolveNodeId( + state, + request.id !== undefined ? request.id : request.nodeId, + request.id !== undefined ? 'id' : 'nodeId', + ); +}; + const createSerializableSnapshot = ( value: unknown, depth = 3, @@ -544,6 +596,8 @@ export const createReactTreeStore = (options?: { const created: DeviceReactTreeState = { rootIds: [], nodesById: new Map(), + labelByNodeId: new Map(), + nodeIdByLabel: new Map(), inspectedById: new Map(), bridge: null, bridgePromise: null, @@ -595,6 +649,34 @@ export const createReactTreeStore = (options?: { state.rootIds = rootIds.filter((rootId) => nodesById.has(rootId)); state.nodesById = nodesById; + state.labelByNodeId.clear(); + state.nodeIdByLabel.clear(); + + const visited = new Set(); + let nextLabelIndex = 1; + const assignLabels = (nodeId: number): void => { + if (visited.has(nodeId)) { + return; + } + const node = nodesById.get(nodeId); + if (!node) { + return; + } + + visited.add(nodeId); + const label = `@c${nextLabelIndex++}`; + state.labelByNodeId.set(nodeId, label); + state.nodeIdByLabel.set(label, nodeId); + + for (const childId of node.childIds) { + assignLabels(childId); + } + }; + + for (const rootId of state.rootIds) { + assignLabels(rootId); + } + state.inspectedById.clear(); }; @@ -962,7 +1044,9 @@ export const createReactTreeStore = (options?: { const safeOffset = Math.max(0, Math.min(offset, matched.length)); const end = Math.min(safeOffset + limit, matched.length); - const items = matched.slice(safeOffset, end).map(ensureNodeSummary); + const items = matched + .slice(safeOffset, end) + .map((node) => ensureNodeSummaryForState(state, node)); const hasMore = end < matched.length; const nextCursor = hasMore ? encodeCursor({ @@ -1043,7 +1127,7 @@ export const createReactTreeStore = (options?: { state.nodesById.has(childId), ); allItems.push( - ensureTreeNode(node, { + ensureTreeNode(state, node, { depth: currentDepth, childIds, }), @@ -1087,9 +1171,9 @@ export const createReactTreeStore = (options?: { const getNode = (deviceId: string, rawRequest: unknown): ReactNodeSummary => { const request = getRecord(rawRequest) || {}; const state = getOrCreateState(deviceId); - const nodeId = getNodeId(request.nodeId); + const nodeId = getRequestedNodeId(state, request); const node = ensureNodeExists(state, nodeId); - return ensureNodeSummary(node); + return ensureNodeSummaryForState(state, node); }; const getChildren = ( @@ -1098,7 +1182,7 @@ export const createReactTreeStore = (options?: { ): ReactGetChildrenResult => { const request = getRecord(rawRequest) || {}; const state = getOrCreateState(deviceId); - const nodeId = getNodeId(request.nodeId); + const nodeId = getRequestedNodeId(state, request); const node = ensureNodeExists(state, nodeId); const limit = normalizeLimit(request.limit); const filtersHash = hashFilters({ nodeId }); @@ -1126,7 +1210,9 @@ export const createReactTreeStore = (options?: { .filter((child): child is ReactNodeRecord => Boolean(child)); const safeOffset = Math.max(0, Math.min(offset, children.length)); const end = Math.min(safeOffset + limit, children.length); - const items = children.slice(safeOffset, end).map(ensureNodeSummary); + const items = children + .slice(safeOffset, end) + .map((child) => ensureNodeSummaryForState(state, child)); const hasMore = end < children.length; const nextCursor = hasMore ? encodeCursor({ @@ -1207,7 +1293,7 @@ export const createReactTreeStore = (options?: { ): Promise => { const request = getRecord(rawRequest) || {}; const state = getOrCreateState(deviceId); - const nodeId = getNodeId(request.nodeId); + const nodeId = getRequestedNodeId(state, request); ensureNodeExists(state, nodeId); const path = kind === 'hooks' ? normalizePath(request.path) : []; diff --git a/packages/middleware/src/agent/runtime/react/types.ts b/packages/middleware/src/agent/runtime/react/types.ts index f7ba3e18..e1223fde 100644 --- a/packages/middleware/src/agent/runtime/react/types.ts +++ b/packages/middleware/src/agent/runtime/react/types.ts @@ -1,13 +1,21 @@ export interface ReactNodeSummary { nodeId: number; + label: string; displayName: string; elementType: string; key?: string; childCount: number; parentId?: number; + parentLabel?: string; } -export interface ReactNodeRecord extends ReactNodeSummary { +export interface ReactNodeRecord { + nodeId: number; + displayName: string; + elementType: string; + key?: string; + childCount: number; + parentId?: number; childIds: number[]; rendererId?: number; }