[react-devtools-facade] add host instance component lookup (#36820)
Adds a new tool to the Facade, where the input is the `HostInstance` and the output is the corresponding component payload for the `HostFiber` of this `HostInstance`. Supports multiple Host objects from Fiber: `HostComponent`, `HostText`, `HostSingleton`, `HostHoistable`. > [!NOTE] > This doesn't bring much value now, because this returns the payload for the corresponding HostFiber, not the closest user-defined Fiber. This will be improved by adding a parentUid reference to the element payload, so the agent can crawl up through the component tree.
Ruslan Lesiutin committed
Jun 30, 2026 at 20:33 UTC
e27313127e4b138bed374b2a931598ff42275620
3 files changed
+277
-36
packages/react-devtools-facade/src/DevToolsFacadeTools.js
+2
@@ -59,6 +59,7 @@ export type Tools = {
59
uid: string,
60
includeHooks?: boolean,
61
) => NodeInfo | ToolError,
62
+ getComponentByHostInstance: (hostInstance: mixed) => NodeInfo | ToolError,
63
findComponents: (
64
name: string,
65
rootUid?: string,
@@ -97,6 +98,7 @@ export function createTools(facade: Facade): Tools {
98
return {
99
getComponentTree: tree.getComponentTree,
100
getComponentByUid: tree.getComponentByUid,
101
+ getComponentByHostInstance: tree.getComponentByHostInstance,
102
findComponents: tree.findComponents,
103
getComponentSource: tree.getComponentSource,
104
getOwnerStackTrace: tree.getOwnerStackTrace,
packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js
+143
-36
@@ -85,6 +85,7 @@ export type TreeTools = {
85
uid: string,
86
includeHooks?: boolean,
87
) => NodeInfo | ToolError,
88
+ getComponentByHostInstance: (hostInstance: mixed) => NodeInfo | ToolError,
89
findComponents: (
90
name: string,
91
rootUid?: string,
@@ -377,6 +378,108 @@ export function createTreeTools(
378
};
379
}
380
381
+ function getHostInstanceForFiber(
382
+ internals: RendererInternals,
383
+ fiber: Fiber,
384
+ ): mixed {
385
+ const {HostComponent, HostText, HostSingleton, HostHoistable} =
386
+ internals.ReactTypeOfWork;
387
+
388
+ if (
389
+ fiber.tag === HostComponent ||
390
+ fiber.tag === HostText ||
391
+ fiber.tag === HostSingleton
392
+ ) {
393
+ return fiber.stateNode;
394
+ }
395
+
396
+ if (fiber.tag === HostHoistable) {
397
+ const resource = fiber.memoizedState;
398
+ if (
399
+ resource != null &&
400
+ typeof resource === 'object' &&
401
+ (resource as any).instance != null
402
+ ) {
403
+ return (resource as any).instance;
404
+ }
405
+ }
406
+
407
+ return null;
408
+ }
409
+
410
+ function findByHostInstance(
411
+ internals: RendererInternals,
412
+ root: Fiber,
413
+ hostInstance: mixed,
414
+ ): Fiber | null {
415
+ let current: Fiber | null = root;
416
+ while (current !== null) {
417
+ if (getHostInstanceForFiber(internals, current) === hostInstance) {
418
+ return current;
419
+ }
420
+
421
+ if (current.child !== null) {
422
+ current = current.child;
423
+ continue;
424
+ }
425
+
426
+ while (current !== null && current !== root && current.sibling === null) {
427
+ current = current.return;
428
+ }
429
+ if (current === null || current === root) {
430
+ return null;
431
+ }
432
+ current = current.sibling;
433
+ }
434
+ return null;
435
+ }
436
+
437
+ function buildNodeInfo(
438
+ fiber: Fiber,
439
+ internals: RendererInternals,
440
+ includeHooks?: boolean = false,
441
+ ): NodeInfo | ToolError {
442
+ const info: NodeInfo = {
443
+ uid: getUid(fiber),
444
+ type: getTypeTagForFiber(internals, fiber),
445
+ name: getDisplayName(internals, fiber),
446
+ };
447
+ if (fiber.key != null) {
448
+ info.key = String(fiber.key);
449
+ }
450
+ const props = normalizeProps(fiber.memoizedProps);
451
+ if (props != null) {
452
+ info.props = props;
453
+ }
454
+ if (includeHooks) {
455
+ // Hooks are only inspectable for function components, forwardRef, and
456
+ // simple-memo components. inspectHooksOfFiberWithoutDefaultDispatcher
457
+ // re-renders the component (using the renderer's injected dispatcher,
458
+ // never React's shared internals), so guard by tag and tolerate failures
459
+ // (e.g. a component that throws).
460
+ const {FunctionComponent, SimpleMemoComponent, ForwardRef} =
461
+ internals.ReactTypeOfWork;
462
+ if (
463
+ fiber.tag === FunctionComponent ||
464
+ fiber.tag === SimpleMemoComponent ||
465
+ fiber.tag === ForwardRef
466
+ ) {
467
+ try {
468
+ const hooksTree = inspectHooksOfFiberWithoutDefaultDispatcher(
469
+ fiber,
470
+ getDispatcherRef(internals),
471
+ );
472
+ info.hooks = normalizeHooks(hooksTree);
473
+ } catch (error) {
474
+ return {
475
+ error: new Error('Failed to inspect hooks.', {cause: error}),
476
+ };
477
+ }
478
+ }
479
+ }
480
+ return info;
481
+ }
482
+
483
/**
484
* Returns a snapshot of the component tree as an array of nodes. Each node
485
* includes: uid, type, name, key, firstChild, nextSibling (the last two
@@ -436,46 +539,49 @@ export function createTreeTools(
539
if (result.error != null) {
540
return {error: result.error};
541
}
439
- const {fiber, internals} = result;
440
- const info: NodeInfo = {
441
- uid: getUid(fiber),
442
- type: getTypeTagForFiber(internals, fiber),
443
- name: getDisplayName(internals, fiber),
444
- };
445
- if (fiber.key != null) {
446
- info.key = String(fiber.key);
447
- }
448
- const props = normalizeProps(fiber.memoizedProps);
449
- if (props != null) {
450
- info.props = props;
542
+ return buildNodeInfo(result.fiber, result.internals, includeHooks);
543
+ }
544
+
545
+ /**
546
+ * Returns detailed info about the React host component for a host instance
547
+ * reference. The reference is opaque: for react-dom it may be a DOM
548
+ * Element/Text, but the facade only compares it by identity with host fiber
549
+ * state. It does not read platform-specific fields or walk host parents.
550
+ *
551
+ * @param hostInstance - A renderer host instance reference.
552
+ */
553
+ function getComponentByHostInstance(
554
+ hostInstance: mixed,
555
+ ): NodeInfo | ToolError {
556
+ if (hostInstance == null) {
557
+ return {error: 'Host instance is required'};
558
}
452
- if (includeHooks) {
453
- // Hooks are only inspectable for function components, forwardRef, and
454
- // simple-memo components. inspectHooksOfFiberWithoutDefaultDispatcher
455
- // re-renders the component (using the renderer's injected dispatcher,
456
- // never React's shared internals), so guard by tag and tolerate failures
457
- // (e.g. a component that throws).
458
- const {FunctionComponent, SimpleMemoComponent, ForwardRef} =
459
- internals.ReactTypeOfWork;
460
- if (
461
- fiber.tag === FunctionComponent ||
462
- fiber.tag === SimpleMemoComponent ||
463
- fiber.tag === ForwardRef
464
- ) {
465
- try {
466
- const hooksTree = inspectHooksOfFiberWithoutDefaultDispatcher(
467
- fiber,
468
- getDispatcherRef(internals),
469
- );
470
- info.hooks = normalizeHooks(hooksTree);
471
- } catch (error) {
472
- return {
473
- error: new Error('Failed to inspect hooks.', {cause: error}),
474
- };
559
+
560
+ let sawRoot = false;
561
+ // eslint-disable-next-line no-for-of-loops/no-for-of-loops
562
+ for (const [rendererID, roots] of fiberRoots) {
563
+ const internals = rendererInternals.get(rendererID);
564
+ if (internals == null) {
565
+ return {error: 'Missing internals for renderer ' + rendererID};
566
+ }
567
+ // eslint-disable-next-line no-for-of-loops/no-for-of-loops
568
+ for (const root of roots) {
569
+ sawRoot = true;
570
+ const hostFiber = findByHostInstance(
571
+ internals,
572
+ root.current,
573
+ hostInstance,
574
+ );
575
+ if (hostFiber !== null) {
576
+ return buildNodeInfo(hostFiber, internals);
577
}
578
}
579
}
478
- return info;
580
+
581
+ if (!sawRoot) {
582
+ return {error: 'No mounted React roots found'};
583
+ }
584
+ return {error: 'Host instance is not managed by React'};
585
}
586
587
function collectMatches(
@@ -677,6 +783,7 @@ export function createTreeTools(
783
return {
784
getComponentTree,
785
getComponentByUid,
786
+ getComponentByHostInstance,
787
findComponents,
788
getComponentSource,
789
getOwnerStackTrace,
packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js
+132
@@ -1634,6 +1634,138 @@ describe('react-devtools-facade', () => {
1634
});
1635
});
1636
1637
+ describe('getComponentByHostInstance', () => {
1638
+ let getComponentTree;
1639
+ let getComponentByUid;
1640
+ let getComponentByHostInstance;
1641
+
1642
+ beforeEach(() => {
1643
+ const tools = createTools(facade);
1644
+ getComponentTree = tools.getComponentTree;
1645
+ getComponentByUid = tools.getComponentByUid;
1646
+ getComponentByHostInstance = tools.getComponentByHostInstance;
1647
+ });
1648
+
1649
+ it('returns the host component for a DOM host element', () => {
1650
+ function Child({label}) {
1651
+ return <span className="leaf">{label}</span>;
1652
+ }
1653
+ function App() {
1654
+ return (
1655
+ <div>
1656
+ <Child label="leaf" />
1657
+ </div>
1658
+ );
1659
+ }
1660
+
1661
+ act(() => {
1662
+ ReactDOMClient.createRoot(container).render(<App />);
1663
+ });
1664
+
1665
+ const span = container.querySelector('span.leaf');
1666
+ const host = getComponentTree().find(n => n.name === 'span');
1667
+ const result = getComponentByHostInstance(span);
1668
+
1669
+ expect(result).toEqual(getComponentByUid(host.uid));
1670
+ expect(result).toMatchObject({
1671
+ uid: host.uid,
1672
+ type: 'host',
1673
+ name: 'span',
1674
+ props: {className: 'leaf'},
1675
+ });
1676
+ });
1677
+
1678
+ it('returns the host component rather than the tree owner', () => {
1679
+ function Wrapper({children}) {
1680
+ return <section className="wrap">{children}</section>;
1681
+ }
1682
+ function App() {
1683
+ return (
1684
+ <Wrapper>
1685
+ <button className="action">Run</button>
1686
+ </Wrapper>
1687
+ );
1688
+ }
1689
+
1690
+ act(() => {
1691
+ ReactDOMClient.createRoot(container).render(<App />);
1692
+ });
1693
+
1694
+ const button = container.querySelector('button.action');
1695
+ const tree = getComponentTree();
1696
+ const host = tree.find(n => n.name === 'button');
1697
+ const wrapper = tree.find(n => n.name === 'Wrapper');
1698
+ const app = tree.find(n => n.name === 'App');
1699
+ const result = getComponentByHostInstance(button);
1700
+
1701
+ expect(result.uid).toBe(host.uid);
1702
+ expect(result.uid).not.toBe(wrapper.uid);
1703
+ expect(result.uid).not.toBe(app.uid);
1704
+ expect(result).toMatchObject({
1705
+ type: 'host',
1706
+ name: 'button',
1707
+ props: {className: 'action'},
1708
+ });
1709
+ });
1710
+
1711
+ it('keeps uids stable across re-renders via alternate fibers', () => {
1712
+ function Counter({count}) {
1713
+ return <div className="counter">{'Count: ' + count}</div>;
1714
+ }
1715
+
1716
+ const root = ReactDOMClient.createRoot(container);
1717
+ act(() => {
1718
+ root.render(<Counter count={0} />);
1719
+ });
1720
+
1721
+ const div = container.querySelector('div.counter');
1722
+ const first = getComponentByHostInstance(div);
1723
+
1724
+ act(() => {
1725
+ root.render(<Counter count={1} />);
1726
+ });
1727
+
1728
+ const second = getComponentByHostInstance(div);
1729
+ expect(second.uid).toBe(first.uid);
1730
+ expect(second.name).toBe('div');
1731
+ expect(second.type).toBe('host');
1732
+ expect(second.props.className).toBe('counter');
1733
+ });
1734
+
1735
+ it('does not walk platform parent pointers for unmanaged nested nodes', () => {
1736
+ function App() {
1737
+ return <div className="host" />;
1738
+ }
1739
+
1740
+ act(() => {
1741
+ ReactDOMClient.createRoot(container).render(<App />);
1742
+ });
1743
+
1744
+ const host = container.querySelector('div.host');
1745
+ const unmanagedChild = document.createElement('i');
1746
+ host.appendChild(unmanagedChild);
1747
+
1748
+ expect(getComponentByHostInstance(unmanagedChild)).toEqual({
1749
+ error: 'Host instance is not managed by React',
1750
+ });
1751
+ });
1752
+
1753
+ it('returns an error when no roots are mounted', () => {
1754
+ expect(getComponentByHostInstance({})).toEqual({
1755
+ error: 'No mounted React roots found',
1756
+ });
1757
+ });
1758
+
1759
+ it('returns an error for null or undefined references', () => {
1760
+ expect(getComponentByHostInstance(null)).toEqual({
1761
+ error: 'Host instance is required',
1762
+ });
1763
+ expect(getComponentByHostInstance(undefined)).toEqual({
1764
+ error: 'Host instance is required',
1765
+ });
1766
+ });
1767
+ });
1768
+
1769
describe('profiler', () => {
1770
let startProfiling;
1771
let stopProfiling;