Repository: facebook/react
Pull request: Devtools: Unwrap Promise in useFormState

## Summary

Instead of just showing plain "Promise" after an async action, we now unwrap the fulfilled value of the Promise.

## How did you test this change?

- added case to devtools fixture:  

  https://github.com/facebook/react/assets/12292047/4a37eef8-281a-4f54-9924-b29cd17aad60



Changed files:
filename: packages/react-debug-tools/src/ReactDebugHooks.js
patch: @@ -521,19 +521,62 @@ function useFormState<S, P>(
 ): [Awaited<S>, (P) => void] {
   const hook = nextHook(); // FormState
   nextHook(); // ActionQueue
-  let state;
+  const stackError = new Error();
+  let value;
+  let debugInfo = null;
+  let error = null;
+
   if (hook !== null) {
-    state = hook.memoizedState;
+    const actionResult = hook.memoizedState;
+    if (
+      typeof actionResult === 'object' &&
+      actionResult !== null &&
+      // $FlowFixMe[method-unbinding]
+      typeof actionResult.then === 'function'
+    ) {
+      const thenable: Thenable<Awaited<S>> = (actionResult: any);
+      switch (thenable.status) {
+        case 'fulfilled': {
+          value = thenable.value;
+          debugInfo =
+            thenable._debugInfo === undefined ? null : thenable._debugInfo;
+          break;
+        }
+        case 'rejected': {
+          const rejectedError = thenable.reason;
+          error = rejectedError;
+          break;
+        }
+        default:
+          // If this was an uncached Promise we have to abandon this attempt
+          // but we can still emit anything up until this point.
+          error = SuspenseException;
+          debugInfo =
+            thenable._debugInfo === undefined ? null : thenable._debugInfo;
+          value = thenable;
+      }
+    } else {
+      value = (actionResult: any);
+    }
   } else {
-    state = initialState;
+    value = initialState;
   }
+
   hookLog.push({
     displayName: null,
     primitive: 'FormState',
-    stackError: new Error(),
-    value: state,
-    debugInfo: null,
+    stackError: stackError,
+    value: value,
+    debugInfo: debugInfo,
   });
+
+  if (error !== null) {
+    throw error;
+  }
+
+  // value being a Thenable is equivalent to error being not null
+  // i.e. we only reach this point with Awaited<S>
+  const state = ((value: any): Awaited<S>);
   return [state, (payload: P) => {}];
 }
 

filename: packages/react-devtools-shell/src/app/InspectableElements/CustomHooks.js
patch: @@ -120,26 +120,72 @@ function wrapWithHoc(Component: (props: any, ref: React$Ref<any>) => any) {
 }
 const HocWithHooks = wrapWithHoc(FunctionWithHooks);
 
+function incrementWithDelay(previousState: number, formData: FormData) {
+  const incrementDelay = +formData.get('incrementDelay');
+  const shouldReject = formData.get('shouldReject');
+  const reason = formData.get('reason');
+
+  return new Promise((resolve, reject) => {
+    setTimeout(() => {
+      if (shouldReject) {
+        reject(reason);
+      } else {
+        resolve(previousState + 1);
+      }
+    }, incrementDelay);
+  });
+}
+
 function Forms() {
-  const [state, formAction] = useFormState((n: number, formData: FormData) => {
-    return n + 1;
-  }, 0);
+  const [state, formAction] = useFormState<any, any>(incrementWithDelay, 0);
   return (
     <form>
-      {state}
+      State: {state}&nbsp;
+      <label>
+        delay:
+        <input
+          name="incrementDelay"
+          defaultValue={5000}
+          type="text"
+          inputMode="numeric"
+        />
+      </label>
+      <label>
+        Reject:
+        <input name="reason" type="text" />
+        <input name="shouldReject" type="checkbox" />
+      </label>
       <button formAction={formAction}>Increment</button>
     </form>
   );
 }
 
+class ErrorBoundary extends React.Component<{children?: React$Node}> {
+  state: {error: any} = {error: null};
+  static getDerivedStateFromError(error: mixed): {error: any} {
+    return {error};
+  }
+  componentDidCatch(error: any, info: any) {
+    console.error(error, info);
+  }
+  render(): any {
+    if (this.state.error) {
+      return <div>Error: {String(this.state.error)}</div>;
+    }
+    return this.props.children;
+  }
+}
+
 export default function CustomHooks(): React.Node {
   return (
     <Fragment>
       <FunctionWithHooks />
       <MemoWithHooks />
       <ForwardRefWithHooks />
       <HocWithHooks />
-      <Forms />
+      <ErrorBoundary>
+        <Forms />
+      </ErrorBoundary>
     </Fragment>
   );
 }