@samitouri / QOS-React-1 / commits / 3a669170e9

[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}