diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 9a60c3bd66b29..0fd9b869c6141 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -2822,7 +2822,7 @@ describe('ReactFlight', () => { expect(getDebugInfo(promise)).toEqual( __DEV__ ? [ - {time: 20}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 22 : 20}, { name: 'ServerComponent', env: 'Server', @@ -2832,7 +2832,7 @@ describe('ReactFlight', () => { transport: expect.arrayContaining([]), }, }, - {time: 21}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 23 : 21}, ] : undefined, ); @@ -2843,7 +2843,7 @@ describe('ReactFlight', () => { expect(getDebugInfo(thirdPartyChildren[0])).toEqual( __DEV__ ? [ - {time: 22}, // Clamped to the start + {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, // Clamped to the start { name: 'ThirdPartyComponent', env: 'third-party', @@ -2851,15 +2851,15 @@ describe('ReactFlight', () => { stack: ' in Object. (at **)', props: {}, }, - {time: 22}, - {time: 23}, // This last one is when the promise resolved into the first party. + {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 25 : 23}, // This last one is when the promise resolved into the first party. ] : undefined, ); expect(getDebugInfo(thirdPartyChildren[1])).toEqual( __DEV__ ? [ - {time: 22}, // Clamped to the start + {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, // Clamped to the start { name: 'ThirdPartyLazyComponent', env: 'third-party', @@ -2867,14 +2867,14 @@ describe('ReactFlight', () => { stack: ' in myLazy (at **)\n in lazyInitializer (at **)', props: {}, }, - {time: 22}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, ] : undefined, ); expect(getDebugInfo(thirdPartyChildren[2])).toEqual( __DEV__ ? [ - {time: 22}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, { name: 'ThirdPartyFragmentComponent', env: 'third-party', @@ -2882,7 +2882,7 @@ describe('ReactFlight', () => { stack: ' in Object. (at **)', props: {}, }, - {time: 22}, + {time: gate(flags => flags.enableAsyncDebugInfo) ? 24 : 22}, ] : undefined, ); diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 8242b27d4e5be..54a6dd3e43a33 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -147,6 +147,8 @@ function getPrimitiveStackCache(): Map> { let currentFiber: null | Fiber = null; let currentHook: null | Hook = null; let currentContextDependency: null | ContextDependency = null; +let currentThenableIndex: number = 0; +let currentThenableState: null | Array> = null; function nextHook(): null | Hook { const hook = currentHook; @@ -201,7 +203,15 @@ function use(usable: Usable): T { if (usable !== null && typeof usable === 'object') { // $FlowFixMe[method-unbinding] if (typeof usable.then === 'function') { - const thenable: Thenable = (usable: any); + const thenable: Thenable = + // If we have thenable state, then the actually used thenable will be the one + // stashed in it. It's possible for uncached Promises to be new each render + // and in that case the one we're inspecting is the in the thenable state. + currentThenableState !== null && + currentThenableIndex < currentThenableState.length + ? currentThenableState[currentThenableIndex++] + : (usable: any); + switch (thenable.status) { case 'fulfilled': { const fulfilledValue: T = thenable.value; @@ -1285,6 +1295,14 @@ export function inspectHooksOfFiber( // current state from them. currentHook = (fiber.memoizedState: Hook); currentFiber = fiber; + const thenableState = + fiber.dependencies && fiber.dependencies._debugThenableState; + // In DEV the thenableState is an inner object. + const usedThenables: any = thenableState + ? thenableState.thenables || thenableState + : null; + currentThenableState = Array.isArray(usedThenables) ? usedThenables : null; + currentThenableIndex = 0; if (hasOwnProperty.call(currentFiber, 'dependencies')) { // $FlowFixMe[incompatible-use]: Flow thinks hasOwnProperty might have nulled `currentFiber` @@ -1339,6 +1357,8 @@ export function inspectHooksOfFiber( currentFiber = null; currentHook = null; currentContextDependency = null; + currentThenableState = null; + currentThenableIndex = 0; restoreContexts(contextMap); } diff --git a/packages/react-devtools-inline/__tests__/__e2e__/devtools-utils.js b/packages/react-devtools-inline/__tests__/__e2e__/devtools-utils.js index fe2bb3f6f222e..c39f63dc5bb4c 100644 --- a/packages/react-devtools-inline/__tests__/__e2e__/devtools-utils.js +++ b/packages/react-devtools-inline/__tests__/__e2e__/devtools-utils.js @@ -64,11 +64,22 @@ async function selectElement( createTestNameSelector('InspectedElementView-Owners'), ])[0]; + if (!ownersList) { + return false; + } + + const owners = findAllNodes(ownersList, [ + createTestNameSelector('OwnerView'), + ]); + return ( title && title.innerText.includes(titleText) && - ownersList && - ownersList.innerText.includes(ownersListText) + owners && + owners + .map(node => node.innerText) + .join('\n') + .includes(ownersListText) ); }, {titleText: displayName, ownersListText: waitForOwnersText} diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js index 522d211aeb06f..09f811172f30d 100644 --- a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js +++ b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js @@ -682,6 +682,7 @@ describe('InspectedElement', () => { object_with_symbol={objectWithSymbol} proxy={proxyInstance} react_element={} + react_lazy={React.lazy(async () => ({default: 'foo'}))} regexp={/abc/giu} set={setShallow} set_of_sets={setOfSets} @@ -780,9 +781,18 @@ describe('InspectedElement', () => { "preview_short": () => {}, "preview_long": () => {}, }, - "react_element": Dehydrated { - "preview_short": , - "preview_long": , + "react_element": { + "key": null, + "props": Dehydrated { + "preview_short": {…}, + "preview_long": {}, + }, + }, + "react_lazy": { + "_payload": Dehydrated { + "preview_short": {…}, + "preview_long": {_ioInfo: {…}, _result: () => {}, _status: -1}, + }, }, "regexp": Dehydrated { "preview_short": /abc/giu, @@ -930,13 +940,13 @@ describe('InspectedElement', () => { const inspectedElement = await inspectElementAtIndex(0); expect(inspectedElement.props).toMatchInlineSnapshot(` - { - "unusedPromise": Dehydrated { - "preview_short": Promise, - "preview_long": Promise, - }, - } - `); + { + "unusedPromise": Dehydrated { + "preview_short": Promise, + "preview_long": Promise, + }, + } + `); }); it('should not consume iterables while inspecting', async () => { diff --git a/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js b/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js index cf1ce1ffa3e38..f306ab97093d9 100644 --- a/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js +++ b/packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js @@ -289,9 +289,13 @@ describe('InspectedElementContext', () => { "preview_long": {boolean: true, number: 123, string: "abc"}, }, }, - "react_element": Dehydrated { - "preview_short": , - "preview_long": , + "react_element": { + "key": null, + "props": Dehydrated { + "preview_short": {…}, + "preview_long": {}, + }, + "ref": null, }, "regexp": Dehydrated { "preview_short": /abc/giu, diff --git a/packages/react-devtools-shared/src/__tests__/profilingCache-test.js b/packages/react-devtools-shared/src/__tests__/profilingCache-test.js index 795f37183a81f..d16062c69f488 100644 --- a/packages/react-devtools-shared/src/__tests__/profilingCache-test.js +++ b/packages/react-devtools-shared/src/__tests__/profilingCache-test.js @@ -949,6 +949,7 @@ describe('ProfilingCache', () => { "hocDisplayNames": null, "id": 1, "key": null, + "stack": null, "type": 11, }, ], diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 236b31a3d9ef4..1d4541253f7a2 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -8,12 +8,17 @@ */ import type { + Thenable, ReactComponentInfo, ReactDebugInfo, ReactAsyncInfo, ReactIOInfo, + ReactStackTrace, + ReactCallSite, } from 'shared/ReactTypes'; +import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; + import { ComponentFilterDisplayName, ComponentFilterElementType, @@ -104,6 +109,7 @@ import { MEMO_NUMBER, MEMO_SYMBOL_STRING, SERVER_CONTEXT_SYMBOL_STRING, + LAZY_SYMBOL_STRING, } from '../shared/ReactSymbols'; import {enableStyleXFeatures} from 'react-devtools-feature-flags'; @@ -2369,6 +2375,15 @@ export function attach( const keyString = key === null ? null : String(key); const keyStringID = getStringID(keyString); + const nameProp = + fiber.tag === SuspenseComponent + ? fiber.memoizedProps.name + : fiber.tag === ActivityComponent + ? fiber.memoizedProps.name + : null; + const namePropString = nameProp == null ? null : String(nameProp); + const namePropStringID = getStringID(namePropString); + pushOperation(TREE_OPERATION_ADD); pushOperation(id); pushOperation(elementType); @@ -2376,6 +2391,7 @@ export function attach( pushOperation(ownerID); pushOperation(displayNameStringID); pushOperation(keyStringID); + pushOperation(namePropStringID); // If this subtree has a new mode, let the frontend know. if ((fiber.mode & StrictModeBits) !== 0) { @@ -2478,6 +2494,7 @@ export function attach( // in such a way as to bypass the default stringification of the "key" property. const keyString = key === null ? null : String(key); const keyStringID = getStringID(keyString); + const namePropStringID = getStringID(null); const id = instance.id; @@ -2488,6 +2505,7 @@ export function attach( pushOperation(ownerID); pushOperation(displayNameStringID); pushOperation(keyStringID); + pushOperation(namePropStringID); const componentLogsEntry = componentInfoToComponentLogsMap.get(componentInfo); @@ -3149,6 +3167,146 @@ export function attach( return null; } + function trackDebugInfoFromLazyType(fiber: Fiber): void { + // The debugInfo from a Lazy isn't propagated onto _debugInfo of the parent Fiber the way + // it is when used in child position. So we need to pick it up explicitly. + const type = fiber.elementType; + const typeSymbol = getTypeSymbol(type); // The elementType might be have been a LazyComponent. + if (typeSymbol === LAZY_SYMBOL_STRING) { + const debugInfo: ?ReactDebugInfo = type._debugInfo; + if (debugInfo) { + for (let i = 0; i < debugInfo.length; i++) { + const debugEntry = debugInfo[i]; + if (debugEntry.awaited) { + const asyncInfo: ReactAsyncInfo = (debugEntry: any); + insertSuspendedBy(asyncInfo); + } + } + } + } + } + + function trackDebugInfoFromUsedThenables(fiber: Fiber): void { + // If a Fiber called use() in DEV mode then we may have collected _debugThenableState on + // the dependencies. If so, then this will contain the thenables passed to use(). + // These won't have their debug info picked up by fiber._debugInfo since that just + // contains things suspending the children. We have to collect use() separately. + const dependencies = fiber.dependencies; + if (dependencies == null) { + return; + } + const thenableState = dependencies._debugThenableState; + if (thenableState == null) { + return; + } + // In DEV the thenableState is an inner object. + const usedThenables: any = thenableState.thenables || thenableState; + if (!Array.isArray(usedThenables)) { + return; + } + for (let i = 0; i < usedThenables.length; i++) { + const thenable: Thenable = usedThenables[i]; + const debugInfo = thenable._debugInfo; + if (debugInfo) { + for (let j = 0; j < debugInfo.length; j++) { + const debugEntry = debugInfo[i]; + if (debugEntry.awaited) { + const asyncInfo: ReactAsyncInfo = (debugEntry: any); + insertSuspendedBy(asyncInfo); + } + } + } + } + } + + const hostAsyncInfoCache: WeakMap<{...}, ReactAsyncInfo> = new WeakMap(); + + function trackDebugInfoFromHostResource( + devtoolsInstance: DevToolsInstance, + fiber: Fiber, + ): void { + const resource: ?{ + type: 'stylesheet' | 'style' | 'script' | 'void', + instance?: null | HostInstance, + ... + } = fiber.memoizedState; + if (resource == null) { + return; + } + + // Use a cached entry based on the resource. This ensures that if we use the same + // resource in multiple places, it gets deduped and inner boundaries don't consider it + // as contributing to those boundaries. + const existingEntry = hostAsyncInfoCache.get(resource); + if (existingEntry !== undefined) { + insertSuspendedBy(existingEntry); + return; + } + + const props: { + href?: string, + media?: string, + ... + } = fiber.memoizedProps; + + // Stylesheet resources may suspend. We need to track that. + const mayResourceSuspendCommit = + resource.type === 'stylesheet' && + // If it doesn't match the currently debugged media, then it doesn't count. + (typeof props.media !== 'string' || + typeof matchMedia !== 'function' || + matchMedia(props.media)); + if (!mayResourceSuspendCommit) { + return; + } + + const instance = resource.instance; + if (instance == null) { + return; + } + + // Unlike props.href, this href will be fully qualified which we need for comparison below. + const href = instance.href; + if (typeof href !== 'string') { + return; + } + let start = -1; + let end = -1; + // $FlowFixMe[method-unbinding] + if (typeof performance.getEntriesByType === 'function') { + // We may be able to collect the start and end time of this resource from Performance Observer. + const resourceEntries = performance.getEntriesByType('resource'); + for (let i = 0; i < resourceEntries.length; i++) { + const resourceEntry = resourceEntries[i]; + if (resourceEntry.name === href) { + start = resourceEntry.startTime; + end = start + resourceEntry.duration; + } + } + } + const value = instance.sheet; + const promise = Promise.resolve(value); + (promise: any).status = 'fulfilled'; + (promise: any).value = value; + const ioInfo: ReactIOInfo = { + name: 'stylesheet', + start, + end, + value: promise, + // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. + owner: fiber, // Allow linking to the if it's not filtered. + }; + const asyncInfo: ReactAsyncInfo = { + awaited: ioInfo, + // $FlowFixMe: This field doesn't usually take a Fiber but we're only using inside this file. + owner: fiber._debugOwner == null ? null : fiber._debugOwner, + debugStack: fiber._debugStack == null ? null : fiber._debugStack, + debugTask: fiber._debugTask == null ? null : fiber._debugTask, + }; + hostAsyncInfoCache.set(resource, asyncInfo); + insertSuspendedBy(asyncInfo); + } + function mountVirtualChildrenRecursively( firstChild: Fiber, lastChild: null | Fiber, // non-inclusive @@ -3367,12 +3525,16 @@ export function attach( // because we don't want to highlight every host node inside of a newly mounted subtree. } + trackDebugInfoFromLazyType(fiber); + trackDebugInfoFromUsedThenables(fiber); + if (fiber.tag === HostHoistable) { const nearestInstance = reconcilingParent; if (nearestInstance === null) { throw new Error('Did not expect a host hoistable to be the root'); } aquireHostResource(nearestInstance, fiber.memoizedState); + trackDebugInfoFromHostResource(nearestInstance, fiber); } else if ( fiber.tag === HostComponent || fiber.tag === HostText || @@ -4162,7 +4324,7 @@ export function attach( const stashedSuspenseParent = reconcilingParentSuspenseNode; const stashedSuspensePrevious = previouslyReconciledSiblingSuspenseNode; const stashedSuspenseRemaining = remainingReconcilingChildrenSuspenseNodes; - let shouldPopSuspenseNode = false; + let shouldMeasureSuspenseNode = false; let previousSuspendedBy = null; if (fiberInstance !== null) { previousSuspendedBy = fiberInstance.suspendedBy; @@ -4192,10 +4354,13 @@ export function attach( previouslyReconciledSiblingSuspenseNode = null; remainingReconcilingChildrenSuspenseNodes = suspenseNode.firstChild; suspenseNode.firstChild = null; - shouldPopSuspenseNode = true; + shouldMeasureSuspenseNode = true; } } try { + trackDebugInfoFromLazyType(nextFiber); + trackDebugInfoFromUsedThenables(nextFiber); + if ( nextFiber.tag === HostHoistable && prevFiber.memoizedState !== nextFiber.memoizedState @@ -4206,6 +4371,7 @@ export function attach( } releaseHostResource(nearestInstance, prevFiber.memoizedState); aquireHostResource(nearestInstance, nextFiber.memoizedState); + trackDebugInfoFromHostResource(nearestInstance, nextFiber); } else if ( (nextFiber.tag === HostComponent || nextFiber.tag === HostText || @@ -4379,38 +4545,40 @@ export function attach( 0, ); - // Next, we'll pop back out of the SuspenseNode that we added above and now we'll - // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode. - // Since the fallback conceptually blocks the parent. - reconcilingParentSuspenseNode = stashedSuspenseParent; - previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; - remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; - shouldPopSuspenseNode = false; + shouldMeasureSuspenseNode = false; if (nextFallbackFiber !== null) { - updateFlags |= updateVirtualChildrenRecursively( - nextFallbackFiber, - null, - prevFallbackFiber, - traceNearestHostComponentUpdate, - 0, - ); - } else if ( - nextFiber.memoizedState === null && - fiberInstance.suspenseNode !== null - ) { - if (!isInDisconnectedSubtree) { - // Measure this Suspense node in case it changed. We don't update the rect while - // we're inside a disconnected subtree nor if we are the Suspense boundary that - // is suspended. This lets us keep the rectangle of the displayed content while - // we're suspended to visualize the resulting state. - const suspenseNode = fiberInstance.suspenseNode; - const prevRects = suspenseNode.rects; - const nextRects = measureInstance(fiberInstance); - if (!areEqualRects(prevRects, nextRects)) { - suspenseNode.rects = nextRects; - recordSuspenseResize(suspenseNode); - } + const fallbackStashedSuspenseParent = reconcilingParentSuspenseNode; + const fallbackStashedSuspensePrevious = + previouslyReconciledSiblingSuspenseNode; + const fallbackStashedSuspenseRemaining = + remainingReconcilingChildrenSuspenseNodes; + // Next, we'll pop back out of the SuspenseNode that we added above and now we'll + // reconcile the fallback, reconciling anything by inserting into the parent SuspenseNode. + // Since the fallback conceptually blocks the parent. + reconcilingParentSuspenseNode = stashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; + try { + updateFlags |= updateVirtualChildrenRecursively( + nextFallbackFiber, + null, + prevFallbackFiber, + traceNearestHostComponentUpdate, + 0, + ); + } finally { + reconcilingParentSuspenseNode = fallbackStashedSuspenseParent; + previouslyReconciledSiblingSuspenseNode = + fallbackStashedSuspensePrevious; + remainingReconcilingChildrenSuspenseNodes = + fallbackStashedSuspenseRemaining; } + } else if (nextFiber.memoizedState === null) { + // Measure this Suspense node in case it changed. We don't update the rect while + // we're inside a disconnected subtree nor if we are the Suspense boundary that + // is suspended. This lets us keep the rectangle of the displayed content while + // we're suspended to visualize the resulting state. + shouldMeasureSuspenseNode = !isInDisconnectedSubtree; } } else { // Common case: Primary -> Primary. @@ -4519,7 +4687,7 @@ export function attach( reconcilingParent = stashedParent; previouslyReconciledSibling = stashedPrevious; remainingReconcilingChildren = stashedRemaining; - if (shouldPopSuspenseNode) { + if (shouldMeasureSuspenseNode) { if ( !isInDisconnectedSubtree && reconcilingParentSuspenseNode !== null @@ -4535,6 +4703,8 @@ export function attach( recordSuspenseResize(suspenseNode); } } + } + if (fiberInstance.suspenseNode !== null) { reconcilingParentSuspenseNode = stashedSuspenseParent; previouslyReconciledSiblingSuspenseNode = stashedSuspensePrevious; remainingReconcilingChildrenSuspenseNodes = stashedSuspenseRemaining; @@ -4987,6 +5157,10 @@ export function attach( id: instance.id, key: fiber.key, env: null, + stack: + fiber._debugOwner == null || fiber._debugStack == null + ? null + : parseStackTrace(fiber._debugStack, 1), type: getElementTypeForFiber(fiber), }; } else { @@ -4996,6 +5170,10 @@ export function attach( id: instance.id, key: componentInfo.key == null ? null : componentInfo.key, env: componentInfo.env == null ? null : componentInfo.env, + stack: + componentInfo.owner == null || componentInfo.debugStack == null + ? null + : parseStackTrace(componentInfo.debugStack, 1), type: ElementTypeVirtual, }; } @@ -5103,6 +5281,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 { @@ -5112,6 +5316,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) { @@ -5133,18 +5342,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( @@ -5184,6 +5475,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 @@ -5454,31 +5750,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; @@ -5557,8 +5831,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, @@ -5594,6 +5868,11 @@ export function attach( source, + stack: + fiber._debugOwner == null || fiber._debugStack == null + ? null + : parseStackTrace(fiber._debugStack, 1), + // Does the component have legacy context attached to it. hasLegacyContext, @@ -5694,6 +5973,11 @@ export function attach( source, + stack: + componentInfo.owner == null || componentInfo.debugStack == null + ? null + : parseStackTrace(componentInfo.debugStack, 1), + // Does the component have legacy context attached to it. hasLegacyContext: false, @@ -5719,8 +6003,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/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 6153e08832a11..c2c278393602a 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -426,6 +426,7 @@ export function attach( pushOperation(ownerID); pushOperation(displayNameStringID); pushOperation(keyStringID); + pushOperation(getStringID(null)); // name prop } } @@ -796,6 +797,7 @@ export function attach( id: getID(owner), key: element.key, env: null, + stack: null, type: getElementType(owner), }); if (owner._currentElement) { @@ -837,6 +839,8 @@ export function attach( source: null, + stack: null, + // Only legacy context exists in legacy versions. hasLegacyContext: true, diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 585654252da20..55a1bc6532e22 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -257,6 +257,7 @@ export type SerializedElement = { id: number, key: number | string | null, env: null | string, + stack: null | ReactStackTrace, type: ElementType, }; @@ -308,6 +309,9 @@ export type InspectedElement = { source: ReactFunctionLocation | null, + // The location of the JSX creation. + stack: ReactStackTrace | null, + type: ElementType, // Meta information about the root this element belongs to. diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index a27e70c26d008..db22606377da1 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -257,6 +257,7 @@ export function convertInspectedElementBackendToFrontend( owners, env, source, + stack, context, hooks, plugins, @@ -295,6 +296,7 @@ export function convertInspectedElementBackendToFrontend( // Previous backend implementations (<= 6.1.5) have a different interface for Source. // This gates the source features for only compatible backends: >= 6.1.6 source: Array.isArray(source) ? source : null, + stack: stack, type, owners: owners === null diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 622c9a475419c..2d6b67ef12cc8 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -1116,6 +1116,7 @@ export default class Store extends EventEmitter<{ isCollapsed: false, // Never collapse roots; it would hide the entire tree. isStrictModeNonCompliant, key: null, + nameProp: null, ownerID: 0, parentID: 0, type, @@ -1139,6 +1140,10 @@ export default class Store extends EventEmitter<{ const key = stringTable[keyStringID]; i++; + const namePropStringID = operations[i]; + const nameProp = stringTable[namePropStringID]; + i++; + if (__DEBUG__) { debug( 'Add', @@ -1180,6 +1185,7 @@ export default class Store extends EventEmitter<{ isCollapsed: this._collapseNodesByDefault, isStrictModeNonCompliant: parentElement.isStrictModeNonCompliant, key, + nameProp, ownerID, parentID, type, diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Element.js b/packages/react-devtools-shared/src/devtools/views/Components/Element.js index c3ddf1da07518..25e5208ce9b6f 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Element.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Element.js @@ -119,6 +119,7 @@ export default function Element({data, index, style}: Props): React.Node { hocDisplayNames, isStrictModeNonCompliant, key, + nameProp, compiledWithForget, } = element; const { @@ -179,7 +180,24 @@ export default function Element({data, index, style}: Props): React.Node { className={styles.KeyValue} title={key} onDoubleClick={handleKeyDoubleClick}> -
{key}
+
+                
+              
+
+ " + + )} + + {nameProp && ( + +  name=" + +
+                
+              
"
diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js index cc37953f4d271..7b19908cc8c4a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -51,12 +51,19 @@ export default function InspectedElementWrapper(_: Props): React.Node { const fetchFileWithCaching = useContext(FetchFileWithCachingContext); + const source = + inspectedElement == null + ? null + : inspectedElement.source != null + ? inspectedElement.source + : inspectedElement.stack != null && inspectedElement.stack.length > 0 + ? inspectedElement.stack[0] + : null; + const symbolicatedSourcePromise: null | Promise = React.useMemo(() => { - if (inspectedElement == null) return null; if (fetchFileWithCaching == null) return Promise.resolve(null); - const {source} = inspectedElement; if (source == null) return Promise.resolve(null); const [, sourceURL, line, column] = source; @@ -66,7 +73,7 @@ export default function InspectedElementWrapper(_: Props): React.Node { line, column, ); - }, [inspectedElement]); + }, [source]); const element = inspectedElementID !== null @@ -223,13 +230,12 @@ export default function InspectedElementWrapper(_: Props): React.Node { {!alwaysOpenInEditor && !!editorURL && - inspectedElement != null && - inspectedElement.source != null && + source != null && symbolicatedSourcePromise != null && ( }> @@ -276,7 +282,7 @@ export default function InspectedElementWrapper(_: Props): React.Node { {!hideViewSourceAction && ( )} 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 c24dd881e9891..e5e094955887a 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); @@ -104,11 +118,15 @@ function SuspendedByRow({ // Only show the awaited stack if the I/O started in a different owner // than where it was awaited. If it's started by the same component it's // probably easy enough to infer and less noise in the common case. + const canShowAwaitStack = + (asyncInfo.stack !== null && asyncInfo.stack.length > 0) || + (asyncOwner !== null && asyncOwner.id !== inspectedElement.id); const showAwaitStack = - !showIOStack || - (ioOwner === null - ? asyncOwner !== null - : asyncOwner === null || ioOwner.id !== asyncOwner.id); + canShowAwaitStack && + (!showIOStack || + (ioOwner === null + ? asyncOwner !== null + : asyncOwner === null || ioOwner.id !== asyncOwner.id)); const value: any = ioInfo.value; const metaName = @@ -160,9 +178,12 @@ function SuspendedByRow({ } /> )} - {(showIOStack || !showAwaitStack) && - ioOwner !== null && - ioOwner.id !== inspectedElement.id ? ( + {ioOwner !== null && + ioOwner.id !== inspectedElement.id && + (showIOStack || + !showAwaitStack || + asyncOwner === null || + ioOwner.id !== asyncOwner.id) ? ( 0; + const showStack = stack != null && stack.length > 0; const showRenderedBy = - showOwnersList || rendererLabel !== null || rootType !== null; + showStack || showOwnersList || rendererLabel !== null || rootType !== null; return ( @@ -168,20 +171,26 @@ export default function InspectedElementView({ data-testname="InspectedElementView-Owners">
rendered by
+ {showStack ? : null} {showOwnersList && owners?.map(owner => ( - + <> + + {owner.stack != null && owner.stack.length > 0 ? ( + + ) : null} + ))} {rootType !== null && ( diff --git a/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js b/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js index ac848484378bb..2b0f4b035a261 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js @@ -60,7 +60,8 @@ export default function OwnerView({ + title={displayName} + data-testname="OwnerView"> {'<' + displayName + '>'} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js index f43ced82447ef..72556543f4b33 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js @@ -995,7 +995,14 @@ function recursivelySearchTree( return; } - const {children, displayName, hocDisplayNames, compiledWithForget} = element; + const { + children, + displayName, + hocDisplayNames, + compiledWithForget, + key, + nameProp, + } = element; if (displayName != null && regExp.test(displayName) === true) { searchResults.push(elementID); } else if ( @@ -1006,6 +1013,10 @@ function recursivelySearchTree( searchResults.push(elementID); } else if (compiledWithForget && regExp.test('Forget')) { searchResults.push(elementID); + } else if (typeof key === 'string' && regExp.test(key)) { + searchResults.push(elementID); + } else if (typeof nameProp === 'string' && regExp.test(nameProp)) { + searchResults.push(elementID); } children.forEach(childID => diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js index dfa515fffa1d1..d685263a22603 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -239,6 +239,9 @@ function updateTree( const key = stringTable[keyStringID]; i++; + // skip name prop + i++; + if (__DEBUG__) { debug( 'Add', diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index 3fff08877ce92..0089059df9d7a 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -157,6 +157,7 @@ export type Element = { type: ElementType, displayName: string | null, key: number | string | null, + nameProp: null | string, hocDisplayNames: null | Array, @@ -216,6 +217,7 @@ export type SerializedElement = { id: number, key: number | string | null, env: null | string, + stack: null | ReactStackTrace, hocDisplayNames: Array | null, compiledWithForget: boolean, type: ElementType, @@ -279,6 +281,9 @@ export type InspectedElement = { // Location of component in source code. source: ReactFunctionLocation | null, + // The location of the JSX creation. + stack: ReactStackTrace | null, + type: ElementType, // Meta information about the root this element belongs to. diff --git a/packages/react-devtools-shared/src/hydration.js b/packages/react-devtools-shared/src/hydration.js index 7ce5a8ec6ab38..ecadad7ab3fe2 100644 --- a/packages/react-devtools-shared/src/hydration.js +++ b/packages/react-devtools-shared/src/hydration.js @@ -16,6 +16,8 @@ import { setInObject, } from 'react-devtools-shared/src/utils'; +import {REACT_LEGACY_ELEMENT_TYPE} from 'shared/ReactSymbols'; + import type { DehydratedData, InspectedElementPath, @@ -188,18 +190,103 @@ export function dehydrate( type, }; - // React Elements aren't very inspector-friendly, - // and often contain private fields or circular references. - case 'react_element': - cleaned.push(path); - return { - inspectable: false, + case 'react_element': { + isPathAllowedCheck = isPathAllowed(path); + + if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) { + cleaned.push(path); + return { + inspectable: true, + preview_short: formatDataForPreview(data, false), + preview_long: formatDataForPreview(data, true), + name: getDisplayNameForReactElement(data) || 'Unknown', + type, + }; + } + + const unserializableValue: Unserializable = { + unserializable: true, + type, + readonly: true, preview_short: formatDataForPreview(data, false), preview_long: formatDataForPreview(data, true), name: getDisplayNameForReactElement(data) || 'Unknown', - type, }; + // TODO: We can't expose type because that name is already taken on Unserializable. + unserializableValue.key = dehydrate( + data.key, + cleaned, + unserializable, + path.concat(['key']), + isPathAllowed, + isPathAllowedCheck ? 1 : level + 1, + ); + if (data.$$typeof === REACT_LEGACY_ELEMENT_TYPE) { + unserializableValue.ref = dehydrate( + data.ref, + cleaned, + unserializable, + path.concat(['ref']), + isPathAllowed, + isPathAllowedCheck ? 1 : level + 1, + ); + } + unserializableValue.props = dehydrate( + data.props, + cleaned, + unserializable, + path.concat(['props']), + isPathAllowed, + isPathAllowedCheck ? 1 : level + 1, + ); + unserializable.push(path); + return unserializableValue; + } + case 'react_lazy': { + isPathAllowedCheck = isPathAllowed(path); + + const payload = data._payload; + + if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) { + cleaned.push(path); + const inspectable = + payload !== null && + typeof payload === 'object' && + (payload._status === 1 || + payload._status === 2 || + payload.status === 'fulfilled' || + payload.status === 'rejected'); + return { + inspectable, + preview_short: formatDataForPreview(data, false), + preview_long: formatDataForPreview(data, true), + name: 'lazy()', + type, + }; + } + + const unserializableValue: Unserializable = { + unserializable: true, + type: type, + preview_short: formatDataForPreview(data, false), + preview_long: formatDataForPreview(data, true), + name: 'lazy()', + }; + // Ideally we should alias these properties to something more readable but + // unfortunately because of how the hydration algorithm uses a single concept of + // "path" we can't alias the path. + unserializableValue._payload = dehydrate( + payload, + cleaned, + unserializable, + path.concat(['_payload']), + isPathAllowed, + isPathAllowedCheck ? 1 : level + 1, + ); + unserializable.push(path); + return unserializableValue; + } // ArrayBuffers error if you try to inspect them. case 'array_buffer': case 'data_view': @@ -309,6 +396,7 @@ export function dehydrate( isPathAllowedCheck = isPathAllowed(path); if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) { + cleaned.push(path); return { inspectable: data.status === 'fulfilled' || data.status === 'rejected', diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index ef5e7450acdfb..c585d90500dff 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -271,6 +271,7 @@ export function printOperationsArray(operations: Array) { i++; i++; // key + i++; // name logs.push( `Add node ${id} (${displayName || 'null'}) as child of ${parentID}`, @@ -633,6 +634,7 @@ export type DataType = | 'thenable' | 'object' | 'react_element' + | 'react_lazy' | 'regexp' | 'string' | 'symbol' @@ -686,11 +688,12 @@ export function getDataType(data: Object): DataType { return 'number'; } case 'object': - if ( - data.$$typeof === REACT_ELEMENT_TYPE || - data.$$typeof === REACT_LEGACY_ELEMENT_TYPE - ) { - return 'react_element'; + switch (data.$$typeof) { + case REACT_ELEMENT_TYPE: + case REACT_LEGACY_ELEMENT_TYPE: + return 'react_element'; + case REACT_LAZY_TYPE: + return 'react_lazy'; } if (isArray(data)) { return 'array'; @@ -906,6 +909,62 @@ export function formatDataForPreview( return `<${truncateForDisplay( getDisplayNameForReactElement(data) || 'Unknown', )} />`; + case 'react_lazy': + // To avoid actually initialize a lazy to cause a side-effect we make some assumptions + // about the structure of the payload even though that's not really part of the contract. + // In practice, this is really just coming from React.lazy helper or Flight. + const payload = data._payload; + if (payload !== null && typeof payload === 'object') { + if (payload._status === 0) { + // React.lazy constructor pending + return `pending lazy()`; + } + if (payload._status === 1 && payload._result != null) { + // React.lazy constructor fulfilled + if (showFormattedValue) { + const formatted = formatDataForPreview( + payload._result.default, + false, + ); + return `fulfilled lazy() {${truncateForDisplay(formatted)}}`; + } else { + return `fulfilled lazy() {…}`; + } + } + if (payload._status === 2) { + // React.lazy constructor rejected + if (showFormattedValue) { + const formatted = formatDataForPreview(payload._result, false); + return `rejected lazy() {${truncateForDisplay(formatted)}}`; + } else { + return `rejected lazy() {…}`; + } + } + if (payload.status === 'pending' || payload.status === 'blocked') { + // React Flight pending + return `pending lazy()`; + } + if (payload.status === 'fulfilled') { + // React Flight fulfilled + if (showFormattedValue) { + const formatted = formatDataForPreview(payload.value, false); + return `fulfilled lazy() {${truncateForDisplay(formatted)}}`; + } else { + return `fulfilled lazy() {…}`; + } + } + if (payload.status === 'rejected') { + // React Flight rejected + if (showFormattedValue) { + const formatted = formatDataForPreview(payload.reason, false); + return `rejected lazy() {${truncateForDisplay(formatted)}}`; + } else { + return `rejected lazy() {…}`; + } + } + } + // Some form of uninitialized + return 'lazy()'; case 'array_buffer': return `ArrayBuffer(${data.byteLength})`; case 'data_view': diff --git a/packages/react-reconciler/src/ReactFiberThenable.js b/packages/react-reconciler/src/ReactFiberThenable.js index f4ae1d45b271e..643be63ffa1c9 100644 --- a/packages/react-reconciler/src/ReactFiberThenable.js +++ b/packages/react-reconciler/src/ReactFiberThenable.js @@ -12,6 +12,7 @@ import type { PendingThenable, FulfilledThenable, RejectedThenable, + ReactIOInfo, } from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; @@ -22,6 +23,8 @@ import {getWorkInProgressRoot} from './ReactFiberWorkLoop'; import ReactSharedInternals from 'shared/ReactSharedInternals'; +import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags'; + import noop from 'shared/noop'; opaque type ThenableStateDev = { @@ -154,6 +157,33 @@ export function trackUsedThenable( } } + if (__DEV__ && enableAsyncDebugInfo && thenable._debugInfo === undefined) { + // In DEV mode if the thenable that we observed had no debug info, then we add + // an inferred debug info so that we're able to track its potential I/O uniquely. + // We don't know the real start time since the I/O could have started much + // earlier and this could even be a cached Promise. Could be misleading. + const startTime = performance.now(); + const displayName = thenable.displayName; + const ioInfo: ReactIOInfo = { + name: typeof displayName === 'string' ? displayName : 'Promise', + start: startTime, + end: startTime, + value: (thenable: any), + // We don't know the requesting owner nor stack. + }; + // We can infer the await owner/stack lazily from where this promise ends up + // used. It can be used in more than one place so we can't assign it here. + thenable._debugInfo = [{awaited: ioInfo}]; + // Track when we resolved the Promise as the approximate end time. + if (thenable.status !== 'fulfilled' && thenable.status !== 'rejected') { + const trackEndTime = () => { + // $FlowFixMe[cannot-write] + ioInfo.end = performance.now(); + }; + thenable.then(trackEndTime, trackEndTime); + } + } + // We use an expando to track the status and result of a thenable so that we // can synchronously unwrap the value. Think of this as an extension of the // Promise API, or a custom interface that is a superset of Thenable. diff --git a/packages/react/src/ReactLazy.js b/packages/react/src/ReactLazy.js index 2ac29c87774ec..69b35b58cc8bd 100644 --- a/packages/react/src/ReactLazy.js +++ b/packages/react/src/ReactLazy.js @@ -7,7 +7,16 @@ * @flow */ -import type {Wakeable, Thenable, ReactDebugInfo} from 'shared/ReactTypes'; +import type { + Wakeable, + Thenable, + FulfilledThenable, + RejectedThenable, + ReactDebugInfo, + ReactIOInfo, +} from 'shared/ReactTypes'; + +import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags'; import {REACT_LAZY_TYPE} from 'shared/ReactSymbols'; @@ -19,21 +28,25 @@ const Rejected = 2; type UninitializedPayload = { _status: -1, _result: () => Thenable<{default: T, ...}>, + _ioInfo?: ReactIOInfo, // DEV-only }; type PendingPayload = { _status: 0, _result: Wakeable, + _ioInfo?: ReactIOInfo, // DEV-only }; type ResolvedPayload = { _status: 1, _result: {default: T, ...}, + _ioInfo?: ReactIOInfo, // DEV-only }; type RejectedPayload = { _status: 2, _result: mixed, + _ioInfo?: ReactIOInfo, // DEV-only }; type Payload = @@ -51,6 +64,14 @@ export type LazyComponent = { function lazyInitializer(payload: Payload): T { if (payload._status === Uninitialized) { + if (__DEV__ && enableAsyncDebugInfo) { + const ioInfo = payload._ioInfo; + if (ioInfo != null) { + // Mark when we first kicked off the lazy request. + // $FlowFixMe[cannot-write] + ioInfo.start = ioInfo.end = performance.now(); + } + } const ctor = payload._result; const thenable = ctor(); // Transition to the next state. @@ -68,6 +89,21 @@ function lazyInitializer(payload: Payload): T { const resolved: ResolvedPayload = (payload: any); resolved._status = Resolved; resolved._result = moduleObject; + if (__DEV__) { + const ioInfo = payload._ioInfo; + if (ioInfo != null) { + // Mark the end time of when we resolved. + // $FlowFixMe[cannot-write] + ioInfo.end = performance.now(); + } + // Make the thenable introspectable + if (thenable.status === undefined) { + const fulfilledThenable: FulfilledThenable<{default: T, ...}> = + (thenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = moduleObject; + } + } } }, error => { @@ -79,9 +115,37 @@ function lazyInitializer(payload: Payload): T { const rejected: RejectedPayload = (payload: any); rejected._status = Rejected; rejected._result = error; + if (__DEV__ && enableAsyncDebugInfo) { + const ioInfo = payload._ioInfo; + if (ioInfo != null) { + // Mark the end time of when we rejected. + // $FlowFixMe[cannot-write] + ioInfo.end = performance.now(); + } + // Make the thenable introspectable + if (thenable.status === undefined) { + const rejectedThenable: RejectedThenable<{default: T, ...}> = + (thenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; + } + } } }, ); + if (__DEV__ && enableAsyncDebugInfo) { + const ioInfo = payload._ioInfo; + if (ioInfo != null) { + // Stash the thenable for introspection of the value later. + // $FlowFixMe[cannot-write] + ioInfo.value = thenable; + const displayName = thenable.displayName; + if (typeof displayName === 'string') { + // $FlowFixMe[cannot-write] + ioInfo.name = displayName; + } + } + } if (payload._status === Uninitialized) { // In case, we're still uninitialized, then we're waiting for the thenable // to resolve. Set it as pending in the meantime. @@ -140,5 +204,26 @@ export function lazy( _init: lazyInitializer, }; + if (__DEV__ && enableAsyncDebugInfo) { + // TODO: We should really track the owner here but currently ReactIOInfo + // can only contain ReactComponentInfo and not a Fiber. It's unusual to + // create a lazy inside an owner though since they should be in module scope. + const owner = null; + const ioInfo: ReactIOInfo = { + name: 'lazy', + start: -1, + end: -1, + value: null, + owner: owner, + debugStack: new Error('react-stack-top-frame'), + // eslint-disable-next-line react-internal/no-production-logging + debugTask: console.createTask ? console.createTask('lazy()') : null, + }; + payload._ioInfo = ioInfo; + // Add debug info to the lazy, but this doesn't have an await stack yet. + // That will be inferred by later usage. + lazyType._debugInfo = [{awaited: ioInfo}]; + } + return lazyType; } diff --git a/packages/shared/ReactIODescription.js b/packages/shared/ReactIODescription.js index 7767b93da4653..10c888213ddb7 100644 --- a/packages/shared/ReactIODescription.js +++ b/packages/shared/ReactIODescription.js @@ -24,6 +24,8 @@ export function getIODescription(value: any): string { return String(value.message); } else if (typeof value.url === 'string') { return value.url; + } else if (typeof value.href === 'string') { + return value.href; } else if (typeof value.command === 'string') { return value.command; } else if ( diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 5c7af1d1b305f..ff2649a23dc0d 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -108,6 +108,7 @@ interface ThenableImpl { onFulfill: (value: T) => mixed, onReject: (error: mixed) => mixed, ): void | Wakeable; + displayName?: string; } interface UntrackedThenable extends ThenableImpl { status?: void; @@ -298,6 +299,7 @@ export type ViewTransitionProps = { export type ActivityProps = { mode?: 'hidden' | 'visible' | null | void, children?: ReactNodeList, + name?: string, }; export type SuspenseProps = { diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 52a85eec8c61c..9cd9ac4ab5700 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -13,7 +13,6 @@ import typeof * as ExportsType from './ReactFeatureFlags.test-renderer'; export const alwaysThrottleRetries = false; export const disableClientCache = true; export const disableCommentsAsDOMContainers = true; -export const disableDefaultPropsExceptForClasses = true; export const disableInputAttributeSyncing = false; export const disableLegacyContext = false; export const disableLegacyContextForFunctionComponents = false;