[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:
Ruslan Lesiutin
2026-07-02 19:49:03 +04:00
committed by GitHub
parent 7bac16f3ea
commit 48ed07a4b1
7 changed files with 323 additions and 20 deletions
+13 -2
View File
@@ -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
View File
@@ -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;