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 d67cc9a9fe3..00ca3e14594 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 94e51ef63d3..4668ede127d 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 f1f96a33e00..cbb76e41647 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}"`); }