Repository: facebook/react
Pull request: DevTools: Rely on sourcemaps to compute hook name of built-in hooks in newer versions

Stack:
1. https://github.com/facebook/react/pull/28593 <--- You're here
1. https://github.com/facebook/react/pull/28413 
1. https://github.com/facebook/react/pull/28728

## Summary

As a fallback, we assume the dispatcher method name is the hook name. This holds for currently released React versions.
As a follow-up, we introduce a separate field that holds the list of possible wrappers we try to match against e.g. for `useFormStatus` -> `dispatcher.useHostTransitionStatus` (see https://github.com/facebook/react/pull/28413)

One downside is that sourcemaps are bugged in Jest tests and browsers (but not Node.js). See https://github.com/facebook/react/pull/28413#discussion_r1513318378. I'm trying to find out what's up with that.

## How did you test this change?

- [x] react-debug-tools tests
- [x] inspected components with hooks (e.g. `FunctionWithHooks`) in the shell (requires https://github.com/facebook/react/pull/28596)
- [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/


Changed files:
filename: packages/react-debug-tools/src/ReactDebugHooks.js
patch: @@ -47,6 +47,7 @@ type HookLogEntry = {
   stackError: Error,
   value: mixed,
   debugInfo: ReactDebugInfo | null,
+  dispatcherHookName: string,
 };
 
 let hookLog: Array<HookLogEntry> = [];
@@ -131,6 +132,8 @@ function getPrimitiveStackCache(): Map<string, Array<any>> {
           );
         } catch (x) {}
       }
+
+      Dispatcher.useId();
     } finally {
       readHookLog = hookLog;
       hookLog = [];
@@ -207,6 +210,7 @@ function use<T>(usable: Usable<T>): T {
             value: fulfilledValue,
             debugInfo:
               thenable._debugInfo === undefined ? null : thenable._debugInfo,
+            dispatcherHookName: 'Use',
           });
           return fulfilledValue;
         }
@@ -224,6 +228,7 @@ function use<T>(usable: Usable<T>): T {
         value: thenable,
         debugInfo:
           thenable._debugInfo === undefined ? null : thenable._debugInfo,
+        dispatcherHookName: 'Use',
       });
       throw SuspenseException;
     } else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
@@ -236,6 +241,7 @@ function use<T>(usable: Usable<T>): T {
         stackError: new Error(),
         value,
         debugInfo: null,
+        dispatcherHookName: 'Use',
       });
 
       return value;
@@ -254,6 +260,7 @@ function useContext<T>(context: ReactContext<T>): T {
     stackError: new Error(),
     value: value,
     debugInfo: null,
+    dispatcherHookName: 'Context',
   });
   return value;
 }
@@ -275,6 +282,7 @@ function useState<S>(
     stackError: new Error(),
     value: state,
     debugInfo: null,
+    dispatcherHookName: 'State',
   });
   return [state, (action: BasicStateAction<S>) => {}];
 }
@@ -297,6 +305,7 @@ function useReducer<S, I, A>(
     stackError: new Error(),
     value: state,
     debugInfo: null,
+    dispatcherHookName: 'Reducer',
   });
   return [state, (action: A) => {}];
 }
@@ -310,6 +319,7 @@ function useRef<T>(initialValue: T): {current: T} {
     stackError: new Error(),
     value: ref.current,
     debugInfo: null,
+    dispatcherHookName: 'Ref',
   });
   return ref;
 }
@@ -322,6 +332,7 @@ function useCacheRefresh(): () => void {
     stackError: new Error(),
     value: hook !== null ? hook.memoizedState : function refresh() {},
     debugInfo: null,
+    dispatcherHookName: 'CacheRefresh',
   });
   return () => {};
 }
@@ -337,6 +348,7 @@ function useLayoutEffect(
     stackError: new Error(),
     value: create,
     debugInfo: null,
+    dispatcherHookName: 'LayoutEffect',
   });
 }
 
@@ -351,6 +363,7 @@ function useInsertionEffect(
     stackError: new Error(),
     value: create,
     debugInfo: null,
+    dispatcherHookName: 'InsertionEffect',
   });
 }
 
@@ -365,6 +378,7 @@ function useEffect(
     stackError: new Error(),
     value: create,
     debugInfo: null,
+    dispatcherHookName: 'Effect',
   });
 }
 
@@ -388,6 +402,7 @@ function useImperativeHandle<T>(
     stackError: new Error(),
     value: instance,
     debugInfo: null,
+    dispatcherHookName: 'ImperativeHandle',
   });
 }
 
@@ -398,6 +413,7 @@ function useDebugValue(value: any, formatterFn: ?(value: any) => any) {
     stackError: new Error(),
     value: typeof formatterFn === 'function' ? formatterFn(value) : value,
     debugInfo: null,
+    dispatcherHookName: 'DebugValue',
   });
 }
 
@@ -409,6 +425,7 @@ function useCallback<T>(callback: T, inputs: Array<mixed> | void | null): T {
     stackError: new Error(),
     value: hook !== null ? hook.memoizedState[0] : callback,
     debugInfo: null,
+    dispatcherHookName: 'Callback',
   });
   return callback;
 }
@@ -425,6 +442,7 @@ function useMemo<T>(
     stackError: new Error(),
     value,
     debugInfo: null,
+    dispatcherHookName: 'Memo',
   });
   return value;
 }
@@ -446,6 +464,7 @@ function useSyncExternalStore<T>(
     stackError: new Error(),
     value,
     debugInfo: null,
+    dispatcherHookName: 'SyncExternalStore',
   });
   return value;
 }
@@ -468,6 +487,7 @@ function useTransition(): [
     stackError: new Error(),
     value: isPending,
     debugInfo: null,
+    dispatcherHookName: 'Transition',
   });
   return [isPending, () => {}];
 }
@@ -481,6 +501,7 @@ function useDeferredValue<T>(value: T, initialValue?: T): T {
     stackError: new Error(),
     value: prevValue,
     debugInfo: null,
+    dispatcherHookName: 'DeferredValue',
   });
   return prevValue;
 }
@@ -494,6 +515,7 @@ function useId(): string {
     stackError: new Error(),
     value: id,
     debugInfo: null,
+    dispatcherHookName: 'Id',
   });
   return id;
 }
@@ -544,6 +566,7 @@ function useOptimistic<S, A>(
     stackError: new Error(),
     value: state,
     debugInfo: null,
+    dispatcherHookName: 'Optimistic',
   });
   return [state, (action: A) => {}];
 }
@@ -603,6 +626,7 @@ function useFormState<S, P>(
     stackError: stackError,
     value: value,
     debugInfo: debugInfo,
+    dispatcherHookName: 'FormState',
   });
 
   if (error !== null) {
@@ -672,6 +696,7 @@ function useActionState<S, P>(
     stackError: stackError,
     value: value,
     debugInfo: debugInfo,
+    dispatcherHookName: 'ActionState',
   });
 
   if (error !== null) {
@@ -759,8 +784,7 @@ export type HooksTree = Array<HooksNode>;
 // of a hook call. A simple way to demonstrate this is wrapping `new Error()`
 // in a wrapper constructor like a polyfill. That'll add an extra frame.
 // Similar things can happen with the call to the dispatcher. The top frame
-// may not be the primitive. Likewise the primitive can have fewer stack frames
-// such as when a call to useState got inlined to use dispatcher.useState.
+// may not be the primitive.
 //
 // We also can't assume that the last frame of the root call is the same
 // frame as the last frame of the hook call because long stack traces can be
@@ -810,27 +834,8 @@ function findCommonAncestorIndex(rootStack: any, hookStack: any) {
   return -1;
 }
 
-function isReactWrapper(functionName: any, primitiveName: string) {
-  if (!functionName) {
-    return false;
-  }
-  switch (primitiveName) {
-    case 'Context':
-    case 'Context (use)':
-    case 'Promise':
-    case 'Unresolved':
-      if (functionName.endsWith('use')) {
-        return true;
-      }
-  }
-  const expectedPrimitiveName = 'use' + primitiveName;
-  if (functionName.length < expectedPrimitiveName.length) {
-    return false;
-  }
-  return (
-    functionName.lastIndexOf(expectedPrimitiveName) ===
-    functionName.length - expectedPrimitiveName.length
-  );
+function isReactWrapper(functionName: any, wrapperName: string) {
+  return parseHookName(functionName) === wrapperName;
 }
 
 function findPrimitiveIndex(hookStack: any, hook: HookLogEntry) {
@@ -841,17 +846,18 @@ function findPrimitiveIndex(hookStack: any, hook: HookLogEntry) {
   }
   for (let i = 0; i < primitiveStack.length && i < hookStack.length; i++) {
     if (primitiveStack[i].source !== hookStack[i].source) {
-      // If the next two frames are functions called `useX` then we assume that they're part of the
-      // wrappers that the React packager or other packages adds around the dispatcher.
+      // If the next frame is a method from the dispatcher, we
+      // assume that the next frame after that is the actual public API call.
+      // This prohibits nesting dispatcher calls in hooks.
       if (
         i < hookStack.length - 1 &&
-        isReactWrapper(hookStack[i].functionName, hook.primitive)
+        isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName)
       ) {
         i++;
       }
       if (
         i < hookStack.length - 1 &&
-        isReactWrapper(hookStack[i].functionName, hook.primitive)
+        isReactWrapper(hookStack[i].functionName, hook.dispatcherHookName)
       ) {
         i++;
       }
@@ -872,21 +878,41 @@ function parseTrimmedStack(rootStack: any, hook: HookLogEntry) {
     primitiveIndex === -1 ||
     rootIndex - primitiveIndex < 2
   ) {
-    // Something went wrong. Give up.
-    return null;
+    if (primitiveIndex === -1) {
+      // Something went wrong. Give up.
+      return [null, null];
+    } else {
+      return [hookStack[primitiveIndex - 1], null];
+    }
   }
-  return hookStack.slice(primitiveIndex, rootIndex - 1);
+  return [
+    hookStack[primitiveIndex - 1],
+    hookStack.slice(primitiveIndex, rootIndex - 1),
+  ];
 }
 
-function parseCustomHookName(functionName: void | string): string {
+function parseHookName(functionName: void | string): string {
   if (!functionName) {
     return '';
   }
-  let startIndex = functionName.lastIndexOf('.');
+  let startIndex = functionName.lastIndexOf('[as ');
+
+  if (startIndex !== -1) {
+    // Workaround for sourcemaps in Jest and Chrome.
+    // In `node --enable-source-maps`, we don't see "Object.useHostTransitionStatus [as useFormStatus]" but "Object.useFormStatus"
+    // "Object.useHostTransitionStatus [as useFormStatus]" -> "useFormStatus"
+    return parseHookName(functionName.slice(startIndex + '[as '.length, -1));
+  }
+  startIndex = functionName.lastIndexOf('.');
   if (startIndex === -1) {
     startIndex = 0;
+  } else {
+    startIndex += 1;
   }
   if (functionName.slice(startIndex, startIndex + 3) === 'use') {
+    if (functionName.length - startIndex === 3) {
+      return 'Use';
+    }
     startIndex += 3;
   }
   return functionName.slice(startIndex);
@@ -903,7 +929,17 @@ function buildTree(
   const stackOfChildren = [];
   for (let i = 0; i < readHookLog.length; i++) {
     const hook = readHookLog[i];
-    const stack = parseTrimmedStack(rootStack, hook);
+    const parseResult = parseTrimmedStack(rootStack, hook);
+    const primitiveFrame = parseResult[0];
+    const stack = parseResult[1];
+    let displayName = hook.displayName;
+    if (displayName === null && primitiveFrame !== null) {
+      displayName =
+        parseHookName(primitiveFrame.functionName) ||
+        // Older versions of React do not have sourcemaps.
+        // In those versions there was always a 1:1 mapping between wrapper and dispatcher method.
+        parseHookName(hook.dispatcherHookName);
+    }
     if (stack !== null) {
       // Note: The indices 0 <= n < length-1 will contain the names.
       // The indices 1 <= n < length will contain the source locations.
@@ -934,7 +970,7 @@ function buildTree(
         const levelChild: HooksNode = {
           id: null,
           isStateEditable: false,
-          name: parseCustomHookName(stack[j - 1].functionName),
+          name: parseHookName(stack[j - 1].functionName),
           value: undefined,
           subHooks: children,
           debugInfo: null,
@@ -952,7 +988,7 @@ function buildTree(
       }
       prevStack = stack;
     }
-    const {displayName, primitive, debugInfo} = hook;
+    const {primitive, debugInfo} = hook;
 
     // For now, the "id" of stateful hooks is just the stateful hook index.
     // Custom hooks have no ids, nor do non-stateful native hooks (e.g. Context, DebugValue).

filename: packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js
patch: @@ -518,7 +518,7 @@ describe('ReactHooksInspection', () => {
               },
               "id": null,
               "isStateEditable": false,
-              "name": "Promise",
+              "name": "Use",
               "subHooks": [],
               "value": "world",
             },
@@ -568,7 +568,7 @@ describe('ReactHooksInspection', () => {
         },
         "id": null,
         "isStateEditable": false,
-        "name": "Unresolved",
+        "name": "Use",
         "subHooks": [],
         "value": Any<Promise>,
       }