Repository: facebook/react
Pull request: [DevTools] Double click a Suspense Rect to jump to its position in the timeline

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.

Changed files:
filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
patch: @@ -98,13 +98,29 @@ function SuspenseRects({
     });
   }
 
+  function handleDoubleClick(event: SyntheticMouseEvent) {
+    if (event.defaultPrevented) {
+      // Already clicked on an inner rect
+      return;
+    }
+    event.preventDefault();
+    suspenseTreeDispatch({
+      type: 'TOGGLE_TIMELINE_FOR_ID',
+      payload: suspenseID,
+    });
+  }
+
   function handlePointerOver(event: SyntheticPointerEvent) {
     if (event.defaultPrevented) {
       // Already hovered an inner rect
       return;
     }
     event.preventDefault();
     highlightHostInstance(suspenseID);
+    suspenseTreeDispatch({
+      type: 'HOVER_TIMELINE_FOR_ID',
+      payload: suspenseID,
+    });
   }
 
   function handlePointerLeave(event: SyntheticPointerEvent) {
@@ -114,6 +130,10 @@ function SuspenseRects({
     }
     event.preventDefault();
     clearHighlightHostInstance();
+    suspenseTreeDispatch({
+      type: 'HOVER_TIMELINE_FOR_ID',
+      payload: -1,
+    });
   }
 
   // TODO: Use the nearest Suspense boundary
@@ -137,6 +157,7 @@ function SuspenseRects({
                 rect={rect}
                 data-highlighted={selected}
                 onClick={handleClick}
+                onDoubleClick={handleDoubleClick}
                 onPointerOver={handlePointerOver}
                 onPointerLeave={handlePointerLeave}
                 // Reach-UI tooltip will go out of bounds of parent scroll container.

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css
patch: @@ -51,6 +51,8 @@
   background: var(--color-background-selected);
 }
 
+.SuspenseScrubberStepHighlight > .SuspenseScrubberBead,
+.SuspenseScrubberStepHighlight > .SuspenseScrubberBeadSelected,
 .SuspenseScrubberStep:hover > .SuspenseScrubberBead,
 .SuspenseScrubberStep:hover > .SuspenseScrubberBeadSelected {
   height: 0.75rem;

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js
patch: @@ -18,6 +18,7 @@ export default function SuspenseScrubber({
   min,
   max,
   value,
+  highlight,
   onBlur,
   onChange,
   onFocus,
@@ -27,6 +28,7 @@ export default function SuspenseScrubber({
   min: number,
   max: number,
   value: number,
+  highlight: number,
   onBlur: () => void,
   onChange: (index: number) => void,
   onFocus: () => void,
@@ -53,7 +55,12 @@ export default function SuspenseScrubber({
     steps.push(
       <div
         key={index}
-        className={styles.SuspenseScrubberStep}
+        className={
+          styles.SuspenseScrubberStep +
+          (highlight === index
+            ? ' ' + styles.SuspenseScrubberStepHighlight
+            : '')
+        }
         onPointerDown={handlePress.bind(null, index)}
         onMouseEnter={onHoverSegment.bind(null, index)}>
         <div

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
patch: @@ -33,6 +33,7 @@ function SuspenseTimelineInput() {
     selectedRootID: rootID,
     timeline,
     timelineIndex,
+    hoveredTimelineIndex,
     playing,
   } = useContext(SuspenseTreeStateContext);
 
@@ -202,6 +203,7 @@ function SuspenseTimelineInput() {
           min={min}
           max={max}
           value={timelineIndex}
+          highlight={hoveredTimelineIndex}
           onBlur={handleBlur}
           onChange={handleChange}
           onFocus={handleFocus}

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
patch: @@ -31,6 +31,7 @@ export type SuspenseTreeState = {
   selectedSuspenseID: SuspenseNode['id'] | null,
   timeline: $ReadOnlyArray<SuspenseNode['id']>,
   timelineIndex: number | -1,
+  hoveredTimelineIndex: number | -1,
   uniqueSuspendersOnly: boolean,
   playing: boolean,
 };
@@ -72,6 +73,14 @@ type ACTION_SUSPENSE_PLAY_PAUSE = {
 type ACTION_SUSPENSE_PLAY_TICK = {
   type: 'SUSPENSE_PLAY_TICK',
 };
+type ACTION_TOGGLE_TIMELINE_FOR_ID = {
+  type: 'TOGGLE_TIMELINE_FOR_ID',
+  payload: SuspenseNode['id'],
+};
+type ACTION_HOVER_TIMELINE_FOR_ID = {
+  type: 'HOVER_TIMELINE_FOR_ID',
+  payload: SuspenseNode['id'],
+};
 
 export type SuspenseTreeAction =
   | ACTION_SUSPENSE_TREE_MUTATION
@@ -81,7 +90,9 @@ export type SuspenseTreeAction =
   | ACTION_SUSPENSE_SET_TIMELINE_INDEX
   | ACTION_SUSPENSE_SKIP_TIMELINE_INDEX
   | ACTION_SUSPENSE_PLAY_PAUSE
-  | ACTION_SUSPENSE_PLAY_TICK;
+  | ACTION_SUSPENSE_PLAY_TICK
+  | ACTION_TOGGLE_TIMELINE_FOR_ID
+  | ACTION_HOVER_TIMELINE_FOR_ID;
 export type SuspenseTreeDispatch = (action: SuspenseTreeAction) => void;
 
 const SuspenseTreeStateContext: ReactContext<SuspenseTreeState> =
@@ -122,6 +133,7 @@ function getInitialState(store: Store): SuspenseTreeState {
       selectedRootID,
       timeline: [],
       timelineIndex: -1,
+      hoveredTimelineIndex: -1,
       uniqueSuspendersOnly,
       playing: false,
     };
@@ -144,6 +156,7 @@ function getInitialState(store: Store): SuspenseTreeState {
       selectedRootID,
       timeline,
       timelineIndex,
+      hoveredTimelineIndex: -1,
       uniqueSuspendersOnly,
       playing: false,
     };
@@ -397,6 +410,47 @@ function SuspenseTreeContextController({children}: Props): React.Node {
               playing: nextPlaying,
             };
           }
+          case 'TOGGLE_TIMELINE_FOR_ID': {
+            const suspenseID = action.payload;
+            const timelineIndexForSuspenseID =
+              state.timeline.indexOf(suspenseID);
+            if (timelineIndexForSuspenseID === -1) {
+              // This boundary is no longer in the timeline.
+              return state;
+            }
+            const nextTimelineIndex =
+              timelineIndexForSuspenseID === 0
+                ? // For roots, there's no toggling. It's always just jump to beginning.
+                  0
+                : // For boundaries, we'll either jump to before or after its reveal depending
+                  // on if we're currently displaying it or not according to the timeline.
+                  state.timelineIndex < timelineIndexForSuspenseID
+                  ? // We're currently before this suspense boundary has been revealed so we
+                    // should jump ahead to reveal it.
+                    timelineIndexForSuspenseID
+                  : // Otherwise, if we're currently showing it, jump to right before to hide it.
+                    timelineIndexForSuspenseID - 1;
+            const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
+            const nextLineage = store.getSuspenseLineage(
+              nextSelectedSuspenseID,
+            );
+            return {
+              ...state,
+              lineage: nextLineage,
+              selectedSuspenseID: nextSelectedSuspenseID,
+              timelineIndex: nextTimelineIndex,
+              playing: false, // pause
+            };
+          }
+          case 'HOVER_TIMELINE_FOR_ID': {
+            const suspenseID = action.payload;
+            const timelineIndexForSuspenseID =
+              state.timeline.indexOf(suspenseID);
+            return {
+              ...state,
+              hoveredTimelineIndex: timelineIndexForSuspenseID,
+            };
+          }
           default:
             throw new Error(`Unrecognized action "${action.type}"`);
         }