diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index a5a4080a7195b..e7f3fb0ee58ae 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -21,6 +21,7 @@ import type { Fiber, Dispatcher as DispatcherType, } from 'react-reconciler/src/ReactInternalTypes'; +import type {TransitionStatus} from 'react-reconciler/src/ReactFiberConfig'; import ErrorStackParser from 'error-stack-parser'; import assign from 'shared/assign'; @@ -134,6 +135,11 @@ function getPrimitiveStackCache(): Map> { } Dispatcher.useId(); + + if (typeof Dispatcher.useHostTransitionStatus === 'function') { + // This type check is for Flow only. + Dispatcher.useHostTransitionStatus(); + } } finally { readHookLog = hookLog; hookLog = []; @@ -711,6 +717,27 @@ function useActionState( return [state, (payload: P) => {}, false]; } +function useHostTransitionStatus(): TransitionStatus { + const status = readContext( + // $FlowFixMe[prop-missing] `readContext` only needs _currentValue + ({ + // $FlowFixMe[incompatible-cast] TODO: Incorrect bottom value without access to Fiber config. + _currentValue: null, + }: ReactContext), + ); + + hookLog.push({ + displayName: null, + primitive: 'HostTransitionStatus', + stackError: new Error(), + value: status, + debugInfo: null, + dispatcherHookName: 'HostTransitionStatus', + }); + + return status; +} + const Dispatcher: DispatcherType = { use, readContext, @@ -734,6 +761,7 @@ const Dispatcher: DispatcherType = { useId, useFormState, useActionState, + useHostTransitionStatus, }; // create a proxy to throw a custom error @@ -854,12 +882,11 @@ function findPrimitiveIndex(hookStack: any, hook: HookLogEntry) { isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName) ) { i++; - } - if ( - i < hookStack.length - 1 && - isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName) - ) { - i++; + // Guard against the dispatcher call being inlined. + // At this point we wouldn't be able to recover the actual React Hook name. + if (i < hookStack.length - 1) { + i++; + } } return i; } @@ -997,7 +1024,8 @@ function buildTree( primitive === 'Context (use)' || primitive === 'DebugValue' || primitive === 'Promise' || - primitive === 'Unresolved' + primitive === 'Unresolved' || + primitive === 'HostTransitionStatus' ? null : nativeHookID++; diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js index 97aaeb8f4a48a..8d159a22105c0 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js @@ -426,6 +426,150 @@ describe('ReactHooksInspection', () => { `); }); + it('should not confuse built-in hooks with custom hooks that have the same name', () => { + function useState(value) { + React.useState(value); + React.useDebugValue('custom useState'); + } + function useFormStatus() { + React.useState('custom useState'); + React.useDebugValue('custom useFormStatus'); + } + function Foo(props) { + useFormStatus(); + useState('Hello, Dave!'); + return null; + } + const tree = ReactDebugTools.inspectHooks(Foo, {}); + if (__DEV__) { + expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(` + [ + { + "debugInfo": null, + "hookSource": { + "columnNumber": 0, + "fileName": "**", + "functionName": "Foo", + "lineNumber": 0, + }, + "id": null, + "isStateEditable": false, + "name": "FormStatus", + "subHooks": [ + { + "debugInfo": null, + "hookSource": { + "columnNumber": 0, + "fileName": "**", + "functionName": "useFormStatus", + "lineNumber": 0, + }, + "id": 0, + "isStateEditable": true, + "name": "State", + "subHooks": [], + "value": "custom useState", + }, + ], + "value": "custom useFormStatus", + }, + { + "debugInfo": null, + "hookSource": { + "columnNumber": 0, + "fileName": "**", + "functionName": "Foo", + "lineNumber": 0, + }, + "id": null, + "isStateEditable": false, + "name": "State", + "subHooks": [ + { + "debugInfo": null, + "hookSource": { + "columnNumber": 0, + "fileName": "**", + "functionName": "useState", + "lineNumber": 0, + }, + "id": 1, + "isStateEditable": true, + "name": "State", + "subHooks": [], + "value": "Hello, Dave!", + }, + ], + "value": "custom useState", + }, + ] + `); + } else { + expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(` + [ + { + "debugInfo": null, + "hookSource": { + "columnNumber": 0, + "fileName": "**", + "functionName": "Foo", + "lineNumber": 0, + }, + "id": null, + "isStateEditable": false, + "name": "FormStatus", + "subHooks": [ + { + "debugInfo": null, + "hookSource": { + "columnNumber": 0, + "fileName": "**", + "functionName": "useFormStatus", + "lineNumber": 0, + }, + "id": 0, + "isStateEditable": true, + "name": "State", + "subHooks": [], + "value": "custom useState", + }, + ], + "value": undefined, + }, + { + "debugInfo": null, + "hookSource": { + "columnNumber": 0, + "fileName": "**", + "functionName": "Foo", + "lineNumber": 0, + }, + "id": null, + "isStateEditable": false, + "name": "State", + "subHooks": [ + { + "debugInfo": null, + "hookSource": { + "columnNumber": 0, + "fileName": "**", + "functionName": "useState", + "lineNumber": 0, + }, + "id": 1, + "isStateEditable": true, + "name": "State", + "subHooks": [], + "value": "Hello, Dave!", + }, + ], + "value": undefined, + }, + ] + `); + } + }); + it('should inspect the default value using the useContext hook', () => { const MyContext = React.createContext('default'); function Foo(props) { diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegrationDOM-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegrationDOM-test.js new file mode 100644 index 0000000000000..752d93fa30ab8 --- /dev/null +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegrationDOM-test.js @@ -0,0 +1,154 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + * @jest-environment jsdom + */ + +'use strict'; + +let React; +let ReactDOM; +let ReactDOMClient; +let ReactDebugTools; +let act; + +function normalizeSourceLoc(tree) { + tree.forEach(node => { + if (node.hookSource) { + node.hookSource.fileName = '**'; + node.hookSource.lineNumber = 0; + node.hookSource.columnNumber = 0; + } + normalizeSourceLoc(node.subHooks); + }); + return tree; +} + +describe('ReactHooksInspectionIntegration', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOM = require('react-dom'); + ReactDOMClient = require('react-dom/client'); + act = require('internal-test-utils').act; + ReactDebugTools = require('react-debug-tools'); + }); + + it('should support useFormStatus hook', async () => { + function FormStatus() { + const status = ReactDOM.useFormStatus(); + React.useMemo(() => 'memo', []); + React.useMemo(() => 'not used', []); + + return JSON.stringify(status); + } + + const treeWithoutFiber = ReactDebugTools.inspectHooks(FormStatus); + expect(normalizeSourceLoc(treeWithoutFiber)).toEqual([ + { + debugInfo: null, + hookSource: { + columnNumber: 0, + fileName: '**', + functionName: 'FormStatus', + lineNumber: 0, + }, + id: null, + isStateEditable: false, + name: 'FormStatus', + subHooks: [], + value: null, + }, + { + debugInfo: null, + hookSource: { + columnNumber: 0, + fileName: '**', + functionName: 'FormStatus', + lineNumber: 0, + }, + id: 0, + isStateEditable: false, + name: 'Memo', + subHooks: [], + value: 'memo', + }, + { + debugInfo: null, + hookSource: { + columnNumber: 0, + fileName: '**', + functionName: 'FormStatus', + lineNumber: 0, + }, + id: 1, + isStateEditable: false, + name: 'Memo', + subHooks: [], + value: 'not used', + }, + ]); + + const root = ReactDOMClient.createRoot(document.createElement('div')); + + await act(() => { + root.render( +
+ + , + ); + }); + + // Implementation detail. Feel free to adjust the position of the Fiber in the tree. + const formStatusFiber = root._internalRoot.current.child.child; + const treeWithFiber = ReactDebugTools.inspectHooksOfFiber(formStatusFiber); + expect(normalizeSourceLoc(treeWithFiber)).toEqual([ + { + debugInfo: null, + hookSource: { + columnNumber: 0, + fileName: '**', + functionName: 'FormStatus', + lineNumber: 0, + }, + id: null, + isStateEditable: false, + name: 'FormStatus', + subHooks: [], + value: null, + }, + { + debugInfo: null, + hookSource: { + columnNumber: 0, + fileName: '**', + functionName: 'FormStatus', + lineNumber: 0, + }, + id: 0, + isStateEditable: false, + name: 'Memo', + subHooks: [], + value: 'memo', + }, + { + debugInfo: null, + hookSource: { + columnNumber: 0, + fileName: '**', + functionName: 'FormStatus', + lineNumber: 0, + }, + id: 1, + isStateEditable: false, + name: 'Memo', + subHooks: [], + value: 'not used', + }, + ]); + }); +}); diff --git a/packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js b/packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js index c8fbca78a9dea..a05acf138d3d3 100644 --- a/packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js +++ b/packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js @@ -21,7 +21,7 @@ import { useState, use, } from 'react'; -import {useFormState} from 'react-dom'; +import {useFormState, useFormStatus} from 'react-dom'; const object = { string: 'abc', @@ -164,6 +164,12 @@ function incrementWithDelay(previousState: number, formData: FormData) { }); } +function FormStatus() { + const status = useFormStatus(); + + return
{JSON.stringify(status)}
; +} + function Forms() { const [state, formAction] = useFormState(incrementWithDelay, 0); return ( @@ -184,6 +190,7 @@ function Forms() { + ); }