From ae28e82d7d2f379152d2e5dcebc0c1783eb663df Mon Sep 17 00:00:00 2001 From: eps1lon Date: Wed, 13 Mar 2024 20:33:43 +0100 Subject: [PATCH] DevTools: Rely on sourcemaps to compute hook name of built-in hooks 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`. --- .../react-debug-tools/src/ReactDebugHooks.js | 88 +++++++++++++------ .../__tests__/ReactHooksInspection-test.js | 4 +- 2 files changed, 62 insertions(+), 30 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index a6770ccd34bfa..0822050589ad5 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, + dispatcherMethodName: string, }; let hookLog: Array = []; @@ -127,6 +128,8 @@ function getPrimitiveStackCache(): Map> { ); } catch (x) {} } + + Dispatcher.useId(); } finally { readHookLog = hookLog; hookLog = []; @@ -203,6 +206,7 @@ function use(usable: Usable): T { value: fulfilledValue, debugInfo: thenable._debugInfo === undefined ? null : thenable._debugInfo, + dispatcherMethodName: 'use', }); return fulfilledValue; } @@ -220,6 +224,7 @@ function use(usable: Usable): T { value: thenable, debugInfo: thenable._debugInfo === undefined ? null : thenable._debugInfo, + dispatcherMethodName: 'use', }); throw SuspenseException; } else if (usable.$$typeof === REACT_CONTEXT_TYPE) { @@ -232,6 +237,7 @@ function use(usable: Usable): T { stackError: new Error(), value, debugInfo: null, + dispatcherMethodName: 'use', }); return value; @@ -250,6 +256,7 @@ function useContext(context: ReactContext): T { stackError: new Error(), value: value, debugInfo: null, + dispatcherMethodName: 'useContext', }); return value; } @@ -271,6 +278,7 @@ function useState( stackError: new Error(), value: state, debugInfo: null, + dispatcherMethodName: 'useState', }); return [state, (action: BasicStateAction) => {}]; } @@ -293,6 +301,7 @@ function useReducer( stackError: new Error(), value: state, debugInfo: null, + dispatcherMethodName: 'useReducer', }); return [state, (action: A) => {}]; } @@ -306,6 +315,7 @@ function useRef(initialValue: T): {current: T} { stackError: new Error(), value: ref.current, debugInfo: null, + dispatcherMethodName: 'useRef', }); return ref; } @@ -318,6 +328,7 @@ function useCacheRefresh(): () => void { stackError: new Error(), value: hook !== null ? hook.memoizedState : function refresh() {}, debugInfo: null, + dispatcherMethodName: 'useCacheRefresh', }); return () => {}; } @@ -333,6 +344,7 @@ function useLayoutEffect( stackError: new Error(), value: create, debugInfo: null, + dispatcherMethodName: 'useLayoutEffect', }); } @@ -347,6 +359,7 @@ function useInsertionEffect( stackError: new Error(), value: create, debugInfo: null, + dispatcherMethodName: 'useInsertionEffect', }); } @@ -361,6 +374,7 @@ function useEffect( stackError: new Error(), value: create, debugInfo: null, + dispatcherMethodName: 'useEffect', }); } @@ -384,6 +398,7 @@ function useImperativeHandle( stackError: new Error(), value: instance, debugInfo: null, + dispatcherMethodName: 'useImperativeHandle', }); } @@ -394,6 +409,7 @@ function useDebugValue(value: any, formatterFn: ?(value: any) => any) { stackError: new Error(), value: typeof formatterFn === 'function' ? formatterFn(value) : value, debugInfo: null, + dispatcherMethodName: 'useDebugValue', }); } @@ -405,6 +421,7 @@ function useCallback(callback: T, inputs: Array | void | null): T { stackError: new Error(), value: hook !== null ? hook.memoizedState[0] : callback, debugInfo: null, + dispatcherMethodName: 'useCallback', }); return callback; } @@ -421,6 +438,7 @@ function useMemo( stackError: new Error(), value, debugInfo: null, + dispatcherMethodName: 'useMemo', }); return value; } @@ -442,6 +460,7 @@ function useSyncExternalStore( stackError: new Error(), value, debugInfo: null, + dispatcherMethodName: 'useSyncExternalStore', }); return value; } @@ -464,6 +483,7 @@ function useTransition(): [ stackError: new Error(), value: isPending, debugInfo: null, + dispatcherMethodName: 'useTransition', }); return [isPending, () => {}]; } @@ -477,6 +497,7 @@ function useDeferredValue(value: T, initialValue?: T): T { stackError: new Error(), value: prevValue, debugInfo: null, + dispatcherMethodName: 'useDeferredValue', }); return prevValue; } @@ -490,6 +511,7 @@ function useId(): string { stackError: new Error(), value: id, debugInfo: null, + dispatcherMethodName: 'useId', }); return id; } @@ -540,6 +562,7 @@ function useOptimistic( stackError: new Error(), value: state, debugInfo: null, + dispatcherMethodName: 'useOptimistic', }); return [state, (action: A) => {}]; } @@ -599,6 +622,7 @@ function useFormState( stackError: stackError, value: value, debugInfo: debugInfo, + dispatcherMethodName: 'useFormState', }); if (error !== null) { @@ -685,8 +709,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 @@ -736,26 +759,16 @@ function findCommonAncestorIndex(rootStack: any, hookStack: any) { return -1; } -function isReactWrapper(functionName: any, primitiveName: string) { +function isReactWrapper(functionName: any, wrapperName: 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) { + if (functionName.length < wrapperName.length) { return false; } return ( - functionName.lastIndexOf(expectedPrimitiveName) === - functionName.length - expectedPrimitiveName.length + functionName.lastIndexOf(wrapperName) === + functionName.length - wrapperName.length ); } @@ -767,17 +780,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.dispatcherMethodName) ) { i++; } if ( i < hookStack.length - 1 && - isReactWrapper(hookStack[i].functionName, hook.primitive) + isReactWrapper(hookStack[i].functionName, hook.dispatcherMethodName) ) { i++; } @@ -801,18 +815,26 @@ function parseTrimmedStack(rootStack: any, hook: HookLogEntry) { // Something went wrong. Give up. return 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('.'); 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); @@ -829,8 +851,17 @@ function buildTree( const stackOfChildren = []; for (let i = 0; i < readHookLog.length; i++) { const hook = readHookLog[i]; - const stack = parseTrimmedStack(rootStack, hook); - if (stack !== null) { + const parseResult = parseTrimmedStack(rootStack, hook); + let displayName = hook.displayName; + if (parseResult !== null) { + const [primitiveFrame, stack] = parseResult; + if (hook.displayName === 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.dispatcherMethodName); + } // Note: The indices 0 <= n < length-1 will contain the names. // The indices 1 <= n < length will contain the source locations. // That's why we get the name from n - 1 and don't check the source @@ -860,7 +891,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, @@ -878,7 +909,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). @@ -893,11 +924,11 @@ function buildTree( // For the time being, only State and Reducer hooks support runtime overrides. const isStateEditable = primitive === 'Reducer' || primitive === 'State'; - const name = displayName || primitive; + const levelChild: HooksNode = { id, isStateEditable, - name: name, + name: displayName || 'Unknown', value: hook.value, subHooks: [], debugInfo: debugInfo, @@ -910,6 +941,7 @@ function buildTree( fileName: null, columnNumber: null, }; + const stack = parseResult !== null ? parseResult[1] : null; if (stack && stack.length >= 1) { const stackFrame = stack[0]; hookSource.lineNumber = stackFrame.lineNumber; diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js index 79c1261c3b063..27cb9a967b9d6 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.js @@ -551,7 +551,7 @@ describe('ReactHooksInspection', () => { }, "id": null, "isStateEditable": false, - "name": "Promise", + "name": "Use", "subHooks": [], "value": "world", }, @@ -601,7 +601,7 @@ describe('ReactHooksInspection', () => { }, "id": null, "isStateEditable": false, - "name": "Unresolved", + "name": "Use", "subHooks": [], "value": Any, }