[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.
This commit is contained in:
Sebastian Markbåge 2025-08-15 13:34:07 -04:00 committed by GitHub
parent a96a0f3903
commit 2ba7b07ce1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 83 additions and 1 deletions

View File

@ -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,

View File

@ -858,6 +858,7 @@ export function attach(
// Not supported in legacy renderers.
suspendedBy: [],
suspendedByRange: null,
// List of owners
owners,

View File

@ -300,6 +300,7 @@ export type InspectedElement = {
// Things that suspended this Instances
suspendedBy: Object, // DehydratedData or Array<SerializedAsyncInfo>
suspendedByRange: null | [number, number],
// List of owners
owners: Array<SerializedElement> | null,

View File

@ -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,
};

View File

@ -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) {

View File

@ -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<SerializedElement> | null,