diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index b927b1a58fd..d6186935fef 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -15,6 +15,7 @@ import type { ReactIOInfo, ReactStackTrace, ReactCallSite, + Wakeable, } from 'shared/ReactTypes'; import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; @@ -87,6 +88,10 @@ import { SUSPENSE_TREE_OPERATION_REMOVE, SUSPENSE_TREE_OPERATION_REORDER_CHILDREN, SUSPENSE_TREE_OPERATION_RESIZE, + UNKNOWN_SUSPENDERS_NONE, + UNKNOWN_SUSPENDERS_REASON_PRODUCTION, + UNKNOWN_SUSPENDERS_REASON_OLD_VERSION, + UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE, } from '../../constants'; import {inspectHooksOfFiber} from 'react-debug-tools'; import { @@ -296,6 +301,9 @@ type SuspenseNode = { // Track whether any of the items in suspendedBy are unique this this Suspense boundaries or if they're all // also in the parent sets. This determine whether this could contribute in the loading sequence. hasUniqueSuspenders: boolean, + // Track whether anything suspended in this boundary that we can't track either because it was using throw + // a promise, an older version of React or because we're inspecting prod. + hasUnknownSuspenders: boolean, }; function createSuspenseNode( @@ -309,6 +317,7 @@ function createSuspenseNode( rects: null, suspendedBy: new Map(), hasUniqueSuspenders: false, + hasUnknownSuspenders: false, }); } @@ -2745,6 +2754,8 @@ export function attach( parentSuspenseNode.hasUniqueSuspenders = true; } } + // We have observed at least one known reason this might have been suspended. + parentSuspenseNode.hasUnknownSuspenders = false; // Suspending right below the root is not attributed to any particular component in UI // other than the SuspenseNode and the HostRoot's FiberInstance. const suspendedBy = parentInstance.suspendedBy; @@ -2783,6 +2794,7 @@ export function attach( // It can now be marked as having unique suspenders. We can skip its children // since they'll still be blocked by this one. node.hasUniqueSuspenders = true; + node.hasUnknownSuspenders = false; } else if (node.firstChild !== null) { node = node.firstChild; continue; @@ -3458,6 +3470,25 @@ export function attach( insertSuspendedBy(asyncInfo); } + function trackThrownPromisesFromRetryCache( + suspenseNode: SuspenseNode, + retryCache: ?WeakSet, + ): void { + if (retryCache != null) { + // If a Suspense boundary ever committed in fallback state with a retryCache, that + // suggests that something unique to that boundary was suspensey since otherwise + // it wouldn't have thrown and so never created the retryCache. + // Unfortunately if we don't have any DEV time debug info or debug thenables then + // we have no meta data to show. However, we still mark this Suspense boundary as + // participating in the loading sequence since apparently it can suspend. + suspenseNode.hasUniqueSuspenders = true; + // We have not seen any reason yet for why this suspense node might have been + // suspended but it clearly has been at some point. If we later discover a reason + // we'll clear this flag again. + suspenseNode.hasUnknownSuspenders = true; + } + } + function mountVirtualChildrenRecursively( firstChild: Fiber, lastChild: null | Fiber, // non-inclusive @@ -3749,6 +3780,9 @@ export function attach( } else if (fiber.tag === SuspenseComponent && OffscreenComponent === -1) { // Legacy Suspense without the Offscreen wrapper. For the modern Suspense we just handle the // Offscreen wrapper itself specially. + if (newSuspenseNode !== null) { + trackThrownPromisesFromRetryCache(newSuspenseNode, fiber.stateNode); + } const isTimedOut = fiber.memoizedState !== null; if (isTimedOut) { // Special case: if Suspense mounts in a timed-out state, @@ -3791,6 +3825,9 @@ export function attach( 'There should always be an Offscreen Fiber child in a Suspense boundary.', ); } + + trackThrownPromisesFromRetryCache(newSuspenseNode, fiber.stateNode); + const fallbackFiber = contentFiber.sibling; // First update only the Offscreen boundary. I.e. the main content. @@ -4600,6 +4637,18 @@ export function attach( const prevWasHidden = isOffscreen && prevFiber.memoizedState !== null; const nextIsHidden = isOffscreen && nextFiber.memoizedState !== null; + if (isLegacySuspense) { + if ( + fiberInstance !== null && + fiberInstance.suspenseNode !== null && + (prevFiber.stateNode === null) !== (nextFiber.stateNode === null) + ) { + trackThrownPromisesFromRetryCache( + fiberInstance.suspenseNode, + nextFiber.stateNode, + ); + } + } // The logic below is inspired by the code paths in updateSuspenseComponent() // inside ReactFiberBeginWork in the React source code. if (prevDidTimeout && nextDidTimeOut) { @@ -4726,6 +4775,13 @@ export function attach( const prevFallbackFiber = prevContentFiber.sibling; const nextFallbackFiber = nextContentFiber.sibling; + if ((prevFiber.stateNode === null) !== (nextFiber.stateNode === null)) { + trackThrownPromisesFromRetryCache( + fiberInstance.suspenseNode, + nextFiber.stateNode, + ); + } + // First update only the Offscreen boundary. I.e. the main content. updateFlags |= updateVirtualChildrenRecursively( nextContentFiber, @@ -6090,6 +6146,23 @@ export function attach( getNearestSuspenseNode(fiberInstance), ); + let unknownSuspenders = UNKNOWN_SUSPENDERS_NONE; + if ( + fiberInstance.suspenseNode !== null && + fiberInstance.suspenseNode.hasUnknownSuspenders && + !isTimedOutSuspense + ) { + // Something unknown threw to suspended this boundary. Let's figure out why that might be. + if (renderer.bundleType === 0) { + unknownSuspenders = UNKNOWN_SUSPENDERS_REASON_PRODUCTION; + } else if (!('_debugInfo' in fiber)) { + // TODO: We really should detect _debugThenable and the auto-instrumentation for lazy/thenables too. + unknownSuspenders = UNKNOWN_SUSPENDERS_REASON_OLD_VERSION; + } else { + unknownSuspenders = UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE; + } + } + return { id: fiberInstance.id, @@ -6153,6 +6226,7 @@ export function attach( suspendedBy: suspendedBy, suspendedByRange: suspendedByRange, + unknownSuspenders: unknownSuspenders, // List of owners owners, @@ -6267,6 +6341,7 @@ export function attach( serializeAsyncInfo(info, virtualInstance, null), ), suspendedByRange: suspendedByRange, + unknownSuspenders: UNKNOWN_SUSPENDERS_NONE, // List of owners owners, diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 82a3a8d1905..764f926a2e7 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -34,6 +34,7 @@ import { TREE_OPERATION_ADD, TREE_OPERATION_REMOVE, TREE_OPERATION_REORDER_CHILDREN, + UNKNOWN_SUSPENDERS_NONE, } from '../../constants'; import {decorateMany, forceUpdate, restoreMany} from './utils'; @@ -859,6 +860,7 @@ export function attach( // Not supported in legacy renderers. suspendedBy: [], suspendedByRange: null, + unknownSuspenders: UNKNOWN_SUSPENDERS_NONE, // List of owners owners, diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 0d2a86beb5b..1da09046862 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -34,6 +34,7 @@ import type {TimelineDataExport} from 'react-devtools-timeline/src/types'; import type {BackendBridge} from 'react-devtools-shared/src/bridge'; import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes'; import type Agent from './agent'; +import type {UnknownSuspendersReason} from '../constants'; type BundleType = | 0 // PROD @@ -301,6 +302,7 @@ export type InspectedElement = { // Things that suspended this Instances suspendedBy: Object, // DehydratedData or Array suspendedByRange: null | [number, number], + unknownSuspenders: UnknownSuspendersReason, // List of owners owners: Array | null, diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index dcaec7dc034..16b1b8f89f8 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -271,6 +271,7 @@ export function convertInspectedElementBackendToFrontend( warnings, suspendedBy, suspendedByRange, + unknownSuspenders, nativeTag, } = inspectedElementBackend; @@ -315,6 +316,7 @@ export function convertInspectedElementBackendToFrontend( ? [] : hydratedSuspendedBy.map(backendToFrontendSerializedAsyncInfo), suspendedByRange, + unknownSuspenders, nativeTag, }; diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index ce6ed0b308a..8071d3d4a2c 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -32,6 +32,13 @@ export const SUSPENSE_TREE_OPERATION_RESIZE = 11; export const PROFILING_FLAG_BASIC_SUPPORT = 0b01; export const PROFILING_FLAG_TIMELINE_SUPPORT = 0b10; +export const UNKNOWN_SUSPENDERS_NONE: UnknownSuspendersReason = 0; // If we had at least one debugInfo, then that might have been the reason. +export const UNKNOWN_SUSPENDERS_REASON_PRODUCTION: UnknownSuspendersReason = 1; // We're running in prod. That might be why we had unknown suspenders. +export const UNKNOWN_SUSPENDERS_REASON_OLD_VERSION: UnknownSuspendersReason = 2; // We're running an old version of React that doesn't have full coverage. That might be the reason. +export const UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE: UnknownSuspendersReason = 3; // If we're in dev, didn't detect and debug info and still suspended (other than CSS/image) the only reason is thrown promise. + +export opaque type UnknownSuspendersReason = 0 | 1 | 2 | 3; + export const LOCAL_STORAGE_DEFAULT_TAB_KEY = 'React::DevTools::defaultTab'; export const LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY = 'React::DevTools::componentFilters'; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css index 0fb5107361c..978077d2d9a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css @@ -52,6 +52,15 @@ min-width: 1rem; } +.InfoRow { + border-top: 1px solid var(--color-border); + padding: 0.5rem 1rem; +} + +.InfoRow:last-child { + margin-bottom: -0.25rem; +} + .CollapsableRow { border-top: 1px solid var(--color-border); } 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 3527c23b066..451b53b4ac5 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -27,6 +27,13 @@ import type { } from 'react-devtools-shared/src/frontend/types'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; +import { + UNKNOWN_SUSPENDERS_NONE, + UNKNOWN_SUSPENDERS_REASON_PRODUCTION, + UNKNOWN_SUSPENDERS_REASON_OLD_VERSION, + UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE, +} from '../../../constants'; + type RowProps = { bridge: FrontendBridge, element: Element, @@ -295,7 +302,10 @@ export default function InspectedElementSuspendedBy({ const {suspendedBy, suspendedByRange} = inspectedElement; // Skip the section if nothing suspended this component. - if (suspendedBy == null || suspendedBy.length === 0) { + if ( + (suspendedBy == null || suspendedBy.length === 0) && + inspectedElement.unknownSuspenders === UNKNOWN_SUSPENDERS_NONE + ) { return null; } @@ -327,9 +337,41 @@ export default function InspectedElementSuspendedBy({ minTime = maxTime - 25; } - const sortedSuspendedBy = suspendedBy.slice(0); + const sortedSuspendedBy = suspendedBy === null ? [] : suspendedBy.slice(0); sortedSuspendedBy.sort(compareTime); + let unknownSuspenders = null; + switch (inspectedElement.unknownSuspenders) { + case UNKNOWN_SUSPENDERS_REASON_PRODUCTION: + unknownSuspenders = ( +
+ Something suspended but we don't know the exact reason in production + builds of React. Test this in development mode to see exactly what + might suspend. +
+ ); + break; + case UNKNOWN_SUSPENDERS_REASON_OLD_VERSION: + unknownSuspenders = ( +
+ Something suspended but we don't track all the necessary information + in older versions of React. Upgrade to the latest version of React to + see exactly what might suspend. +
+ ); + break; + case UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE: + unknownSuspenders = ( +
+ Something threw a Promise to suspend this boundary. It's likely an + outdated version of a library that doesn't yet fully take advantage of + use(). Upgrade your data fetching library to see exactly what might + suspend. +
+ ); + break; + } + return (
@@ -351,6 +393,7 @@ export default function InspectedElementSuspendedBy({ maxTime={maxTime} /> ))} + {unknownSuspenders}
); } diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index 7c7487b7bd6..61cc8b68fbf 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -19,6 +19,7 @@ import type { Unserializable, } from 'react-devtools-shared/src/hydration'; import type {ReactFunctionLocation, ReactStackTrace} from 'shared/ReactTypes'; +import type {UnknownSuspendersReason} from '../constants'; export type BrowserTheme = 'dark' | 'light'; @@ -281,6 +282,7 @@ export type InspectedElement = { suspendedBy: Object, // Minimum start time to maximum end time + a potential (not actual) throttle, within the nearest boundary. suspendedByRange: null | [number, number], + unknownSuspenders: UnknownSuspendersReason, // List of owners owners: Array | null,