Skip to content

Commit 519e64f

Browse files
committed
[DevTools] Name root "Transition" when focusing on Activity
1 parent a782ec9 commit 519e64f

File tree

6 files changed

+192
-39
lines changed

6 files changed

+192
-39
lines changed

packages/react-devtools-shared/src/devtools/store.js

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ export default class Store extends EventEmitter<{
189189
{errorCount: number, warningCount: number},
190190
> = new Map();
191191

192+
_focusedTransition: 0 | Element['id'] = 0;
193+
192194
// At least one of the injected renderers contains (DEV only) owner metadata.
193195
_hasOwnerMetadata: boolean = false;
194196

@@ -935,10 +937,9 @@ export default class Store extends EventEmitter<{
935937
}
936938

937939
/**
938-
* @param rootID
939940
* @param uniqueSuspendersOnly Filters out boundaries without unique suspenders
940941
*/
941-
getSuspendableDocumentOrderSuspense(
942+
getSuspendableDocumentOrderSuspenseInitialPaint(
942943
uniqueSuspendersOnly: boolean,
943944
): Array<SuspenseTimelineStep> {
944945
const target: Array<SuspenseTimelineStep> = [];
@@ -990,6 +991,76 @@ export default class Store extends EventEmitter<{
990991
return target;
991992
}
992993

994+
_pushSuspenseChildrenInDocumentOrder(
995+
children: Array<Element['id']>,
996+
target: Array<SuspenseNode['id']>,
997+
): void {
998+
for (let i = 0; i < children.length; i++) {
999+
const childID = children[i];
1000+
const suspense = this.getSuspenseByID(childID);
1001+
if (suspense !== null) {
1002+
target.push(suspense.id);
1003+
} else {
1004+
const childElement = this.getElementByID(childID);
1005+
if (childElement !== null) {
1006+
this._pushSuspenseChildrenInDocumentOrder(
1007+
childElement.children,
1008+
target,
1009+
);
1010+
}
1011+
}
1012+
}
1013+
}
1014+
1015+
getSuspenseChildren(id: Element['id']): Array<SuspenseNode['id']> {
1016+
const transitionChildren: Array<SuspenseNode['id']> = [];
1017+
1018+
const root = this._idToElement.get(id);
1019+
if (root === undefined) {
1020+
return transitionChildren;
1021+
}
1022+
1023+
this._pushSuspenseChildrenInDocumentOrder(
1024+
root.children,
1025+
transitionChildren,
1026+
);
1027+
1028+
return transitionChildren;
1029+
}
1030+
1031+
/**
1032+
* @param uniqueSuspendersOnly Filters out boundaries without unique suspenders
1033+
*/
1034+
getSuspendableDocumentOrderSuspenseTransition(
1035+
uniqueSuspendersOnly: boolean,
1036+
): Array<SuspenseTimelineStep> {
1037+
const target: Array<SuspenseTimelineStep> = [];
1038+
const focusedTransitionID = this._focusedTransition;
1039+
if (focusedTransitionID === null) {
1040+
return target;
1041+
}
1042+
1043+
target.push({
1044+
id: focusedTransitionID,
1045+
// TODO: Get environment for Activity
1046+
environment: null,
1047+
endTime: 0,
1048+
});
1049+
1050+
const transitionChildren = this.getSuspenseChildren(focusedTransitionID);
1051+
1052+
this.pushTimelineStepsInDocumentOrder(
1053+
transitionChildren,
1054+
target,
1055+
uniqueSuspendersOnly,
1056+
// TODO: Get environment for Activity
1057+
[],
1058+
0, // Don't pass a minimum end time at the root. The root is always first so doesn't matter.
1059+
);
1060+
1061+
return target;
1062+
}
1063+
9931064
pushTimelineStepsInDocumentOrder(
9941065
children: Array<SuspenseNode['id']>,
9951066
target: Array<SuspenseTimelineStep>,
@@ -1045,7 +1116,14 @@ export default class Store extends EventEmitter<{
10451116
uniqueSuspendersOnly: boolean,
10461117
): $ReadOnlyArray<SuspenseTimelineStep> {
10471118
const timeline =
1048-
this.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
1119+
this._focusedTransition === 0
1120+
? this.getSuspendableDocumentOrderSuspenseInitialPaint(
1121+
uniqueSuspendersOnly,
1122+
)
1123+
: this.getSuspendableDocumentOrderSuspenseTransition(
1124+
uniqueSuspendersOnly,
1125+
);
1126+
10491127
if (timeline.length === 0) {
10501128
return timeline;
10511129
}
@@ -1271,7 +1349,7 @@ export default class Store extends EventEmitter<{
12711349
const removedElementIDs: Map<number, number> = new Map();
12721350
const removedSuspenseIDs: Map<SuspenseNode['id'], SuspenseNode['id']> =
12731351
new Map();
1274-
let nextActivitySliceID = null;
1352+
let nextActivitySliceID: Element['id'] | null = null;
12751353

12761354
let i = 2;
12771355

@@ -2146,6 +2224,10 @@ export default class Store extends EventEmitter<{
21462224
}
21472225
}
21482226

2227+
if (nextActivitySliceID !== null) {
2228+
this._focusedTransition = nextActivitySliceID;
2229+
}
2230+
21492231
this.emit('mutated', [
21502232
addedElementIDs,
21512233
removedElementIDs,

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/Syntheti
1212

1313
import * as React from 'react';
1414
import {useContext} from 'react';
15-
import {TreeDispatcherContext} from '../Components/TreeContext';
15+
import {
16+
TreeDispatcherContext,
17+
TreeStateContext,
18+
} from '../Components/TreeContext';
1619
import {StoreContext} from '../context';
1720
import {useHighlightHostInstance} from '../hooks';
1821
import styles from './SuspenseBreadcrumbs.css';
@@ -23,6 +26,7 @@ import {
2326

2427
export default function SuspenseBreadcrumbs(): React$Node {
2528
const store = useContext(StoreContext);
29+
const {activityID} = useContext(TreeStateContext);
2630
const treeDispatch = useContext(TreeDispatcherContext);
2731
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
2832
const {selectedSuspenseID, lineage, roots} = useContext(
@@ -42,18 +46,21 @@ export default function SuspenseBreadcrumbs(): React$Node {
4246
<ol className={styles.SuspenseBreadcrumbsList}>
4347
{lineage === null ? null : lineage.length === 0 ? (
4448
// We selected the root. This means that we're currently viewing the Transition
45-
// that rendered the whole screen. In laymans terms this is really "Initial Paint".
46-
// TODO: Once we add subtree selection, then the equivalent should be called
49+
// that rendered the whole screen. In laymans terms this is really "Initial Paint" .
50+
// When we're looking at a subtree selection, then the equivalent is a
4751
// "Transition" since in that case it's really about a Transition within the page.
4852
roots.length > 0 ? (
4953
<li
5054
className={styles.SuspenseBreadcrumbsListItem}
5155
aria-current="true">
5256
<button
5357
className={styles.SuspenseBreadcrumbsButton}
54-
onClick={handleClick.bind(null, roots[0])}
58+
onClick={handleClick.bind(
59+
null,
60+
activityID === null ? roots[0] : activityID,
61+
)}
5562
type="button">
56-
Initial Paint
63+
{activityID === null ? 'Initial Paint' : 'Transition'}
5764
</button>
5865
</li>
5966
) : null

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import type Store from 'react-devtools-shared/src/devtools/store';
1111
import type {
12+
Element,
1213
SuspenseNode,
1314
Rect,
1415
} from 'react-devtools-shared/src/frontend/types';
@@ -18,7 +19,7 @@ import typeof {
1819
} from 'react-dom-bindings/src/events/SyntheticEvent';
1920

2021
import * as React from 'react';
21-
import {createContext, useContext, useLayoutEffect} from 'react';
22+
import {createContext, useContext, useLayoutEffect, useMemo} from 'react';
2223
import {
2324
TreeDispatcherContext,
2425
TreeStateContext,
@@ -426,6 +427,30 @@ function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node {
426427
});
427428
}
428429

430+
function SuspenseRectsInitialPaint(): React$Node {
431+
const {roots} = useContext(SuspenseTreeStateContext);
432+
return roots.map(rootID => {
433+
return <SuspenseRectsRoot key={rootID} rootID={rootID} />;
434+
});
435+
}
436+
437+
function SuspenseRectsTransition({id}: {id: Element['id']}): React$Node {
438+
const store = useContext(StoreContext);
439+
const children = useMemo(() => {
440+
return store.getSuspenseChildren(id);
441+
}, [id, store]);
442+
443+
return children.map(suspenseID => {
444+
return (
445+
<SuspenseRects
446+
key={suspenseID}
447+
suspenseID={suspenseID}
448+
parentRects={null}
449+
/>
450+
);
451+
});
452+
}
453+
429454
const ViewBox = createContext<Rect>((null: any));
430455

431456
function SuspenseRectsContainer({
@@ -434,14 +459,25 @@ function SuspenseRectsContainer({
434459
scaleRef: {current: number},
435460
}): React$Node {
436461
const store = useContext(StoreContext);
437-
const {inspectedElementID} = useContext(TreeStateContext);
462+
const {activityID, inspectedElementID} = useContext(TreeStateContext);
438463
const treeDispatch = useContext(TreeDispatcherContext);
439464
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
440465
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
441466
const {roots, timeline, hoveredTimelineIndex, uniqueSuspendersOnly} =
442467
useContext(SuspenseTreeStateContext);
443468

444-
// TODO: bbox does not consider uniqueSuspendersOnly filter
469+
const activityChildren: $ReadOnlyArray<SuspenseNode['id']> | null =
470+
useMemo(() => {
471+
if (activityID === null) {
472+
return null;
473+
}
474+
return store.getSuspenseChildren(activityID);
475+
}, [activityID, store]);
476+
const transitionChildren =
477+
activityChildren === null ? roots : activityChildren;
478+
479+
// We're using the bounding box of the entire document to anchor the Transition
480+
// in the actual document.
445481
const boundingBox = getDocumentBoundingRect(store, roots);
446482

447483
const boundingBoxWidth = boundingBox.width;
@@ -456,14 +492,18 @@ function SuspenseRectsContainer({
456492
// Already clicked on an inner rect
457493
return;
458494
}
459-
if (roots.length === 0) {
495+
if (transitionChildren.length === 0) {
460496
// Nothing to select
461497
return;
462498
}
463499
const arbitraryRootID = roots[0];
500+
const transitionRoot = activityID === null ? arbitraryRootID : activityID;
464501

465502
event.preventDefault();
466-
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: arbitraryRootID});
503+
treeDispatch({
504+
type: 'SELECT_ELEMENT_BY_ID',
505+
payload: transitionRoot,
506+
});
467507
suspenseTreeDispatch({
468508
type: 'SET_SUSPENSE_LINEAGE',
469509
payload: arbitraryRootID,
@@ -483,7 +523,8 @@ function SuspenseRectsContainer({
483523
}
484524

485525
const isRootSelected = roots.includes(inspectedElementID);
486-
const isRootHovered = hoveredTimelineIndex === 0;
526+
// When we're focusing a Transition, the first timeline step will not be a root.
527+
const isRootHovered = activityID === null && hoveredTimelineIndex === 0;
487528

488529
let hasRootSuspenders = false;
489530
if (!uniqueSuspendersOnly) {
@@ -536,7 +577,13 @@ function SuspenseRectsContainer({
536577
<div
537578
className={
538579
styles.SuspenseRectsContainer +
539-
(hasRootSuspenders ? ' ' + styles.SuspenseRectsRoot : '') +
580+
(hasRootSuspenders &&
581+
// We don't want to draw attention to the root if we're looking at a Transition.
582+
// TODO: Draw bounding rect of Transition and check if the Transition
583+
// has unique suspenders.
584+
activityID === null
585+
? ' ' + styles.SuspenseRectsRoot
586+
: '') +
540587
(isRootSelected ? ' ' + styles.SuspenseRectsRootOutline : '') +
541588
' ' +
542589
getClassNameForEnvironment(rootEnvironment)
@@ -548,9 +595,11 @@ function SuspenseRectsContainer({
548595
<div
549596
className={styles.SuspenseRectsViewBox}
550597
style={{aspectRatio, width}}>
551-
{roots.map(rootID => {
552-
return <SuspenseRectsRoot key={rootID} rootID={rootID} />;
553-
})}
598+
{activityID === null ? (
599+
<SuspenseRectsInitialPaint />
600+
) : (
601+
<SuspenseRectsTransition id={activityID} />
602+
)}
554603
{selectedBoundingBox !== null ? (
555604
<ScaledRect
556605
className={

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@ import type {SuspenseTimelineStep} from 'react-devtools-shared/src/frontend/type
1212
import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
1313

1414
import * as React from 'react';
15-
import {useRef} from 'react';
15+
import {useContext, useRef} from 'react';
16+
import {ElementTypeRoot} from 'react-devtools-shared/src/frontend/types';
1617

1718
import styles from './SuspenseScrubber.css';
1819

1920
import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
2021

2122
import Tooltip from '../Components/reach-ui/tooltip';
23+
import {StoreContext} from '../context';
2224

2325
export default function SuspenseScrubber({
2426
min,
@@ -43,6 +45,7 @@ export default function SuspenseScrubber({
4345
onHoverSegment: (index: number) => void,
4446
onHoverLeave: () => void,
4547
}): React$Node {
48+
const store = useContext(StoreContext);
4649
const inputRef = useRef();
4750
function handleChange(event: SyntheticEvent) {
4851
const newValue = +event.currentTarget.value;
@@ -60,12 +63,16 @@ export default function SuspenseScrubber({
6063
}
6164
const steps = [];
6265
for (let index = min; index <= max; index++) {
63-
const environment = timeline[index].environment;
66+
const step = timeline[index];
67+
const environment = step.environment;
68+
const element = store.getElementByID(step.id);
6469
const label =
6570
index === min
6671
? // The first step in the timeline is always a Transition (Initial Paint).
67-
'Initial Paint' +
68-
(environment === null ? '' : ' (' + environment + ')')
72+
element === null || element.type === ElementTypeRoot
73+
? 'Initial Paint'
74+
: 'Transition' +
75+
(environment === null ? '' : ' (' + environment + ')')
6976
: // TODO: Consider adding the name of this specific boundary if this step has only one.
7077
environment === null
7178
? 'Suspense'

packages/react-devtools-shared/src/frontend/types.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,11 @@ export type Rect = {
204204
};
205205

206206
export type SuspenseTimelineStep = {
207-
id: SuspenseNode['id'], // TODO: Will become a group.
207+
/**
208+
* The first step is either a host root (initial paint) or Activity (Transition).
209+
* Subsequent steps are always Suspense nodes.
210+
*/
211+
id: SuspenseNode['id'] | Element['id'], // TODO: Will become a group.
208212
environment: null | string,
209213
endTime: number,
210214
};

0 commit comments

Comments
 (0)