@samitouri / QOS-React-2 / commits / 2ba7b07ce1

[DevTools] Compute a min and max range for the currently selected suspense boundary (#34201)

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. <img width="661" height="353" alt="Screenshot 2025-08-14 at 12 41 43 AM" src="https://github.com/user-attachments/assets/b0155e5e-a83f-400c-a6b9-5c38a9d8a34f" /> 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.

Sebastian Markbåge committed Aug 15, 2025 at 13:34 UTC 2ba7b07ce10448cc37d793a50d5ca0999e63aad8
6 files changed +83 -1
packages/react-devtools-shared/src/backend/fiber/renderer.js
+71
@@ -5268,6 +5268,18 @@ export function attach(
5268 }
5269 }
5270
5271 + function getNearestSuspenseNode(instance: DevToolsInstance): SuspenseNode {
5272 + while (instance.suspenseNode === null) {
5273 + if (instance.parent === null) {
5274 + throw new Error(
5275 + 'There should always be a SuspenseNode parent on a mounted instance.',
5276 + );
5277 + }
5278 + instance = instance.parent;
5279 + }
5280 + return instance.suspenseNode;
5281 + }
5282 +
5283 function getNearestMountedDOMNode(publicInstance: Element): null | Element {
5284 let domNode: null | Element = publicInstance;
5285 while (domNode && !publicInstanceToDevToolsInstanceMap.has(domNode)) {
@@ -5556,6 +5568,56 @@ export function attach(
5568 return result;
5569 }
5570
5571 + const FALLBACK_THROTTLE_MS: number = 300;
5572 +
5573 + function getSuspendedByRange(
5574 + suspenseNode: SuspenseNode,
5575 + ): null | [number, number] {
5576 + let min = Infinity;
5577 + let max = -Infinity;
5578 + suspenseNode.suspendedBy.forEach((_, ioInfo) => {
5579 + if (ioInfo.end > max) {
5580 + max = ioInfo.end;
5581 + }
5582 + if (ioInfo.start < min) {
5583 + min = ioInfo.start;
5584 + }
5585 + });
5586 + const parentSuspenseNode = suspenseNode.parent;
5587 + if (parentSuspenseNode !== null) {
5588 + let parentMax = -Infinity;
5589 + parentSuspenseNode.suspendedBy.forEach((_, ioInfo) => {
5590 + if (ioInfo.end > parentMax) {
5591 + parentMax = ioInfo.end;
5592 + }
5593 + });
5594 + // The parent max is theoretically the earlier the parent could've committed.
5595 + // Therefore, the theoretical max that the child could be throttled is that plus 300ms.
5596 + const throttleTime = parentMax + FALLBACK_THROTTLE_MS;
5597 + if (throttleTime > max) {
5598 + // If the theoretical throttle time is later than the earliest reveal then we extend
5599 + // the max time to show that this is timespan could possibly get throttled.
5600 + max = throttleTime;
5601 + }
5602 +
5603 + // We use the end of the previous boundary as the start time for this boundary unless,
5604 + // that's earlier than we'd need to expand to the full fallback throttle range. It
5605 + // suggests that the parent was loaded earlier than this one.
5606 + let startTime = max - FALLBACK_THROTTLE_MS;
5607 + if (parentMax > startTime) {
5608 + startTime = parentMax;
5609 + }
5610 + // If the first fetch of this boundary starts before that, then we use that as the start.
5611 + if (startTime < min) {
5612 + min = startTime;
5613 + }
5614 + }
5615 + if (min < Infinity && max > -Infinity) {
5616 + return [min, max];
5617 + }
5618 + return null;
5619 + }
5620 +
5621 function getAwaitStackFromHooks(
5622 hooks: HooksTree,
5623 asyncInfo: ReactAsyncInfo,
@@ -6024,6 +6086,10 @@ export function attach(
6086 : fiberInstance.suspendedBy.map(info =>
6087 serializeAsyncInfo(info, fiberInstance, hooks),
6088 );
6089 + const suspendedByRange = getSuspendedByRange(
6090 + getNearestSuspenseNode(fiberInstance),
6091 + );
6092 +
6093 return {
6094 id: fiberInstance.id,
6095
@@ -6086,6 +6152,7 @@ export function attach(
6152 : Array.from(componentLogsEntry.warnings.entries()),
6153
6154 suspendedBy: suspendedBy,
6155 + suspendedByRange: suspendedByRange,
6156
6157 // List of owners
6158 owners,
@@ -6144,6 +6211,9 @@ export function attach(
6211
6212 // Things that Suspended this Server Component (use(), awaits and direct child promises)
6213 const suspendedBy = virtualInstance.suspendedBy;
6214 + const suspendedByRange = getSuspendedByRange(
6215 + getNearestSuspenseNode(virtualInstance),
6216 + );
6217
6218 return {
6219 id: virtualInstance.id,
@@ -6196,6 +6266,7 @@ export function attach(
6266 : suspendedBy.map(info =>
6267 serializeAsyncInfo(info, virtualInstance, null),
6268 ),
6269 + suspendedByRange: suspendedByRange,
6270
6271 // List of owners
6272 owners,
packages/react-devtools-shared/src/backend/legacy/renderer.js
+1
@@ -858,6 +858,7 @@ export function attach(
858
859 // Not supported in legacy renderers.
860 suspendedBy: [],
861 + suspendedByRange: null,
862
863 // List of owners
864 owners,
packages/react-devtools-shared/src/backend/types.js
+1
@@ -300,6 +300,7 @@ export type InspectedElement = {
300
301 // Things that suspended this Instances
302 suspendedBy: Object, // DehydratedData or Array<SerializedAsyncInfo>
303 + suspendedByRange: null | [number, number],
304
305 // List of owners
306 owners: Array<SerializedElement> | null,
packages/react-devtools-shared/src/backendAPI.js
+2
@@ -270,6 +270,7 @@ export function convertInspectedElementBackendToFrontend(
270 errors,
271 warnings,
272 suspendedBy,
273 + suspendedByRange,
274 nativeTag,
275 } = inspectedElementBackend;
276
@@ -313,6 +314,7 @@ export function convertInspectedElementBackendToFrontend(
314 hydratedSuspendedBy == null // backwards compat
315 ? []
316 : hydratedSuspendedBy.map(backendToFrontendSerializedAsyncInfo),
317 + suspendedByRange,
318 nativeTag,
319 };
320
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
+6 -1
@@ -292,7 +292,7 @@ export default function InspectedElementSuspendedBy({
292 inspectedElement,
293 store,
294 }: Props): React.Node {
295 - const {suspendedBy} = inspectedElement;
295 + const {suspendedBy, suspendedByRange} = inspectedElement;
296
297 // Skip the section if nothing suspended this component.
298 if (suspendedBy == null || suspendedBy.length === 0) {
@@ -306,6 +306,11 @@ export default function InspectedElementSuspendedBy({
306
307 let minTime = Infinity;
308 let maxTime = -Infinity;
309 + if (suspendedByRange !== null) {
310 + // The range of the whole suspense boundary.
311 + minTime = suspendedByRange[0];
312 + maxTime = suspendedByRange[1];
313 + }
314 for (let i = 0; i < suspendedBy.length; i++) {
315 const asyncInfo: SerializedAsyncInfo = suspendedBy[i];
316 if (asyncInfo.awaited.start < minTime) {
packages/react-devtools-shared/src/frontend/types.js
+2
@@ -279,6 +279,8 @@ export type InspectedElement = {
279
280 // Things that suspended this Instances
281 suspendedBy: Object,
282 + // Minimum start time to maximum end time + a potential (not actual) throttle, within the nearest boundary.
283 + suspendedByRange: null | [number, number],
284
285 // List of owners
286 owners: Array<SerializedElement> | null,