[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" />
Sebastian Markbåge committed
Oct 17, 2025 at 19:03 UTC
3a669170e96a63a4ce3a44f78401fb9f4f803510
7 files changed
+102
-22
packages/react-devtools-shared/src/devtools/constants.js
+4
-4
@@ -154,8 +154,8 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
154
'--color-warning-text-color': '#ffffff',
155
'--color-warning-text-color-inverted': '#fd4d69',
156
157
- '--color-suspense': '#0088fa',
158
- '--color-transition': '#6a51b2',
157
+ '--color-suspense-default': '#0088fa',
158
+ '--color-transition-default': '#6a51b2',
159
'--color-suspense-server': '#62bc6a',
160
'--color-transition-server': '#3f7844',
161
'--color-suspense-other': '#f3ce49',
@@ -315,8 +315,8 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
315
'--color-warning-text-color': '#ffffff',
316
'--color-warning-text-color-inverted': '#ee1638',
317
318
- '--color-suspense': '#61dafb',
319
- '--color-transition': '#6a51b2',
318
+ '--color-suspense-default': '#61dafb',
319
+ '--color-transition-default': '#6a51b2',
320
'--color-suspense-server': '#62bc6a',
321
'--color-transition-server': '#3f7844',
322
'--color-suspense-other': '#f3ce49',
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
+21
-2
@@ -22,6 +22,8 @@ import OwnerView from './OwnerView';
22
import {meta} from '../../../hydration';
23
import useInferredName from '../useInferredName';
24
25
+import {getClassNameForEnvironment} from '../SuspenseTab/SuspenseEnvironmentColors.js';
26
+
27
import type {
28
InspectedElement,
29
SerializedAsyncInfo,
@@ -181,7 +183,12 @@ function SuspendedByRow({
183
</>
184
)}
185
<div className={styles.CollapsableHeaderFiller} />
184
- <div className={styles.TimeBarContainer}>
186
+ <div
187
+ className={
188
+ styles.TimeBarContainer +
189
+ ' ' +
190
+ getClassNameForEnvironment(ioInfo.env)
191
+ }>
192
<div
193
className={
194
!isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
@@ -341,6 +348,7 @@ type GroupProps = {
348
inspectedElement: InspectedElement,
349
store: Store,
350
name: string,
351
+ environment: null | string,
352
suspendedBy: Array<{
353
index: number,
354
value: SerializedAsyncInfo,
@@ -355,6 +363,7 @@ function SuspendedByGroup({
363
inspectedElement,
364
store,
365
name,
366
+ environment,
367
suspendedBy,
368
minTime,
369
maxTime,
@@ -407,7 +416,12 @@ function SuspendedByGroup({
416
<span className={styles.CollapsableHeaderTitle}>{pluralizedName}</span>
417
<div className={styles.CollapsableHeaderFiller} />
418
{isOpen ? null : (
410
- <div className={styles.TimeBarContainer}>
419
+ <div
420
+ className={
421
+ styles.TimeBarContainer +
422
+ ' ' +
423
+ getClassNameForEnvironment(environment)
424
+ }>
425
<div
426
className={
427
!isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
@@ -502,17 +516,21 @@ export default function InspectedElementSuspendedBy({
516
const groups = [];
517
let currentGroup = null;
518
let currentGroupName = null;
519
+ let currentGroupEnv = null;
520
for (let i = 0; i < sortedSuspendedBy.length; i++) {
521
const entry = sortedSuspendedBy[i];
522
const name = entry.value.awaited.name;
523
+ const env = entry.value.awaited.env;
524
if (
525
currentGroupName !== name ||
526
+ currentGroupEnv !== env ||
527
!name ||
528
name === 'Promise' ||
529
currentGroup === null
530
) {
531
// Create a new group.
532
currentGroupName = name;
533
+ currentGroupEnv = env;
534
currentGroup = [];
535
groups.push(currentGroup);
536
}
@@ -591,6 +609,7 @@ export default function InspectedElementSuspendedBy({
609
<SuspendedByGroup
610
key={entries[0].index}
611
name={entries[0].value.awaited.name}
612
+ environment={entries[0].value.awaited.env}
613
suspendedBy={entries}
614
bridge={bridge}
615
element={element}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseEnvironmentColors.css
new
+14
@@ -0,0 +1,14 @@
1
+.SuspenseEnvironmentDefault {
2
+ --color-suspense: var(--color-suspense-default);
3
+ --color-transition: var(--color-transition-default);
4
+}
5
+
6
+.SuspenseEnvironmentServer {
7
+ --color-suspense: var(--color-suspense-server);
8
+ --color-transition: var(--color-transition-server);
9
+}
10
+
11
+.SuspenseEnvironmentOther {
12
+ --color-suspense: var(--color-suspense-other);
13
+ --color-transition: var(--color-transition-other);
14
+}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseEnvironmentColors.js
new
+20
@@ -0,0 +1,20 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import styles from './SuspenseEnvironmentColors.css';
11
+
12
+export function getClassNameForEnvironment(environment: null | string): string {
13
+ if (environment === null) {
14
+ return styles.SuspenseEnvironmentDefault;
15
+ }
16
+ if (environment === 'Server') {
17
+ return styles.SuspenseEnvironmentServer;
18
+ }
19
+ return styles.SuspenseEnvironmentOther;
20
+}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
+23
-5
@@ -30,6 +30,7 @@ import {
30
SuspenseTreeStateContext,
31
SuspenseTreeDispatcherContext,
32
} from './SuspenseTreeContext';
33
+import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
34
35
function ScaledRect({
36
className,
@@ -157,12 +158,25 @@ function SuspenseRects({
158
hoveredTimelineIndex > -1 &&
159
timeline[hoveredTimelineIndex].id === suspenseID;
160
161
+ let environment: null | string = null;
162
+ for (let i = 0; i < timeline.length; i++) {
163
+ const timelineStep = timeline[i];
164
+ if (timelineStep.id === suspenseID) {
165
+ environment = timelineStep.environment;
166
+ break;
167
+ }
168
+ }
169
+
170
const boundingBox = getBoundingBox(suspense.rects);
171
172
return (
173
<ScaledRect
174
rect={boundingBox}
165
- className={styles.SuspenseRectsBoundary}
175
+ className={
176
+ styles.SuspenseRectsBoundary +
177
+ ' ' +
178
+ getClassNameForEnvironment(environment)
179
+ }
180
visible={visible}
181
selected={selected}
182
suspended={suspense.isSuspended}
@@ -327,9 +341,8 @@ function SuspenseRectsContainer(): React$Node {
341
const treeDispatch = useContext(TreeDispatcherContext);
342
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
343
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
330
- const {roots, hoveredTimelineIndex, uniqueSuspendersOnly} = useContext(
331
- SuspenseTreeStateContext,
332
- );
344
+ const {roots, timeline, hoveredTimelineIndex, uniqueSuspendersOnly} =
345
+ useContext(SuspenseTreeStateContext);
346
347
// TODO: bbox does not consider uniqueSuspendersOnly filter
348
const boundingBox = getDocumentBoundingRect(store, roots);
@@ -389,11 +402,16 @@ function SuspenseRectsContainer(): React$Node {
402
}
403
}
404
405
+ const rootEnvironment =
406
+ timeline.length === 0 ? null : timeline[0].environment;
407
+
408
return (
409
<div
410
className={
411
styles.SuspenseRectsContainer +
396
- (hasRootSuspenders ? ' ' + styles.SuspenseRectsRoot : '')
412
+ (hasRootSuspenders ? ' ' + styles.SuspenseRectsRoot : '') +
413
+ ' ' +
414
+ getClassNameForEnvironment(rootEnvironment)
415
}
416
onClick={handleClick}
417
onDoubleClick={handleDoubleClick}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js
+19
-11
@@ -7,6 +7,8 @@
7
* @flow
8
*/
9
10
+import type {SuspenseTimelineStep} from 'react-devtools-shared/src/frontend/types';
11
+
12
import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
13
14
import * as React from 'react';
@@ -14,11 +16,14 @@ import {useRef} from 'react';
16
17
import styles from './SuspenseScrubber.css';
18
19
+import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
20
+
21
import Tooltip from '../Components/reach-ui/tooltip';
22
23
export default function SuspenseScrubber({
24
min,
25
max,
26
+ timeline,
27
value,
28
highlight,
29
onBlur,
@@ -29,6 +34,7 @@ export default function SuspenseScrubber({
34
}: {
35
min: number,
36
max: number,
37
+ timeline: $ReadOnlyArray<SuspenseTimelineStep>,
38
value: number,
39
highlight: number,
40
onBlur?: () => void,
@@ -54,17 +60,18 @@ export default function SuspenseScrubber({
60
}
61
const steps = [];
62
for (let index = min; index <= max; index++) {
63
+ const environment = timeline[index].environment;
64
+ const label =
65
+ index === min
66
+ ? // The first step in the timeline is always a Transition (Initial Paint).
67
+ 'Initial Paint' +
68
+ (environment === null ? '' : ' (' + environment + ')')
69
+ : // TODO: Consider adding the name of this specific boundary if this step has only one.
70
+ environment === null
71
+ ? 'Suspense'
72
+ : environment;
73
steps.push(
58
- <Tooltip
59
- key={index}
60
- label={
61
- index === min
62
- ? // The first step in the timeline is always a Transition (Initial Paint).
63
- // TODO: Support multiple environments.
64
- 'Initial Paint'
65
- : // TODO: Consider adding the name of this specific boundary if this step has only one.
66
- 'Suspense'
67
- }>
74
+ <Tooltip key={index} label={label}>
75
<div
76
className={
77
styles.SuspenseScrubberStep +
@@ -79,9 +86,10 @@ export default function SuspenseScrubber({
86
styles.SuspenseScrubberBead +
87
(index === min
88
? // The first step in the timeline is always a Transition (Initial Paint).
82
- // TODO: Support multiple environments.
89
' ' + styles.SuspenseScrubberBeadTransition
90
: '') +
91
+ ' ' +
92
+ getClassNameForEnvironment(environment) +
93
(index <= value ? ' ' + styles.SuspenseScrubberBeadSelected : '')
94
}
95
/>
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+1
@@ -173,6 +173,7 @@ function SuspenseTimelineInput() {
173
<SuspenseScrubber
174
min={min}
175
max={max}
176
+ timeline={timeline}
177
value={timelineIndex}
178
highlight={hoveredTimelineIndex}
179
onChange={handleChange}