Date: Fri, 17 Oct 2025 18:54:53 -0400
Subject: [PATCH 7/8] [DevTools] Compute environment names for the timeline
 (#34892)
Stacked on #34885.
This refactors the timeline to store not just an id but a complex object
for each step. This will later represent a group of boundaries.
Each timeline step is assigned an environment name. We pick the last
environment name (assumed to have resolved last) from the union of the
parent and child environment names. I.e. a child step is considered to
be blocked by the parent so if a child isn't blocked on any environment
name it still gets marked as the parent's environment name.
In a follow up, I'd like to reorder the document order timeline based on
environment names to favor loading everything in one environment before
the next.
---
 .../src/devtools/store.js                     | 119 +++++++++++-------
 .../views/SuspenseTab/SuspenseRects.js        |   3 +-
 .../views/SuspenseTab/SuspenseTimeline.js     |  10 +-
 .../views/SuspenseTab/SuspenseTreeContext.js  |  79 +++++++-----
 .../src/frontend/types.js                     |   6 +
 packages/react-devtools-shared/src/utils.js   |  15 +++
 6 files changed, 155 insertions(+), 77 deletions(-)
diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js
index f1aa61bfe9b86..b75e30d9c47ec 100644
--- a/packages/react-devtools-shared/src/devtools/store.js
+++ b/packages/react-devtools-shared/src/devtools/store.js
@@ -34,6 +34,7 @@ import {
   shallowDiffers,
   utfDecodeStringWithRanges,
   parseElementDisplayNameFromBackend,
+  unionOfTwoArrays,
 } from '../utils';
 import {localStorageGetItem, localStorageSetItem} from '../storage';
 import {__DEBUG__} from '../constants';
@@ -51,6 +52,7 @@ import type {
   ComponentFilter,
   ElementType,
   SuspenseNode,
+  SuspenseTimelineStep,
   Rect,
 } from 'react-devtools-shared/src/frontend/types';
 import type {
@@ -895,13 +897,10 @@ export default class Store extends EventEmitter<{
    */
   getSuspendableDocumentOrderSuspense(
     uniqueSuspendersOnly: boolean,
-  ): $ReadOnlyArray
 {
+  ): $ReadOnlyArray {
+    const target: Array = [];
     const roots = this.roots;
-    if (roots.length === 0) {
-      return [];
-    }
-
-    const list: SuspenseNode['id'][] = [];
+    let rootStep: null | SuspenseTimelineStep = null;
     for (let i = 0; i < roots.length; i++) {
       const rootID = roots[i];
       const root = this.getElementByID(rootID);
@@ -912,44 +911,76 @@ export default class Store extends EventEmitter<{
 
       const suspense = this.getSuspenseByID(rootID);
       if (suspense !== null) {
-        if (list.length === 0) {
-          // start with an arbitrary root that will allow inspection of the Screen
-          list.push(suspense.id);
-        }
-
-        const stack = [suspense];
-        while (stack.length > 0) {
-          const current = stack.pop();
-          if (current === undefined) {
-            continue;
-          }
-          // Ignore any suspense boundaries that has no visual representation as this is not
-          // part of the visible loading sequence.
-          // TODO: Consider making visible meta data and other side-effects get virtual rects.
-          const hasRects =
-            current.rects !== null &&
-            current.rects.length > 0 &&
-            current.rects.some(isNonZeroRect);
-          if (
-            hasRects &&
-            (!uniqueSuspendersOnly || current.hasUniqueSuspenders) &&
-            // Roots are already included as part of the Screen
-            current.id !== rootID
-          ) {
-            list.push(current.id);
-          }
-          // Add children in reverse order to maintain document order
-          for (let j = current.children.length - 1; j >= 0; j--) {
-            const childSuspense = this.getSuspenseByID(current.children[j]);
-            if (childSuspense !== null) {
-              stack.push(childSuspense);
-            }
-          }
+        const environments = suspense.environments;
+        const environmentName =
+          environments.length > 0
+            ? environments[environments.length - 1]
+            : null;
+        if (rootStep === null) {
+          // Arbitrarily use the first root as the root step id.
+          rootStep = {
+            id: suspense.id,
+            environment: environmentName,
+          };
+          target.push(rootStep);
+        } else if (rootStep.environment === null) {
+          // If any root has an environment name, then let's use it.
+          rootStep.environment = environmentName;
         }
+        this.pushTimelineStepsInDocumentOrder(
+          suspense.children,
+          target,
+          uniqueSuspendersOnly,
+          environments,
+        );
       }
     }
 
-    return list;
+    return target;
+  }
+
+  pushTimelineStepsInDocumentOrder(
+    children: Array,
+    target: Array,
+    uniqueSuspendersOnly: boolean,
+    parentEnvironments: Array,
+  ): void {
+    for (let i = 0; i < children.length; i++) {
+      const child = this.getSuspenseByID(children[i]);
+      if (child === null) {
+        continue;
+      }
+      // Ignore any suspense boundaries that has no visual representation as this is not
+      // part of the visible loading sequence.
+      // TODO: Consider making visible meta data and other side-effects get virtual rects.
+      const hasRects =
+        child.rects !== null &&
+        child.rects.length > 0 &&
+        child.rects.some(isNonZeroRect);
+      const childEnvironments = child.environments;
+      // Since children are blocked on the parent, they're also blocked by the parent environments.
+      // Only if we discover a novel environment do we add that and it becomes the name we use.
+      const unionEnvironments = unionOfTwoArrays(
+        parentEnvironments,
+        childEnvironments,
+      );
+      const environmentName =
+        unionEnvironments.length > 0
+          ? unionEnvironments[unionEnvironments.length - 1]
+          : null;
+      if (hasRects && (!uniqueSuspendersOnly || child.hasUniqueSuspenders)) {
+        target.push({
+          id: child.id,
+          environment: environmentName,
+        });
+      }
+      this.pushTimelineStepsInDocumentOrder(
+        child.children,
+        target,
+        uniqueSuspendersOnly,
+        unionEnvironments,
+      );
+    }
   }
 
   getRendererIDForElement(id: number): number | null {
@@ -1627,6 +1658,7 @@ export default class Store extends EventEmitter<{
             rects,
             hasUniqueSuspenders: false,
             isSuspended: isSuspended,
+            environments: [],
           });
 
           hasSuspenseTreeChanged = true;
@@ -1812,7 +1844,10 @@ export default class Store extends EventEmitter<{
               envIndex++
             ) {
               const environmentNameStringID = operations[i++];
-              environmentNames.push(stringTable[environmentNameStringID]);
+              const environmentName = stringTable[environmentNameStringID];
+              if (environmentName != null) {
+                environmentNames.push(environmentName);
+              }
             }
             const suspense = this._idToSuspense.get(id);
 
@@ -1836,7 +1871,7 @@ export default class Store extends EventEmitter<{
 
             suspense.hasUniqueSuspenders = hasUniqueSuspenders;
             suspense.isSuspended = isSuspended;
-            // TODO: Recompute the environment names.
+            suspense.environments = environmentNames;
           }
 
           hasSuspenseTreeChanged = true;
diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
index 8b171ae31a44f..c19360567aebe 100644
--- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
@@ -154,7 +154,8 @@ function SuspenseRects({
   const selected = inspectedElementID === suspenseID;
 
   const hovered =
-    hoveredTimelineIndex > -1 && timeline[hoveredTimelineIndex] === suspenseID;
+    hoveredTimelineIndex > -1 &&
+    timeline[hoveredTimelineIndex].id === suspenseID;
 
   const boundingBox = getBoundingBox(suspense.rects);
 
diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
index af50a8c689cbd..f230cfb549a3f 100644
--- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
@@ -34,7 +34,7 @@ function SuspenseTimelineInput() {
   const max = timeline.length > 0 ? timeline.length - 1 : 0;
 
   function switchSuspenseNode(nextTimelineIndex: number) {
-    const nextSelectedSuspenseID = timeline[nextTimelineIndex];
+    const nextSelectedSuspenseID = timeline[nextTimelineIndex].id;
     treeDispatch({
       type: 'SELECT_ELEMENT_BY_ID',
       payload: nextSelectedSuspenseID,
@@ -54,7 +54,7 @@ function SuspenseTimelineInput() {
   }
 
   function handleHoverSegment(hoveredIndex: number) {
-    const nextSelectedSuspenseID = timeline[hoveredIndex];
+    const nextSelectedSuspenseID = timeline[hoveredIndex].id;
     suspenseTreeDispatch({
       type: 'HOVER_TIMELINE_FOR_ID',
       payload: nextSelectedSuspenseID,
@@ -68,7 +68,7 @@ function SuspenseTimelineInput() {
   }
 
   function skipPrevious() {
-    const nextSelectedSuspenseID = timeline[timelineIndex - 1];
+    const nextSelectedSuspenseID = timeline[timelineIndex - 1].id;
     treeDispatch({
       type: 'SELECT_ELEMENT_BY_ID',
       payload: nextSelectedSuspenseID,
@@ -80,7 +80,7 @@ function SuspenseTimelineInput() {
   }
 
   function skipForward() {
-    const nextSelectedSuspenseID = timeline[timelineIndex + 1];
+    const nextSelectedSuspenseID = timeline[timelineIndex + 1].id;
     treeDispatch({
       type: 'SELECT_ELEMENT_BY_ID',
       payload: nextSelectedSuspenseID,
@@ -106,7 +106,7 @@ function SuspenseTimelineInput() {
     // anything suspended in the root. The step after that should have one less
     // thing suspended. I.e. the first suspense boundary should be unsuspended
     // when it's selected. This also lets you show everything in the last step.
-    const suspendedSet = timeline.slice(timelineIndex + 1);
+    const suspendedSet = timeline.slice(timelineIndex + 1).map(step => step.id);
     bridge.send('overrideSuspenseMilestone', {
       suspendedSet,
     });
diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
index 484a336c34959..b1ba98acfb55c 100644
--- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
@@ -7,7 +7,10 @@
  * @flow
  */
 import type {ReactContext} from 'shared/ReactTypes';
-import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
+import type {
+  SuspenseNode,
+  SuspenseTimelineStep,
+} from 'react-devtools-shared/src/frontend/types';
 import type Store from '../../store';
 
 import * as React from 'react';
@@ -25,7 +28,7 @@ export type SuspenseTreeState = {
   lineage: $ReadOnlyArray | null,
   roots: $ReadOnlyArray,
   selectedSuspenseID: SuspenseNode['id'] | null,
-  timeline: $ReadOnlyArray,
+  timeline: $ReadOnlyArray,
   timelineIndex: number | -1,
   hoveredTimelineIndex: number | -1,
   uniqueSuspendersOnly: boolean,
@@ -49,7 +52,7 @@ type ACTION_SELECT_SUSPENSE_BY_ID = {
 type ACTION_SET_SUSPENSE_TIMELINE = {
   type: 'SET_SUSPENSE_TIMELINE',
   payload: [
-    $ReadOnlyArray,
+    $ReadOnlyArray,
     // The next Suspense ID to select in the timeline
     SuspenseNode['id'] | null,
     // Whether this timeline includes only unique suspenders
@@ -111,7 +114,7 @@ function getInitialState(store: Store): SuspenseTreeState {
     store.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
   const timelineIndex = timeline.length - 1;
   const selectedSuspenseID =
-    timelineIndex === -1 ? null : timeline[timelineIndex];
+    timelineIndex === -1 ? null : timeline[timelineIndex].id;
   const lineage =
     selectedSuspenseID !== null
       ? store.getSuspenseLineage(selectedSuspenseID)
@@ -164,16 +167,18 @@ function SuspenseTreeContextController({children}: Props): React.Node {
               selectedSuspenseID = null;
             }
 
-            let selectedTimelineID =
-              state.timeline === null
+            const selectedTimelineStep =
+              state.timeline === null || state.timelineIndex === -1
                 ? null
                 : state.timeline[state.timelineIndex];
-            while (
-              selectedTimelineID !== null &&
-              removedIDs.has(selectedTimelineID)
-            ) {
-              // $FlowExpectedError[incompatible-type]
-              selectedTimelineID = removedIDs.get(selectedTimelineID);
+            let selectedTimelineID: null | number = null;
+            if (selectedTimelineStep !== null) {
+              selectedTimelineID = selectedTimelineStep.id;
+              // $FlowFixMe
+              while (removedIDs.has(selectedTimelineID)) {
+                // $FlowFixMe
+                selectedTimelineID = removedIDs.get(selectedTimelineID);
+              }
             }
 
             // TODO: Handle different timeline modes (e.g. random order)
@@ -181,20 +186,25 @@ function SuspenseTreeContextController({children}: Props): React.Node {
               state.uniqueSuspendersOnly,
             );
 
-            let nextTimelineIndex =
-              selectedTimelineID === null || nextTimeline.length === 0
-                ? -1
-                : nextTimeline.indexOf(selectedTimelineID);
+            let nextTimelineIndex = -1;
+            if (selectedTimelineID !== null && nextTimeline.length !== 0) {
+              for (let i = 0; i < nextTimeline.length; i++) {
+                if (nextTimeline[i].id === selectedTimelineID) {
+                  nextTimelineIndex = i;
+                  break;
+                }
+              }
+            }
             if (
               nextTimeline.length > 0 &&
               (nextTimelineIndex === -1 || state.autoSelect)
             ) {
               nextTimelineIndex = nextTimeline.length - 1;
-              selectedSuspenseID = nextTimeline[nextTimelineIndex];
+              selectedSuspenseID = nextTimeline[nextTimelineIndex].id;
             }
 
             if (selectedSuspenseID === null && nextTimeline.length > 0) {
-              selectedSuspenseID = nextTimeline[nextTimeline.length - 1];
+              selectedSuspenseID = nextTimeline[nextTimeline.length - 1].id;
             }
 
             const nextLineage =
@@ -256,12 +266,12 @@ function SuspenseTreeContextController({children}: Props): React.Node {
               nextMilestoneIndex = nextTimeline.indexOf(previousMilestoneID);
               if (nextMilestoneIndex === -1 && nextTimeline.length > 0) {
                 nextMilestoneIndex = nextTimeline.length - 1;
-                nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
+                nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex].id;
                 nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
               }
             } else if (nextRootID !== null) {
               nextMilestoneIndex = nextTimeline.length - 1;
-              nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
+              nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex].id;
               nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
             }
 
@@ -276,7 +286,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
           }
           case 'SUSPENSE_SET_TIMELINE_INDEX': {
             const nextTimelineIndex = action.payload;
-            const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
+            const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
             const nextLineage = store.getSuspenseLineage(
               nextSelectedSuspenseID,
             );
@@ -301,7 +311,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
             ) {
               return state;
             }
-            const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
+            const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
             const nextLineage = store.getSuspenseLineage(
               nextSelectedSuspenseID,
             );
@@ -329,7 +339,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
             ) {
               // If we're restarting at the end. Then loop around and start again from the beginning.
               nextTimelineIndex = 0;
-              nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
+              nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
               nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
             }
 
@@ -352,7 +362,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
             if (nextTimelineIndex > state.timeline.length - 1) {
               return state;
             }
-            const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
+            const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
             const nextLineage = store.getSuspenseLineage(
               nextSelectedSuspenseID,
             );
@@ -369,8 +379,14 @@ function SuspenseTreeContextController({children}: Props): React.Node {
           }
           case 'TOGGLE_TIMELINE_FOR_ID': {
             const suspenseID = action.payload;
-            const timelineIndexForSuspenseID =
-              state.timeline.indexOf(suspenseID);
+
+            let timelineIndexForSuspenseID = -1;
+            for (let i = 0; i < state.timeline.length; i++) {
+              if (state.timeline[i].id === suspenseID) {
+                timelineIndexForSuspenseID = i;
+                break;
+              }
+            }
             if (timelineIndexForSuspenseID === -1) {
               // This boundary is no longer in the timeline.
               return state;
@@ -387,7 +403,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
                     timelineIndexForSuspenseID
                   : // Otherwise, if we're currently showing it, jump to right before to hide it.
                     timelineIndexForSuspenseID - 1;
-            const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
+            const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
             const nextLineage = store.getSuspenseLineage(
               nextSelectedSuspenseID,
             );
@@ -403,8 +419,13 @@ function SuspenseTreeContextController({children}: Props): React.Node {
           }
           case 'HOVER_TIMELINE_FOR_ID': {
             const suspenseID = action.payload;
-            const timelineIndexForSuspenseID =
-              state.timeline.indexOf(suspenseID);
+            let timelineIndexForSuspenseID = -1;
+            for (let i = 0; i < state.timeline.length; i++) {
+              if (state.timeline[i].id === suspenseID) {
+                timelineIndexForSuspenseID = i;
+                break;
+              }
+            }
             return {
               ...state,
               hoveredTimelineIndex: timelineIndexForSuspenseID,
diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js
index 2a012ce33a17d..4eed49e6bac8f 100644
--- a/packages/react-devtools-shared/src/frontend/types.js
+++ b/packages/react-devtools-shared/src/frontend/types.js
@@ -193,6 +193,11 @@ export type Rect = {
   height: number,
 };
 
+export type SuspenseTimelineStep = {
+  id: SuspenseNode['id'], // TODO: Will become a group.
+  environment: null | string,
+};
+
 export type SuspenseNode = {
   id: Element['id'],
   parentID: SuspenseNode['id'] | 0,
@@ -201,6 +206,7 @@ export type SuspenseNode = {
   rects: null | Array,
   hasUniqueSuspenders: boolean,
   isSuspended: boolean,
+  environments: Array,
 };
 
 // Serialized version of ReactIOInfo
diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js
index 29ff6d566bd6f..6d31888cd9d0c 100644
--- a/packages/react-devtools-shared/src/utils.js
+++ b/packages/react-devtools-shared/src/utils.js
@@ -1305,3 +1305,18 @@ export function onReloadAndProfileFlagsReset(): void {
   sessionStorageRemoveItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY);
   sessionStorageRemoveItem(SESSION_STORAGE_RECORD_TIMELINE_KEY);
 }
+
+export function unionOfTwoArrays(a: Array, b: Array): Array {
+  let result = a;
+  for (let i = 0; i < b.length; i++) {
+    const value = b[i];
+    if (a.indexOf(value) === -1) {
+      if (result === a) {
+        // Lazily copy
+        result = a.slice(0);
+      }
+      result.push(value);
+    }
+  }
+  return result;
+}
From 3a669170e96a63a4ce3a44f78401fb9f4f803510 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= 
Date: Fri, 17 Oct 2025 19:03:15 -0400
Subject: [PATCH 8/8] [DevTools] Assign a different color and label based on
 environment (#34893)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
 ---
 .../src/devtools/constants.js                 |  8 ++---
 .../Components/InspectedElementSuspendedBy.js | 23 ++++++++++++--
 .../SuspenseTab/SuspenseEnvironmentColors.css | 14 +++++++++
 .../SuspenseTab/SuspenseEnvironmentColors.js  | 20 +++++++++++++
 .../views/SuspenseTab/SuspenseRects.js        | 28 +++++++++++++----
 .../views/SuspenseTab/SuspenseScrubber.js     | 30 ++++++++++++-------
 .../views/SuspenseTab/SuspenseTimeline.js     |  1 +
 7 files changed, 102 insertions(+), 22 deletions(-)
 create mode 100644 packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseEnvironmentColors.css
 create mode 100644 packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseEnvironmentColors.js
diff --git a/packages/react-devtools-shared/src/devtools/constants.js b/packages/react-devtools-shared/src/devtools/constants.js
index 5c90501d70f5c..d093a798bcd30 100644
--- a/packages/react-devtools-shared/src/devtools/constants.js
+++ b/packages/react-devtools-shared/src/devtools/constants.js
@@ -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',
diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
index 9078d3c3beabc..78c137deaf37c 100644
--- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
+++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
@@ -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({
           >
         )}
         
-
---
 .../src/devtools/constants.js                 |  8 ++---
 .../Components/InspectedElementSuspendedBy.js | 23 ++++++++++++--
 .../SuspenseTab/SuspenseEnvironmentColors.css | 14 +++++++++
 .../SuspenseTab/SuspenseEnvironmentColors.js  | 20 +++++++++++++
 .../views/SuspenseTab/SuspenseRects.js        | 28 +++++++++++++----
 .../views/SuspenseTab/SuspenseScrubber.js     | 30 ++++++++++++-------
 .../views/SuspenseTab/SuspenseTimeline.js     |  1 +
 7 files changed, 102 insertions(+), 22 deletions(-)
 create mode 100644 packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseEnvironmentColors.css
 create mode 100644 packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseEnvironmentColors.js
diff --git a/packages/react-devtools-shared/src/devtools/constants.js b/packages/react-devtools-shared/src/devtools/constants.js
index 5c90501d70f5c..d093a798bcd30 100644
--- a/packages/react-devtools-shared/src/devtools/constants.js
+++ b/packages/react-devtools-shared/src/devtools/constants.js
@@ -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',
diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
index 9078d3c3beabc..78c137deaf37c 100644
--- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
+++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
@@ -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({
           >
         )}
         
-        
+        
           {pluralizedName}
         
         {isOpen ? null : (
-          
+          
              -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 (
     
,
   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(
-      
+      
         
diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
index f230cfb549a3f..89f349ae6ea7d 100644
--- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
@@ -173,6 +173,7 @@ function SuspenseTimelineInput() {