From 80a8e1328261f4a8a61a3adec6a5767912596fa8 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 16 Oct 2025 16:39:16 -0400 Subject: [PATCH 1/4] Track environment names on the node --- packages/react-devtools-shared/src/devtools/store.js | 8 ++++++-- packages/react-devtools-shared/src/frontend/types.js | 1 + 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index f1aa61bfe9b86..8abd9238b8599 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -1627,6 +1627,7 @@ export default class Store extends EventEmitter<{ rects, hasUniqueSuspenders: false, isSuspended: isSuspended, + environments: [], }); hasSuspenseTreeChanged = true; @@ -1812,7 +1813,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 +1840,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/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index 2a012ce33a17d..22fc944a11032 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -201,6 +201,7 @@ export type SuspenseNode = { rects: null | Array, hasUniqueSuspenders: boolean, isSuspended: boolean, + environments: Array, }; // Serialized version of ReactIOInfo From 95a31111ebabffd1735accc28402ae9f46724e21 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 16 Oct 2025 20:54:07 -0400 Subject: [PATCH 2/4] Make each timeline entry a complex object Each step can be a group and have an environment separately from the suspense nodes that are part of the group. --- .../src/devtools/store.js | 15 +++- .../views/SuspenseTab/SuspenseRects.js | 3 +- .../views/SuspenseTab/SuspenseTimeline.js | 10 +-- .../views/SuspenseTab/SuspenseTreeContext.js | 79 ++++++++++++------- .../src/frontend/types.js | 5 ++ 5 files changed, 73 insertions(+), 39 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 8abd9238b8599..e7d7c7735fc5a 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -51,6 +51,7 @@ import type { ComponentFilter, ElementType, SuspenseNode, + SuspenseTimelineStep, Rect, } from 'react-devtools-shared/src/frontend/types'; import type { @@ -895,13 +896,13 @@ export default class Store extends EventEmitter<{ */ getSuspendableDocumentOrderSuspense( uniqueSuspendersOnly: boolean, - ): $ReadOnlyArray { + ): $ReadOnlyArray { const roots = this.roots; if (roots.length === 0) { return []; } - const list: SuspenseNode['id'][] = []; + const list: SuspenseTimelineStep[] = []; for (let i = 0; i < roots.length; i++) { const rootID = roots[i]; const root = this.getElementByID(rootID); @@ -914,7 +915,10 @@ export default class Store extends EventEmitter<{ if (suspense !== null) { if (list.length === 0) { // start with an arbitrary root that will allow inspection of the Screen - list.push(suspense.id); + list.push({ + id: suspense.id, + environment: null, + }); } const stack = [suspense]; @@ -936,7 +940,10 @@ export default class Store extends EventEmitter<{ // Roots are already included as part of the Screen current.id !== rootID ) { - list.push(current.id); + list.push({ + id: current.id, + environment: null, + }); } // Add children in reverse order to maintain document order for (let j = current.children.length - 1; j >= 0; j--) { 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 22fc944a11032..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, From 68d25226f16db8c74039737b5c9f8dfd546c1e3f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 16 Oct 2025 21:25:57 -0400 Subject: [PATCH 3/4] Build timeline recursively --- .../src/devtools/store.js | 103 ++++++++++-------- 1 file changed, 59 insertions(+), 44 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index e7d7c7735fc5a..f91114505a12f 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -897,12 +897,9 @@ export default class Store extends EventEmitter<{ getSuspendableDocumentOrderSuspense( uniqueSuspendersOnly: boolean, ): $ReadOnlyArray { + const target: Array = []; const roots = this.roots; - if (roots.length === 0) { - return []; - } - - const list: SuspenseTimelineStep[] = []; + let rootStep: null | SuspenseTimelineStep = null; for (let i = 0; i < roots.length; i++) { const rootID = roots[i]; const root = this.getElementByID(rootID); @@ -913,50 +910,68 @@ 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({ + 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: null, - }); - } - - 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({ - id: current.id, - environment: null, - }); - } - // 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); - } - } + 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 environments = child.environments; + const environmentName = + environments.length > 0 ? environments[environments.length - 1] : null; + if (hasRects && (!uniqueSuspendersOnly || child.hasUniqueSuspenders)) { + target.push({ + id: child.id, + environment: environmentName, + }); + } + this.pushTimelineStepsInDocumentOrder( + child.children, + target, + uniqueSuspendersOnly, + environments, + ); + } } getRendererIDForElement(id: number): number | null { From 190e7150452a8e65e93e63c4e4ff34bad0e799bb Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 16 Oct 2025 22:39:57 -0400 Subject: [PATCH 4/4] Union the parent environment and child environments on the way down the tree --- .../react-devtools-shared/src/devtools/store.js | 15 ++++++++++++--- packages/react-devtools-shared/src/utils.js | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index f91114505a12f..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'; @@ -956,9 +957,17 @@ export default class Store extends EventEmitter<{ child.rects !== null && child.rects.length > 0 && child.rects.some(isNonZeroRect); - const environments = child.environments; + 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 = - environments.length > 0 ? environments[environments.length - 1] : null; + unionEnvironments.length > 0 + ? unionEnvironments[unionEnvironments.length - 1] + : null; if (hasRects && (!uniqueSuspendersOnly || child.hasUniqueSuspenders)) { target.push({ id: child.id, @@ -969,7 +978,7 @@ export default class Store extends EventEmitter<{ child.children, target, uniqueSuspendersOnly, - environments, + unionEnvironments, ); } } 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; +}