diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 41436e8a6e9c9..7634c6c472da1 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -13,8 +13,12 @@ import type { ReactDebugInfo, ReactAsyncInfo, ReactIOInfo, + ReactStackTrace, + ReactCallSite, } from 'shared/ReactTypes'; +import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; + import { ComponentFilterDisplayName, ComponentFilterElementType, @@ -5187,6 +5191,32 @@ export function attach( return null; } + function inspectHooks(fiber: Fiber): HooksTree { + const originalConsoleMethods: {[string]: $FlowFixMe} = {}; + + // Temporarily disable all console logging before re-running the hook. + for (const method in console) { + try { + // $FlowFixMe[invalid-computed-prop] + originalConsoleMethods[method] = console[method]; + // $FlowFixMe[prop-missing] + console[method] = () => {}; + } catch (error) {} + } + + try { + return inspectHooksOfFiber(fiber, getDispatcherRef(renderer)); + } finally { + // Restore original console functionality. + for (const method in originalConsoleMethods) { + try { + // $FlowFixMe[prop-missing] + console[method] = originalConsoleMethods[method]; + } catch (error) {} + } + } + } + function getSuspendedByOfSuspenseNode( suspenseNode: SuspenseNode, ): Array { @@ -5196,6 +5226,11 @@ export function attach( if (!suspenseNode.hasUniqueSuspenders) { return result; } + // Cache the inspection of Hooks in case we need it for multiple entries. + // We don't need a full map here since it's likely that every ioInfo that's unique + // to a specific instance will have those appear in order of when that instance was discovered. + let hooksCacheKey: null | DevToolsInstance = null; + let hooksCache: null | HooksTree = null; suspenseNode.suspendedBy.forEach((set, ioInfo) => { let parentNode = suspenseNode.parent; while (parentNode !== null) { @@ -5217,18 +5252,100 @@ export function attach( ioInfo, ); if (asyncInfo !== null) { - const index = result.length; - result.push(serializeAsyncInfo(asyncInfo, index, firstInstance)); + let hooks: null | HooksTree = null; + if (asyncInfo.stack == null && asyncInfo.owner == null) { + if (hooksCacheKey === firstInstance) { + hooks = hooksCache; + } else if (firstInstance.kind !== VIRTUAL_INSTANCE) { + const fiber = firstInstance.data; + if ( + fiber.dependencies && + fiber.dependencies._debugThenableState + ) { + // This entry had no stack nor owner but this Fiber used Hooks so we might + // be able to get the stack from the Hook. + hooksCacheKey = firstInstance; + hooksCache = hooks = inspectHooks(fiber); + } + } + } + result.push(serializeAsyncInfo(asyncInfo, firstInstance, hooks)); } } }); return result; } + function getAwaitStackFromHooks( + hooks: HooksTree, + asyncInfo: ReactAsyncInfo, + ): null | ReactStackTrace { + // TODO: We search through the hooks tree generated by inspectHooksOfFiber so that we can + // use the information already extracted but ideally this search would be faster since we + // could know which index to extract from the debug state. + for (let i = 0; i < hooks.length; i++) { + const node = hooks[i]; + const debugInfo = node.debugInfo; + if (debugInfo != null && debugInfo.indexOf(asyncInfo) !== -1) { + // Found a matching Hook. We'll now use its source location to construct a stack. + const source = node.hookSource; + if ( + source != null && + source.functionName !== null && + source.fileName !== null && + source.lineNumber !== null && + source.columnNumber !== null + ) { + // Unfortunately this is in a slightly different format. TODO: Unify HookNode with ReactCallSite. + const callSite: ReactCallSite = [ + source.functionName, + source.fileName, + source.lineNumber, + source.columnNumber, + 0, + 0, + false, + ]; + // As we return we'll add any custom hooks parent stacks to the array. + return [callSite]; + } else { + return []; + } + } + // Otherwise, search the sub hooks of any custom hook. + const matchedStack = getAwaitStackFromHooks(node.subHooks, asyncInfo); + if (matchedStack !== null) { + // Append this custom hook to the stack trace since it must have been called inside of it. + const source = node.hookSource; + if ( + source != null && + source.functionName !== null && + source.fileName !== null && + source.lineNumber !== null && + source.columnNumber !== null + ) { + // Unfortunately this is in a slightly different format. TODO: Unify HookNode with ReactCallSite. + const callSite: ReactCallSite = [ + source.functionName, + source.fileName, + source.lineNumber, + source.columnNumber, + 0, + 0, + false, + ]; + matchedStack.push(callSite); + } + return matchedStack; + } + } + return null; + } + function serializeAsyncInfo( asyncInfo: ReactAsyncInfo, - index: number, parentInstance: DevToolsInstance, + hooks: null | HooksTree, ): SerializedAsyncInfo { const ioInfo = asyncInfo.awaited; const ioOwnerInstance = findNearestOwnerInstance( @@ -5268,6 +5385,11 @@ export function attach( // If we awaited in the child position of a component, then the best stack would be the // return callsite but we don't have that available so instead we skip. The callsite of // the JSX would be misleading in this case. The same thing happens with throw-a-Promise. + if (hooks !== null) { + // If this component used Hooks we might be able to instead infer the stack from the + // use() callsite if this async info came from a hook. Let's search the tree to find it. + awaitStack = getAwaitStackFromHooks(hooks, asyncInfo); + } break; default: // If we awaited by passing a Promise to a built-in element, then the JSX callsite is a @@ -5538,31 +5660,9 @@ export function attach( const owners: null | Array = getOwnersListFromInstance(fiberInstance); - let hooks = null; + let hooks: null | HooksTree = null; if (usesHooks) { - const originalConsoleMethods: {[string]: $FlowFixMe} = {}; - - // Temporarily disable all console logging before re-running the hook. - for (const method in console) { - try { - // $FlowFixMe[invalid-computed-prop] - originalConsoleMethods[method] = console[method]; - // $FlowFixMe[prop-missing] - console[method] = () => {}; - } catch (error) {} - } - - try { - hooks = inspectHooksOfFiber(fiber, getDispatcherRef(renderer)); - } finally { - // Restore original console functionality. - for (const method in originalConsoleMethods) { - try { - // $FlowFixMe[prop-missing] - console[method] = originalConsoleMethods[method]; - } catch (error) {} - } - } + hooks = inspectHooks(fiber); } let rootType = null; @@ -5641,8 +5741,8 @@ export function attach( // TODO: Prepend other suspense sources like css, images and use(). fiberInstance.suspendedBy === null ? [] - : fiberInstance.suspendedBy.map((info, index) => - serializeAsyncInfo(info, index, fiberInstance), + : fiberInstance.suspendedBy.map(info => + serializeAsyncInfo(info, fiberInstance, hooks), ); return { id: fiberInstance.id, @@ -5813,8 +5913,8 @@ export function attach( suspendedBy: suspendedBy === null ? [] - : suspendedBy.map((info, index) => - serializeAsyncInfo(info, index, virtualInstance), + : suspendedBy.map(info => + serializeAsyncInfo(info, virtualInstance, null), ), // List of owners diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js index 3608cff85ce55..da74bc579e415 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -81,7 +81,21 @@ function SuspendedByRow({ }: RowProps) { const [isOpen, setIsOpen] = useState(false); const ioInfo = asyncInfo.awaited; - const name = ioInfo.name; + let name = ioInfo.name; + if (name === '' || name === 'Promise') { + // If all we have is a generic name, we can try to infer a better name from + // the stack. We only do this if the stack has more than one frame since + // otherwise it's likely to just be the name of the component which isn't better. + const bestStack = ioInfo.stack || asyncInfo.stack; + if (bestStack !== null && bestStack.length > 1) { + // TODO: Ideally we'd get the name from the last ignore listed frame before the + // first visible frame since this is the same algorithm as the Flight server uses. + // Ideally, we'd also get the name from the source mapped entry instead of the + // original entry. However, that would require suspending the immediate display + // of these rows to first do source mapping before we can show the name. + name = bestStack[0][0]; + } + } const description = ioInfo.description; const longName = description === '' ? name : name + ' (' + description + ')'; const shortDescription = getShortDescription(name, description);