Repository: facebook/react
Pull request: Devtools: Add support for useFormStatus

Stack:
1. https://github.com/facebook/react/pull/28593 
1. https://github.com/facebook/react/pull/28413 <--- You're here
1. https://github.com/facebook/react/pull/28728

Unfortunately, the initial value will be incorrect since that value specified in the Fiber config to which react-debug-tools has no access until we land https://github.com/facebook/react/pull/28728. 


## Test plan

- [x] `yarn test ReactHooksInspectionIntegrationDOM`
- [x] inspected components with hooks (`FormStatus`) in the shell
- [x] Backwards compat with 16.8: https://8qq2sv.csb.app/
- [x] Backwards compat with 17.0: https://g67r45.csb.app/
- [x] Backwards compat with 18.0: https://ydjfsq.csb.app/
- [x] Compat with Experimental: https://react-devtools-hook-inspection-experimental.vercel.app/


https://github.com/facebook/react/assets/12292047/780136d5-56ff-4848-9997-1043d11b0738


   

Changed files:
filename: packages/react-debug-tools/src/ReactDebugHooks.js
patch: @@ -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<string, Array<any>> {
       }
 
       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<S, P>(
   return [state, (payload: P) => {}, false];
 }
 
+function useHostTransitionStatus(): TransitionStatus {
+  const status = readContext<TransitionStatus>(
+    // $FlowFixMe[prop-missing] `readContext` only needs _currentValue
+    ({
+      // $FlowFixMe[incompatible-cast] TODO: Incorrect bottom value without access to Fiber config.
+      _currentValue: null,
+    }: ReactContext<TransitionStatus>),
+  );
+
+  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++;
 

filename: packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js
patch: @@ -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) {

filename: packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegrationDOM-test.js
patch: @@ -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(
+        <form>
+          <FormStatus />
+        </form>,
+      );
+    });
+
+    // 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',
+      },
+    ]);
+  });
+});

filename: packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js
patch: @@ -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 <pre>{JSON.stringify(status)}</pre>;
+}
+
 function Forms() {
   const [state, formAction] = useFormState<any, any>(incrementWithDelay, 0);
   return (
@@ -184,6 +190,7 @@ function Forms() {
         <input name="shouldReject" type="checkbox" />
       </label>
       <button formAction={formAction}>Increment</button>
+      <FormStatus />
     </form>
   );
 }