[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.
This commit is contained in:
Sebastian Markbåge
2025-09-29 10:43:01 -04:00
committed by GitHub
parent 8674c3ba28
commit d15d7fd79e
5 changed files with 88 additions and 2 deletions

View File

@@ -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.

View File

@@ -51,6 +51,8 @@
background: var(--color-background-selected);
}
.SuspenseScrubberStepHighlight > .SuspenseScrubberBead,
.SuspenseScrubberStepHighlight > .SuspenseScrubberBeadSelected,
.SuspenseScrubberStep:hover > .SuspenseScrubberBead,
.SuspenseScrubberStep:hover > .SuspenseScrubberBeadSelected {
height: 0.75rem;

View File

@@ -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

View File

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

View File

@@ -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}"`);
}