From d15d7fd79e00fe095a70d8855562172cd46187b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 29 Sep 2025 10:43:01 -0400 Subject: [PATCH] [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. --- .../views/SuspenseTab/SuspenseRects.js | 21 +++++++ .../views/SuspenseTab/SuspenseScrubber.css | 2 + .../views/SuspenseTab/SuspenseScrubber.js | 9 ++- .../views/SuspenseTab/SuspenseTimeline.js | 2 + .../views/SuspenseTab/SuspenseTreeContext.js | 56 ++++++++++++++++++- 5 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index d67cc9a9fe..00ca3e1459 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -98,6 +98,18 @@ 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 @@ -105,6 +117,10 @@ function SuspenseRects({ } 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. diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css index 94e51ef63d..4668ede127 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css @@ -51,6 +51,8 @@ background: var(--color-background-selected); } +.SuspenseScrubberStepHighlight > .SuspenseScrubberBead, +.SuspenseScrubberStepHighlight > .SuspenseScrubberBeadSelected, .SuspenseScrubberStep:hover > .SuspenseScrubberBead, .SuspenseScrubberStep:hover > .SuspenseScrubberBeadSelected { height: 0.75rem; diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js index f1f96a33e0..cbb76e4164 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js @@ -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(
, 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 = @@ -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}"`); }