mirror of
https://github.com/hanzoai/react.git
synced 2026-09-24 17:02:27 +00:00
[react-devtools] add parent stack tool (#36825)
This PR adds a new `getParentStack` tool to the Facade and an integration of it in `react-devtools-cdt-mcp`. Similarly to the owner stack tool, it receives a `uid` of a specific component and returns structural parent stack. For every node, it includes `uid`, `name`, `type`.
This commit is contained in:
@@ -89,13 +89,24 @@ Raw owner stack trace — the chain of JSX creation locations up to the root.
|
||||
- **Input:** `uid` (string, required).
|
||||
- **Output:** `{stack: string}` (DEV-only; empty in production).
|
||||
|
||||
### `react_get_parent_stack`
|
||||
|
||||
Rendered parent list — where a component is mounted in the rendered component
|
||||
tree.
|
||||
|
||||
- **Input:** `uid` (string, required).
|
||||
- **Output:** an array of `{uid, name, type}`, ordered from immediate parent to
|
||||
root (empty for the root). This can include host DOM components and the root.
|
||||
|
||||
### `react_get_owner_stack`
|
||||
|
||||
Structured owner list — which components rendered this one.
|
||||
Structured owner list — which components created/rendered this element through
|
||||
JSX.
|
||||
|
||||
- **Input:** `uid` (string, required).
|
||||
- **Output:** an array of `{uid, name, type}`, ordered from immediate owner to
|
||||
root ancestor (empty for a root component). DEV-only.
|
||||
root owner (empty for a root component). DEV-only. Owners are not structural
|
||||
parents; use `react_get_parent_stack` for mounted tree ancestry.
|
||||
|
||||
### `react_start_profiling`
|
||||
|
||||
|
||||
@@ -110,8 +110,10 @@ type OwnersStackResult = {
|
||||
stack: string,
|
||||
...
|
||||
};
|
||||
type Owner = {
|
||||
type ComponentBranchEntry = {
|
||||
uid: string,
|
||||
name: string,
|
||||
type: string,
|
||||
...
|
||||
};
|
||||
type ErrorPayload = {
|
||||
@@ -146,6 +148,7 @@ const TOOL_NAMES = [
|
||||
'react_find_components',
|
||||
'react_get_component_source',
|
||||
'react_get_owner_stack_trace',
|
||||
'react_get_parent_stack',
|
||||
'react_get_owner_stack',
|
||||
'react_start_profiling',
|
||||
'react_stop_profiling',
|
||||
@@ -665,10 +668,21 @@ function parseNamedObject(value: mixed, message: string): {name: string, ...} {
|
||||
};
|
||||
}
|
||||
|
||||
function parseComponentBranchEntry(
|
||||
value: mixed,
|
||||
message: string
|
||||
): ComponentBranchEntry {
|
||||
const object = expectObject(value, message);
|
||||
return {
|
||||
uid: expectString(object.uid, `${message} uid`),
|
||||
name: expectString(object.name, `${message} name`),
|
||||
type: expectString(object.type, `${message} type`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseComponentDetails(value: mixed): ComponentDetails {
|
||||
const object = expectObject(value, 'Expected component details object');
|
||||
return {
|
||||
...object,
|
||||
const details: ComponentDetails = {
|
||||
name: expectString(object.name, 'Expected component details name'),
|
||||
type: expectString(object.type, 'Expected component details type'),
|
||||
hooks: expectArray(object.hooks, 'Expected component details hooks').map(
|
||||
@@ -676,6 +690,7 @@ function parseComponentDetails(value: mixed): ComponentDetails {
|
||||
parseNamedObject(hook, `Expected component hook ${index}`)
|
||||
),
|
||||
};
|
||||
return details;
|
||||
}
|
||||
|
||||
function parseComponentType(value: mixed): string {
|
||||
@@ -775,9 +790,13 @@ function parseOwnersStack(value: mixed): OwnersStackResult {
|
||||
};
|
||||
}
|
||||
|
||||
function parseOwnersBranch(value: mixed): Array<Owner> {
|
||||
return expectArray(value, 'Expected owners branch array').map(
|
||||
(owner, index) => parseNamedObject(owner, `Expected owner ${index}`)
|
||||
function parseComponentBranch(
|
||||
value: mixed,
|
||||
label: string
|
||||
): Array<ComponentBranchEntry> {
|
||||
return expectArray(value, `Expected ${label} branch array`).map(
|
||||
(entry, index) =>
|
||||
parseComponentBranchEntry(entry, `Expected ${label} ${index}`)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -989,6 +1008,31 @@ async function runE2E(chrome: Chrome, appUrl: string): Promise<void> {
|
||||
node => node.name === 'Todo' && node.type === 'function',
|
||||
'Expected function component Todo'
|
||||
);
|
||||
const todoList = findNode(
|
||||
tree,
|
||||
node => node.name === 'TodoList' && node.type === 'function',
|
||||
'Expected function component TodoList'
|
||||
);
|
||||
const todoListHost = findNode(
|
||||
tree,
|
||||
node => node.name === 'ul' && node.type === 'host',
|
||||
'Expected host ul for TodoList'
|
||||
);
|
||||
const mainNode = findNode(
|
||||
tree,
|
||||
node => node.name === 'main' && node.type === 'host',
|
||||
'Expected host main'
|
||||
);
|
||||
const app = findNode(
|
||||
tree,
|
||||
node => node.name === 'App' && node.type === 'function',
|
||||
'Expected function component App'
|
||||
);
|
||||
const root = findNode(
|
||||
tree,
|
||||
node => node.type === 'root',
|
||||
'Expected root node'
|
||||
);
|
||||
const memoBox = findNode(
|
||||
tree,
|
||||
node => node.name.includes('MemoBox') && node.type === 'memo',
|
||||
@@ -1069,7 +1113,7 @@ async function runE2E(chrome: Chrome, appUrl: string): Promise<void> {
|
||||
assert.strictEqual(domLookup.type, 'host');
|
||||
assert.strictEqual(domLookup.name, 'button');
|
||||
|
||||
log('Checking source, owners, and error payloads...');
|
||||
log('Checking source, parents, owners, and error payloads...');
|
||||
const source = parseSourceResult(
|
||||
await callTool('react_get_component_source', {
|
||||
uid: counter.uid,
|
||||
@@ -1088,10 +1132,45 @@ async function runE2E(chrome: Chrome, appUrl: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const ownersBranch = parseOwnersBranch(
|
||||
const parentsBranch = parseComponentBranch(
|
||||
await callTool('react_get_parent_stack', {
|
||||
uid: todo.uid,
|
||||
}),
|
||||
'parents'
|
||||
);
|
||||
assert.deepStrictEqual(parentsBranch, [
|
||||
{
|
||||
uid: todoListHost.uid,
|
||||
name: todoListHost.name,
|
||||
type: todoListHost.type,
|
||||
},
|
||||
{
|
||||
uid: todoList.uid,
|
||||
name: todoList.name,
|
||||
type: todoList.type,
|
||||
},
|
||||
{
|
||||
uid: mainNode.uid,
|
||||
name: mainNode.name,
|
||||
type: mainNode.type,
|
||||
},
|
||||
{
|
||||
uid: app.uid,
|
||||
name: app.name,
|
||||
type: app.type,
|
||||
},
|
||||
{
|
||||
uid: root.uid,
|
||||
name: root.name,
|
||||
type: root.type,
|
||||
},
|
||||
]);
|
||||
|
||||
const ownersBranch = parseComponentBranch(
|
||||
await callTool('react_get_owner_stack', {
|
||||
uid: todo.uid,
|
||||
})
|
||||
}),
|
||||
'owners'
|
||||
);
|
||||
assert(
|
||||
ownersBranch.some(owner => owner.name === 'TodoList'),
|
||||
|
||||
+22
-3
@@ -191,12 +191,31 @@ const TOOL_DEFINITIONS: Array<ToolDefinition> = [
|
||||
},
|
||||
call: (tools, args) => tools.getOwnerStackTrace(args.uid),
|
||||
},
|
||||
{
|
||||
name: 'react_get_parent_stack',
|
||||
description:
|
||||
'Rendered parent chain for a component, from immediate parent to root: ' +
|
||||
'an array of {uid, name, type}. Parents describe where the node is ' +
|
||||
'mounted in the rendered component tree and may include host DOM ' +
|
||||
'components and the root. This differs from owners, which describe JSX ' +
|
||||
'creation/render ownership.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
uid: {type: 'string', description: 'Component uid, e.g. "r5".'},
|
||||
},
|
||||
required: ['uid'],
|
||||
},
|
||||
call: (tools, args) => tools.getParentStack(args.uid),
|
||||
},
|
||||
{
|
||||
name: 'react_get_owner_stack',
|
||||
description:
|
||||
'Structured owner chain for a component, from immediate owner to root ' +
|
||||
'ancestor: an array of {uid, name, type} (empty for a root ' +
|
||||
'component). DEV-only.',
|
||||
'JSX owner chain for a component, from immediate owner to root owner: ' +
|
||||
'an array of {uid, name, type} (empty for a root component). Owners ' +
|
||||
'describe which components created/rendered this element through JSX, ' +
|
||||
'not where it is mounted in the rendered component tree. This DEV-only metadata ' +
|
||||
'differs from structural parents.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
@@ -14,6 +14,7 @@ const TOOL_NAMES = [
|
||||
'react_find_components',
|
||||
'react_get_component_source',
|
||||
'react_get_owner_stack_trace',
|
||||
'react_get_parent_stack',
|
||||
'react_get_owner_stack',
|
||||
'react_start_profiling',
|
||||
'react_stop_profiling',
|
||||
@@ -105,6 +106,12 @@ describe('react-devtools-cdt-mcp', () => {
|
||||
expect(tool.inputSchema.type).toBe('object');
|
||||
expect(typeof tool.execute).toBe('function');
|
||||
});
|
||||
expect(getTool('react_get_parent_stack').description).toEqual(
|
||||
expect.stringContaining('Rendered parent chain'),
|
||||
);
|
||||
expect(getTool('react_get_owner_stack').description).toEqual(
|
||||
expect.stringContaining('Owners describe'),
|
||||
);
|
||||
});
|
||||
|
||||
it('declares JSON-Schema input with required params', () => {
|
||||
@@ -144,6 +151,16 @@ describe('react-devtools-cdt-mcp', () => {
|
||||
},
|
||||
required: ['name'],
|
||||
});
|
||||
expect(getTool('react_get_parent_stack').inputSchema).toEqual({
|
||||
type: 'object',
|
||||
properties: {uid: {type: 'string', description: expect.any(String)}},
|
||||
required: ['uid'],
|
||||
});
|
||||
expect(getTool('react_get_owner_stack').inputSchema).toEqual({
|
||||
type: 'object',
|
||||
properties: {uid: {type: 'string', description: expect.any(String)}},
|
||||
required: ['uid'],
|
||||
});
|
||||
expect(getTool('react_start_profiling').inputSchema).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -174,8 +191,8 @@ describe('react-devtools-cdt-mcp', () => {
|
||||
});
|
||||
|
||||
const result = getTool('react_get_component_tree').execute({});
|
||||
// Uids are assigned deterministically as fibers are first encountered. A
|
||||
// fiber's first child is assigned before the fiber itself, so App (the host
|
||||
// Uids are assigned deterministically as nodes are first encountered. A
|
||||
// node's first child is assigned before the node itself, so App (the host
|
||||
// root's first child) is r0, the host root is r1, and the div is r2.
|
||||
expect(result).toEqual({
|
||||
nodes: [
|
||||
@@ -320,6 +337,34 @@ describe('react-devtools-cdt-mcp', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('react_get_parent_stack returns structural ancestors', () => {
|
||||
function Child() {
|
||||
return <span>leaf</span>;
|
||||
}
|
||||
function Owner() {
|
||||
return (
|
||||
<section>
|
||||
<Child />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
ReactDOMClient.createRoot(container).render(<Owner />);
|
||||
});
|
||||
|
||||
const tree = getTool('react_get_component_tree').execute({}).nodes;
|
||||
const child = tree.find(n => n.name === 'Child');
|
||||
|
||||
expect(getTool('react_get_parent_stack').execute({uid: child.uid})).toEqual(
|
||||
[
|
||||
{uid: 'r2', name: 'section', type: 'host'},
|
||||
{uid: 'r0', name: 'Owner', type: 'function'},
|
||||
{uid: 'r1', name: expect.any(String), type: 'root'},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('react_get_component_by_dom_element returns the DOM element component', () => {
|
||||
function Wrapper({children}) {
|
||||
return <section className="wrap">{children}</section>;
|
||||
@@ -428,7 +473,7 @@ describe('react-devtools-cdt-mcp', () => {
|
||||
expect(overview).toHaveLength(1);
|
||||
expect(overview[0].commit).toBe(0);
|
||||
|
||||
// The commit report lists the fibers that rendered, sorted by actualDuration
|
||||
// The commit report lists the components that rendered, sorted by actualDuration
|
||||
// descending, with uids assigned in commit-walk order: the host root r0
|
||||
// (widest duration), then Counter r1, then the div r2. Durations are
|
||||
// timing-dependent, so assert identity (uid/name/type) only.
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
NodeInfo,
|
||||
ComponentSource,
|
||||
OwnersStack,
|
||||
ParentEntry,
|
||||
OwnerEntry,
|
||||
FindComponentsResult,
|
||||
ToolError,
|
||||
@@ -34,6 +35,8 @@ export type {
|
||||
ComponentSource,
|
||||
SourceLocation,
|
||||
OwnersStack,
|
||||
ComponentBranchEntry,
|
||||
ParentEntry,
|
||||
OwnerEntry,
|
||||
FindComponentsResult,
|
||||
ToolError,
|
||||
@@ -68,6 +71,7 @@ export type Tools = {
|
||||
) => FindComponentsResult | ToolError,
|
||||
getComponentSource: (uid: string) => ComponentSource | ToolError,
|
||||
getOwnerStackTrace: (uid: string) => OwnersStack | ToolError,
|
||||
getParentStack: (uid: string) => Array<ParentEntry> | ToolError,
|
||||
getOwnerStack: (uid: string) => Array<OwnerEntry> | ToolError,
|
||||
startProfiling: (traceName?: string) => StartProfilingResult | ToolError,
|
||||
stopProfiling: () => StopProfilingResult | ToolError,
|
||||
@@ -102,6 +106,7 @@ export function createTools(facade: Facade): Tools {
|
||||
findComponents: tree.findComponents,
|
||||
getComponentSource: tree.getComponentSource,
|
||||
getOwnerStackTrace: tree.getOwnerStackTrace,
|
||||
getParentStack: tree.getParentStack,
|
||||
getOwnerStack: tree.getOwnerStack,
|
||||
startProfiling: profiler.startProfiling,
|
||||
stopProfiling: profiler.stopProfiling,
|
||||
|
||||
@@ -66,7 +66,11 @@ export type ComponentSource = {source: SourceLocation | null};
|
||||
|
||||
export type OwnersStack = {stack: string};
|
||||
|
||||
export type OwnerEntry = {uid: string, name: string, type: string};
|
||||
export type ComponentBranchEntry = {uid: string, name: string, type: string};
|
||||
|
||||
export type ParentEntry = ComponentBranchEntry;
|
||||
|
||||
export type OwnerEntry = ComponentBranchEntry;
|
||||
|
||||
export type FindComponentsResult = {
|
||||
page: number,
|
||||
@@ -94,6 +98,7 @@ export type TreeTools = {
|
||||
) => FindComponentsResult | ToolError,
|
||||
getComponentSource: (uid: string) => ComponentSource | ToolError,
|
||||
getOwnerStackTrace: (uid: string) => OwnersStack | ToolError,
|
||||
getParentStack: (uid: string) => Array<ParentEntry> | ToolError,
|
||||
getOwnerStack: (uid: string) => Array<OwnerEntry> | ToolError,
|
||||
// Shared with the profiler tools so component uids are consistent across all
|
||||
// tools. Maps a fiber to its stable uid (assigning one on first encounter).
|
||||
@@ -737,10 +742,48 @@ export function createTreeTools(
|
||||
return {stack: stackString};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the structural parent branch for this fiber — the path formed by
|
||||
* following Fiber.return pointers from this component to the host root.
|
||||
* Parents describe where a node is mounted in the rendered tree, so this
|
||||
* branch can include host DOM components and the host root.
|
||||
*
|
||||
* This differs from owners: owners describe which components created/rendered
|
||||
* an element through JSX and are DEV-only metadata. Parents are structural
|
||||
* runtime relationships and are available whenever the fiber tree exists.
|
||||
*
|
||||
* Returns an array of {uid, name, type}, ordered from immediate parent to
|
||||
* root ancestor. The host root has an empty parent branch.
|
||||
*
|
||||
* @param uid - The component uid (e.g. "r5").
|
||||
*/
|
||||
function getParentStack(uid: string): Array<ParentEntry> | ToolError {
|
||||
const result = findFiberByUid(uid);
|
||||
if (result.error != null) {
|
||||
return {error: result.error};
|
||||
}
|
||||
const {internals} = result;
|
||||
const parents: Array<ParentEntry> = [];
|
||||
let parent = result.fiber.return;
|
||||
while (parent !== null) {
|
||||
parents.push({
|
||||
uid: getUid(parent),
|
||||
name: getDisplayName(internals, parent),
|
||||
type: getTypeTagForFiber(internals, parent),
|
||||
});
|
||||
parent = parent.return;
|
||||
}
|
||||
return parents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the structured list of owner components — which components rendered
|
||||
* this component, ordered from immediate owner to root ancestor. Each entry
|
||||
* includes a uid for cross-referencing with other tools (e.g.
|
||||
* or created this element through JSX, ordered from immediate owner to root
|
||||
* ancestor. Owners describe creation/render ownership, not where a node is
|
||||
* mounted in the rendered tree. Use getParentStack for structural Fiber
|
||||
* parent ancestry, including host DOM parents and the host root.
|
||||
*
|
||||
* Each entry includes a uid for cross-referencing with other tools (e.g.
|
||||
* getComponentByUid, getComponentSource, getComponentTree).
|
||||
*
|
||||
* Returns an array of {uid, name, type}, or an empty array if the component
|
||||
@@ -787,6 +830,7 @@ export function createTreeTools(
|
||||
findComponents,
|
||||
getComponentSource,
|
||||
getOwnerStackTrace,
|
||||
getParentStack,
|
||||
getOwnerStack,
|
||||
getUid,
|
||||
};
|
||||
|
||||
@@ -1035,6 +1035,106 @@ describe('react-devtools-facade', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getParentStack', () => {
|
||||
let getParentStack;
|
||||
let getOwnerStack;
|
||||
let getComponentTree;
|
||||
|
||||
beforeEach(() => {
|
||||
const tools = createTools(facade);
|
||||
getParentStack = tools.getParentStack;
|
||||
getOwnerStack = tools.getOwnerStack;
|
||||
getComponentTree = tools.getComponentTree;
|
||||
});
|
||||
|
||||
it('returns structural parents from immediate parent to host root', () => {
|
||||
function Child() {
|
||||
return <span>leaf</span>;
|
||||
}
|
||||
function App() {
|
||||
return (
|
||||
<section>
|
||||
<Child />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
ReactDOMClient.createRoot(container).render(<App />);
|
||||
});
|
||||
|
||||
const child = getComponentTree().find(n => n.name === 'Child');
|
||||
expect(child).toBeDefined();
|
||||
|
||||
const parents = getParentStack(child.uid);
|
||||
expect(parents).toEqual([
|
||||
{
|
||||
uid: 'r2',
|
||||
name: 'section',
|
||||
type: 'host',
|
||||
},
|
||||
{
|
||||
uid: 'r0',
|
||||
name: 'App',
|
||||
type: 'function',
|
||||
},
|
||||
{
|
||||
uid: 'r1',
|
||||
name: expect.any(String),
|
||||
type: 'root',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('distinguishes structural parents from JSX owners', () => {
|
||||
function Child() {
|
||||
return <span>leaf</span>;
|
||||
}
|
||||
function App() {
|
||||
return (
|
||||
<section>
|
||||
<Child />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
act(() => {
|
||||
ReactDOMClient.createRoot(container).render(<App />);
|
||||
});
|
||||
|
||||
const child = getComponentTree().find(n => n.name === 'Child');
|
||||
const parents = getParentStack(child.uid);
|
||||
const owners = getOwnerStack(child.uid);
|
||||
|
||||
expect(parents[0]).toMatchObject({
|
||||
name: 'section',
|
||||
type: 'host',
|
||||
});
|
||||
expect(owners[0]).toMatchObject({
|
||||
name: 'App',
|
||||
type: 'function',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty array for the host root', () => {
|
||||
function App() {
|
||||
return <div>hello</div>;
|
||||
}
|
||||
|
||||
act(() => {
|
||||
ReactDOMClient.createRoot(container).render(<App />);
|
||||
});
|
||||
|
||||
const root = getComponentTree().find(n => n.type === 'root');
|
||||
expect(getParentStack(root.uid)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns error for non-existent uid', () => {
|
||||
const result = getParentStack('r9999');
|
||||
expect(result.error).toMatch(/Component not found/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOwnerStack', () => {
|
||||
let getOwnerStack;
|
||||
let getComponentTree;
|
||||
|
||||
Reference in New Issue
Block a user