[DevTools] Assign a different color and label based on environment (#34893)

Stacked on #34892.

In the timeline scrubber each timeline entry gets a label and color
assigned based on the environment computed for that step.

In the rects, we find the timeline step that this boundary is part of
and use that environment to assign a color. This is slightly different
than picking from the boundary itself since it takes into account parent
boundaries.

In the "suspended by" section we color each entry individually based on
the environment that spawned the I/O.

<img width="790" height="813" alt="Screenshot 2025-10-17 at 12 18 56 AM"
src="https://github.com/user-attachments/assets/c902b1fb-0992-4e24-8e94-a97ca8507551"
/>
This commit is contained in:
Sebastian Markbåge 2025-10-17 19:03:15 -04:00 committed by GitHub
parent a083344699
commit 3a669170e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 102 additions and 22 deletions

View File

@ -154,8 +154,8 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-warning-text-color': '#ffffff',
'--color-warning-text-color-inverted': '#fd4d69',
'--color-suspense': '#0088fa',
'--color-transition': '#6a51b2',
'--color-suspense-default': '#0088fa',
'--color-transition-default': '#6a51b2',
'--color-suspense-server': '#62bc6a',
'--color-transition-server': '#3f7844',
'--color-suspense-other': '#f3ce49',
@ -315,8 +315,8 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-warning-text-color': '#ffffff',
'--color-warning-text-color-inverted': '#ee1638',
'--color-suspense': '#61dafb',
'--color-transition': '#6a51b2',
'--color-suspense-default': '#61dafb',
'--color-transition-default': '#6a51b2',
'--color-suspense-server': '#62bc6a',
'--color-transition-server': '#3f7844',
'--color-suspense-other': '#f3ce49',

View File

@ -22,6 +22,8 @@ import OwnerView from './OwnerView';
import {meta} from '../../../hydration';
import useInferredName from '../useInferredName';
import {getClassNameForEnvironment} from '../SuspenseTab/SuspenseEnvironmentColors.js';
import type {
InspectedElement,
SerializedAsyncInfo,
@ -181,7 +183,12 @@ function SuspendedByRow({
</>
)}
<div className={styles.CollapsableHeaderFiller} />
<div className={styles.TimeBarContainer}>
<div
className={
styles.TimeBarContainer +
' ' +
getClassNameForEnvironment(ioInfo.env)
}>
<div
className={
!isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
@ -341,6 +348,7 @@ type GroupProps = {
inspectedElement: InspectedElement,
store: Store,
name: string,
environment: null | string,
suspendedBy: Array<{
index: number,
value: SerializedAsyncInfo,
@ -355,6 +363,7 @@ function SuspendedByGroup({
inspectedElement,
store,
name,
environment,
suspendedBy,
minTime,
maxTime,
@ -407,7 +416,12 @@ function SuspendedByGroup({
<span className={styles.CollapsableHeaderTitle}>{pluralizedName}</span>
<div className={styles.CollapsableHeaderFiller} />
{isOpen ? null : (
<div className={styles.TimeBarContainer}>
<div
className={
styles.TimeBarContainer +
' ' +
getClassNameForEnvironment(environment)
}>
<div
className={
!isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
@ -502,17 +516,21 @@ export default function InspectedElementSuspendedBy({
const groups = [];
let currentGroup = null;
let currentGroupName = null;
let currentGroupEnv = null;
for (let i = 0; i < sortedSuspendedBy.length; i++) {
const entry = sortedSuspendedBy[i];
const name = entry.value.awaited.name;
const env = entry.value.awaited.env;
if (
currentGroupName !== name ||
currentGroupEnv !== env ||
!name ||
name === 'Promise' ||
currentGroup === null
) {
// Create a new group.
currentGroupName = name;
currentGroupEnv = env;
currentGroup = [];
groups.push(currentGroup);
}
@ -591,6 +609,7 @@ export default function InspectedElementSuspendedBy({
<SuspendedByGroup
key={entries[0].index}
name={entries[0].value.awaited.name}
environment={entries[0].value.awaited.env}
suspendedBy={entries}
bridge={bridge}
element={element}

View File

@ -0,0 +1,14 @@
.SuspenseEnvironmentDefault {
--color-suspense: var(--color-suspense-default);
--color-transition: var(--color-transition-default);
}
.SuspenseEnvironmentServer {
--color-suspense: var(--color-suspense-server);
--color-transition: var(--color-transition-server);
}
.SuspenseEnvironmentOther {
--color-suspense: var(--color-suspense-other);
--color-transition: var(--color-transition-other);
}

View File

@ -0,0 +1,20 @@
/**
* 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
*/
import styles from './SuspenseEnvironmentColors.css';
export function getClassNameForEnvironment(environment: null | string): string {
if (environment === null) {
return styles.SuspenseEnvironmentDefault;
}
if (environment === 'Server') {
return styles.SuspenseEnvironmentServer;
}
return styles.SuspenseEnvironmentOther;
}

View File

@ -30,6 +30,7 @@ import {
SuspenseTreeStateContext,
SuspenseTreeDispatcherContext,
} from './SuspenseTreeContext';
import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
function ScaledRect({
className,
@ -157,12 +158,25 @@ function SuspenseRects({
hoveredTimelineIndex > -1 &&
timeline[hoveredTimelineIndex].id === suspenseID;
let environment: null | string = null;
for (let i = 0; i < timeline.length; i++) {
const timelineStep = timeline[i];
if (timelineStep.id === suspenseID) {
environment = timelineStep.environment;
break;
}
}
const boundingBox = getBoundingBox(suspense.rects);
return (
<ScaledRect
rect={boundingBox}
className={styles.SuspenseRectsBoundary}
className={
styles.SuspenseRectsBoundary +
' ' +
getClassNameForEnvironment(environment)
}
visible={visible}
selected={selected}
suspended={suspense.isSuspended}
@ -327,9 +341,8 @@ function SuspenseRectsContainer(): React$Node {
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
const {roots, hoveredTimelineIndex, uniqueSuspendersOnly} = useContext(
SuspenseTreeStateContext,
);
const {roots, timeline, hoveredTimelineIndex, uniqueSuspendersOnly} =
useContext(SuspenseTreeStateContext);
// TODO: bbox does not consider uniqueSuspendersOnly filter
const boundingBox = getDocumentBoundingRect(store, roots);
@ -389,11 +402,16 @@ function SuspenseRectsContainer(): React$Node {
}
}
const rootEnvironment =
timeline.length === 0 ? null : timeline[0].environment;
return (
<div
className={
styles.SuspenseRectsContainer +
(hasRootSuspenders ? ' ' + styles.SuspenseRectsRoot : '')
(hasRootSuspenders ? ' ' + styles.SuspenseRectsRoot : '') +
' ' +
getClassNameForEnvironment(rootEnvironment)
}
onClick={handleClick}
onDoubleClick={handleDoubleClick}

View File

@ -7,6 +7,8 @@
* @flow
*/
import type {SuspenseTimelineStep} from 'react-devtools-shared/src/frontend/types';
import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react';
@ -14,11 +16,14 @@ import {useRef} from 'react';
import styles from './SuspenseScrubber.css';
import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
import Tooltip from '../Components/reach-ui/tooltip';
export default function SuspenseScrubber({
min,
max,
timeline,
value,
highlight,
onBlur,
@ -29,6 +34,7 @@ export default function SuspenseScrubber({
}: {
min: number,
max: number,
timeline: $ReadOnlyArray<SuspenseTimelineStep>,
value: number,
highlight: number,
onBlur?: () => void,
@ -54,17 +60,18 @@ export default function SuspenseScrubber({
}
const steps = [];
for (let index = min; index <= max; index++) {
const environment = timeline[index].environment;
const label =
index === min
? // The first step in the timeline is always a Transition (Initial Paint).
'Initial Paint' +
(environment === null ? '' : ' (' + environment + ')')
: // TODO: Consider adding the name of this specific boundary if this step has only one.
environment === null
? 'Suspense'
: environment;
steps.push(
<Tooltip
key={index}
label={
index === min
? // The first step in the timeline is always a Transition (Initial Paint).
// TODO: Support multiple environments.
'Initial Paint'
: // TODO: Consider adding the name of this specific boundary if this step has only one.
'Suspense'
}>
<Tooltip key={index} label={label}>
<div
className={
styles.SuspenseScrubberStep +
@ -79,9 +86,10 @@ export default function SuspenseScrubber({
styles.SuspenseScrubberBead +
(index === min
? // The first step in the timeline is always a Transition (Initial Paint).
// TODO: Support multiple environments.
' ' + styles.SuspenseScrubberBeadTransition
: '') +
' ' +
getClassNameForEnvironment(environment) +
(index <= value ? ' ' + styles.SuspenseScrubberBeadSelected : '')
}
/>

View File

@ -173,6 +173,7 @@ function SuspenseTimelineInput() {
<SuspenseScrubber
min={min}
max={max}
timeline={timeline}
value={timelineIndex}
highlight={hoveredTimelineIndex}
onChange={handleChange}