[DevTools] Name root "Transition" when focusing on Activity (#35108)
Sebastian "Sebbie" Silbermann committed
Nov 18, 2025 at 10:16 UTC
194c12d949cb142d2f29637fbee2ab4cec057eef
6 files changed
+192
-39
packages/react-devtools-shared/src/devtools/store.js
+86
-4
@@ -189,6 +189,8 @@ export default class Store extends EventEmitter<{
189
{errorCount: number, warningCount: number},
190
> = new Map();
191
192
+ _focusedTransition: 0 | Element['id'] = 0;
193
+
194
// At least one of the injected renderers contains (DEV only) owner metadata.
195
_hasOwnerMetadata: boolean = false;
196
@@ -935,10 +937,9 @@ export default class Store extends EventEmitter<{
937
}
938
939
/**
938
- * @param rootID
940
* @param uniqueSuspendersOnly Filters out boundaries without unique suspenders
941
*/
941
- getSuspendableDocumentOrderSuspense(
942
+ getSuspendableDocumentOrderSuspenseInitialPaint(
943
uniqueSuspendersOnly: boolean,
944
): Array<SuspenseTimelineStep> {
945
const target: Array<SuspenseTimelineStep> = [];
@@ -990,6 +991,76 @@ export default class Store extends EventEmitter<{
991
return target;
992
}
993
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
+
1064
pushTimelineStepsInDocumentOrder(
1065
children: Array<SuspenseNode['id']>,
1066
target: Array<SuspenseTimelineStep>,
@@ -1045,7 +1116,14 @@ export default class Store extends EventEmitter<{
1116
uniqueSuspendersOnly: boolean,
1117
): $ReadOnlyArray<SuspenseTimelineStep> {
1118
const timeline =
1048
- this.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
1119
+ this._focusedTransition === 0
1120
+ ? this.getSuspendableDocumentOrderSuspenseInitialPaint(
1121
+ uniqueSuspendersOnly,
1122
+ )
1123
+ : this.getSuspendableDocumentOrderSuspenseTransition(
1124
+ uniqueSuspendersOnly,
1125
+ );
1126
+
1127
if (timeline.length === 0) {
1128
return timeline;
1129
}
@@ -1271,7 +1349,7 @@ export default class Store extends EventEmitter<{
1349
const removedElementIDs: Map<number, number> = new Map();
1350
const removedSuspenseIDs: Map<SuspenseNode['id'], SuspenseNode['id']> =
1351
new Map();
1274
- let nextActivitySliceID = null;
1352
+ let nextActivitySliceID: Element['id'] | null = null;
1353
1354
let i = 2;
1355
@@ -2146,6 +2224,10 @@ export default class Store extends EventEmitter<{
2224
}
2225
}
2226
2227
+ if (nextActivitySliceID !== null) {
2228
+ this._focusedTransition = nextActivitySliceID;
2229
+ }
2230
+
2231
this.emit('mutated', [
2232
addedElementIDs,
2233
removedElementIDs,
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js
+12
-5
@@ -12,7 +12,10 @@ import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/Syntheti
12
13
import * as React from 'react';
14
import {useContext} from 'react';
15
-import {TreeDispatcherContext} from '../Components/TreeContext';
15
+import {
16
+ TreeDispatcherContext,
17
+ TreeStateContext,
18
+} from '../Components/TreeContext';
19
import {StoreContext} from '../context';
20
import {useHighlightHostInstance} from '../hooks';
21
import styles from './SuspenseBreadcrumbs.css';
@@ -23,6 +26,7 @@ import {
26
27
export default function SuspenseBreadcrumbs(): React$Node {
28
const store = useContext(StoreContext);
29
+ const {activityID} = useContext(TreeStateContext);
30
const treeDispatch = useContext(TreeDispatcherContext);
31
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
32
const {selectedSuspenseID, lineage, roots} = useContext(
@@ -42,8 +46,8 @@ export default function SuspenseBreadcrumbs(): React$Node {
46
<ol className={styles.SuspenseBreadcrumbsList}>
47
{lineage === null ? null : lineage.length === 0 ? (
48
// 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
51
// "Transition" since in that case it's really about a Transition within the page.
52
roots.length > 0 ? (
53
<li
@@ -51,9 +55,12 @@ export default function SuspenseBreadcrumbs(): React$Node {
55
aria-current="true">
56
<button
57
className={styles.SuspenseBreadcrumbsButton}
54
- onClick={handleClick.bind(null, roots[0])}
58
+ onClick={handleClick.bind(
59
+ null,
60
+ activityID === null ? roots[0] : activityID,
61
+ )}
62
type="button">
56
- Initial Paint
63
+ {activityID === null ? 'Initial Paint' : 'Transition'}
64
</button>
65
</li>
66
) : null
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
+59
-10
@@ -9,6 +9,7 @@
9
10
import type Store from 'react-devtools-shared/src/devtools/store';
11
import type {
12
+ Element,
13
SuspenseNode,
14
Rect,
15
} from 'react-devtools-shared/src/frontend/types';
@@ -18,7 +19,7 @@ import typeof {
19
} from 'react-dom-bindings/src/events/SyntheticEvent';
20
21
import * as React from 'react';
21
-import {createContext, useContext, useLayoutEffect} from 'react';
22
+import {createContext, useContext, useLayoutEffect, useMemo} from 'react';
23
import {
24
TreeDispatcherContext,
25
TreeStateContext,
@@ -426,6 +427,30 @@ function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node {
427
});
428
}
429
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
+
454
const ViewBox = createContext<Rect>((null: any));
455
456
function SuspenseRectsContainer({
@@ -434,14 +459,25 @@ function SuspenseRectsContainer({
459
scaleRef: {current: number},
460
}): React$Node {
461
const store = useContext(StoreContext);
437
- const {inspectedElementID} = useContext(TreeStateContext);
462
+ const {activityID, inspectedElementID} = useContext(TreeStateContext);
463
const treeDispatch = useContext(TreeDispatcherContext);
464
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
465
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
466
const {roots, timeline, hoveredTimelineIndex, uniqueSuspendersOnly} =
467
useContext(SuspenseTreeStateContext);
468
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.
481
const boundingBox = getDocumentBoundingRect(store, roots);
482
483
const boundingBoxWidth = boundingBox.width;
@@ -456,14 +492,18 @@ function SuspenseRectsContainer({
492
// Already clicked on an inner rect
493
return;
494
}
459
- if (roots.length === 0) {
495
+ if (transitionChildren.length === 0) {
496
// Nothing to select
497
return;
498
}
499
const arbitraryRootID = roots[0];
500
+ const transitionRoot = activityID === null ? arbitraryRootID : activityID;
501
502
event.preventDefault();
466
- treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: arbitraryRootID});
503
+ treeDispatch({
504
+ type: 'SELECT_ELEMENT_BY_ID',
505
+ payload: transitionRoot,
506
+ });
507
suspenseTreeDispatch({
508
type: 'SET_SUSPENSE_LINEAGE',
509
payload: arbitraryRootID,
@@ -483,7 +523,8 @@ function SuspenseRectsContainer({
523
}
524
525
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;
528
529
let hasRootSuspenders = false;
530
if (!uniqueSuspendersOnly) {
@@ -536,7 +577,13 @@ function SuspenseRectsContainer({
577
<div
578
className={
579
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
+ : '') +
587
(isRootSelected ? ' ' + styles.SuspenseRectsRootOutline : '') +
588
' ' +
589
getClassNameForEnvironment(rootEnvironment)
@@ -548,9 +595,11 @@ function SuspenseRectsContainer({
595
<div
596
className={styles.SuspenseRectsViewBox}
597
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
+ )}
603
{selectedBoundingBox !== null ? (
604
<ScaledRect
605
className={
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js
+11
-4
@@ -12,13 +12,15 @@ import type {SuspenseTimelineStep} from 'react-devtools-shared/src/frontend/type
12
import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
13
14
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';
17
18
import styles from './SuspenseScrubber.css';
19
20
import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
21
22
import Tooltip from '../Components/reach-ui/tooltip';
23
+import {StoreContext} from '../context';
24
25
export default function SuspenseScrubber({
26
min,
@@ -43,6 +45,7 @@ export default function SuspenseScrubber({
45
onHoverSegment: (index: number) => void,
46
onHoverLeave: () => void,
47
}): React$Node {
48
+ const store = useContext(StoreContext);
49
const inputRef = useRef();
50
function handleChange(event: SyntheticEvent) {
51
const newValue = +event.currentTarget.value;
@@ -60,12 +63,16 @@ export default function SuspenseScrubber({
63
}
64
const steps = [];
65
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);
69
const label =
70
index === min
71
? // 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 + ')')
76
: // TODO: Consider adding the name of this specific boundary if this step has only one.
77
environment === null
78
? 'Suspense'
packages/react-devtools-shared/src/frontend/types.js
+5
-1
@@ -204,7 +204,11 @@ export type Rect = {
204
};
205
206
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.
212
environment: null | string,
213
endTime: number,
214
};
packages/react-devtools-shell/src/app/Segments/index.js
+19
-15
@@ -75,22 +75,26 @@ function Root({children}: {children: React.Node}): React.Node {
75
);
76
}
77
78
+const dynamicData = deferred(10, 'Dynamic Data: 📈📉📊', 'dynamicData');
79
export default function Segments(): React.Node {
80
return (
80
- <React.Activity name="root" mode="visible">
81
- <Root>
82
- <React.Activity name="outer" mode="visible">
83
- <OuterSegment>
84
- <React.Activity name="inner" mode="visible">
85
- <InnerSegment>
86
- <React.Activity name="slot" mode="visible">
87
- <Page />
88
- </React.Activity>
89
- </InnerSegment>
90
- </React.Activity>
91
- </OuterSegment>
92
- </React.Activity>
93
- </Root>
94
- </React.Activity>
81
+ <>
82
+ <p>{dynamicData}</p>
83
+ <React.Activity name="root" mode="visible">
84
+ <Root>
85
+ <React.Activity name="outer" mode="visible">
86
+ <OuterSegment>
87
+ <React.Activity name="inner" mode="visible">
88
+ <InnerSegment>
89
+ <React.Activity name="slot" mode="visible">
90
+ <Page />
91
+ </React.Activity>
92
+ </InnerSegment>
93
+ </React.Activity>
94
+ </OuterSegment>
95
+ </React.Activity>
96
+ </Root>
97
+ </React.Activity>
98
+ </>
99
);
100
}