[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`.
Ruslan Lesiutin committed
Jul 2, 2026 at 19:49 UTC
3508aee63d59b7d4eb8e82171b78ef214a2487ca
7 files changed
+323
-20
packages/react-devtools-cdt-mcp/README.md
+13
-2
@@ -89,13 +89,24 @@ Raw owner stack trace — the chain of JSX creation locations up to the root.
89
- **Input:** `uid` (string, required).
90
- **Output:** `{stack: string}` (DEV-only; empty in production).
91
92
+### `react_get_parent_stack`
93
+
94
+Rendered parent list — where a component is mounted in the rendered component
95
+tree.
96
+
97
+- **Input:** `uid` (string, required).
98
+- **Output:** an array of `{uid, name, type}`, ordered from immediate parent to
99
+ root (empty for the root). This can include host DOM components and the root.
100
+
101
### `react_get_owner_stack`
102
94
-Structured owner list — which components rendered this one.
103
+Structured owner list — which components created/rendered this element through
104
+JSX.
105
106
- **Input:** `uid` (string, required).
107
- **Output:** an array of `{uid, name, type}`, ordered from immediate owner to
98
- root ancestor (empty for a root component). DEV-only.
108
+ root owner (empty for a root component). DEV-only. Owners are not structural
109
+ parents; use `react_get_parent_stack` for mounted tree ancestry.
110
111
### `react_start_profiling`
112
packages/react-devtools-cdt-mcp/e2e/run.flow.js
+88
-9
@@ -110,8 +110,10 @@ type OwnersStackResult = {
110
stack: string,
111
...
112
};
113
-type Owner = {
113
+type ComponentBranchEntry = {
114
+ uid: string,
115
name: string,
116
+ type: string,
117
...
118
};
119
type ErrorPayload = {
@@ -146,6 +148,7 @@ const TOOL_NAMES = [
148
'react_find_components',
149
'react_get_component_source',
150
'react_get_owner_stack_trace',
151
+ 'react_get_parent_stack',
152
'react_get_owner_stack',
153
'react_start_profiling',
154
'react_stop_profiling',
@@ -665,10 +668,21 @@ function parseNamedObject(value: mixed, message: string): {name: string, ...} {
668
};
669
}
670
671
+function parseComponentBranchEntry(
672
+ value: mixed,
673
+ message: string
674
+): ComponentBranchEntry {
675
+ const object = expectObject(value, message);
676
+ return {
677
+ uid: expectString(object.uid, `${message} uid`),
678
+ name: expectString(object.name, `${message} name`),
679
+ type: expectString(object.type, `${message} type`),
680
+ };
681
+}
682
+
683
function parseComponentDetails(value: mixed): ComponentDetails {
684
const object = expectObject(value, 'Expected component details object');
670
- return {
671
- ...object,
685
+ const details: ComponentDetails = {
686
name: expectString(object.name, 'Expected component details name'),
687
type: expectString(object.type, 'Expected component details type'),
688
hooks: expectArray(object.hooks, 'Expected component details hooks').map(
@@ -676,6 +690,7 @@ function parseComponentDetails(value: mixed): ComponentDetails {
690
parseNamedObject(hook, `Expected component hook ${index}`)
691
),
692
};
693
+ return details;
694
}
695
696
function parseComponentType(value: mixed): string {
@@ -775,9 +790,13 @@ function parseOwnersStack(value: mixed): OwnersStackResult {
790
};
791
}
792
778
-function parseOwnersBranch(value: mixed): Array<Owner> {
779
- return expectArray(value, 'Expected owners branch array').map(
780
- (owner, index) => parseNamedObject(owner, `Expected owner ${index}`)
793
+function parseComponentBranch(
794
+ value: mixed,
795
+ label: string
796
+): Array<ComponentBranchEntry> {
797
+ return expectArray(value, `Expected ${label} branch array`).map(
798
+ (entry, index) =>
799
+ parseComponentBranchEntry(entry, `Expected ${label} ${index}`)
800
);
801
}
802
@@ -989,6 +1008,31 @@ async function runE2E(chrome: Chrome, appUrl: string): Promise<void> {
1008
node => node.name === 'Todo' && node.type === 'function',
1009
'Expected function component Todo'
1010
);
1011
+ const todoList = findNode(
1012
+ tree,
1013
+ node => node.name === 'TodoList' && node.type === 'function',
1014
+ 'Expected function component TodoList'
1015
+ );
1016
+ const todoListHost = findNode(
1017
+ tree,
1018
+ node => node.name === 'ul' && node.type === 'host',
1019
+ 'Expected host ul for TodoList'
1020
+ );
1021
+ const mainNode = findNode(
1022
+ tree,
1023
+ node => node.name === 'main' && node.type === 'host',
1024
+ 'Expected host main'
1025
+ );
1026
+ const app = findNode(
1027
+ tree,
1028
+ node => node.name === 'App' && node.type === 'function',
1029
+ 'Expected function component App'
1030
+ );
1031
+ const root = findNode(
1032
+ tree,
1033
+ node => node.type === 'root',
1034
+ 'Expected root node'
1035
+ );
1036
const memoBox = findNode(
1037
tree,
1038
node => node.name.includes('MemoBox') && node.type === 'memo',
@@ -1069,7 +1113,7 @@ async function runE2E(chrome: Chrome, appUrl: string): Promise<void> {
1113
assert.strictEqual(domLookup.type, 'host');
1114
assert.strictEqual(domLookup.name, 'button');
1115
1072
- log('Checking source, owners, and error payloads...');
1116
+ log('Checking source, parents, owners, and error payloads...');
1117
const source = parseSourceResult(
1118
await callTool('react_get_component_source', {
1119
uid: counter.uid,
@@ -1088,10 +1132,45 @@ async function runE2E(chrome: Chrome, appUrl: string): Promise<void> {
1132
);
1133
}
1134
1091
- const ownersBranch = parseOwnersBranch(
1135
+ const parentsBranch = parseComponentBranch(
1136
+ await callTool('react_get_parent_stack', {
1137
+ uid: todo.uid,
1138
+ }),
1139
+ 'parents'
1140
+ );
1141
+ assert.deepStrictEqual(parentsBranch, [
1142
+ {
1143
+ uid: todoListHost.uid,
1144
+ name: todoListHost.name,
1145
+ type: todoListHost.type,
1146
+ },
1147
+ {
1148
+ uid: todoList.uid,
1149
+ name: todoList.name,
1150
+ type: todoList.type,
1151
+ },
1152
+ {
1153
+ uid: mainNode.uid,
1154
+ name: mainNode.name,
1155
+ type: mainNode.type,
1156
+ },
1157
+ {
1158
+ uid: app.uid,
1159
+ name: app.name,
1160
+ type: app.type,
1161
+ },
1162
+ {
1163
+ uid: root.uid,
1164
+ name: root.name,
1165
+ type: root.type,
1166
+ },
1167
+ ]);
1168
+
1169
+ const ownersBranch = parseComponentBranch(
1170
await callTool('react_get_owner_stack', {
1171
uid: todo.uid,
1094
- })
1172
+ }),
1173
+ 'owners'
1174
);
1175
assert(
1176
ownersBranch.some(owner => owner.name === 'TodoList'),
packages/react-devtools-cdt-mcp/src/DevToolsCdtMcp.js
+22
-3
@@ -191,12 +191,31 @@ const TOOL_DEFINITIONS: Array<ToolDefinition> = [
191
},
192
call: (tools, args) => tools.getOwnerStackTrace(args.uid),
193
},
194
+ {
195
+ name: 'react_get_parent_stack',
196
+ description:
197
+ 'Rendered parent chain for a component, from immediate parent to root: ' +
198
+ 'an array of {uid, name, type}. Parents describe where the node is ' +
199
+ 'mounted in the rendered component tree and may include host DOM ' +
200
+ 'components and the root. This differs from owners, which describe JSX ' +
201
+ 'creation/render ownership.',
202
+ inputSchema: {
203
+ type: 'object',
204
+ properties: {
205
+ uid: {type: 'string', description: 'Component uid, e.g. "r5".'},
206
+ },
207
+ required: ['uid'],
208
+ },
209
+ call: (tools, args) => tools.getParentStack(args.uid),
210
+ },
211
{
212
name: 'react_get_owner_stack',
213
description:
197
- 'Structured owner chain for a component, from immediate owner to root ' +
198
- 'ancestor: an array of {uid, name, type} (empty for a root ' +
199
- 'component). DEV-only.',
214
+ 'JSX owner chain for a component, from immediate owner to root owner: ' +
215
+ 'an array of {uid, name, type} (empty for a root component). Owners ' +
216
+ 'describe which components created/rendered this element through JSX, ' +
217
+ 'not where it is mounted in the rendered component tree. This DEV-only metadata ' +
218
+ 'differs from structural parents.',
219
inputSchema: {
220
type: 'object',
221
properties: {
packages/react-devtools-cdt-mcp/src/__tests__/DevToolsCdtMcp-test.js
+48
-3
@@ -14,6 +14,7 @@ const TOOL_NAMES = [
14
'react_find_components',
15
'react_get_component_source',
16
'react_get_owner_stack_trace',
17
+ 'react_get_parent_stack',
18
'react_get_owner_stack',
19
'react_start_profiling',
20
'react_stop_profiling',
@@ -105,6 +106,12 @@ describe('react-devtools-cdt-mcp', () => {
106
expect(tool.inputSchema.type).toBe('object');
107
expect(typeof tool.execute).toBe('function');
108
});
109
+ expect(getTool('react_get_parent_stack').description).toEqual(
110
+ expect.stringContaining('Rendered parent chain'),
111
+ );
112
+ expect(getTool('react_get_owner_stack').description).toEqual(
113
+ expect.stringContaining('Owners describe'),
114
+ );
115
});
116
117
it('declares JSON-Schema input with required params', () => {
@@ -144,6 +151,16 @@ describe('react-devtools-cdt-mcp', () => {
151
},
152
required: ['name'],
153
});
154
+ expect(getTool('react_get_parent_stack').inputSchema).toEqual({
155
+ type: 'object',
156
+ properties: {uid: {type: 'string', description: expect.any(String)}},
157
+ required: ['uid'],
158
+ });
159
+ expect(getTool('react_get_owner_stack').inputSchema).toEqual({
160
+ type: 'object',
161
+ properties: {uid: {type: 'string', description: expect.any(String)}},
162
+ required: ['uid'],
163
+ });
164
expect(getTool('react_start_profiling').inputSchema).toEqual({
165
type: 'object',
166
properties: {
@@ -174,8 +191,8 @@ describe('react-devtools-cdt-mcp', () => {
191
});
192
193
const result = getTool('react_get_component_tree').execute({});
177
- // Uids are assigned deterministically as fibers are first encountered. A
178
- // fiber's first child is assigned before the fiber itself, so App (the host
194
+ // Uids are assigned deterministically as nodes are first encountered. A
195
+ // node's first child is assigned before the node itself, so App (the host
196
// root's first child) is r0, the host root is r1, and the div is r2.
197
expect(result).toEqual({
198
nodes: [
@@ -320,6 +337,34 @@ describe('react-devtools-cdt-mcp', () => {
337
});
338
});
339
340
+ it('react_get_parent_stack returns structural ancestors', () => {
341
+ function Child() {
342
+ return <span>leaf</span>;
343
+ }
344
+ function Owner() {
345
+ return (
346
+ <section>
347
+ <Child />
348
+ </section>
349
+ );
350
+ }
351
+
352
+ act(() => {
353
+ ReactDOMClient.createRoot(container).render(<Owner />);
354
+ });
355
+
356
+ const tree = getTool('react_get_component_tree').execute({}).nodes;
357
+ const child = tree.find(n => n.name === 'Child');
358
+
359
+ expect(getTool('react_get_parent_stack').execute({uid: child.uid})).toEqual(
360
+ [
361
+ {uid: 'r2', name: 'section', type: 'host'},
362
+ {uid: 'r0', name: 'Owner', type: 'function'},
363
+ {uid: 'r1', name: expect.any(String), type: 'root'},
364
+ ],
365
+ );
366
+ });
367
+
368
it('react_get_component_by_dom_element returns the DOM element component', () => {
369
function Wrapper({children}) {
370
return <section className="wrap">{children}</section>;
@@ -428,7 +473,7 @@ describe('react-devtools-cdt-mcp', () => {
473
expect(overview).toHaveLength(1);
474
expect(overview[0].commit).toBe(0);
475
431
- // The commit report lists the fibers that rendered, sorted by actualDuration
476
+ // The commit report lists the components that rendered, sorted by actualDuration
477
// descending, with uids assigned in commit-walk order: the host root r0
478
// (widest duration), then Counter r1, then the div r2. Durations are
479
// timing-dependent, so assert identity (uid/name/type) only.
packages/react-devtools-facade/src/DevToolsFacadeTools.js
+5
@@ -13,6 +13,7 @@ import type {
13
NodeInfo,
14
ComponentSource,
15
OwnersStack,
16
+ ParentEntry,
17
OwnerEntry,
18
FindComponentsResult,
19
ToolError,
@@ -34,6 +35,8 @@ export type {
35
ComponentSource,
36
SourceLocation,
37
OwnersStack,
38
+ ComponentBranchEntry,
39
+ ParentEntry,
40
OwnerEntry,
41
FindComponentsResult,
42
ToolError,
@@ -68,6 +71,7 @@ export type Tools = {
71
) => FindComponentsResult | ToolError,
72
getComponentSource: (uid: string) => ComponentSource | ToolError,
73
getOwnerStackTrace: (uid: string) => OwnersStack | ToolError,
74
+ getParentStack: (uid: string) => Array<ParentEntry> | ToolError,
75
getOwnerStack: (uid: string) => Array<OwnerEntry> | ToolError,
76
startProfiling: (traceName?: string) => StartProfilingResult | ToolError,
77
stopProfiling: () => StopProfilingResult | ToolError,
@@ -102,6 +106,7 @@ export function createTools(facade: Facade): Tools {
106
findComponents: tree.findComponents,
107
getComponentSource: tree.getComponentSource,
108
getOwnerStackTrace: tree.getOwnerStackTrace,
109
+ getParentStack: tree.getParentStack,
110
getOwnerStack: tree.getOwnerStack,
111
startProfiling: profiler.startProfiling,
112
stopProfiling: profiler.stopProfiling,
packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js
+47
-3
@@ -66,7 +66,11 @@ export type ComponentSource = {source: SourceLocation | null};
66
67
export type OwnersStack = {stack: string};
68
69
-export type OwnerEntry = {uid: string, name: string, type: string};
69
+export type ComponentBranchEntry = {uid: string, name: string, type: string};
70
+
71
+export type ParentEntry = ComponentBranchEntry;
72
+
73
+export type OwnerEntry = ComponentBranchEntry;
74
75
export type FindComponentsResult = {
76
page: number,
@@ -94,6 +98,7 @@ export type TreeTools = {
98
) => FindComponentsResult | ToolError,
99
getComponentSource: (uid: string) => ComponentSource | ToolError,
100
getOwnerStackTrace: (uid: string) => OwnersStack | ToolError,
101
+ getParentStack: (uid: string) => Array<ParentEntry> | ToolError,
102
getOwnerStack: (uid: string) => Array<OwnerEntry> | ToolError,
103
// Shared with the profiler tools so component uids are consistent across all
104
// tools. Maps a fiber to its stable uid (assigning one on first encounter).
@@ -737,10 +742,48 @@ export function createTreeTools(
742
return {stack: stackString};
743
}
744
745
+ /**
746
+ * Returns the structural parent branch for this fiber — the path formed by
747
+ * following Fiber.return pointers from this component to the host root.
748
+ * Parents describe where a node is mounted in the rendered tree, so this
749
+ * branch can include host DOM components and the host root.
750
+ *
751
+ * This differs from owners: owners describe which components created/rendered
752
+ * an element through JSX and are DEV-only metadata. Parents are structural
753
+ * runtime relationships and are available whenever the fiber tree exists.
754
+ *
755
+ * Returns an array of {uid, name, type}, ordered from immediate parent to
756
+ * root ancestor. The host root has an empty parent branch.
757
+ *
758
+ * @param uid - The component uid (e.g. "r5").
759
+ */
760
+ function getParentStack(uid: string): Array<ParentEntry> | ToolError {
761
+ const result = findFiberByUid(uid);
762
+ if (result.error != null) {
763
+ return {error: result.error};
764
+ }
765
+ const {internals} = result;
766
+ const parents: Array<ParentEntry> = [];
767
+ let parent = result.fiber.return;
768
+ while (parent !== null) {
769
+ parents.push({
770
+ uid: getUid(parent),
771
+ name: getDisplayName(internals, parent),
772
+ type: getTypeTagForFiber(internals, parent),
773
+ });
774
+ parent = parent.return;
775
+ }
776
+ return parents;
777
+ }
778
+
779
/**
780
* Returns the structured list of owner components — which components rendered
742
- * this component, ordered from immediate owner to root ancestor. Each entry
743
- * includes a uid for cross-referencing with other tools (e.g.
781
+ * or created this element through JSX, ordered from immediate owner to root
782
+ * ancestor. Owners describe creation/render ownership, not where a node is
783
+ * mounted in the rendered tree. Use getParentStack for structural Fiber
784
+ * parent ancestry, including host DOM parents and the host root.
785
+ *
786
+ * Each entry includes a uid for cross-referencing with other tools (e.g.
787
* getComponentByUid, getComponentSource, getComponentTree).
788
*
789
* Returns an array of {uid, name, type}, or an empty array if the component
@@ -787,6 +830,7 @@ export function createTreeTools(
830
findComponents,
831
getComponentSource,
832
getOwnerStackTrace,
833
+ getParentStack,
834
getOwnerStack,
835
getUid,
836
};
packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js
+100
@@ -1035,6 +1035,106 @@ describe('react-devtools-facade', () => {
1035
});
1036
});
1037
1038
+ describe('getParentStack', () => {
1039
+ let getParentStack;
1040
+ let getOwnerStack;
1041
+ let getComponentTree;
1042
+
1043
+ beforeEach(() => {
1044
+ const tools = createTools(facade);
1045
+ getParentStack = tools.getParentStack;
1046
+ getOwnerStack = tools.getOwnerStack;
1047
+ getComponentTree = tools.getComponentTree;
1048
+ });
1049
+
1050
+ it('returns structural parents from immediate parent to host root', () => {
1051
+ function Child() {
1052
+ return <span>leaf</span>;
1053
+ }
1054
+ function App() {
1055
+ return (
1056
+ <section>
1057
+ <Child />
1058
+ </section>
1059
+ );
1060
+ }
1061
+
1062
+ act(() => {
1063
+ ReactDOMClient.createRoot(container).render(<App />);
1064
+ });
1065
+
1066
+ const child = getComponentTree().find(n => n.name === 'Child');
1067
+ expect(child).toBeDefined();
1068
+
1069
+ const parents = getParentStack(child.uid);
1070
+ expect(parents).toEqual([
1071
+ {
1072
+ uid: 'r2',
1073
+ name: 'section',
1074
+ type: 'host',
1075
+ },
1076
+ {
1077
+ uid: 'r0',
1078
+ name: 'App',
1079
+ type: 'function',
1080
+ },
1081
+ {
1082
+ uid: 'r1',
1083
+ name: expect.any(String),
1084
+ type: 'root',
1085
+ },
1086
+ ]);
1087
+ });
1088
+
1089
+ it('distinguishes structural parents from JSX owners', () => {
1090
+ function Child() {
1091
+ return <span>leaf</span>;
1092
+ }
1093
+ function App() {
1094
+ return (
1095
+ <section>
1096
+ <Child />
1097
+ </section>
1098
+ );
1099
+ }
1100
+
1101
+ act(() => {
1102
+ ReactDOMClient.createRoot(container).render(<App />);
1103
+ });
1104
+
1105
+ const child = getComponentTree().find(n => n.name === 'Child');
1106
+ const parents = getParentStack(child.uid);
1107
+ const owners = getOwnerStack(child.uid);
1108
+
1109
+ expect(parents[0]).toMatchObject({
1110
+ name: 'section',
1111
+ type: 'host',
1112
+ });
1113
+ expect(owners[0]).toMatchObject({
1114
+ name: 'App',
1115
+ type: 'function',
1116
+ });
1117
+ });
1118
+
1119
+ it('returns an empty array for the host root', () => {
1120
+ function App() {
1121
+ return <div>hello</div>;
1122
+ }
1123
+
1124
+ act(() => {
1125
+ ReactDOMClient.createRoot(container).render(<App />);
1126
+ });
1127
+
1128
+ const root = getComponentTree().find(n => n.type === 'root');
1129
+ expect(getParentStack(root.uid)).toEqual([]);
1130
+ });
1131
+
1132
+ it('returns error for non-existent uid', () => {
1133
+ const result = getParentStack('r9999');
1134
+ expect(result.error).toMatch(/Component not found/);
1135
+ });
1136
+ });
1137
+
1138
describe('getOwnerStack', () => {
1139
let getOwnerStack;
1140
let getComponentTree;