diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 1eee4a3ad8..b927b1a58f 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -5268,6 +5268,18 @@ export function attach( } } + function getNearestSuspenseNode(instance: DevToolsInstance): SuspenseNode { + while (instance.suspenseNode === null) { + if (instance.parent === null) { + throw new Error( + 'There should always be a SuspenseNode parent on a mounted instance.', + ); + } + instance = instance.parent; + } + return instance.suspenseNode; + } + function getNearestMountedDOMNode(publicInstance: Element): null | Element { let domNode: null | Element = publicInstance; while (domNode && !publicInstanceToDevToolsInstanceMap.has(domNode)) { @@ -5556,6 +5568,56 @@ export function attach( return result; } + const FALLBACK_THROTTLE_MS: number = 300; + + function getSuspendedByRange( + suspenseNode: SuspenseNode, + ): null | [number, number] { + let min = Infinity; + let max = -Infinity; + suspenseNode.suspendedBy.forEach((_, ioInfo) => { + if (ioInfo.end > max) { + max = ioInfo.end; + } + if (ioInfo.start < min) { + min = ioInfo.start; + } + }); + const parentSuspenseNode = suspenseNode.parent; + if (parentSuspenseNode !== null) { + let parentMax = -Infinity; + parentSuspenseNode.suspendedBy.forEach((_, ioInfo) => { + if (ioInfo.end > parentMax) { + parentMax = ioInfo.end; + } + }); + // The parent max is theoretically the earlier the parent could've committed. + // Therefore, the theoretical max that the child could be throttled is that plus 300ms. + const throttleTime = parentMax + FALLBACK_THROTTLE_MS; + if (throttleTime > max) { + // If the theoretical throttle time is later than the earliest reveal then we extend + // the max time to show that this is timespan could possibly get throttled. + max = throttleTime; + } + + // We use the end of the previous boundary as the start time for this boundary unless, + // that's earlier than we'd need to expand to the full fallback throttle range. It + // suggests that the parent was loaded earlier than this one. + let startTime = max - FALLBACK_THROTTLE_MS; + if (parentMax > startTime) { + startTime = parentMax; + } + // If the first fetch of this boundary starts before that, then we use that as the start. + if (startTime < min) { + min = startTime; + } + } + if (min < Infinity && max > -Infinity) { + return [min, max]; + } + return null; + } + function getAwaitStackFromHooks( hooks: HooksTree, asyncInfo: ReactAsyncInfo, @@ -6024,6 +6086,10 @@ export function attach( : fiberInstance.suspendedBy.map(info => serializeAsyncInfo(info, fiberInstance, hooks), ); + const suspendedByRange = getSuspendedByRange( + getNearestSuspenseNode(fiberInstance), + ); + return { id: fiberInstance.id, @@ -6086,6 +6152,7 @@ export function attach( : Array.from(componentLogsEntry.warnings.entries()), suspendedBy: suspendedBy, + suspendedByRange: suspendedByRange, // List of owners owners, @@ -6144,6 +6211,9 @@ export function attach( // Things that Suspended this Server Component (use(), awaits and direct child promises) const suspendedBy = virtualInstance.suspendedBy; + const suspendedByRange = getSuspendedByRange( + getNearestSuspenseNode(virtualInstance), + ); return { id: virtualInstance.id, @@ -6196,6 +6266,7 @@ export function attach( : suspendedBy.map(info => serializeAsyncInfo(info, virtualInstance, null), ), + suspendedByRange: suspendedByRange, // 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 c2c2783936..82a3a8d190 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -858,6 +858,7 @@ export function attach( // Not supported in legacy renderers. suspendedBy: [], + suspendedByRange: null, // 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 55a1bc6532..0d2a86beb5 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -300,6 +300,7 @@ export type InspectedElement = { // Things that suspended this Instances suspendedBy: Object, // DehydratedData or Array + suspendedByRange: null | [number, number], // 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 db22606377..dcaec7dc03 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -270,6 +270,7 @@ export function convertInspectedElementBackendToFrontend( errors, warnings, suspendedBy, + suspendedByRange, nativeTag, } = inspectedElementBackend; @@ -313,6 +314,7 @@ export function convertInspectedElementBackendToFrontend( hydratedSuspendedBy == null // backwards compat ? [] : hydratedSuspendedBy.map(backendToFrontendSerializedAsyncInfo), + suspendedByRange, nativeTag, }; 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 e5e0949558..3527c23b06 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -292,7 +292,7 @@ export default function InspectedElementSuspendedBy({ inspectedElement, store, }: Props): React.Node { - const {suspendedBy} = inspectedElement; + const {suspendedBy, suspendedByRange} = inspectedElement; // Skip the section if nothing suspended this component. if (suspendedBy == null || suspendedBy.length === 0) { @@ -306,6 +306,11 @@ export default function InspectedElementSuspendedBy({ let minTime = Infinity; let maxTime = -Infinity; + if (suspendedByRange !== null) { + // The range of the whole suspense boundary. + minTime = suspendedByRange[0]; + maxTime = suspendedByRange[1]; + } for (let i = 0; i < suspendedBy.length; i++) { const asyncInfo: SerializedAsyncInfo = suspendedBy[i]; if (asyncInfo.awaited.start < minTime) { diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index 4c61a8b1e9..7c7487b7bd 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -279,6 +279,8 @@ export type InspectedElement = { // Things that suspended this Instances suspendedBy: Object, + // Minimum start time to maximum end time + a potential (not actual) throttle, within the nearest boundary. + suspendedByRange: null | [number, number], // List of owners owners: Array | null,