From 2ba7b07ce10448cc37d793a50d5ca0999e63aad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 15 Aug 2025 13:34:07 -0400 Subject: [PATCH] [DevTools] Compute a min and max range for the currently selected suspense boundary (#34201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This computes a min and max range for the whole suspense boundary even when selecting a single component so that each component in a boundary has a consistent range. The start of this range is the earliest start of I/O in that boundary or the end of the previous suspense boundary, whatever is earlier. If the end of the previous boundary would make the range large, then we cap it since it's likely that the other boundary was just an independent render. The end of the range is the latest end of I/O in that boundary. If this is smaller than the end of the previous boundary plus the 300ms throttle, then we extend the end. This visualizes what throttling could potentially do if the previous boundary committed right at its end. Ofc, it might not have committed exactly at that time in this render. So this is just showing a potential throttle that could happen. To see actual throttle, you look in the Performance Track. Screenshot 2025-08-14 at 12 41 43 AM We could come up with some annotation to highlight that this is eligible to be throttled in this case. If the lines don't extend to the edge, then it's likely it was throttled. --- .../src/backend/fiber/renderer.js | 71 +++++++++++++++++++ .../src/backend/legacy/renderer.js | 1 + .../src/backend/types.js | 1 + .../react-devtools-shared/src/backendAPI.js | 2 + .../Components/InspectedElementSuspendedBy.js | 7 +- .../src/frontend/types.js | 2 + 6 files changed, 83 insertions(+), 1 deletion(-) 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,