@samitouri / QOS-React-1 / commits / a083344699

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