[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.
Sebastian Markbåge committed
Oct 17, 2025 at 18:54 UTC
a0833446991a913349309861338da6ab68b637a3
6 files changed
+155
-77
packages/react-devtools-shared/src/devtools/store.js
+77
-42
@@ -34,6 +34,7 @@ import {
34
shallowDiffers,
35
utfDecodeStringWithRanges,
36
parseElementDisplayNameFromBackend,
37
+ unionOfTwoArrays,
38
} from '../utils';
39
import {localStorageGetItem, localStorageSetItem} from '../storage';
40
import {__DEBUG__} from '../constants';
@@ -51,6 +52,7 @@ import type {
52
ComponentFilter,
53
ElementType,
54
SuspenseNode,
55
+ SuspenseTimelineStep,
56
Rect,
57
} from 'react-devtools-shared/src/frontend/types';
58
import type {
@@ -895,13 +897,10 @@ export default class Store extends EventEmitter<{
897
*/
898
getSuspendableDocumentOrderSuspense(
899
uniqueSuspendersOnly: boolean,
898
- ): $ReadOnlyArray<SuspenseNode['id']> {
900
+ ): $ReadOnlyArray<SuspenseTimelineStep> {
901
+ const target: Array<SuspenseTimelineStep> = [];
902
const roots = this.roots;
900
- if (roots.length === 0) {
901
- return [];
902
- }
903
-
904
- const list: SuspenseNode['id'][] = [];
903
+ let rootStep: null | SuspenseTimelineStep = null;
904
for (let i = 0; i < roots.length; i++) {
905
const rootID = roots[i];
906
const root = this.getElementByID(rootID);
@@ -912,44 +911,76 @@ export default class Store extends EventEmitter<{
911
912
const suspense = this.getSuspenseByID(rootID);
913
if (suspense !== null) {
915
- if (list.length === 0) {
916
- // start with an arbitrary root that will allow inspection of the Screen
917
- list.push(suspense.id);
918
- }
919
-
920
- const stack = [suspense];
921
- while (stack.length > 0) {
922
- const current = stack.pop();
923
- if (current === undefined) {
924
- continue;
925
- }
926
- // Ignore any suspense boundaries that has no visual representation as this is not
927
- // part of the visible loading sequence.
928
- // TODO: Consider making visible meta data and other side-effects get virtual rects.
929
- const hasRects =
930
- current.rects !== null &&
931
- current.rects.length > 0 &&
932
- current.rects.some(isNonZeroRect);
933
- if (
934
- hasRects &&
935
- (!uniqueSuspendersOnly || current.hasUniqueSuspenders) &&
936
- // Roots are already included as part of the Screen
937
- current.id !== rootID
938
- ) {
939
- list.push(current.id);
940
- }
941
- // Add children in reverse order to maintain document order
942
- for (let j = current.children.length - 1; j >= 0; j--) {
943
- const childSuspense = this.getSuspenseByID(current.children[j]);
944
- if (childSuspense !== null) {
945
- stack.push(childSuspense);
946
- }
947
- }
914
+ const environments = suspense.environments;
915
+ const environmentName =
916
+ environments.length > 0
917
+ ? environments[environments.length - 1]
918
+ : null;
919
+ if (rootStep === null) {
920
+ // Arbitrarily use the first root as the root step id.
921
+ rootStep = {
922
+ id: suspense.id,
923
+ environment: environmentName,
924
+ };
925
+ target.push(rootStep);
926
+ } else if (rootStep.environment === null) {
927
+ // If any root has an environment name, then let's use it.
928
+ rootStep.environment = environmentName;
929
}
930
+ this.pushTimelineStepsInDocumentOrder(
931
+ suspense.children,
932
+ target,
933
+ uniqueSuspendersOnly,
934
+ environments,
935
+ );
936
}
937
}
938
952
- return list;
939
+ return target;
940
+ }
941
+
942
+ pushTimelineStepsInDocumentOrder(
943
+ children: Array<SuspenseNode['id']>,
944
+ target: Array<SuspenseTimelineStep>,
945
+ uniqueSuspendersOnly: boolean,
946
+ parentEnvironments: Array<string>,
947
+ ): void {
948
+ for (let i = 0; i < children.length; i++) {
949
+ const child = this.getSuspenseByID(children[i]);
950
+ if (child === null) {
951
+ continue;
952
+ }
953
+ // Ignore any suspense boundaries that has no visual representation as this is not
954
+ // part of the visible loading sequence.
955
+ // TODO: Consider making visible meta data and other side-effects get virtual rects.
956
+ const hasRects =
957
+ child.rects !== null &&
958
+ child.rects.length > 0 &&
959
+ child.rects.some(isNonZeroRect);
960
+ const childEnvironments = child.environments;
961
+ // Since children are blocked on the parent, they're also blocked by the parent environments.
962
+ // Only if we discover a novel environment do we add that and it becomes the name we use.
963
+ const unionEnvironments = unionOfTwoArrays(
964
+ parentEnvironments,
965
+ childEnvironments,
966
+ );
967
+ const environmentName =
968
+ unionEnvironments.length > 0
969
+ ? unionEnvironments[unionEnvironments.length - 1]
970
+ : null;
971
+ if (hasRects && (!uniqueSuspendersOnly || child.hasUniqueSuspenders)) {
972
+ target.push({
973
+ id: child.id,
974
+ environment: environmentName,
975
+ });
976
+ }
977
+ this.pushTimelineStepsInDocumentOrder(
978
+ child.children,
979
+ target,
980
+ uniqueSuspendersOnly,
981
+ unionEnvironments,
982
+ );
983
+ }
984
}
985
986
getRendererIDForElement(id: number): number | null {
@@ -1627,6 +1658,7 @@ export default class Store extends EventEmitter<{
1658
rects,
1659
hasUniqueSuspenders: false,
1660
isSuspended: isSuspended,
1661
+ environments: [],
1662
});
1663
1664
hasSuspenseTreeChanged = true;
@@ -1812,7 +1844,10 @@ export default class Store extends EventEmitter<{
1844
envIndex++
1845
) {
1846
const environmentNameStringID = operations[i++];
1815
- environmentNames.push(stringTable[environmentNameStringID]);
1847
+ const environmentName = stringTable[environmentNameStringID];
1848
+ if (environmentName != null) {
1849
+ environmentNames.push(environmentName);
1850
+ }
1851
}
1852
const suspense = this._idToSuspense.get(id);
1853
@@ -1836,7 +1871,7 @@ export default class Store extends EventEmitter<{
1871
1872
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
1873
suspense.isSuspended = isSuspended;
1839
- // TODO: Recompute the environment names.
1874
+ suspense.environments = environmentNames;
1875
}
1876
1877
hasSuspenseTreeChanged = true;
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
+2
-1
@@ -154,7 +154,8 @@ function SuspenseRects({
154
const selected = inspectedElementID === suspenseID;
155
156
const hovered =
157
- hoveredTimelineIndex > -1 && timeline[hoveredTimelineIndex] === suspenseID;
157
+ hoveredTimelineIndex > -1 &&
158
+ timeline[hoveredTimelineIndex].id === suspenseID;
159
160
const boundingBox = getBoundingBox(suspense.rects);
161
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+5
-5
@@ -34,7 +34,7 @@ function SuspenseTimelineInput() {
34
const max = timeline.length > 0 ? timeline.length - 1 : 0;
35
36
function switchSuspenseNode(nextTimelineIndex: number) {
37
- const nextSelectedSuspenseID = timeline[nextTimelineIndex];
37
+ const nextSelectedSuspenseID = timeline[nextTimelineIndex].id;
38
treeDispatch({
39
type: 'SELECT_ELEMENT_BY_ID',
40
payload: nextSelectedSuspenseID,
@@ -54,7 +54,7 @@ function SuspenseTimelineInput() {
54
}
55
56
function handleHoverSegment(hoveredIndex: number) {
57
- const nextSelectedSuspenseID = timeline[hoveredIndex];
57
+ const nextSelectedSuspenseID = timeline[hoveredIndex].id;
58
suspenseTreeDispatch({
59
type: 'HOVER_TIMELINE_FOR_ID',
60
payload: nextSelectedSuspenseID,
@@ -68,7 +68,7 @@ function SuspenseTimelineInput() {
68
}
69
70
function skipPrevious() {
71
- const nextSelectedSuspenseID = timeline[timelineIndex - 1];
71
+ const nextSelectedSuspenseID = timeline[timelineIndex - 1].id;
72
treeDispatch({
73
type: 'SELECT_ELEMENT_BY_ID',
74
payload: nextSelectedSuspenseID,
@@ -80,7 +80,7 @@ function SuspenseTimelineInput() {
80
}
81
82
function skipForward() {
83
- const nextSelectedSuspenseID = timeline[timelineIndex + 1];
83
+ const nextSelectedSuspenseID = timeline[timelineIndex + 1].id;
84
treeDispatch({
85
type: 'SELECT_ELEMENT_BY_ID',
86
payload: nextSelectedSuspenseID,
@@ -106,7 +106,7 @@ function SuspenseTimelineInput() {
106
// anything suspended in the root. The step after that should have one less
107
// thing suspended. I.e. the first suspense boundary should be unsuspended
108
// when it's selected. This also lets you show everything in the last step.
109
- const suspendedSet = timeline.slice(timelineIndex + 1);
109
+ const suspendedSet = timeline.slice(timelineIndex + 1).map(step => step.id);
110
bridge.send('overrideSuspenseMilestone', {
111
suspendedSet,
112
});
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+50
-29
@@ -7,7 +7,10 @@
7
* @flow
8
*/
9
import type {ReactContext} from 'shared/ReactTypes';
10
-import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
10
+import type {
11
+ SuspenseNode,
12
+ SuspenseTimelineStep,
13
+} from 'react-devtools-shared/src/frontend/types';
14
import type Store from '../../store';
15
16
import * as React from 'react';
@@ -25,7 +28,7 @@ export type SuspenseTreeState = {
28
lineage: $ReadOnlyArray<SuspenseNode['id']> | null,
29
roots: $ReadOnlyArray<SuspenseNode['id']>,
30
selectedSuspenseID: SuspenseNode['id'] | null,
28
- timeline: $ReadOnlyArray<SuspenseNode['id']>,
31
+ timeline: $ReadOnlyArray<SuspenseTimelineStep>,
32
timelineIndex: number | -1,
33
hoveredTimelineIndex: number | -1,
34
uniqueSuspendersOnly: boolean,
@@ -49,7 +52,7 @@ type ACTION_SELECT_SUSPENSE_BY_ID = {
52
type ACTION_SET_SUSPENSE_TIMELINE = {
53
type: 'SET_SUSPENSE_TIMELINE',
54
payload: [
52
- $ReadOnlyArray<SuspenseNode['id']>,
55
+ $ReadOnlyArray<SuspenseTimelineStep>,
56
// The next Suspense ID to select in the timeline
57
SuspenseNode['id'] | null,
58
// Whether this timeline includes only unique suspenders
@@ -111,7 +114,7 @@ function getInitialState(store: Store): SuspenseTreeState {
114
store.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
115
const timelineIndex = timeline.length - 1;
116
const selectedSuspenseID =
114
- timelineIndex === -1 ? null : timeline[timelineIndex];
117
+ timelineIndex === -1 ? null : timeline[timelineIndex].id;
118
const lineage =
119
selectedSuspenseID !== null
120
? store.getSuspenseLineage(selectedSuspenseID)
@@ -164,16 +167,18 @@ function SuspenseTreeContextController({children}: Props): React.Node {
167
selectedSuspenseID = null;
168
}
169
167
- let selectedTimelineID =
168
- state.timeline === null
170
+ const selectedTimelineStep =
171
+ state.timeline === null || state.timelineIndex === -1
172
? null
173
: state.timeline[state.timelineIndex];
171
- while (
172
- selectedTimelineID !== null &&
173
- removedIDs.has(selectedTimelineID)
174
- ) {
175
- // $FlowExpectedError[incompatible-type]
176
- selectedTimelineID = removedIDs.get(selectedTimelineID);
174
+ let selectedTimelineID: null | number = null;
175
+ if (selectedTimelineStep !== null) {
176
+ selectedTimelineID = selectedTimelineStep.id;
177
+ // $FlowFixMe
178
+ while (removedIDs.has(selectedTimelineID)) {
179
+ // $FlowFixMe
180
+ selectedTimelineID = removedIDs.get(selectedTimelineID);
181
+ }
182
}
183
184
// TODO: Handle different timeline modes (e.g. random order)
@@ -181,20 +186,25 @@ function SuspenseTreeContextController({children}: Props): React.Node {
186
state.uniqueSuspendersOnly,
187
);
188
184
- let nextTimelineIndex =
185
- selectedTimelineID === null || nextTimeline.length === 0
186
- ? -1
187
- : nextTimeline.indexOf(selectedTimelineID);
189
+ let nextTimelineIndex = -1;
190
+ if (selectedTimelineID !== null && nextTimeline.length !== 0) {
191
+ for (let i = 0; i < nextTimeline.length; i++) {
192
+ if (nextTimeline[i].id === selectedTimelineID) {
193
+ nextTimelineIndex = i;
194
+ break;
195
+ }
196
+ }
197
+ }
198
if (
199
nextTimeline.length > 0 &&
200
(nextTimelineIndex === -1 || state.autoSelect)
201
) {
202
nextTimelineIndex = nextTimeline.length - 1;
193
- selectedSuspenseID = nextTimeline[nextTimelineIndex];
203
+ selectedSuspenseID = nextTimeline[nextTimelineIndex].id;
204
}
205
206
if (selectedSuspenseID === null && nextTimeline.length > 0) {
197
- selectedSuspenseID = nextTimeline[nextTimeline.length - 1];
207
+ selectedSuspenseID = nextTimeline[nextTimeline.length - 1].id;
208
}
209
210
const nextLineage =
@@ -256,12 +266,12 @@ function SuspenseTreeContextController({children}: Props): React.Node {
266
nextMilestoneIndex = nextTimeline.indexOf(previousMilestoneID);
267
if (nextMilestoneIndex === -1 && nextTimeline.length > 0) {
268
nextMilestoneIndex = nextTimeline.length - 1;
259
- nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
269
+ nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex].id;
270
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
271
}
272
} else if (nextRootID !== null) {
273
nextMilestoneIndex = nextTimeline.length - 1;
264
- nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
274
+ nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex].id;
275
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
276
}
277
@@ -276,7 +286,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
286
}
287
case 'SUSPENSE_SET_TIMELINE_INDEX': {
288
const nextTimelineIndex = action.payload;
279
- const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
289
+ const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
290
const nextLineage = store.getSuspenseLineage(
291
nextSelectedSuspenseID,
292
);
@@ -301,7 +311,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
311
) {
312
return state;
313
}
304
- const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
314
+ const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
315
const nextLineage = store.getSuspenseLineage(
316
nextSelectedSuspenseID,
317
);
@@ -329,7 +339,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
339
) {
340
// If we're restarting at the end. Then loop around and start again from the beginning.
341
nextTimelineIndex = 0;
332
- nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
342
+ nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
343
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
344
}
345
@@ -352,7 +362,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
362
if (nextTimelineIndex > state.timeline.length - 1) {
363
return state;
364
}
355
- const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
365
+ const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
366
const nextLineage = store.getSuspenseLineage(
367
nextSelectedSuspenseID,
368
);
@@ -369,8 +379,14 @@ function SuspenseTreeContextController({children}: Props): React.Node {
379
}
380
case 'TOGGLE_TIMELINE_FOR_ID': {
381
const suspenseID = action.payload;
372
- const timelineIndexForSuspenseID =
373
- state.timeline.indexOf(suspenseID);
382
+
383
+ let timelineIndexForSuspenseID = -1;
384
+ for (let i = 0; i < state.timeline.length; i++) {
385
+ if (state.timeline[i].id === suspenseID) {
386
+ timelineIndexForSuspenseID = i;
387
+ break;
388
+ }
389
+ }
390
if (timelineIndexForSuspenseID === -1) {
391
// This boundary is no longer in the timeline.
392
return state;
@@ -387,7 +403,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
403
timelineIndexForSuspenseID
404
: // Otherwise, if we're currently showing it, jump to right before to hide it.
405
timelineIndexForSuspenseID - 1;
390
- const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
406
+ const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
407
const nextLineage = store.getSuspenseLineage(
408
nextSelectedSuspenseID,
409
);
@@ -403,8 +419,13 @@ function SuspenseTreeContextController({children}: Props): React.Node {
419
}
420
case 'HOVER_TIMELINE_FOR_ID': {
421
const suspenseID = action.payload;
406
- const timelineIndexForSuspenseID =
407
- state.timeline.indexOf(suspenseID);
422
+ let timelineIndexForSuspenseID = -1;
423
+ for (let i = 0; i < state.timeline.length; i++) {
424
+ if (state.timeline[i].id === suspenseID) {
425
+ timelineIndexForSuspenseID = i;
426
+ break;
427
+ }
428
+ }
429
return {
430
...state,
431
hoveredTimelineIndex: timelineIndexForSuspenseID,
packages/react-devtools-shared/src/frontend/types.js
+6
@@ -193,6 +193,11 @@ export type Rect = {
193
height: number,
194
};
195
196
+export type SuspenseTimelineStep = {
197
+ id: SuspenseNode['id'], // TODO: Will become a group.
198
+ environment: null | string,
199
+};
200
+
201
export type SuspenseNode = {
202
id: Element['id'],
203
parentID: SuspenseNode['id'] | 0,
@@ -201,6 +206,7 @@ export type SuspenseNode = {
206
rects: null | Array<Rect>,
207
hasUniqueSuspenders: boolean,
208
isSuspended: boolean,
209
+ environments: Array<string>,
210
};
211
212
// Serialized version of ReactIOInfo
packages/react-devtools-shared/src/utils.js
+15
@@ -1305,3 +1305,18 @@ export function onReloadAndProfileFlagsReset(): void {
1305
sessionStorageRemoveItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY);
1306
sessionStorageRemoveItem(SESSION_STORAGE_RECORD_TIMELINE_KEY);
1307
}
1308
+
1309
+export function unionOfTwoArrays<T>(a: Array<T>, b: Array<T>): Array<T> {
1310
+ let result = a;
1311
+ for (let i = 0; i < b.length; i++) {
1312
+ const value = b[i];
1313
+ if (a.indexOf(value) === -1) {
1314
+ if (result === a) {
1315
+ // Lazily copy
1316
+ result = a.slice(0);
1317
+ }
1318
+ result.push(value);
1319
+ }
1320
+ }
1321
+ return result;
1322
+}