From 4f5c812a3c4e52d9ea5d908a27a317ac0f26590a Mon Sep 17 00:00:00 2001 From: Sebastian Silbermann Date: Thu, 11 Apr 2024 22:00:54 +0200 Subject: [PATCH] DevTools: Rely on sourcemaps to compute hook name of built-in hooks in newer versions (#28593) --- .../react-debug-tools/src/ReactDebugHooks.js | 106 ++++++++++++------ .../__tests__/ReactHooksInspection-test.js | 4 +- 2 files changed, 73 insertions(+), 37 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index cb9d940354060..a5a4080a7195b 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -47,6 +47,7 @@ type HookLogEntry = { stackError: Error, value: mixed, debugInfo: ReactDebugInfo | null, + dispatcherHookName: string, }; let hookLog: Array = []; @@ -131,6 +132,8 @@ function getPrimitiveStackCache(): Map> { ); } catch (x) {} } + + Dispatcher.useId(); } finally { readHookLog = hookLog; hookLog = []; @@ -207,6 +210,7 @@ function use(usable: Usable): T { value: fulfilledValue, debugInfo: thenable._debugInfo === undefined ? null : thenable._debugInfo, + dispatcherHookName: 'Use', }); return fulfilledValue; } @@ -224,6 +228,7 @@ function use(usable: Usable): 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(usable: Usable): T { stackError: new Error(), value, debugInfo: null, + dispatcherHookName: 'Use', }); return value; @@ -254,6 +260,7 @@ function useContext(context: ReactContext): T { stackError: new Error(), value: value, debugInfo: null, + dispatcherHookName: 'Context', }); return value; } @@ -275,6 +282,7 @@ function useState( stackError: new Error(), value: state, debugInfo: null, + dispatcherHookName: 'State', }); return [state, (action: BasicStateAction) => {}]; } @@ -297,6 +305,7 @@ function useReducer( stackError: new Error(), value: state, debugInfo: null, + dispatcherHookName: 'Reducer', }); return [state, (action: A) => {}]; } @@ -310,6 +319,7 @@ function useRef(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( 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(callback: T, inputs: Array | 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( stackError: new Error(), value, debugInfo: null, + dispatcherHookName: 'Memo', }); return value; } @@ -446,6 +464,7 @@ function useSyncExternalStore( 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(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( stackError: new Error(), value: state, debugInfo: null, + dispatcherHookName: 'Optimistic', }); return [state, (action: A) => {}]; } @@ -603,6 +626,7 @@ function useFormState( stackError: stackError, value: value, debugInfo: debugInfo, + dispatcherHookName: 'FormState', }); if (error !== null) { @@ -672,6 +696,7 @@ function useActionState( stackError: stackError, value: value, debugInfo: debugInfo, + dispatcherHookName: 'ActionState', }); if (error !== null) { @@ -759,8 +784,7 @@ export type HooksTree = Array; // 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). diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js index a1f194c35c1da..97aaeb8f4a48a 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js @@ -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, }