@samitouri / QOS-React / commits / fb2cfa0ff3

[react-devtools-facade] 2/ implement component tree tools (#36597)

Adds the component-tree building blocks and the `createTools(facade)` assembler — the first tools layered on top of the `installFacade` hook from commit 1. ### `createTools(facade): Tools` Reads the facade's tracked state (fiber roots + per-renderer internals) and returns a plain `Tools` object — no globals; the integrator decides what to do with it. Tools return **typed, plain JavaScript values** (or `{error}`); serialization (to an integration package's wire format) is left to the caller. ### Tools - **`getComponentTree(depth?, rootUid?)`** — the component tree as a flat array of `{uid, type, name, key, firstChild, nextSibling}` nodes (an adjacency list referencing other nodes by label). - **`getComponentByUid(uid)`** — one component's `{type, name, key?, props?, hooks?}`. For function components, `hooks` is the inspected hooks tree (nested `subHooks`), obtained via `react-debug-tools'` `inspectHooksOfFiberWithoutDefaultDispatcher` with the renderer's injected dispatcher (normalized by `getDispatcherRef`) — so hooks introspection never falls back to, or bundles, React's shared internals. - **`findComponents(name, rootUid?, page?, pageSize?)`** — paginated, case-insensitive name search. - **`getComponentSource(uid)`** — the component's definition location `{name, fileName, line, column}` (or `null`). - **`getOwnersStack(uid)`** — the raw JSX owner-stack string (DEV only). - **`getOwnersBranch(uid)`** — the structured owner chain `[{uid, name, type}]`, ordered immediate owner → root (DEV only). ### UIDs Components are addressed by stable `rN` uids, assigned lazily and memoized per fiber (and its alternate), so a component keeps the same uid across re-renders and across every tool. These uids don't survive page reloads.

Ruslan Lesiutin committed Jun 18, 2026 at 20:14 UTC fb2cfa0ff388d26891a2f6504c18d466ffae5a56
4 files changed +2163 -1
packages/react-devtools-facade/src/DevToolsFacade.js
+5
@@ -20,6 +20,11 @@ import type {
20
21 import {getInternalReactConstants} from 'react-devtools-shared/src/backend/fiber/shared/DevToolsFiberInternalReactConstants';
22
23 +// Re-export the tools assembler so the full building-block API is available
24 +// from the package entry point (index.js re-exports this module).
25 +export {createTools} from './DevToolsFacadeTools';
26 +export type {Tools} from './DevToolsFacadeTools';
27 +
28 // Per-renderer internal constants, initialized at inject() time. Building
29 // blocks read these to translate fibers into human-readable output.
30 export type RendererInternals = {
packages/react-devtools-facade/src/DevToolsFacadeTools.js new
+75
@@ -0,0 +1,75 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {Facade} from './DevToolsFacade';
11 +import type {
12 + TreeNode,
13 + NodeInfo,
14 + ComponentSource,
15 + OwnersStack,
16 + OwnerEntry,
17 + FindComponentsResult,
18 + ToolError,
19 +} from './DevToolsFacadeTreeTools';
20 +
21 +import {createTreeTools} from './DevToolsFacadeTreeTools';
22 +
23 +export type {
24 + TreeNode,
25 + NodeInfo,
26 + HookNode,
27 + ComponentSource,
28 + SourceLocation,
29 + OwnersStack,
30 + OwnerEntry,
31 + FindComponentsResult,
32 + ToolError,
33 +} from './DevToolsFacadeTreeTools';
34 +
35 +// The set of tools assembled from a Facade. Each tool returns a plain
36 +// JavaScript value (see the types in ./DevToolsFacadeTreeTools); serialization is the
37 +// integrator's responsibility. Integrators decide whether to expose these on
38 +// globals or call them directly.
39 +export type Tools = {
40 + getComponentTree: (
41 + depth?: number,
42 + rootUid?: string,
43 + ) => Array<TreeNode> | ToolError,
44 + getComponentByUid: (uid: string) => NodeInfo | ToolError,
45 + findComponents: (
46 + name: string,
47 + rootUid?: string,
48 + page?: number,
49 + pageSize?: number,
50 + ) => FindComponentsResult | ToolError,
51 + getComponentSource: (uid: string) => ComponentSource | ToolError,
52 + getOwnersStack: (uid: string) => OwnersStack | ToolError,
53 + getOwnersBranch: (uid: string) => Array<OwnerEntry> | ToolError,
54 +};
55 +
56 +/**
57 + * Assemble the set of tools from a Facade. The tools read the facade's tracked
58 + * runtime state (fiber roots, per-renderer internals) lazily on each call and
59 + * never touch globals, so the integrator fully owns both the facade and the
60 + * returned tools.
61 + *
62 + * @param facade - A Facade returned by installFacade().
63 + */
64 +export function createTools(facade: Facade): Tools {
65 + const tree = createTreeTools(facade.fiberRoots, facade.rendererInternals);
66 +
67 + return {
68 + getComponentTree: tree.getComponentTree,
69 + getComponentByUid: tree.getComponentByUid,
70 + findComponents: tree.findComponents,
71 + getComponentSource: tree.getComponentSource,
72 + getOwnersStack: tree.getOwnersStack,
73 + getOwnersBranch: tree.getOwnersBranch,
74 + };
75 +}
packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js new
+670
@@ -0,0 +1,670 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import {extractLocationFromComponentStack} from 'react-devtools-shared/src/backend/utils/parseStackTrace';
11 +import {
12 + getOwnerStackByFiberInDev,
13 + getSourceLocationByFiber,
14 +} from 'react-devtools-shared/src/backend/fiber/DevToolsFiberComponentStack';
15 +import {getDispatcherRef} from 'react-devtools-shared/src/backend/shared/DevToolsReactDispatcher';
16 +import {inspectHooksOfFiberWithoutDefaultDispatcher} from 'react-debug-tools';
17 +
18 +import type {Fiber, FiberRoot} from 'react-reconciler/src/ReactInternalTypes';
19 +import type {WorkTagMap} from 'react-devtools-shared/src/backend/types';
20 +import type {HooksTree, HooksNode} from 'react-debug-tools/src/ReactDebugHooks';
21 +import type {RendererInternals} from './DevToolsFacade';
22 +
23 +// Tools return plain JavaScript values with the types below. Serialization
24 +// (to TOON, JSON, etc.) is the integrator's responsibility.
25 +
26 +// Returned by any tool when the requested component/root cannot be resolved.
27 +export type ToolError = {error: string};
28 +
29 +// A single component in a tree snapshot. firstChild/nextSibling reference other
30 +// nodes by their uid, forming an adjacency list the integrator can rebuild.
31 +export type TreeNode = {
32 + uid: string,
33 + type: string,
34 + name: string,
35 + key: string | null,
36 + firstChild: string | null,
37 + nextSibling: string | null,
38 +};
39 +
40 +// One inspected hook. value is normalized (serialization-safe); subHooks holds
41 +// the hooks called by a custom hook, recursively.
42 +export type HookNode = {
43 + id: number | null,
44 + name: string,
45 + value: mixed,
46 + subHooks: Array<HookNode>,
47 +};
48 +
49 +export type NodeInfo = {
50 + uid: string,
51 + type: string,
52 + name: string,
53 + key?: string,
54 + props?: {[string]: mixed},
55 + hooks?: Array<HookNode>,
56 +};
57 +
58 +export type SourceLocation = {
59 + name: string,
60 + fileName: string,
61 + line: number,
62 + column: number,
63 +};
64 +
65 +export type ComponentSource = {source: SourceLocation | null};
66 +
67 +export type OwnersStack = {stack: string};
68 +
69 +export type OwnerEntry = {uid: string, name: string, type: string};
70 +
71 +export type FindComponentsResult = {
72 + page: number,
73 + pageSize: number,
74 + totalCount: number,
75 + totalPages: number,
76 + results: Array<TreeNode>,
77 +};
78 +
79 +export type TreeTools = {
80 + getComponentTree: (
81 + depth?: number,
82 + rootUid?: string,
83 + ) => Array<TreeNode> | ToolError,
84 + getComponentByUid: (uid: string) => NodeInfo | ToolError,
85 + findComponents: (
86 + name: string,
87 + rootUid?: string,
88 + page?: number,
89 + pageSize?: number,
90 + ) => FindComponentsResult | ToolError,
91 + getComponentSource: (uid: string) => ComponentSource | ToolError,
92 + getOwnersStack: (uid: string) => OwnersStack | ToolError,
93 + getOwnersBranch: (uid: string) => Array<OwnerEntry> | ToolError,
94 +};
95 +
96 +/**
97 + * Map a fiber work tag number to a human-readable type string.
98 + * Every tag maps to a descriptive string; unknown tags return 'unknown'.
99 + */
100 +export function getTypeTag(workTagMap: WorkTagMap, tag: number): string {
101 + const {
102 + FunctionComponent,
103 + IncompleteFunctionComponent,
104 + ClassComponent,
105 + IncompleteClassComponent,
106 + HostComponent,
107 + HostHoistable,
108 + HostSingleton,
109 + HostRoot,
110 + ForwardRef,
111 + MemoComponent,
112 + SimpleMemoComponent,
113 + ContextConsumer,
114 + ContextProvider,
115 + SuspenseComponent,
116 + SuspenseListComponent,
117 + LazyComponent,
118 + Profiler,
119 + HostPortal,
120 + ActivityComponent,
121 + ViewTransitionComponent,
122 + CacheComponent,
123 + ScopeComponent,
124 + OffscreenComponent,
125 + LegacyHiddenComponent,
126 + Throw,
127 + HostText,
128 + Fragment,
129 + DehydratedSuspenseComponent,
130 + Mode,
131 + } = workTagMap;
132 +
133 + switch (tag) {
134 + case FunctionComponent:
135 + case IncompleteFunctionComponent:
136 + return 'function';
137 + case ClassComponent:
138 + case IncompleteClassComponent:
139 + return 'class';
140 + case HostComponent:
141 + case HostHoistable:
142 + case HostSingleton:
143 + return 'host';
144 + case HostRoot:
145 + return 'root';
146 + case ForwardRef:
147 + return 'forwardRef';
148 + case MemoComponent:
149 + case SimpleMemoComponent:
150 + return 'memo';
151 + case ContextConsumer:
152 + case ContextProvider:
153 + return 'context';
154 + case SuspenseComponent:
155 + return 'suspense';
156 + case SuspenseListComponent:
157 + return 'suspenseList';
158 + case LazyComponent:
159 + return 'lazy';
160 + case Profiler:
161 + return 'profiler';
162 + case HostPortal:
163 + return 'portal';
164 + case ActivityComponent:
165 + return 'activity';
166 + case ViewTransitionComponent:
167 + return 'viewTransition';
168 + case CacheComponent:
169 + return 'cache';
170 + case ScopeComponent:
171 + return 'scope';
172 + case OffscreenComponent:
173 + case LegacyHiddenComponent:
174 + return 'offscreen';
175 + case Throw:
176 + return 'throw';
177 + case HostText:
178 + return 'text';
179 + case Fragment:
180 + return 'fragment';
181 + case Mode:
182 + return 'mode';
183 + case DehydratedSuspenseComponent:
184 + return 'dehydrated';
185 + default:
186 + return 'unknown';
187 + }
188 +}
189 +
190 +const MAX_NORMALIZE_DEPTH = 3;
191 +
192 +// Normalize a value to a plain, serialization-safe shape. Tracks seen objects
193 +// to break circular references and limits depth to avoid stack overflow on
194 +// deeply nested structures. Functions/symbols/elements become descriptive
195 +// strings so the result can be safely serialized downstream.
196 +function normalizeValue(val: mixed, seen?: Set<mixed>, depth?: number): mixed {
197 + if (val === undefined) return null;
198 + if (typeof val === 'function')
199 + return val.name ? '[fn ' + val.name + ']' : '[fn]';
200 + if (typeof val === 'symbol') return '[symbol]';
201 + if (typeof val === 'object' && val !== null) {
202 + if ((val as any).$$typeof != null) return '[React element]';
203 + const currentDepth = depth || 0;
204 + if (currentDepth >= MAX_NORMALIZE_DEPTH) return '[max depth]';
205 + const currentSeen = seen || new Set();
206 + if (currentSeen.has(val)) return '[circular]';
207 + currentSeen.add(val);
208 + if (Array.isArray(val)) {
209 + const mapped = val.map((v: mixed) =>
210 + normalizeValue(v, currentSeen, currentDepth + 1),
211 + );
212 + currentSeen.delete(val);
213 + return mapped;
214 + }
215 + const result: {[string]: mixed} = {};
216 + const keys = Object.keys(val);
217 + for (let i = 0; i < keys.length; i++) {
218 + result[keys[i]] = normalizeValue(
219 + (val as any)[keys[i]],
220 + currentSeen,
221 + currentDepth + 1,
222 + );
223 + }
224 + currentSeen.delete(val);
225 + return result;
226 + }
227 + return val;
228 +}
229 +
230 +// Normalize props for output: skip children, normalize values.
231 +function normalizeProps(props: mixed): {[string]: mixed} | null {
232 + if (props == null || typeof props !== 'object') return null;
233 + const result: {[string]: mixed} = {};
234 + const keys = Object.keys(props);
235 + let hasProps = false;
236 + for (let i = 0; i < keys.length; i++) {
237 + const key = keys[i];
238 + if (key === 'children') continue;
239 + result[key] = normalizeValue((props as any)[key]);
240 + hasProps = true;
241 + }
242 + return hasProps ? result : null;
243 +}
244 +
245 +// Normalize an inspected hooks tree into a serialization-safe shape.
246 +function normalizeHooks(hooks: HooksTree): Array<HookNode> {
247 + return hooks.map((hook: HooksNode) => ({
248 + id: hook.id,
249 + name: hook.name,
250 + value: normalizeValue(hook.value),
251 + subHooks: normalizeHooks(hook.subHooks),
252 + }));
253 +}
254 +
255 +export function createTreeTools(
256 + fiberRoots: Map<number, Set<FiberRoot>>,
257 + rendererInternals: Map<number, RendererInternals>,
258 +): TreeTools {
259 + function getTypeTagForFiber(
260 + internals: RendererInternals,
261 + fiber: Fiber,
262 + ): string {
263 + return getTypeTag(internals.ReactTypeOfWork, fiber.tag);
264 + }
265 +
266 + function getDisplayName(internals: RendererInternals, fiber: Fiber): string {
267 + return internals.getDisplayNameForFiber(fiber) || 'Unknown';
268 + }
269 +
270 + // Persistent uid state — survives across calls so the same fiber
271 + // always maps to the same uid, even after React re-renders (which
272 + // swap fiber objects via double-buffering / alternates).
273 + const fiberToUid: WeakMap<Fiber, string> = new WeakMap();
274 + let nextId: number = 0;
275 +
276 + function getUid(fiber: Fiber): string {
277 + let uid = fiberToUid.get(fiber);
278 + if (uid != null) return uid;
279 + const alt = fiber.alternate;
280 + if (alt != null) {
281 + uid = fiberToUid.get(alt);
282 + if (uid != null) {
283 + fiberToUid.set(fiber, uid);
284 + return uid;
285 + }
286 + }
287 + uid = 'r' + nextId++;
288 + fiberToUid.set(fiber, uid);
289 + return uid;
290 + }
291 +
292 + // Collect direct children of a fiber via the child/sibling linked list.
293 + function collectChildren(fiber: Fiber): Array<Fiber> {
294 + const result: Array<Fiber> = [];
295 + let child = fiber.child;
296 + while (child !== null) {
297 + result.push(child);
298 + child = child.sibling;
299 + }
300 + return result;
301 + }
302 +
303 + function collectNodes(
304 + internals: RendererInternals,
305 + fiber: Fiber,
306 + maxDepth: number,
307 + currentDepth: number,
308 + nodes: Array<TreeNode>,
309 + ): void {
310 + const children = currentDepth < maxDepth ? collectChildren(fiber) : [];
311 + const firstChild = children.length > 0 ? getUid(children[0]) : null;
312 + nodes.push({
313 + uid: getUid(fiber),
314 + type: getTypeTagForFiber(internals, fiber),
315 + name: getDisplayName(internals, fiber),
316 + key: fiber.key != null ? String(fiber.key) : null,
317 + firstChild,
318 + nextSibling: null,
319 + });
320 + for (let i = 0; i < children.length; i++) {
321 + collectNodes(internals, children[i], maxDepth, currentDepth + 1, nodes);
322 + if (i < children.length - 1) {
323 + const childUid = getUid(children[i]);
324 + for (let j = nodes.length - 1; j >= 0; j--) {
325 + if (nodes[j].uid === childUid) {
326 + nodes[j].nextSibling = getUid(children[i + 1]);
327 + break;
328 + }
329 + }
330 + }
331 + }
332 + }
333 +
334 + function findByUid(fiber: Fiber, targetUid: string): Fiber | null {
335 + if (getUid(fiber) === targetUid) return fiber;
336 + const children = collectChildren(fiber);
337 + for (let i = 0; i < children.length; i++) {
338 + const found = findByUid(children[i], targetUid);
339 + if (found != null) return found;
340 + }
341 + return null;
342 + }
343 +
344 + // Find a fiber by uid across all mounted roots.
345 + // Returns the fiber and its renderer's internals, or an error.
346 + function findFiberByUid(
347 + uid: string,
348 + ):
349 + | {fiber: Fiber, internals: RendererInternals, error: null}
350 + | {fiber: null, internals: null, error: string} {
351 + // eslint-disable-next-line no-for-of-loops/no-for-of-loops
352 + for (const [rendererID, roots] of fiberRoots) {
353 + const internals = rendererInternals.get(rendererID);
354 + if (internals == null) {
355 + return {
356 + fiber: null,
357 + internals: null,
358 + error: 'Missing internals for renderer ' + rendererID,
359 + };
360 + }
361 + // eslint-disable-next-line no-for-of-loops/no-for-of-loops
362 + for (const root of roots) {
363 + const fiber = findByUid(root.current, uid);
364 + if (fiber != null) return {fiber, internals, error: null};
365 + }
366 + }
367 + return {
368 + fiber: null,
369 + internals: null,
370 + error: 'Component not found: "' + uid + '"',
371 + };
372 + }
373 +
374 + /**
375 + * Returns a snapshot of the component tree as an array of nodes. Each node
376 + * includes: uid, type, name, key, firstChild, nextSibling (the last two
377 + * reference other nodes by uid).
378 + *
379 + * @param depth - Maximum tree depth to traverse (default 20).
380 + * @param rootUid - If provided, snapshot starts from this component.
381 + */
382 + function getComponentTree(
383 + depth?: number = 20,
384 + rootUid?: string,
385 + ): Array<TreeNode> | ToolError {
386 + if (rootUid != null) {
387 + const result = findFiberByUid(rootUid);
388 + if (result.error != null) {
389 + return {error: result.error};
390 + }
391 + const nodes: Array<TreeNode> = [];
392 + collectNodes(result.internals, result.fiber, depth, 0, nodes);
393 + return nodes;
394 + }
395 +
396 + const nodes: Array<TreeNode> = [];
397 + // eslint-disable-next-line no-for-of-loops/no-for-of-loops
398 + for (const [rendererID, roots] of fiberRoots) {
399 + const internals = rendererInternals.get(rendererID);
400 + if (internals == null) {
401 + return {error: 'Missing internals for renderer ' + rendererID};
402 + }
403 + roots.forEach(root => {
404 + collectNodes(internals, root.current, depth, 0, nodes);
405 + });
406 + }
407 + if (nodes.length === 0) {
408 + return {error: 'No mounted React roots found'};
409 + }
410 + return nodes;
411 + }
412 +
413 + /**
414 + * Returns detailed info about a single component by its uid: type, name,
415 + * key, props (excluding children), and — for function components — the
416 + * inspected hooks tree. Values are normalized to a serialization-safe shape.
417 + *
418 + * Inspecting hooks re-renders the component's render function (effects are
419 + * not run); failures are tolerated and simply omit `hooks`.
420 + *
421 + * @param uid - The component uid (e.g. "r5").
422 + */
423 + function getComponentByUid(uid: string): NodeInfo | ToolError {
424 + const result = findFiberByUid(uid);
425 + if (result.error != null) {
426 + return {error: result.error};
427 + }
428 + const {fiber, internals} = result;
429 + const info: NodeInfo = {
430 + uid: getUid(fiber),
431 + type: getTypeTagForFiber(internals, fiber),
432 + name: getDisplayName(internals, fiber),
433 + };
434 + if (fiber.key != null) {
435 + info.key = String(fiber.key);
436 + }
437 + const props = normalizeProps(fiber.memoizedProps);
438 + if (props != null) {
439 + info.props = props;
440 + }
441 + // Hooks are only inspectable for function components, forwardRef, and
442 + // simple-memo components. inspectHooksOfFiberWithoutDefaultDispatcher
443 + // re-renders the component (using the renderer's injected dispatcher, never
444 + // React's shared internals), so guard by tag and tolerate failures (e.g. a
445 + // component that throws).
446 + const {FunctionComponent, SimpleMemoComponent, ForwardRef} =
447 + internals.ReactTypeOfWork;
448 + if (
449 + fiber.tag === FunctionComponent ||
450 + fiber.tag === SimpleMemoComponent ||
451 + fiber.tag === ForwardRef
452 + ) {
453 + try {
454 + const hooksTree = inspectHooksOfFiberWithoutDefaultDispatcher(
455 + fiber,
456 + getDispatcherRef(internals),
457 + );
458 + info.hooks = normalizeHooks(hooksTree);
459 + } catch {
460 + // Hook inspection failed; omit hooks rather than failing the call.
461 + }
462 + }
463 + return info;
464 + }
465 +
466 + function collectMatches(
467 + internals: RendererInternals,
468 + fiber: Fiber,
469 + query: string,
470 + matches: Array<Fiber>,
471 + ): void {
472 + const displayName = internals.getDisplayNameForFiber(fiber);
473 + if (
474 + displayName != null &&
475 + displayName.toLowerCase().indexOf(query) !== -1
476 + ) {
477 + matches.push(fiber);
478 + }
479 + let child = fiber.child;
480 + while (child !== null) {
481 + collectMatches(internals, child, query, matches);
482 + child = child.sibling;
483 + }
484 + }
485 +
486 + type FiberMatch = {fiber: Fiber, internals: RendererInternals};
487 +
488 + /**
489 + * Searches for components by name (case-insensitive substring match).
490 + * Returns a paginated result with matching components.
491 + *
492 + * @param name - Search query to match against component display names.
493 + * @param rootUid - If provided, limits search to this component's subtree.
494 + * @param page - Page number (default 1, clamped to valid range).
495 + * @param pageSize - Results per page (default 10).
496 + */
497 + function findComponents(
498 + name: string,
499 + rootUid?: string,
500 + page?: number = 1,
501 + pageSize?: number = 10,
502 + ): FindComponentsResult | ToolError {
503 + const query = name.toLowerCase();
504 + const allMatches: Array<FiberMatch> = [];
505 +
506 + if (rootUid != null) {
507 + const found = findFiberByUid(rootUid);
508 + if (found.error != null) {
509 + return {error: found.error};
510 + }
511 + const fibers: Array<Fiber> = [];
512 + collectMatches(found.internals, found.fiber, query, fibers);
513 + for (let i = 0; i < fibers.length; i++) {
514 + allMatches.push({fiber: fibers[i], internals: found.internals});
515 + }
516 + } else {
517 + // eslint-disable-next-line no-for-of-loops/no-for-of-loops
518 + for (const [rendererID, roots] of fiberRoots) {
519 + const internals = rendererInternals.get(rendererID);
520 + if (internals == null) {
521 + return {error: 'Missing internals for renderer ' + rendererID};
522 + }
523 + roots.forEach(root => {
524 + const fibers: Array<Fiber> = [];
525 + collectMatches(internals, root.current, query, fibers);
526 + for (let i = 0; i < fibers.length; i++) {
527 + allMatches.push({fiber: fibers[i], internals});
528 + }
529 + });
530 + }
531 + }
532 +
533 + const totalCount = allMatches.length;
534 + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
535 + const clampedPage = Math.max(1, Math.min(page, totalPages));
536 + const startIdx = (clampedPage - 1) * pageSize;
537 + const pageMatches = allMatches.slice(startIdx, startIdx + pageSize);
538 +
539 + const rows: Array<TreeNode> = [];
540 + for (let i = 0; i < pageMatches.length; i++) {
541 + const {fiber, internals} = pageMatches[i];
542 + const children = collectChildren(fiber);
543 + rows.push({
544 + uid: getUid(fiber),
545 + type: getTypeTagForFiber(internals, fiber),
546 + name: getDisplayName(internals, fiber),
547 + key: fiber.key != null ? String(fiber.key) : null,
548 + firstChild: children.length > 0 ? getUid(children[0]) : null,
549 + nextSibling: null,
550 + });
551 + }
552 +
553 + return {
554 + page: clampedPage,
555 + pageSize,
556 + totalCount,
557 + totalPages,
558 + results: rows,
559 + };
560 + }
561 +
562 + /**
563 + * Returns the definition location of a component — where the component
564 + * function or class is defined in source code. Uses the same "throwing
565 + * trick" as React DevTools to capture a stack frame from within the
566 + * component's function body.
567 + *
568 + * Returns {source: {name, fileName, line, column}} or {source: null} if the
569 + * location cannot be determined (e.g. host components, production builds).
570 + *
571 + * @param uid - The component uid (e.g. "r5").
572 + */
573 + function getComponentSource(uid: string): ComponentSource | ToolError {
574 + const result = findFiberByUid(uid);
575 + if (result.error != null) {
576 + return {error: result.error};
577 + }
578 + const {fiber, internals} = result;
579 + const stackFrame = getSourceLocationByFiber(
580 + internals.ReactTypeOfWork,
581 + fiber,
582 + internals.currentDispatcherRef,
583 + );
584 + if (stackFrame == null) {
585 + return {source: null};
586 + }
587 + const location = extractLocationFromComponentStack(stackFrame);
588 + if (location == null) {
589 + return {source: null};
590 + }
591 + const [name, fileName, line, column] = location;
592 + return {source: {name, fileName, line, column}};
593 + }
594 +
595 + /**
596 + * Returns the raw owner stack trace string — the chain of JSX creation
597 + * locations from this component up to the root. Each line is a stack frame
598 + * showing where <Component /> was written in the owner's code. The stack can
599 + * be passed to source map tools for symbolication.
600 + *
601 + * Returns {stack: string}. DEV-only — in production, the stack will be empty.
602 + *
603 + * @param uid - The component uid (e.g. "r5").
604 + */
605 + function getOwnersStack(uid: string): OwnersStack | ToolError {
606 + const result = findFiberByUid(uid);
607 + if (result.error != null) {
608 + return {error: result.error};
609 + }
610 + const {fiber, internals} = result;
611 + const stackString = getOwnerStackByFiberInDev(
612 + internals.ReactTypeOfWork,
613 + fiber,
614 + internals.currentDispatcherRef,
615 + );
616 + return {stack: stackString};
617 + }
618 +
619 + /**
620 + * Returns the structured list of owner components — which components rendered
621 + * this component, ordered from immediate owner to root ancestor. Each entry
622 + * includes a uid for cross-referencing with other tools (e.g.
623 + * getComponentByUid, getComponentSource, getComponentTree).
624 + *
625 + * Returns an array of {uid, name, type}, or an empty array if the component
626 + * has no owner (root component). DEV-only — in production, _debugOwner is not
627 + * available.
628 + *
629 + * @param uid - The component uid (e.g. "r5").
630 + */
631 + function getOwnersBranch(uid: string): Array<OwnerEntry> | ToolError {
632 + const result = findFiberByUid(uid);
633 + if (result.error != null) {
634 + return {error: result.error};
635 + }
636 + const {fiber, internals} = result;
637 +
638 + const owners: Array<OwnerEntry> = [];
639 + // Walk the JSX-creation owner chain from this component up to the root,
640 + // collecting only Fiber owners (client components). A Fiber's _debugOwner
641 + // points to the next owner — itself a Fiber (client) or a
642 + // ReactComponentInfo (server component); the latter continues the chain
643 + // via its .owner field.
644 + let owner: mixed = fiber._debugOwner;
645 + while (owner != null) {
646 + const node: any = owner;
647 + if (typeof node.tag === 'number') {
648 + owners.push({
649 + uid: getUid(node),
650 + name: getDisplayName(internals, node),
651 + type: getTypeTagForFiber(internals, node),
652 + });
653 + owner = node._debugOwner;
654 + } else {
655 + // Server component (ReactComponentInfo): continue via its .owner.
656 + owner = node.owner;
657 + }
658 + }
659 + return owners;
660 + }
661 +
662 + return {
663 + getComponentTree,
664 + getComponentByUid,
665 + findComponents,
666 + getComponentSource,
667 + getOwnersStack,
668 + getOwnersBranch,
669 + };
670 +}
packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js
+1413 -1
@@ -8,6 +8,7 @@
8 'use strict';
9
10 let installFacade;
11 +let createTools;
12 let facade;
13 let React;
14 let ReactDOMClient;
@@ -27,7 +28,9 @@ describe('react-devtools-facade', () => {
28
29 // Install the facade BEFORE React so the hook captures the first commit.
30 // Import through the package entry point to exercise the public surface.
30 - installFacade = require('../../index').installFacade;
31 + const facadeAPI = require('../../index');
32 + installFacade = facadeAPI.installFacade;
33 + createTools = facadeAPI.createTools;
34 facade = installFacade();
35
36 React = require('react');
@@ -122,4 +125,1413 @@ describe('react-devtools-facade', () => {
125
126 expect(facade.hook.getFiberRoots(rendererID).size).toBe(0);
127 });
128 +
129 + describe('getComponentTree', () => {
130 + let getComponentTree;
131 +
132 + beforeEach(() => {
133 + getComponentTree = createTools(facade).getComponentTree;
134 + });
135 +
136 + it('returns error when nothing is rendered', () => {
137 + const result = getComponentTree();
138 + expect(result.error).toMatch(/No mounted React roots found/);
139 + });
140 +
141 + it('returns an array of component nodes', () => {
142 + function App() {
143 + return <div>hello</div>;
144 + }
145 +
146 + act(() => {
147 + ReactDOMClient.createRoot(container).render(<App />);
148 + });
149 +
150 + const result = getComponentTree();
151 + expect(Array.isArray(result)).toBe(true);
152 + const app = result.find(n => n.name === 'App');
153 + const div = result.find(n => n.name === 'div');
154 + // App is the root's only child; its child is the host div.
155 + expect(app).toEqual({
156 + uid: 'r0',
157 + type: 'function',
158 + name: 'App',
159 + key: null,
160 + firstChild: div.uid,
161 + nextSibling: null,
162 + });
163 + // A single string child ('hello') is stored as a prop, not a child fiber,
164 + // so the div is a leaf in the tree.
165 + expect(div).toEqual({
166 + uid: 'r2',
167 + type: 'host',
168 + name: 'div',
169 + key: null,
170 + firstChild: null,
171 + nextSibling: null,
172 + });
173 + });
174 +
175 + it('encodes firstChild and nextSibling relationships', () => {
176 + function Header() {
177 + return <h1>title</h1>;
178 + }
179 + function Footer() {
180 + return <footer>foot</footer>;
181 + }
182 + function App() {
183 + return (
184 + <div>
185 + <Header />
186 + <Footer />
187 + </div>
188 + );
189 + }
190 +
191 + act(() => {
192 + ReactDOMClient.createRoot(container).render(<App />);
193 + });
194 +
195 + const nodes = getComponentTree();
196 + const app = nodes.find(n => n.name === 'App');
197 + const div = nodes.find(n => n.name === 'div');
198 + const header = nodes.find(n => n.name === 'Header');
199 + const footer = nodes.find(n => n.name === 'Footer');
200 +
201 + // App's firstChild is div
202 + expect(app.firstChild).toBe(div.uid);
203 + // div's firstChild is Header
204 + expect(div.firstChild).toBe(header.uid);
205 + // Header's nextSibling is Footer
206 + expect(header.nextSibling).toBe(footer.uid);
207 + // Footer has no nextSibling
208 + expect(footer.nextSibling).toBe(null);
209 + });
210 +
211 + it('shows keys in the output', () => {
212 + function Item() {
213 + return <li>item</li>;
214 + }
215 + function List() {
216 + return (
217 + <ul>
218 + <Item key="a" />
219 + <Item key="b" />
220 + </ul>
221 + );
222 + }
223 +
224 + act(() => {
225 + ReactDOMClient.createRoot(container).render(<List />);
226 + });
227 +
228 + const items = getComponentTree().filter(n => n.name === 'Item');
229 + expect(items.map(i => i.key)).toEqual(['a', 'b']);
230 + });
231 +
232 + it('limits depth with the depth parameter', () => {
233 + function Child() {
234 + return <span>leaf</span>;
235 + }
236 + function Parent() {
237 + return <Child />;
238 + }
239 + function App() {
240 + return <Parent />;
241 + }
242 +
243 + act(() => {
244 + ReactDOMClient.createRoot(container).render(<App />);
245 + });
246 +
247 + const names = snapshot => snapshot.map(n => n.name);
248 +
249 + // depth=0: only the root node (HostRoot)
250 + const shallow = getComponentTree(0);
251 + expect(shallow).toHaveLength(1);
252 + expect(shallow[0].type).toBe('root');
253 +
254 + // depth=1: root + App
255 + const d1 = getComponentTree(1);
256 + expect(names(d1)).toContain('App');
257 + expect(names(d1)).not.toContain('Parent');
258 +
259 + // depth=2: root + App + Parent
260 + const d2 = getComponentTree(2);
261 + expect(names(d2)).toContain('App');
262 + expect(names(d2)).toContain('Parent');
263 + expect(names(d2)).not.toContain('Child');
264 +
265 + const deep = getComponentTree(20);
266 + expect(names(deep)).toEqual(
267 + expect.arrayContaining(['App', 'Parent', 'Child']),
268 + );
269 + });
270 +
271 + it('starts from a specific node when rootUid is provided', () => {
272 + function Nav() {
273 + return <nav>nav</nav>;
274 + }
275 + function Header() {
276 + return <Nav />;
277 + }
278 + function Footer() {
279 + return <footer>foot</footer>;
280 + }
281 + function App() {
282 + return (
283 + <div>
284 + <Header />
285 + <Footer />
286 + </div>
287 + );
288 + }
289 +
290 + act(() => {
291 + ReactDOMClient.createRoot(container).render(<App />);
292 + });
293 +
294 + // First, get the full tree to find Header's uid
295 + const header = getComponentTree().find(n => n.name === 'Header');
296 + expect(header).toBeDefined();
297 +
298 + // Snapshot from Header
299 + const sub = getComponentTree(20, header.uid);
300 + const names = sub.map(n => n.name);
301 + expect(names).toContain('Header');
302 + expect(names).toContain('Nav');
303 + // Should NOT contain App or Footer
304 + expect(names).not.toContain('App');
305 + expect(names).not.toContain('Footer');
306 + });
307 +
308 + it('returns error for non-existent rootUid', () => {
309 + function App() {
310 + return <div>hello</div>;
311 + }
312 +
313 + act(() => {
314 + ReactDOMClient.createRoot(container).render(<App />);
315 + });
316 +
317 + const result = getComponentTree(20, 'r9999');
318 + expect(result.error).toMatch(/Component not found/);
319 + });
320 +
321 + it('assigns stable uids across calls', () => {
322 + function App() {
323 + return <div>hello</div>;
324 + }
325 +
326 + act(() => {
327 + ReactDOMClient.createRoot(container).render(<App />);
328 + });
329 +
330 + const first = getComponentTree();
331 + const second = getComponentTree();
332 + expect(first).toEqual(second);
333 + });
334 +
335 + it('shows class components with class type', () => {
336 + class MyComponent extends React.Component {
337 + render() {
338 + return <div>class</div>;
339 + }
340 + }
341 +
342 + act(() => {
343 + ReactDOMClient.createRoot(container).render(<MyComponent />);
344 + });
345 +
346 + const node = getComponentTree().find(n => n.name === 'MyComponent');
347 + expect(node.type).toBe('class');
348 + });
349 +
350 + it('shows host components with host type', () => {
351 + function App() {
352 + return <div>hello</div>;
353 + }
354 +
355 + act(() => {
356 + ReactDOMClient.createRoot(container).render(<App />);
357 + });
358 +
359 + const node = getComponentTree().find(n => n.name === 'div');
360 + expect(node.type).toBe('host');
361 + });
362 +
363 + it('shows Memo components with memo type', () => {
364 + function Inner() {
365 + return <span>inner</span>;
366 + }
367 + const Memoized = React.memo(Inner);
368 +
369 + act(() => {
370 + ReactDOMClient.createRoot(container).render(<Memoized />);
371 + });
372 +
373 + const node = getComponentTree().find(n => n.name === 'Memo(Inner)');
374 + expect(node).toBeDefined();
375 + expect(node.type).toBe('memo');
376 + });
377 +
378 + it('shows ForwardRef components with forwardRef type', () => {
379 + const FancyButton = React.forwardRef(function FancyButton(props, ref) {
380 + return <button ref={ref}>{props.children}</button>;
381 + });
382 +
383 + act(() => {
384 + ReactDOMClient.createRoot(container).render(
385 + <FancyButton>click</FancyButton>,
386 + );
387 + });
388 +
389 + const node = getComponentTree().find(
390 + n => n.name === 'ForwardRef(FancyButton)',
391 + );
392 + expect(node).toBeDefined();
393 + expect(node.type).toBe('forwardRef');
394 + });
395 +
396 + it('includes Fragment in the tree', () => {
397 + function A() {
398 + return <span>a</span>;
399 + }
400 + function B() {
401 + return <span>b</span>;
402 + }
403 + function App() {
404 + // Keyed Fragment creates a Fragment fiber
405 + return (
406 + <div>
407 + <React.Fragment key="group">
408 + <A />
409 + <B />
410 + </React.Fragment>
411 + </div>
412 + );
413 + }
414 +
415 + act(() => {
416 + ReactDOMClient.createRoot(container).render(<App />);
417 + });
418 +
419 + const nodes = getComponentTree();
420 + const fragment = nodes.find(n => n.type === 'fragment');
421 + const a = nodes.find(n => n.name === 'A');
422 + const b = nodes.find(n => n.name === 'B');
423 + // The keyed Fragment is the div's child and parents A then B.
424 + expect(fragment).toEqual({
425 + uid: 'r3',
426 + type: 'fragment',
427 + name: 'Fragment',
428 + key: 'group',
429 + firstChild: a.uid,
430 + nextSibling: null,
431 + });
432 + expect(a).toEqual({
433 + uid: 'r4',
434 + type: 'function',
435 + name: 'A',
436 + key: null,
437 + firstChild: 'r5',
438 + nextSibling: b.uid,
439 + });
440 + expect(b).toEqual({
441 + uid: 'r6',
442 + type: 'function',
443 + name: 'B',
444 + key: null,
445 + firstChild: 'r7',
446 + nextSibling: null,
447 + });
448 + });
449 +
450 + it('includes HostRoot with type root', () => {
451 + function App() {
452 + return <div>hello</div>;
453 + }
454 +
455 + act(() => {
456 + ReactDOMClient.createRoot(container).render(<App />);
457 + });
458 +
459 + const nodes = getComponentTree();
460 + const root = nodes.find(n => n.type === 'root');
461 + const app = nodes.find(n => n.name === 'App');
462 + // The HostRoot is the tree's entry; its only child is App.
463 + expect(root).toEqual({
464 + uid: 'r1',
465 + type: 'root',
466 + name: 'createRoot()',
467 + key: null,
468 + firstChild: app.uid,
469 + nextSibling: null,
470 + });
471 + });
472 +
473 + it('includes Suspense in the tree', () => {
474 + function App() {
475 + return (
476 + <React.Suspense fallback={<div>loading</div>}>
477 + <div>content</div>
478 + </React.Suspense>
479 + );
480 + }
481 +
482 + act(() => {
483 + ReactDOMClient.createRoot(container).render(<App />);
484 + });
485 +
486 + const suspense = getComponentTree().find(n => n.type === 'suspense');
487 + // Suspense wraps its content via an internal primary/Offscreen child, so
488 + // firstChild is a valid uid but its exact identity is an internal detail.
489 + expect(suspense).toEqual({
490 + uid: 'r2',
491 + type: 'suspense',
492 + name: 'Suspense',
493 + key: null,
494 + firstChild: 'r3',
495 + nextSibling: null,
496 + });
497 + });
498 +
499 + it('includes Context Provider in the tree', () => {
500 + const MyContext = React.createContext('default');
501 + function App() {
502 + return (
503 + <MyContext value="test">
504 + <div>child</div>
505 + </MyContext>
506 + );
507 + }
508 +
509 + act(() => {
510 + ReactDOMClient.createRoot(container).render(<App />);
511 + });
512 +
513 + const provider = getComponentTree().find(n => n.type === 'context');
514 + expect(provider).toEqual({
515 + uid: 'r2',
516 + type: 'context',
517 + name: 'Context.Provider',
518 + key: null,
519 + firstChild: 'r3',
520 + nextSibling: null,
521 + });
522 + });
523 +
524 + it('uids survive re-renders via alternate fiber handling', () => {
525 + function Counter({count}) {
526 + return <div>{'Count: ' + count}</div>;
527 + }
528 +
529 + const root = ReactDOMClient.createRoot(container);
530 + act(() => {
531 + root.render(<Counter count={0} />);
532 + });
533 +
534 + const counter1 = getComponentTree().find(n => n.name === 'Counter');
535 + expect(counter1).toBeDefined();
536 +
537 + act(() => {
538 + root.render(<Counter count={1} />);
539 + });
540 +
541 + const counter2 = getComponentTree().find(n => n.name === 'Counter');
542 + expect(counter2).toBeDefined();
543 + // Same uid after re-render
544 + expect(counter2.uid).toBe(counter1.uid);
545 + });
546 +
547 + it('removes unmounted roots from the tree', () => {
548 + function App() {
549 + return <div>hello</div>;
550 + }
551 +
552 + const root = ReactDOMClient.createRoot(container);
553 + act(() => {
554 + root.render(<App />);
555 + });
556 +
557 + const before = getComponentTree();
558 + expect(before.find(n => n.name === 'App')).toBeDefined();
559 +
560 + act(() => {
561 + root.unmount();
562 + });
563 +
564 + const after = getComponentTree();
565 + expect(after.error).toMatch(/No mounted React roots found/);
566 + });
567 + });
568 +
569 + describe('findComponents', () => {
570 + let findComponents;
571 + let getComponentTree;
572 +
573 + beforeEach(() => {
574 + const tools = createTools(facade);
575 + findComponents = tools.findComponents;
576 + getComponentTree = tools.getComponentTree;
577 + });
578 +
579 + it('finds components by name (case-insensitive substring match)', () => {
580 + function Header() {
581 + return <h1>title</h1>;
582 + }
583 + function Footer() {
584 + return <footer>foot</footer>;
585 + }
586 + function App() {
587 + return (
588 + <div>
589 + <Header />
590 + <Footer />
591 + </div>
592 + );
593 + }
594 +
595 + act(() => {
596 + ReactDOMClient.createRoot(container).render(<App />);
597 + });
598 +
599 + const result = findComponents('header');
600 + expect(result.totalCount).toBe(1);
601 + expect(result.results).toHaveLength(1);
602 + expect(result.results[0].name).toBe('Header');
603 + expect(result.results[0].type).toBe('function');
604 + expect(result.results[0].uid).toBe('r0');
605 + });
606 +
607 + it('returns all matches when multiple components match', () => {
608 + function Card() {
609 + return <div>card</div>;
610 + }
611 + function App() {
612 + return (
613 + <div>
614 + <Card key="a" />
615 + <Card key="b" />
616 + <Card key="c" />
617 + </div>
618 + );
619 + }
620 +
621 + act(() => {
622 + ReactDOMClient.createRoot(container).render(<App />);
623 + });
624 +
625 + const result = findComponents('Card');
626 + expect(result.totalCount).toBe(3);
627 + expect(result.results.map(r => r.key)).toEqual(['a', 'b', 'c']);
628 + });
629 +
630 + it('returns empty results when no components match', () => {
631 + function App() {
632 + return <div>hello</div>;
633 + }
634 +
635 + act(() => {
636 + ReactDOMClient.createRoot(container).render(<App />);
637 + });
638 +
639 + const result = findComponents('NonExistent');
640 + expect(result.totalCount).toBe(0);
641 + expect(result.results).toEqual([]);
642 + expect(result.page).toBe(1);
643 + expect(result.totalPages).toBe(1);
644 + });
645 +
646 + it('scopes search to subtree when rootUid is provided', () => {
647 + function Badge() {
648 + return <span>badge</span>;
649 + }
650 + function Sidebar() {
651 + return <Badge />;
652 + }
653 + function Main() {
654 + return <Badge />;
655 + }
656 + function App() {
657 + return (
658 + <div>
659 + <Sidebar />
660 + <Main />
661 + </div>
662 + );
663 + }
664 +
665 + act(() => {
666 + ReactDOMClient.createRoot(container).render(<App />);
667 + });
668 +
669 + // Find Sidebar's uid
670 + const sidebar = getComponentTree().find(n => n.name === 'Sidebar');
671 + expect(sidebar).toBeDefined();
672 +
673 + // Search for Badge only under Sidebar
674 + const result = findComponents('Badge', sidebar.uid);
675 + expect(result.totalCount).toBe(1);
676 + expect(result.results[0].name).toBe('Badge');
677 +
678 + // Without rootUid, should find both Badges
679 + const allResult = findComponents('Badge');
680 + expect(allResult.totalCount).toBe(2);
681 + });
682 +
683 + it('paginates results with default page size of 10', () => {
684 + function Item() {
685 + return <li>item</li>;
686 + }
687 + function App() {
688 + const items = [];
689 + for (let i = 0; i < 15; i++) {
690 + items.push(<Item key={String(i)} />);
691 + }
692 + return <ul>{items}</ul>;
693 + }
694 +
695 + act(() => {
696 + ReactDOMClient.createRoot(container).render(<App />);
697 + });
698 +
699 + const page1 = findComponents('Item');
700 + expect(page1.totalCount).toBe(15);
701 + expect(page1.page).toBe(1);
702 + expect(page1.pageSize).toBe(10);
703 + expect(page1.totalPages).toBe(2);
704 + expect(page1.results).toHaveLength(10);
705 +
706 + const page2 = findComponents('Item', undefined, 2);
707 + expect(page2.page).toBe(2);
708 + expect(page2.results).toHaveLength(5);
709 + });
710 +
711 + it('supports custom page size', () => {
712 + function Item() {
713 + return <li>item</li>;
714 + }
715 + function App() {
716 + return (
717 + <ul>
718 + <Item key="0" />
719 + <Item key="1" />
720 + <Item key="2" />
721 + <Item key="3" />
722 + <Item key="4" />
723 + </ul>
724 + );
725 + }
726 +
727 + act(() => {
728 + ReactDOMClient.createRoot(container).render(<App />);
729 + });
730 +
731 + const result = findComponents('Item', undefined, 1, 2);
732 + expect(result.totalCount).toBe(5);
733 + expect(result.pageSize).toBe(2);
734 + expect(result.totalPages).toBe(3);
735 + expect(result.results).toHaveLength(2);
736 + expect(result.results[0].key).toBe('0');
737 + expect(result.results[1].key).toBe('1');
738 +
739 + const page3 = findComponents('Item', undefined, 3, 2);
740 + expect(page3.results).toHaveLength(1);
741 + expect(page3.results[0].key).toBe('4');
742 + });
743 +
744 + it('clamps page number to valid range', () => {
745 + function App() {
746 + return <div>hello</div>;
747 + }
748 +
749 + act(() => {
750 + ReactDOMClient.createRoot(container).render(<App />);
751 + });
752 +
753 + // Page 0 should clamp to 1
754 + const low = findComponents('div', undefined, 0);
755 + expect(low.page).toBe(1);
756 +
757 + // Page beyond total should clamp to last page
758 + const high = findComponents('div', undefined, 999);
759 + expect(high.page).toBe(1);
760 + });
761 +
762 + it('results have same shape as tree snapshot nodes', () => {
763 + function Widget() {
764 + return <span>w</span>;
765 + }
766 + function App() {
767 + return <Widget />;
768 + }
769 +
770 + act(() => {
771 + ReactDOMClient.createRoot(container).render(<App />);
772 + });
773 +
774 + const result = findComponents('Widget');
775 + expect(result.results).toHaveLength(1);
776 + expect(result.results[0]).toEqual({
777 + uid: 'r0',
778 + type: 'function',
779 + name: 'Widget',
780 + key: null,
781 + firstChild: 'r1',
782 + nextSibling: null,
783 + });
784 + });
785 +
786 + it('uids are consistent with getComponentTree', () => {
787 + function Target() {
788 + return <div>target</div>;
789 + }
790 + function App() {
791 + return <Target />;
792 + }
793 +
794 + act(() => {
795 + ReactDOMClient.createRoot(container).render(<App />);
796 + });
797 +
798 + // Get uid from tree snapshot
799 + const target = getComponentTree().find(n => n.name === 'Target');
800 + expect(target).toBeDefined();
801 +
802 + // findComponents should return the same uid
803 + const result = findComponents('Target');
804 + expect(result.results[0].uid).toBe(target.uid);
805 + });
806 +
807 + it('matches host components by tag name', () => {
808 + function App() {
809 + return (
810 + <div>
811 + <span>a</span>
812 + <span>b</span>
813 + </div>
814 + );
815 + }
816 +
817 + act(() => {
818 + ReactDOMClient.createRoot(container).render(<App />);
819 + });
820 +
821 + const result = findComponents('span');
822 + expect(result.totalCount).toBe(2);
823 + expect(result.results[0].type).toBe('host');
824 + expect(result.results[0].name).toBe('span');
825 + });
826 +
827 + it('does not match internal nodes with null displayName', () => {
828 + function App() {
829 + return (
830 + <React.Fragment>
831 + <div>hello</div>
832 + </React.Fragment>
833 + );
834 + }
835 +
836 + act(() => {
837 + ReactDOMClient.createRoot(container).render(<App />);
838 + });
839 +
840 + // Fragment has null displayName in getDisplayNameForFiber,
841 + // so it should not appear in search results
842 + const fragmentResult = findComponents('Fragment');
843 + expect(fragmentResult.totalCount).toBe(0);
844 + });
845 +
846 + it('finds Memo components by wrapped display name', () => {
847 + function Inner() {
848 + return <span>inner</span>;
849 + }
850 + const Memoized = React.memo(Inner);
851 + function App() {
852 + return <Memoized />;
853 + }
854 +
855 + act(() => {
856 + ReactDOMClient.createRoot(container).render(<App />);
857 + });
858 +
859 + // memo(Inner) renders Inner inline (no separate FunctionComponent fiber),
860 + // so the only match for "Inner" is the memo wrapper "Memo(Inner)".
861 + const result = findComponents('Inner');
862 + expect(result.totalCount).toBe(1);
863 + expect(result.results).toHaveLength(1);
864 + expect(result.results[0]).toEqual({
865 + uid: 'r0',
866 + type: 'memo',
867 + name: 'Memo(Inner)',
868 + key: null,
869 + firstChild: 'r1',
870 + nextSibling: null,
871 + });
872 + });
873 +
874 + it('returns error for non-existent rootUid in scoped search', () => {
875 + function App() {
876 + return <div>hello</div>;
877 + }
878 +
879 + act(() => {
880 + ReactDOMClient.createRoot(container).render(<App />);
881 + });
882 +
883 + const result = findComponents('App', 'r9999');
884 + expect(result.error).toMatch(/Component not found/);
885 + });
886 + });
887 +
888 + describe('getComponentSource', () => {
889 + let getComponentSource;
890 + let getComponentTree;
891 +
892 + beforeEach(() => {
893 + const tools = createTools(facade);
894 + getComponentSource = tools.getComponentSource;
895 + getComponentTree = tools.getComponentTree;
896 + });
897 +
898 + it('returns {source: null} for a function component when the location is unavailable', () => {
899 + // The throwing trick that resolves a component's definition location does
900 + // not produce file positions under jsdom, so source is null here. In a
901 + // real browser this returns {name, fileName, line, column}.
902 + function Greeting() {
903 + return <div>Hello</div>;
904 + }
905 +
906 + act(() => {
907 + ReactDOMClient.createRoot(container).render(<Greeting />);
908 + });
909 +
910 + const greeting = getComponentTree().find(n => n.name === 'Greeting');
911 + expect(greeting).toBeDefined();
912 + expect(getComponentSource(greeting.uid)).toEqual({source: null});
913 + });
914 +
915 + it('returns {source: null} for host components', () => {
916 + function App() {
917 + return <div>hello</div>;
918 + }
919 +
920 + act(() => {
921 + ReactDOMClient.createRoot(container).render(<App />);
922 + });
923 +
924 + const div = getComponentTree().find(n => n.name === 'div');
925 + expect(div).toBeDefined();
926 + // Host components like div have no source location.
927 + expect(getComponentSource(div.uid)).toEqual({source: null});
928 + });
929 +
930 + it('returns error for non-existent uid', () => {
931 + const result = getComponentSource('r9999');
932 + expect(result.error).toMatch(/Component not found/);
933 + });
934 + });
935 +
936 + describe('getOwnersStack', () => {
937 + let getOwnersStack;
938 + let getComponentTree;
939 +
940 + beforeEach(() => {
941 + const tools = createTools(facade);
942 + getOwnersStack = tools.getOwnersStack;
943 + getComponentTree = tools.getComponentTree;
944 + });
945 +
946 + it('returns a stack string for a nested component', () => {
947 + function Child() {
948 + return <span>leaf</span>;
949 + }
950 + function Parent() {
951 + return <Child />;
952 + }
953 + function App() {
954 + return <Parent />;
955 + }
956 +
957 + act(() => {
958 + ReactDOMClient.createRoot(container).render(<App />);
959 + });
960 +
961 + const child = getComponentTree().find(n => n.name === 'Child');
962 + expect(child).toBeDefined();
963 +
964 + const result = getOwnersStack(child.uid);
965 + expect(typeof result.stack).toBe('string');
966 + // The stack should mention the owner components
967 + expect(result.stack).toContain('Parent');
968 + expect(result.stack).toContain('App');
969 + });
970 +
971 + it('returns a stack string for the root component', () => {
972 + function App() {
973 + return <div>hello</div>;
974 + }
975 +
976 + act(() => {
977 + ReactDOMClient.createRoot(container).render(<App />);
978 + });
979 +
980 + const app = getComponentTree().find(n => n.name === 'App');
981 + const result = getOwnersStack(app.uid);
982 + expect(typeof result.stack).toBe('string');
983 + });
984 +
985 + it('returns error for non-existent uid', () => {
986 + const result = getOwnersStack('r9999');
987 + expect(result.error).toMatch(/Component not found/);
988 + });
989 + });
990 +
991 + describe('getOwnersBranch', () => {
992 + let getOwnersBranch;
993 + let getComponentTree;
994 +
995 + beforeEach(() => {
996 + const tools = createTools(facade);
997 + getOwnersBranch = tools.getOwnersBranch;
998 + getComponentTree = tools.getComponentTree;
999 + });
1000 +
1001 + it('returns owner list for a nested component', () => {
1002 + function Child() {
1003 + return <span>leaf</span>;
1004 + }
1005 + function Parent() {
1006 + return <Child />;
1007 + }
1008 + function App() {
1009 + return <Parent />;
1010 + }
1011 +
1012 + act(() => {
1013 + ReactDOMClient.createRoot(container).render(<App />);
1014 + });
1015 +
1016 + const child = getComponentTree().find(n => n.name === 'Child');
1017 + expect(child).toBeDefined();
1018 +
1019 + const owners = getOwnersBranch(child.uid);
1020 + expect(owners).toEqual([
1021 + {
1022 + uid: 'r2',
1023 + name: 'Parent',
1024 + type: 'function',
1025 + },
1026 + {
1027 + uid: 'r0',
1028 + name: 'App',
1029 + type: 'function',
1030 + },
1031 + ]);
1032 + });
1033 +
1034 + it('each entry has uid, name, and type', () => {
1035 + function Child() {
1036 + return <span>leaf</span>;
1037 + }
1038 + function App() {
1039 + return <Child />;
1040 + }
1041 +
1042 + act(() => {
1043 + ReactDOMClient.createRoot(container).render(<App />);
1044 + });
1045 +
1046 + const child = getComponentTree().find(n => n.name === 'Child');
1047 + const owners = getOwnersBranch(child.uid);
1048 +
1049 + expect(owners).toHaveLength(1);
1050 + expect(owners[0].uid).toBe('r0');
1051 + expect(owners[0].name).toBe('App');
1052 + expect(owners[0].type).toBe('function');
1053 + });
1054 +
1055 + it('owner uids are consistent with getComponentTree', () => {
1056 + function Child() {
1057 + return <span>leaf</span>;
1058 + }
1059 + function App() {
1060 + return <Child />;
1061 + }
1062 +
1063 + act(() => {
1064 + ReactDOMClient.createRoot(container).render(<App />);
1065 + });
1066 +
1067 + const tree = getComponentTree();
1068 + const child = tree.find(n => n.name === 'Child');
1069 + const app = tree.find(n => n.name === 'App');
1070 +
1071 + const owners = getOwnersBranch(child.uid);
1072 + expect(owners[0].uid).toBe(app.uid);
1073 + });
1074 +
1075 + it('returns empty array for root component with no owner', () => {
1076 + function App() {
1077 + return <div>hello</div>;
1078 + }
1079 +
1080 + act(() => {
1081 + ReactDOMClient.createRoot(container).render(<App />);
1082 + });
1083 +
1084 + const app = getComponentTree().find(n => n.name === 'App');
1085 + const owners = getOwnersBranch(app.uid);
1086 + expect(owners).toEqual([]);
1087 + });
1088 +
1089 + it('returns error for non-existent uid', () => {
1090 + const result = getOwnersBranch('r9999');
1091 + expect(result.error).toMatch(/Component not found/);
1092 + });
1093 +
1094 + it('is ordered from immediate owner to root ancestor', () => {
1095 + function GrandChild() {
1096 + return <span>gc</span>;
1097 + }
1098 + function Child() {
1099 + return <GrandChild />;
1100 + }
1101 + function Parent() {
1102 + return <Child />;
1103 + }
1104 + function App() {
1105 + return <Parent />;
1106 + }
1107 +
1108 + act(() => {
1109 + ReactDOMClient.createRoot(container).render(<App />);
1110 + });
1111 +
1112 + const gc = getComponentTree().find(n => n.name === 'GrandChild');
1113 + const owners = getOwnersBranch(gc.uid);
1114 + expect(owners).toEqual([
1115 + {
1116 + uid: 'r3',
1117 + name: 'Child',
1118 + type: 'function',
1119 + },
1120 + {
1121 + uid: 'r2',
1122 + name: 'Parent',
1123 + type: 'function',
1124 + },
1125 + {
1126 + uid: 'r0',
1127 + name: 'App',
1128 + type: 'function',
1129 + },
1130 + ]);
1131 + });
1132 + });
1133 +
1134 + describe('getComponentByUid', () => {
1135 + let getComponentTree;
1136 + let getComponentByUid;
1137 +
1138 + beforeEach(() => {
1139 + const tools = createTools(facade);
1140 + getComponentTree = tools.getComponentTree;
1141 + getComponentByUid = tools.getComponentByUid;
1142 + });
1143 +
1144 + it('returns error for non-existent uid', () => {
1145 + const result = getComponentByUid('r9999');
1146 + expect(result.error).toMatch(/Component not found/);
1147 + });
1148 +
1149 + it('returns info for a function component', () => {
1150 + function Greeting() {
1151 + return <div>Hello</div>;
1152 + }
1153 +
1154 + act(() => {
1155 + ReactDOMClient.createRoot(container).render(<Greeting />);
1156 + });
1157 +
1158 + const greeting = getComponentTree().find(n => n.name === 'Greeting');
1159 + expect(greeting).toBeDefined();
1160 + const info = getComponentByUid(greeting.uid);
1161 +
1162 + expect(info.uid).toBe(greeting.uid);
1163 + expect(info.type).toBe('function');
1164 + expect(info.name).toBe('Greeting');
1165 + });
1166 +
1167 + it('returns props (excluding children)', () => {
1168 + function Button() {
1169 + return <button>click</button>;
1170 + }
1171 +
1172 + act(() => {
1173 + ReactDOMClient.createRoot(container).render(
1174 + <Button text="Click me" disabled={true} />,
1175 + );
1176 + });
1177 +
1178 + const button = getComponentTree().find(n => n.name === 'Button');
1179 + const info = getComponentByUid(button.uid);
1180 +
1181 + expect(info.props.text).toBe('Click me');
1182 + expect(info.props.disabled).toBe(true);
1183 + expect(info.props).not.toHaveProperty('children');
1184 + });
1185 +
1186 + it('serializes function props as descriptive strings', () => {
1187 + function Button() {
1188 + return <button>click</button>;
1189 + }
1190 +
1191 + function handleClick() {}
1192 +
1193 + act(() => {
1194 + ReactDOMClient.createRoot(container).render(
1195 + <Button onClick={handleClick} />,
1196 + );
1197 + });
1198 +
1199 + const button = getComponentTree().find(n => n.name === 'Button');
1200 + const info = getComponentByUid(button.uid);
1201 +
1202 + expect(info.props.onClick).toBe('[fn handleClick]');
1203 + });
1204 +
1205 + it('returns key when present', () => {
1206 + function Item() {
1207 + return <li>item</li>;
1208 + }
1209 + function List() {
1210 + return (
1211 + <ul>
1212 + <Item key="first" />
1213 + </ul>
1214 + );
1215 + }
1216 +
1217 + act(() => {
1218 + ReactDOMClient.createRoot(container).render(<List />);
1219 + });
1220 +
1221 + const item = getComponentTree().find(n => n.name === 'Item');
1222 + const info = getComponentByUid(item.uid);
1223 +
1224 + expect(info.key).toBe('first');
1225 + });
1226 +
1227 + it('returns correct type for class components', () => {
1228 + class MyClass extends React.Component {
1229 + render() {
1230 + return <div>class</div>;
1231 + }
1232 + }
1233 +
1234 + act(() => {
1235 + ReactDOMClient.createRoot(container).render(<MyClass />);
1236 + });
1237 +
1238 + const myClass = getComponentTree().find(n => n.name === 'MyClass');
1239 + expect(myClass).toBeDefined();
1240 + const info = getComponentByUid(myClass.uid);
1241 +
1242 + expect(info.type).toBe('class');
1243 + expect(info.name).toBe('MyClass');
1244 + });
1245 +
1246 + it('returns correct type for host components', () => {
1247 + function App() {
1248 + return <div className="app" id="root" />;
1249 + }
1250 +
1251 + act(() => {
1252 + ReactDOMClient.createRoot(container).render(<App />);
1253 + });
1254 +
1255 + const div = getComponentTree().find(n => n.name === 'div');
1256 + const info = getComponentByUid(div.uid);
1257 +
1258 + expect(info.type).toBe('host');
1259 + expect(info.name).toBe('div');
1260 + expect(info.props.className).toBe('app');
1261 + expect(info.props.id).toBe('root');
1262 + });
1263 +
1264 + it('uses uids consistent with getComponentTree', () => {
1265 + function Header() {
1266 + return <h1>title</h1>;
1267 + }
1268 + function Footer() {
1269 + return <footer>foot</footer>;
1270 + }
1271 + function App() {
1272 + return (
1273 + <div>
1274 + <Header />
1275 + <Footer />
1276 + </div>
1277 + );
1278 + }
1279 +
1280 + act(() => {
1281 + ReactDOMClient.createRoot(container).render(<App />);
1282 + });
1283 +
1284 + const nodes = getComponentTree();
1285 + nodes.forEach(node => {
1286 + const info = getComponentByUid(node.uid);
1287 + expect(info.uid).toBe(node.uid);
1288 + });
1289 + });
1290 +
1291 + it('normalizes nested objects and arrays in props', () => {
1292 + function Config() {
1293 + return <div>config</div>;
1294 + }
1295 +
1296 + act(() => {
1297 + ReactDOMClient.createRoot(container).render(
1298 + <Config style={{color: 'red', fontSize: 14}} items={[1, 2, 3]} />,
1299 + );
1300 + });
1301 +
1302 + const config = getComponentTree().find(n => n.name === 'Config');
1303 + const info = getComponentByUid(config.uid);
1304 + expect(info.props.style).toEqual({color: 'red', fontSize: 14});
1305 + expect(info.props.items).toEqual([1, 2, 3]);
1306 + });
1307 +
1308 + it('normalizes symbol and undefined props', () => {
1309 + function Widget() {
1310 + return <div>w</div>;
1311 + }
1312 +
1313 + act(() => {
1314 + ReactDOMClient.createRoot(container).render(
1315 + <Widget sym={Symbol('test')} undef={undefined} />,
1316 + );
1317 + });
1318 +
1319 + const widget = getComponentTree().find(n => n.name === 'Widget');
1320 + const info = getComponentByUid(widget.uid);
1321 + expect(info.props.sym).toBe('[symbol]');
1322 + expect(info.props.undef).toBe(null);
1323 + });
1324 +
1325 + it('returns info for Memo component with correct type', () => {
1326 + function Inner() {
1327 + return <span>inner</span>;
1328 + }
1329 + const Memoized = React.memo(Inner);
1330 +
1331 + act(() => {
1332 + ReactDOMClient.createRoot(container).render(<Memoized value={42} />);
1333 + });
1334 +
1335 + const memo = getComponentTree().find(n => n.type === 'memo');
1336 + expect(memo).toBeDefined();
1337 + const info = getComponentByUid(memo.uid);
1338 + expect(info.type).toBe('memo');
1339 + });
1340 +
1341 + it('returns info for ForwardRef component with correct type', () => {
1342 + const FancyInput = React.forwardRef(function FancyInput(props, ref) {
1343 + return <input ref={ref} />;
1344 + });
1345 +
1346 + act(() => {
1347 + ReactDOMClient.createRoot(container).render(<FancyInput />);
1348 + });
1349 +
1350 + const fwd = getComponentTree().find(n => n.type === 'forwardRef');
1351 + expect(fwd).toBeDefined();
1352 + const info = getComponentByUid(fwd.uid);
1353 + expect(info.type).toBe('forwardRef');
1354 + });
1355 +
1356 + it('returns no props when component has only children', () => {
1357 + function Wrapper() {
1358 + return <div>child</div>;
1359 + }
1360 +
1361 + act(() => {
1362 + ReactDOMClient.createRoot(container).render(<Wrapper />);
1363 + });
1364 +
1365 + const wrapper = getComponentTree().find(n => n.name === 'Wrapper');
1366 + const info = getComponentByUid(wrapper.uid);
1367 + // No props key at all (children are excluded)
1368 + expect(info.props).toBeUndefined();
1369 + });
1370 +
1371 + it('handles circular references in props without stack overflow', () => {
1372 + function Widget() {
1373 + return <div>widget</div>;
1374 + }
1375 +
1376 + const circular = {a: 1};
1377 + circular.self = circular;
1378 +
1379 + act(() => {
1380 + ReactDOMClient.createRoot(container).render(<Widget data={circular} />);
1381 + });
1382 +
1383 + const widget = getComponentTree().find(n => n.name === 'Widget');
1384 + // Should not throw or stack overflow
1385 + const info = getComponentByUid(widget.uid);
1386 + expect(info.props.data.a).toBe(1);
1387 + expect(info.props.data.self).toBe('[circular]');
1388 + });
1389 +
1390 + it('handles deeply nested objects in props without stack overflow', () => {
1391 + function Widget() {
1392 + return <div>widget</div>;
1393 + }
1394 +
1395 + // Create a very deeply nested object
1396 + let deep = {value: 'leaf'};
1397 + for (let i = 0; i < 200; i++) {
1398 + deep = {nested: deep};
1399 + }
1400 +
1401 + act(() => {
1402 + ReactDOMClient.createRoot(container).render(<Widget data={deep} />);
1403 + });
1404 +
1405 + const widget = getComponentTree().find(n => n.name === 'Widget');
1406 + // Should not throw or stack overflow
1407 + const info = getComponentByUid(widget.uid);
1408 + expect(info.props.data).toBeDefined();
1409 + });
1410 +
1411 + it('returns the full hooks tree for a function component', () => {
1412 + function useCounter() {
1413 + const [c] = React.useState(0);
1414 + return c;
1415 + }
1416 + function Widget() {
1417 + const [count] = React.useState(7);
1418 + React.useEffect(() => {}, []);
1419 + const [obj] = React.useState({color: 'red'});
1420 + useCounter();
1421 + const ref = React.useRef(1);
1422 + const memo = React.useMemo(() => 5, []);
1423 + return (
1424 + <div>
1425 + {count}
1426 + {obj.color}
1427 + {ref.current}
1428 + {memo}
1429 + </div>
1430 + );
1431 + }
1432 +
1433 + act(() => {
1434 + ReactDOMClient.createRoot(container).render(<Widget />);
1435 + });
1436 +
1437 + const widget = getComponentTree().find(n => n.name === 'Widget');
1438 + const info = getComponentByUid(widget.uid);
1439 +
1440 + // Full structural assertion: every hook node, in order, with its id
1441 + // (sequential per primitive hook; custom hooks are null), name, normalized
1442 + // value (the Effect's create fn becomes '[fn]'), and subHooks.
1443 + expect(info.hooks).toEqual([
1444 + {id: 0, name: 'State', value: 7, subHooks: []},
1445 + {id: 1, name: 'Effect', value: '[fn]', subHooks: []},
1446 + {id: 2, name: 'State', value: {color: 'red'}, subHooks: []},
1447 + {
1448 + id: null,
1449 + name: 'Counter',
1450 + value: null,
1451 + subHooks: [{id: 3, name: 'State', value: 0, subHooks: []}],
1452 + },
1453 + {id: 4, name: 'Ref', value: 1, subHooks: []},
1454 + {id: 5, name: 'Memo', value: 5, subHooks: []},
1455 + ]);
1456 + });
1457 +
1458 + it('captures the useContext hook with its provided value', () => {
1459 + const ThemeContext = React.createContext('light');
1460 + function Themed() {
1461 + const theme = React.useContext(ThemeContext);
1462 + const [count] = React.useState(0);
1463 + return (
1464 + <div>
1465 + {theme}
1466 + {count}
1467 + </div>
1468 + );
1469 + }
1470 + function App() {
1471 + return (
1472 + <ThemeContext value="dark">
1473 + <Themed />
1474 + </ThemeContext>
1475 + );
1476 + }
1477 +
1478 + act(() => {
1479 + ReactDOMClient.createRoot(container).render(<App />);
1480 + });
1481 +
1482 + const themed = getComponentTree().find(n => n.name === 'Themed');
1483 + const info = getComponentByUid(themed.uid);
1484 + // useContext is captured as a "Context" hook holding the provider's value.
1485 + // It does not consume a primitive hook slot, so its id is null; the
1486 + // following useState is the first primitive hook (id 0).
1487 + expect(info.hooks).toEqual([
1488 + {id: null, name: 'Context', value: 'dark', subHooks: []},
1489 + {id: 0, name: 'State', value: 0, subHooks: []},
1490 + ]);
1491 + });
1492 +
1493 + it('returns an empty hooks array for a function component with no hooks', () => {
1494 + function Plain() {
1495 + return <div>plain</div>;
1496 + }
1497 +
1498 + act(() => {
1499 + ReactDOMClient.createRoot(container).render(<Plain />);
1500 + });
1501 +
1502 + const plain = getComponentTree().find(n => n.name === 'Plain');
1503 + const info = getComponentByUid(plain.uid);
1504 + expect(info.hooks).toEqual([]);
1505 + });
1506 +
1507 + it('does not include hooks for class components', () => {
1508 + class MyClass extends React.Component {
1509 + render() {
1510 + return <div>class</div>;
1511 + }
1512 + }
1513 +
1514 + act(() => {
1515 + ReactDOMClient.createRoot(container).render(<MyClass />);
1516 + });
1517 +
1518 + const myClass = getComponentTree().find(n => n.name === 'MyClass');
1519 + const info = getComponentByUid(myClass.uid);
1520 + expect(info.hooks).toBeUndefined();
1521 + });
1522 +
1523 + it('does not include hooks for host components', () => {
1524 + function App() {
1525 + return <div>hello</div>;
1526 + }
1527 +
1528 + act(() => {
1529 + ReactDOMClient.createRoot(container).render(<App />);
1530 + });
1531 +
1532 + const div = getComponentTree().find(n => n.name === 'div');
1533 + const info = getComponentByUid(div.uid);
1534 + expect(info.hooks).toBeUndefined();
1535 + });
1536 + });
1537 });