@samitouri / QOS-React-2 / commits / 194c12d949

[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 }