[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.
This commit is contained in:
Sebastian Markbåge 2025-10-17 18:54:53 -04:00 committed by GitHub
parent 423c44b886
commit a083344699
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 155 additions and 77 deletions

View File

@ -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<SuspenseNode['id']> {
): $ReadOnlyArray<SuspenseTimelineStep> {
const target: Array<SuspenseTimelineStep> = [];
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,45 +911,77 @@ 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 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,
);
}
}
const stack = [suspense];
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) {
return target;
}
pushTimelineStepsInDocumentOrder(
children: Array<SuspenseNode['id']>,
target: Array<SuspenseTimelineStep>,
uniqueSuspendersOnly: boolean,
parentEnvironments: Array<string>,
): 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 =
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);
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,
});
}
// 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);
this.pushTimelineStepsInDocumentOrder(
child.children,
target,
uniqueSuspendersOnly,
unionEnvironments,
);
}
}
}
}
}
return list;
}
getRendererIDForElement(id: number): number | null {
let current = this._idToElement.get(id);
@ -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;

View File

@ -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);

View File

@ -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,
});

View File

@ -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<SuspenseNode['id']> | null,
roots: $ReadOnlyArray<SuspenseNode['id']>,
selectedSuspenseID: SuspenseNode['id'] | null,
timeline: $ReadOnlyArray<SuspenseNode['id']>,
timeline: $ReadOnlyArray<SuspenseTimelineStep>,
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<SuspenseNode['id']>,
$ReadOnlyArray<SuspenseTimelineStep>,
// 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,37 +167,44 @@ 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]
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)
const nextTimeline = store.getSuspendableDocumentOrderSuspense(
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,

View File

@ -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<Rect>,
hasUniqueSuspenders: boolean,
isSuspended: boolean,
environments: Array<string>,
};
// Serialized version of ReactIOInfo

View File

@ -1305,3 +1305,18 @@ export function onReloadAndProfileFlagsReset(): void {
sessionStorageRemoveItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY);
sessionStorageRemoveItem(SESSION_STORAGE_RECORD_TIMELINE_KEY);
}
export function unionOfTwoArrays<T>(a: Array<T>, b: Array<T>): Array<T> {
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;
}