Skip to content

Commit e273131

Browse files
authored
[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.
1 parent d0d623d commit e273131

3 files changed

Lines changed: 277 additions & 36 deletions

File tree

packages/react-devtools-facade/src/DevToolsFacadeTools.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export type Tools = {
5959
uid: string,
6060
includeHooks?: boolean,
6161
) => NodeInfo | ToolError,
62+
getComponentByHostInstance: (hostInstance: mixed) => NodeInfo | ToolError,
6263
findComponents: (
6364
name: string,
6465
rootUid?: string,
@@ -97,6 +98,7 @@ export function createTools(facade: Facade): Tools {
9798
return {
9899
getComponentTree: tree.getComponentTree,
99100
getComponentByUid: tree.getComponentByUid,
101+
getComponentByHostInstance: tree.getComponentByHostInstance,
100102
findComponents: tree.findComponents,
101103
getComponentSource: tree.getComponentSource,
102104
getOwnerStackTrace: tree.getOwnerStackTrace,

packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js

Lines changed: 143 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export type TreeTools = {
8585
uid: string,
8686
includeHooks?: boolean,
8787
) => NodeInfo | ToolError,
88+
getComponentByHostInstance: (hostInstance: mixed) => NodeInfo | ToolError,
8889
findComponents: (
8990
name: string,
9091
rootUid?: string,
@@ -377,6 +378,108 @@ export function createTreeTools(
377378
};
378379
}
379380

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+
380483
/**
381484
* Returns a snapshot of the component tree as an array of nodes. Each node
382485
* includes: uid, type, name, key, firstChild, nextSibling (the last two
@@ -436,46 +539,49 @@ export function createTreeTools(
436539
if (result.error != null) {
437540
return {error: result.error};
438541
}
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'};
451558
}
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);
475577
}
476578
}
477579
}
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'};
479585
}
480586

481587
function collectMatches(
@@ -677,6 +783,7 @@ export function createTreeTools(
677783
return {
678784
getComponentTree,
679785
getComponentByUid,
786+
getComponentByHostInstance,
680787
findComponents,
681788
getComponentSource,
682789
getOwnerStackTrace,

packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,6 +1634,138 @@ describe('react-devtools-facade', () => {
16341634
});
16351635
});
16361636

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+
16371769
describe('profiler', () => {
16381770
let startProfiling;
16391771
let stopProfiling;

0 commit comments

Comments
 (0)