Repository: facebook/react
Pull request: Devtools: Display actual pending state when inspecting `useTransition`

## Summary

We used to always return `false` for is pending and display `undefined` as the values in the inspect panel. Now we return the actual pending state and display that in the value instead.


## How did you test this change?

- Added test (check by-commit to highlight the change in observable behavior)
- Added showcase to devtools fixture


https://github.com/facebook/react/assets/12292047/ce7e0567-0a22-4124-ab31-60bcaf75de55





Changed files:
filename: packages/react-debug-tools/src/ReactDebugHooks.js
patch: @@ -427,16 +427,19 @@ function useTransition(): [
   // useTransition() composes multiple hooks internally.
   // Advance the current hook index the same number of times
   // so that subsequent hooks have the right memoized state.
-  nextHook(); // State
+  const stateHook = nextHook();
   nextHook(); // Callback
+
+  const isPending = stateHook !== null ? stateHook.memoizedState : false;
+
   hookLog.push({
     displayName: null,
     primitive: 'Transition',
     stackError: new Error(),
-    value: undefined,
+    value: isPending,
     debugInfo: null,
   });
-  return [false, callback => {}];
+  return [isPending, () => {}];
 }
 
 function useDeferredValue<T>(value: T, initialValue?: T): T {

filename: packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
patch: @@ -952,7 +952,7 @@ describe('ReactHooksInspectionIntegration', () => {
           "isStateEditable": false,
           "name": "Transition",
           "subHooks": [],
-          "value": undefined,
+          "value": false,
         },
         {
           "debugInfo": null,
@@ -986,6 +986,168 @@ describe('ReactHooksInspectionIntegration', () => {
     `);
   });
 
+  it('should update isPending returned from useTransition', async () => {
+    const IndefiniteSuspender = React.lazy(() => new Promise(() => {}));
+    let startTransition;
+    function Foo(props) {
+      const [show, setShow] = React.useState(false);
+      const [isPending, _startTransition] = React.useTransition();
+      React.useMemo(() => 'hello', []);
+      React.useMemo(() => 'not used', []);
+
+      // Otherwise we capture the version from the react-debug-tools dispatcher.
+      if (startTransition === undefined) {
+        startTransition = () => {
+          _startTransition(() => {
+            setShow(true);
+          });
+        };
+      }
+
+      return (
+        <React.Suspense fallback="Loading">
+          {isPending ? 'Pending' : null}
+          {show ? <IndefiniteSuspender /> : null}
+        </React.Suspense>
+      );
+    }
+    const renderer = await act(() => {
+      return ReactTestRenderer.create(<Foo />, {isConcurrent: true});
+    });
+    expect(renderer).toMatchRenderedOutput(null);
+    let childFiber = renderer.root.findByType(Foo)._currentFiber();
+    let tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
+    expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
+      [
+        {
+          "debugInfo": null,
+          "hookSource": {
+            "columnNumber": 0,
+            "fileName": "**",
+            "functionName": "Foo",
+            "lineNumber": 0,
+          },
+          "id": 0,
+          "isStateEditable": true,
+          "name": "State",
+          "subHooks": [],
+          "value": false,
+        },
+        {
+          "debugInfo": null,
+          "hookSource": {
+            "columnNumber": 0,
+            "fileName": "**",
+            "functionName": "Foo",
+            "lineNumber": 0,
+          },
+          "id": 1,
+          "isStateEditable": false,
+          "name": "Transition",
+          "subHooks": [],
+          "value": false,
+        },
+        {
+          "debugInfo": null,
+          "hookSource": {
+            "columnNumber": 0,
+            "fileName": "**",
+            "functionName": "Foo",
+            "lineNumber": 0,
+          },
+          "id": 2,
+          "isStateEditable": false,
+          "name": "Memo",
+          "subHooks": [],
+          "value": "hello",
+        },
+        {
+          "debugInfo": null,
+          "hookSource": {
+            "columnNumber": 0,
+            "fileName": "**",
+            "functionName": "Foo",
+            "lineNumber": 0,
+          },
+          "id": 3,
+          "isStateEditable": false,
+          "name": "Memo",
+          "subHooks": [],
+          "value": "not used",
+        },
+      ]
+    `);
+
+    await act(() => {
+      startTransition();
+    });
+
+    expect(renderer).toMatchRenderedOutput('Pending');
+
+    childFiber = renderer.root.findByType(Foo)._currentFiber();
+    tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
+    expect(normalizeSourceLoc(tree)).toMatchInlineSnapshot(`
+      [
+        {
+          "debugInfo": null,
+          "hookSource": {
+            "columnNumber": 0,
+            "fileName": "**",
+            "functionName": "Foo",
+            "lineNumber": 0,
+          },
+          "id": 0,
+          "isStateEditable": true,
+          "name": "State",
+          "subHooks": [],
+          "value": false,
+        },
+        {
+          "debugInfo": null,
+          "hookSource": {
+            "columnNumber": 0,
+            "fileName": "**",
+            "functionName": "Foo",
+            "lineNumber": 0,
+          },
+          "id": 1,
+          "isStateEditable": false,
+          "name": "Transition",
+          "subHooks": [],
+          "value": true,
+        },
+        {
+          "debugInfo": null,
+          "hookSource": {
+            "columnNumber": 0,
+            "fileName": "**",
+            "functionName": "Foo",
+            "lineNumber": 0,
+          },
+          "id": 2,
+          "isStateEditable": false,
+          "name": "Memo",
+          "subHooks": [],
+          "value": "hello",
+        },
+        {
+          "debugInfo": null,
+          "hookSource": {
+            "columnNumber": 0,
+            "fileName": "**",
+            "functionName": "Foo",
+            "lineNumber": 0,
+          },
+          "id": 3,
+          "isStateEditable": false,
+          "name": "Memo",
+          "subHooks": [],
+          "value": "not used",
+        },
+      ]
+    `);
+  });
+
   it('should support useDeferredValue hook', () => {
     function Foo(props) {
       React.useDeferredValue('abc');

filename: packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js
patch: @@ -120,6 +120,34 @@ function wrapWithHoc(Component: (props: any, ref: React$Ref<any>) => any) {
 }
 const HocWithHooks = wrapWithHoc(FunctionWithHooks);
 
+const Suspendender = React.lazy(() => {
+  return new Promise<any>(resolve => {
+    setTimeout(() => {
+      resolve({
+        default: () => 'Finished!',
+      });
+    }, 3000);
+  });
+});
+function Transition() {
+  const [show, setShow] = React.useState(false);
+  const [isPending, startTransition] = React.useTransition();
+
+  return (
+    <div>
+      <React.Suspense fallback="Loading">
+        {isPending ? 'Pending' : null}
+        {show ? <Suspendender /> : null}
+      </React.Suspense>
+      {!show && (
+        <button onClick={() => startTransition(() => setShow(true))}>
+          Transition
+        </button>
+      )}
+    </div>
+  );
+}
+
 function incrementWithDelay(previousState: number, formData: FormData) {
   const incrementDelay = +formData.get('incrementDelay');
   const shouldReject = formData.get('shouldReject');
@@ -183,6 +211,7 @@ export default function CustomHooks(): React.Node {
       <MemoWithHooks />
       <ForwardRefWithHooks />
       <HocWithHooks />
+      <Transition />
       <ErrorBoundary>
         <Forms />
       </ErrorBoundary>