diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index f1aa61bfe9b..b75e30d9c47 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 8b171ae31a4..c19360567ae 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 af50a8c689c..f230cfb549a 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 484a336c349..b1ba98acfb5 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 2a012ce33a1..4eed49e6bac 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 29ff6d566bd..6d31888cd9d 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; +}