diff --git a/packages/react-devtools-facade/src/DevToolsFacadeTools.js b/packages/react-devtools-facade/src/DevToolsFacadeTools.js index fb5114dcddaf..4985c70a88bb 100644 --- a/packages/react-devtools-facade/src/DevToolsFacadeTools.js +++ b/packages/react-devtools-facade/src/DevToolsFacadeTools.js @@ -55,7 +55,10 @@ export type Tools = { depth?: number, rootUid?: string, ) => Array | ToolError, - getComponentByUid: (uid: string) => NodeInfo | ToolError, + getComponentByUid: ( + uid: string, + includeHooks?: boolean, + ) => NodeInfo | ToolError, findComponents: ( name: string, rootUid?: string, diff --git a/packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js b/packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js index f058bec56005..9bc32f59324a 100644 --- a/packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js +++ b/packages/react-devtools-facade/src/DevToolsFacadeTreeTools.js @@ -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. @@ -81,7 +81,10 @@ export type TreeTools = { depth?: number, rootUid?: string, ) => Array | ToolError, - getComponentByUid: (uid: string) => NodeInfo | ToolError, + getComponentByUid: ( + uid: string, + includeHooks?: boolean, + ) => NodeInfo | ToolError, findComponents: ( name: string, rootUid?: string, @@ -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}; @@ -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; diff --git a/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js b/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js index be061bea0535..ba8e9856c2a8 100644 --- a/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js +++ b/packages/react-devtools-facade/src/__tests__/DevToolsFacade-test.js @@ -47,6 +47,7 @@ describe('react-devtools-facade', () => { }); afterEach(() => { + jest.dontMock('react-debug-tools'); container = null; }); @@ -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 @@ -1501,6 +1502,58 @@ describe('react-devtools-facade', () => { ]); }); + it('does not inspect hooks by default', () => { + function Widget() { + React.useState(7); + return
widget
; + } + + act(() => { + ReactDOMClient.createRoot(container).render(); + }); + + 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() { @@ -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). @@ -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([]); }); @@ -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(); }); @@ -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(); }); });