[DevTools] Include the description derived from the promise (#34017)

Stacked on #34016.

This is using the same thing we already do for the performance track to
provide a description of the I/O based on the content of the resolved
Promise. E.g. a Response's URL.

<img width="375" height="388" alt="Screenshot 2025-07-28 at 1 09 49 AM"
src="https://github.com/user-attachments/assets/f3fdc40f-4e21-4e83-b49e-21c7ec975137"
/>
This commit is contained in:
Sebastian Markbåge 2025-07-28 15:11:04 -04:00 committed by GitHub
parent 7ee7571212
commit 71236c9409
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 158 additions and 68 deletions

View File

@ -22,6 +22,8 @@ import {
addObjectToProperties,
} from 'shared/ReactPerformanceTrackProperties';
import {getIODescription} from 'shared/ReactIODescription';
const supportsUserTiming =
enableProfilerTimer &&
typeof console !== 'undefined' &&
@ -300,70 +302,6 @@ function getIOColor(
}
}
function getIODescription(value: any): string {
if (!__DEV__) {
return '';
}
try {
switch (typeof value) {
case 'object':
// Test the object for a bunch of common property names that are useful identifiers.
// While we only have the return value here, it should ideally be a name that
// describes the arguments requested.
if (value === null) {
return '';
} else if (value instanceof Error) {
// eslint-disable-next-line react-internal/safe-string-coercion
return String(value.message);
} else if (typeof value.url === 'string') {
return value.url;
} else if (typeof value.command === 'string') {
return value.command;
} else if (
typeof value.request === 'object' &&
typeof value.request.url === 'string'
) {
return value.request.url;
} else if (
typeof value.response === 'object' &&
typeof value.response.url === 'string'
) {
return value.response.url;
} else if (
typeof value.id === 'string' ||
typeof value.id === 'number' ||
typeof value.id === 'bigint'
) {
// eslint-disable-next-line react-internal/safe-string-coercion
return String(value.id);
} else if (typeof value.name === 'string') {
return value.name;
} else {
const str = value.toString();
if (str.startWith('[object ') || str.length < 5 || str.length > 500) {
// This is probably not a useful description.
return '';
}
return str;
}
case 'string':
if (value.length < 5 || value.length > 500) {
return '';
}
return value;
case 'number':
case 'bigint':
// eslint-disable-next-line react-internal/safe-string-coercion
return String(value);
default:
// Not useful descriptors.
return '';
}
} catch (x) {
return '';
}
}
function getIOLongName(
ioInfo: ReactIOInfo,
description: string,

View File

@ -105,6 +105,8 @@ import {componentInfoToComponentLogsMap} from '../shared/DevToolsServerComponent
import is from 'shared/objectIs';
import hasOwnProperty from 'shared/hasOwnProperty';
import {getIODescription} from 'shared/ReactIODescription';
import {
getStackByFiberInDevAndProd,
getOwnerStackByFiberInDev,
@ -4116,9 +4118,26 @@ export function attach(
parentInstance,
asyncInfo.owner,
);
const value: any = ioInfo.value;
let resolvedValue = undefined;
if (
typeof value === 'object' &&
value !== null &&
typeof value.then === 'function'
) {
switch (value.status) {
case 'fulfilled':
resolvedValue = value.value;
break;
case 'rejected':
resolvedValue = value.reason;
break;
}
}
return {
awaited: {
name: ioInfo.name,
description: getIODescription(resolvedValue),
start: ioInfo.start,
end: ioInfo.end,
value: ioInfo.value == null ? null : ioInfo.value,

View File

@ -235,6 +235,7 @@ export type PathMatch = {
// Serialized version of ReactIOInfo
export type SerializedIOInfo = {
name: string,
description: string,
start: number,
end: number,
value: null | Promise<mixed>,

View File

@ -218,6 +218,7 @@ function backendToFrontendSerializedAsyncInfo(
return {
awaited: {
name: ioInfo.name,
description: ioInfo.description,
start: ioInfo.start,
end: ioInfo.end,
value: ioInfo.value,

View File

@ -75,11 +75,25 @@
color: var(--color-expand-collapse-toggle);
}
.CollapsableHeaderTitle {
flex: 1 1 auto;
.CollapsableHeaderTitle, .CollapsableHeaderDescription, .CollapsableHeaderSeparator, .CollapsableHeaderFiller {
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
text-align: left;
white-space: nowrap;
}
.CollapsableHeaderTitle {
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
}
.CollapsableHeaderSeparator {
flex: 0 0 auto;
white-space: pre;
}
.CollapsableHeaderFiller {
flex: 1 0 0;
}
.CollapsableContent {
@ -108,4 +122,4 @@
.TimeBarSpanErrored {
background-color: var(--color-timespan-background-errored);
}
}

View File

@ -38,6 +38,37 @@ type RowProps = {
maxTime: number,
};
function getShortDescription(name: string, description: string): string {
const descMaxLength = 30 - name.length;
if (descMaxLength > 1) {
const l = description.length;
if (l > 0 && l <= descMaxLength) {
// We can fit the full description
return description;
} else if (
description.startsWith('http://') ||
description.startsWith('https://') ||
description.startsWith('/')
) {
// Looks like a URL. Let's see if we can extract something shorter.
// We don't have to do a full parse so let's try something cheaper.
let queryIdx = description.indexOf('?');
if (queryIdx === -1) {
queryIdx = description.length;
}
if (description.charCodeAt(queryIdx - 1) === 47 /* "/" */) {
// Ends with slash. Look before that.
queryIdx--;
}
const slashIdx = description.lastIndexOf('/', queryIdx - 1);
// This may now be either the file name or the host.
// Include the slash to make it more obvious what we trimmed.
return '…' + description.slice(slashIdx, queryIdx);
}
}
return '';
}
function SuspendedByRow({
bridge,
element,
@ -50,6 +81,9 @@ function SuspendedByRow({
}: RowProps) {
const [isOpen, setIsOpen] = useState(false);
const name = asyncInfo.awaited.name;
const description = asyncInfo.awaited.description;
const longName = description === '' ? name : name + ' (' + description + ')';
const shortDescription = getShortDescription(name, description);
let stack;
let owner;
if (asyncInfo.stack === null || asyncInfo.stack.length === 0) {
@ -83,12 +117,22 @@ function SuspendedByRow({
<Button
className={styles.CollapsableHeader}
onClick={() => setIsOpen(prevIsOpen => !prevIsOpen)}
title={name + ' — ' + (end - start).toFixed(2) + ' ms'}>
title={longName + ' — ' + (end - start).toFixed(2) + ' ms'}>
<ButtonIcon
className={styles.CollapsableHeaderIcon}
type={isOpen ? 'expanded' : 'collapsed'}
/>
<span className={styles.CollapsableHeaderTitle}>{name}</span>
{shortDescription === '' ? null : (
<>
<span className={styles.CollapsableHeaderSeparator}>{' ('}</span>
<span className={styles.CollapsableHeaderTitle}>
{shortDescription}
</span>
<span className={styles.CollapsableHeaderSeparator}>{') '}</span>
</>
)}
<div className={styles.CollapsableHeaderFiller} />
<div className={styles.TimeBarContainer}>
<div
className={

View File

@ -187,6 +187,7 @@ export type Element = {
// Serialized version of ReactIOInfo
export type SerializedIOInfo = {
name: string,
description: string,
start: number,
end: number,
value: null | Promise<mixed>,

View File

@ -0,0 +1,72 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
export function getIODescription(value: any): string {
if (!__DEV__) {
return '';
}
try {
switch (typeof value) {
case 'object':
// Test the object for a bunch of common property names that are useful identifiers.
// While we only have the return value here, it should ideally be a name that
// describes the arguments requested.
if (value === null) {
return '';
} else if (value instanceof Error) {
// eslint-disable-next-line react-internal/safe-string-coercion
return String(value.message);
} else if (typeof value.url === 'string') {
return value.url;
} else if (typeof value.command === 'string') {
return value.command;
} else if (
typeof value.request === 'object' &&
typeof value.request.url === 'string'
) {
return value.request.url;
} else if (
typeof value.response === 'object' &&
typeof value.response.url === 'string'
) {
return value.response.url;
} else if (
typeof value.id === 'string' ||
typeof value.id === 'number' ||
typeof value.id === 'bigint'
) {
// eslint-disable-next-line react-internal/safe-string-coercion
return String(value.id);
} else if (typeof value.name === 'string') {
return value.name;
} else {
const str = value.toString();
if (str.startWith('[object ') || str.length < 5 || str.length > 500) {
// This is probably not a useful description.
return '';
}
return str;
}
case 'string':
if (value.length < 5 || value.length > 500) {
return '';
}
return value;
case 'number':
case 'bigint':
// eslint-disable-next-line react-internal/safe-string-coercion
return String(value);
default:
// Not useful descriptors.
return '';
}
} catch (x) {
return '';
}
}