@samitouri / QOS-React / commits / d15d7fd79e

[DevTools] Double click a Suspense Rect to jump to its position in the timeline (#34642)

When you double click it will hide or show by jumping to the selected index or one step before the selected. Let's you go from a suspense boundary into the timeline to find its position. I also highlight the step in the timeline when you hover the rect. This only works if it's in the selected root but all of those should be merged into one single timeline. One thing that's weird about the SuspenseNodes now is that they sometimes gets deleted but not always when they're resupended. Nested ones maybe? This means that if you double click to hide it, you can't double click again to show it. This seems like an unrelated bug that we should fix. We could potentially repurpose the existing "Suspend" button in the toolbar to do this too, or maybe add another icon there.

Sebastian Markbåge committed Sep 29, 2025 at 10:43 UTC d15d7fd79e00fe095a70d8855562172cd46187b4
5 files changed +88 -2
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
+21
@@ -98,6 +98,18 @@ function SuspenseRects({
98 });
99 }
100
101 + function handleDoubleClick(event: SyntheticMouseEvent) {
102 + if (event.defaultPrevented) {
103 + // Already clicked on an inner rect
104 + return;
105 + }
106 + event.preventDefault();
107 + suspenseTreeDispatch({
108 + type: 'TOGGLE_TIMELINE_FOR_ID',
109 + payload: suspenseID,
110 + });
111 + }
112 +
113 function handlePointerOver(event: SyntheticPointerEvent) {
114 if (event.defaultPrevented) {
115 // Already hovered an inner rect
@@ -105,6 +117,10 @@ function SuspenseRects({
117 }
118 event.preventDefault();
119 highlightHostInstance(suspenseID);
120 + suspenseTreeDispatch({
121 + type: 'HOVER_TIMELINE_FOR_ID',
122 + payload: suspenseID,
123 + });
124 }
125
126 function handlePointerLeave(event: SyntheticPointerEvent) {
@@ -114,6 +130,10 @@ function SuspenseRects({
130 }
131 event.preventDefault();
132 clearHighlightHostInstance();
133 + suspenseTreeDispatch({
134 + type: 'HOVER_TIMELINE_FOR_ID',
135 + payload: -1,
136 + });
137 }
138
139 // TODO: Use the nearest Suspense boundary
@@ -137,6 +157,7 @@ function SuspenseRects({
157 rect={rect}
158 data-highlighted={selected}
159 onClick={handleClick}
160 + onDoubleClick={handleDoubleClick}
161 onPointerOver={handlePointerOver}
162 onPointerLeave={handlePointerLeave}
163 // Reach-UI tooltip will go out of bounds of parent scroll container.
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css
+2
@@ -51,6 +51,8 @@
51 background: var(--color-background-selected);
52 }
53
54 +.SuspenseScrubberStepHighlight > .SuspenseScrubberBead,
55 +.SuspenseScrubberStepHighlight > .SuspenseScrubberBeadSelected,
56 .SuspenseScrubberStep:hover > .SuspenseScrubberBead,
57 .SuspenseScrubberStep:hover > .SuspenseScrubberBeadSelected {
58 height: 0.75rem;
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js
+8 -1
@@ -18,6 +18,7 @@ export default function SuspenseScrubber({
18 min,
19 max,
20 value,
21 + highlight,
22 onBlur,
23 onChange,
24 onFocus,
@@ -27,6 +28,7 @@ export default function SuspenseScrubber({
28 min: number,
29 max: number,
30 value: number,
31 + highlight: number,
32 onBlur: () => void,
33 onChange: (index: number) => void,
34 onFocus: () => void,
@@ -53,7 +55,12 @@ export default function SuspenseScrubber({
55 steps.push(
56 <div
57 key={index}
56 - className={styles.SuspenseScrubberStep}
58 + className={
59 + styles.SuspenseScrubberStep +
60 + (highlight === index
61 + ? ' ' + styles.SuspenseScrubberStepHighlight
62 + : '')
63 + }
64 onPointerDown={handlePress.bind(null, index)}
65 onMouseEnter={onHoverSegment.bind(null, index)}>
66 <div
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+2
@@ -33,6 +33,7 @@ function SuspenseTimelineInput() {
33 selectedRootID: rootID,
34 timeline,
35 timelineIndex,
36 + hoveredTimelineIndex,
37 playing,
38 } = useContext(SuspenseTreeStateContext);
39
@@ -202,6 +203,7 @@ function SuspenseTimelineInput() {
203 min={min}
204 max={max}
205 value={timelineIndex}
206 + highlight={hoveredTimelineIndex}
207 onBlur={handleBlur}
208 onChange={handleChange}
209 onFocus={handleFocus}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+55 -1
@@ -31,6 +31,7 @@ export type SuspenseTreeState = {
31 selectedSuspenseID: SuspenseNode['id'] | null,
32 timeline: $ReadOnlyArray<SuspenseNode['id']>,
33 timelineIndex: number | -1,
34 + hoveredTimelineIndex: number | -1,
35 uniqueSuspendersOnly: boolean,
36 playing: boolean,
37 };
@@ -72,6 +73,14 @@ type ACTION_SUSPENSE_PLAY_PAUSE = {
73 type ACTION_SUSPENSE_PLAY_TICK = {
74 type: 'SUSPENSE_PLAY_TICK',
75 };
76 +type ACTION_TOGGLE_TIMELINE_FOR_ID = {
77 + type: 'TOGGLE_TIMELINE_FOR_ID',
78 + payload: SuspenseNode['id'],
79 +};
80 +type ACTION_HOVER_TIMELINE_FOR_ID = {
81 + type: 'HOVER_TIMELINE_FOR_ID',
82 + payload: SuspenseNode['id'],
83 +};
84
85 export type SuspenseTreeAction =
86 | ACTION_SUSPENSE_TREE_MUTATION
@@ -81,7 +90,9 @@ export type SuspenseTreeAction =
90 | ACTION_SUSPENSE_SET_TIMELINE_INDEX
91 | ACTION_SUSPENSE_SKIP_TIMELINE_INDEX
92 | ACTION_SUSPENSE_PLAY_PAUSE
84 - | ACTION_SUSPENSE_PLAY_TICK;
93 + | ACTION_SUSPENSE_PLAY_TICK
94 + | ACTION_TOGGLE_TIMELINE_FOR_ID
95 + | ACTION_HOVER_TIMELINE_FOR_ID;
96 export type SuspenseTreeDispatch = (action: SuspenseTreeAction) => void;
97
98 const SuspenseTreeStateContext: ReactContext<SuspenseTreeState> =
@@ -122,6 +133,7 @@ function getInitialState(store: Store): SuspenseTreeState {
133 selectedRootID,
134 timeline: [],
135 timelineIndex: -1,
136 + hoveredTimelineIndex: -1,
137 uniqueSuspendersOnly,
138 playing: false,
139 };
@@ -144,6 +156,7 @@ function getInitialState(store: Store): SuspenseTreeState {
156 selectedRootID,
157 timeline,
158 timelineIndex,
159 + hoveredTimelineIndex: -1,
160 uniqueSuspendersOnly,
161 playing: false,
162 };
@@ -397,6 +410,47 @@ function SuspenseTreeContextController({children}: Props): React.Node {
410 playing: nextPlaying,
411 };
412 }
413 + case 'TOGGLE_TIMELINE_FOR_ID': {
414 + const suspenseID = action.payload;
415 + const timelineIndexForSuspenseID =
416 + state.timeline.indexOf(suspenseID);
417 + if (timelineIndexForSuspenseID === -1) {
418 + // This boundary is no longer in the timeline.
419 + return state;
420 + }
421 + const nextTimelineIndex =
422 + timelineIndexForSuspenseID === 0
423 + ? // For roots, there's no toggling. It's always just jump to beginning.
424 + 0
425 + : // For boundaries, we'll either jump to before or after its reveal depending
426 + // on if we're currently displaying it or not according to the timeline.
427 + state.timelineIndex < timelineIndexForSuspenseID
428 + ? // We're currently before this suspense boundary has been revealed so we
429 + // should jump ahead to reveal it.
430 + timelineIndexForSuspenseID
431 + : // Otherwise, if we're currently showing it, jump to right before to hide it.
432 + timelineIndexForSuspenseID - 1;
433 + const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
434 + const nextLineage = store.getSuspenseLineage(
435 + nextSelectedSuspenseID,
436 + );
437 + return {
438 + ...state,
439 + lineage: nextLineage,
440 + selectedSuspenseID: nextSelectedSuspenseID,
441 + timelineIndex: nextTimelineIndex,
442 + playing: false, // pause
443 + };
444 + }
445 + case 'HOVER_TIMELINE_FOR_ID': {
446 + const suspenseID = action.payload;
447 + const timelineIndexForSuspenseID =
448 + state.timeline.indexOf(suspenseID);
449 + return {
450 + ...state,
451 + hoveredTimelineIndex: timelineIndexForSuspenseID,
452 + };
453 + }
454 default:
455 throw new Error(`Unrecognized action "${action.type}"`);
456 }