Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/react-devtools-facade/src/DevToolsFacadeTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ export type Tools = {
depth?: number,
rootUid?: string,
) => Array<TreeNode> | ToolError,
getComponentByUid: (uid: string) => NodeInfo | ToolError,
getComponentByUid: (
uid: string,
includeHooks?: boolean,
) => NodeInfo | ToolError,
findComponents: (
name: string,
rootUid?: string,
Expand Down
68 changes: 40 additions & 28 deletions packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {RendererInternals} from './DevToolsFacade';
// (to TOON, JSON, etc.) is the integrator's responsibility.

// Returned by any tool when the requested component/root cannot be resolved.
export type ToolError = {error: string};
export type ToolError = {error: string | Error};

// A single component in a tree snapshot. firstChild/nextSibling reference other
// nodes by their uid, forming an adjacency list the integrator can rebuild.
Expand Down Expand Up @@ -81,7 +81,10 @@ export type TreeTools = {
depth?: number,
rootUid?: string,
) => Array<TreeNode> | ToolError,
getComponentByUid: (uid: string) => NodeInfo | ToolError,
getComponentByUid: (
uid: string,
includeHooks?: boolean,
) => NodeInfo | ToolError,
findComponents: (
name: string,
rootUid?: string,
Expand Down Expand Up @@ -414,16 +417,21 @@ export function createTreeTools(
}

/**
* Returns detailed info about a single component by its uid: type, name,
* key, props (excluding children), and — for function components — the
* inspected hooks tree. Values are normalized to a serialization-safe shape.
* Returns detailed info about a single component by its uid: type, name, key,
* and props (excluding children). Values are normalized to a serialization-safe
* shape.
*
* Inspecting hooks re-renders the component's render function (effects are
* not run); failures are tolerated and simply omit `hooks`.
* If includeHooks is true, function components also include the inspected
* hooks tree. Inspecting hooks re-renders the component's render function
* (effects are not run); failures return an error payload.
*
* @param uid - The component uid (e.g. "r5").
* @param includeHooks - Whether to inspect hooks for function components.
*/
function getComponentByUid(uid: string): NodeInfo | ToolError {
function getComponentByUid(
uid: string,
includeHooks?: boolean = false,
): NodeInfo | ToolError {
const result = findFiberByUid(uid);
if (result.error != null) {
return {error: result.error};
Expand All @@ -441,26 +449,30 @@ export function createTreeTools(
if (props != null) {
info.props = props;
}
// Hooks are only inspectable for function components, forwardRef, and
// simple-memo components. inspectHooksOfFiberWithoutDefaultDispatcher
// re-renders the component (using the renderer's injected dispatcher, never
// React's shared internals), so guard by tag and tolerate failures (e.g. a
// component that throws).
const {FunctionComponent, SimpleMemoComponent, ForwardRef} =
internals.ReactTypeOfWork;
if (
fiber.tag === FunctionComponent ||
fiber.tag === SimpleMemoComponent ||
fiber.tag === ForwardRef
) {
try {
const hooksTree = inspectHooksOfFiberWithoutDefaultDispatcher(
fiber,
getDispatcherRef(internals),
);
info.hooks = normalizeHooks(hooksTree);
} catch {
// Hook inspection failed; omit hooks rather than failing the call.
if (includeHooks) {
// Hooks are only inspectable for function components, forwardRef, and
// simple-memo components. inspectHooksOfFiberWithoutDefaultDispatcher
// re-renders the component (using the renderer's injected dispatcher,
// never React's shared internals), so guard by tag and tolerate failures
// (e.g. a component that throws).
const {FunctionComponent, SimpleMemoComponent, ForwardRef} =
internals.ReactTypeOfWork;
if (
fiber.tag === FunctionComponent ||
fiber.tag === SimpleMemoComponent ||
fiber.tag === ForwardRef
) {
try {
const hooksTree = inspectHooksOfFiberWithoutDefaultDispatcher(
fiber,
getDispatcherRef(internals),
);
info.hooks = normalizeHooks(hooksTree);
} catch (error) {
return {
error: new Error('Failed to inspect hooks.', {cause: error}),
};
}
}
}
return info;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ describe('react-devtools-facade', () => {
});

afterEach(() => {
jest.dontMock('react-debug-tools');
container = null;
});

Expand Down Expand Up @@ -1481,7 +1482,7 @@ describe('react-devtools-facade', () => {
});

const widget = getComponentTree().find(n => n.name === 'Widget');
const info = getComponentByUid(widget.uid);
const info = getComponentByUid(widget.uid, true);

// Full structural assertion: every hook node, in order, with its id
// (sequential per primitive hook; custom hooks are null), name, normalized
Expand All @@ -1501,6 +1502,58 @@ describe('react-devtools-facade', () => {
]);
});

it('does not inspect hooks by default', () => {
function Widget() {
React.useState(7);
return <div>widget</div>;
}

act(() => {
ReactDOMClient.createRoot(container).render(<Widget />);
});

const widget = getComponentTree().find(n => n.name === 'Widget');
const info = getComponentByUid(widget.uid);

expect(info.hooks).toBeUndefined();
});

it('returns an error when requested hook inspection fails', () => {
jest.resetModules();
jest.doMock('react-debug-tools', () => ({
inspectHooksOfFiberWithoutDefaultDispatcher() {
throw new Error('Cannot inspect hooks');
},
}));
delete globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;

const facadeAPI = require('../../index');
const mockedFacade = facadeAPI.installFacade();
const mockedTools = facadeAPI.createTools(mockedFacade);
const MockedReact = require('react');
const MockedReactDOMClient = require('react-dom/client');

function Widget() {
MockedReact.useState(7);
return MockedReact.createElement('div', null, 'widget');
}

MockedReact.act(() => {
MockedReactDOMClient.createRoot(container).render(
MockedReact.createElement(Widget),
);
});

const widget = mockedTools
.getComponentTree()
.find(node => node.name === 'Widget');
const info = mockedTools.getComponentByUid(widget.uid, true);

expect(info.error).toBeInstanceOf(Error);
expect(info.error.message).toBe('Failed to inspect hooks.');
expect(info.error.cause).toEqual(new Error('Cannot inspect hooks'));
});

it('captures the useContext hook with its provided value', () => {
const ThemeContext = React.createContext('light');
function Themed() {
Expand All @@ -1526,7 +1579,7 @@ describe('react-devtools-facade', () => {
});

const themed = getComponentTree().find(n => n.name === 'Themed');
const info = getComponentByUid(themed.uid);
const info = getComponentByUid(themed.uid, true);
// useContext is captured as a "Context" hook holding the provider's value.
// It does not consume a primitive hook slot, so its id is null; the
// following useState is the first primitive hook (id 0).
Expand All @@ -1546,7 +1599,7 @@ describe('react-devtools-facade', () => {
});

const plain = getComponentTree().find(n => n.name === 'Plain');
const info = getComponentByUid(plain.uid);
const info = getComponentByUid(plain.uid, true);
expect(info.hooks).toEqual([]);
});

Expand All @@ -1562,7 +1615,7 @@ describe('react-devtools-facade', () => {
});

const myClass = getComponentTree().find(n => n.name === 'MyClass');
const info = getComponentByUid(myClass.uid);
const info = getComponentByUid(myClass.uid, true);
expect(info.hooks).toBeUndefined();
});

Expand All @@ -1576,7 +1629,7 @@ describe('react-devtools-facade', () => {
});

const div = getComponentTree().find(n => n.name === 'div');
const info = getComponentByUid(div.uid);
const info = getComponentByUid(div.uid, true);
expect(info.hooks).toBeUndefined();
});
});
Expand Down
Loading