Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ export type IconType =
| 'panel-bottom-close'
| 'filter-on'
| 'filter-off'
| 'play'
| 'pause'
| 'skip-previous'
| 'skip-next'
| 'error'
| 'suspend'
| 'undo'
Expand Down Expand Up @@ -163,6 +167,22 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
pathData = PATH_MATERIAL_FILTER_ALT_OFF;
viewBox = panelIcons;
break;
case 'play':
pathData = PATH_MATERIAL_PLAY_ARROW;
viewBox = panelIcons;
break;
case 'pause':
pathData = PATH_MATERIAL_PAUSE;
viewBox = panelIcons;
break;
case 'skip-previous':
pathData = PATH_MATERIAL_SKIP_PREVIOUS_ARROW;
viewBox = panelIcons;
break;
case 'skip-next':
pathData = PATH_MATERIAL_SKIP_NEXT_ARROW;
viewBox = panelIcons;
break;
case 'suspend':
pathData = PATH_SUSPEND;
break;
Expand Down Expand Up @@ -358,3 +378,23 @@ const PATH_MATERIAL_FILTER_ALT = `
const PATH_MATERIAL_FILTER_ALT_OFF = `
m592-481-57-57 143-182H353l-80-80h487q25 0 36 22t-4 42L592-481ZM791-56 560-287v87q0 17-11.5 28.5T520-160h-80q-17 0-28.5-11.5T400-200v-247L56-791l56-57 736 736-57 56ZM535-538Z
`;

// Source: Material Design Icons play_arrow
const PATH_MATERIAL_PLAY_ARROW = `
M320-200v-560l440 280-440 280Zm80-280Zm0 134 210-134-210-134v268Z
`;

// Source: Material Design Icons pause
const PATH_MATERIAL_PAUSE = `
M520-200v-560h240v560H520Zm-320 0v-560h240v560H200Zm400-80h80v-400h-80v400Zm-320 0h80v-400h-80v400Zm0-400v400-400Zm320 0v400-400Z
`;

// Source: Material Design Icons skip_previous
const PATH_MATERIAL_SKIP_PREVIOUS_ARROW = `
M220-240v-480h80v480h-80Zm520 0L380-480l360-240v480Zm-80-240Zm0 90v-180l-136 90 136 90Z
`;

// Source: Material Design Icons skip_next
const PATH_MATERIAL_SKIP_NEXT_ARROW = `
M660-240v-480h80v480h-80Zm-440 0v-480l360 240-360 240Zm80-240Zm0 90 136-90-136-90v180Z
`;
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,7 @@
grid-template-columns: 1fr auto;
align-items: center;
}

.SuspenseTreeViewFooterButtons {
padding: 0.25rem;
}
Original file line number Diff line number Diff line change
Expand Up @@ -441,14 +441,14 @@ function SuspenseTab(_: {}) {
<SuspenseRects />
</div>
<footer className={styles.SuspenseTreeViewFooter}>
<div className={styles.SuspenseTimeline}>
<SuspenseTimeline />
<SuspenseTimeline />
<div className={styles.SuspenseTreeViewFooterButtons}>
<ToggleInspectedElement
dispatch={dispatch}
state={state}
orientation="vertical"
/>
</div>
<ToggleInspectedElement
dispatch={dispatch}
state={state}
orientation="vertical"
/>
</footer>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
.SuspenseTimelineContainer {
width: 100%;
display: flex;
flex-direction: row;
padding: 0.25rem;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

import * as React from 'react';
import {useContext, useLayoutEffect, useRef} from 'react';
import {useContext, useLayoutEffect, useEffect, useRef} from 'react';
import {BridgeContext, StoreContext} from '../context';
import {TreeDispatcherContext} from '../Components/TreeContext';
import {useHighlightHostInstance} from '../hooks';
Expand All @@ -21,6 +21,8 @@ import typeof {
SyntheticEvent,
SyntheticPointerEvent,
} from 'react-dom-bindings/src/events/SyntheticEvent';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';

function SuspenseTimelineInput() {
const bridge = useContext(BridgeContext);
Expand All @@ -34,6 +36,7 @@ function SuspenseTimelineInput() {
selectedRootID: rootID,
timeline,
timelineIndex,
playing,
} = useContext(SuspenseTreeStateContext);

const inputRef = useRef<HTMLElement | null>(null);
Expand Down Expand Up @@ -98,26 +101,7 @@ function SuspenseTimelineInput() {
}

function handleChange(event: SyntheticEvent) {
if (rootID === null) {
return;
}
const rendererID = store.getRendererIDForElement(rootID);
if (rendererID === null) {
console.error(
`No renderer ID found for root element ${rootID} in suspense timeline.`,
);
return;
}

const pendingTimelineIndex = +event.currentTarget.value;
const suspendedSet = timeline.slice(pendingTimelineIndex);

bridge.send('overrideSuspenseMilestone', {
rendererID,
rootID,
suspendedSet,
});

switchSuspenseNode(pendingTimelineIndex);
}

Expand Down Expand Up @@ -153,10 +137,108 @@ function SuspenseTimelineInput() {
highlightHostInstance(suspenseID);
}

function skipPrevious() {
const nextSelectedSuspenseID = timeline[timelineIndex - 1];
highlightHostInstance(nextSelectedSuspenseID);
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
});
suspenseTreeDispatch({
type: 'SUSPENSE_SKIP_TIMELINE_INDEX',
payload: false,
});
}

function skipForward() {
const nextSelectedSuspenseID = timeline[timelineIndex + 1];
highlightHostInstance(nextSelectedSuspenseID);
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
});
suspenseTreeDispatch({
type: 'SUSPENSE_SKIP_TIMELINE_INDEX',
payload: true,
});
}

function togglePlaying() {
suspenseTreeDispatch({
type: 'SUSPENSE_PLAY_PAUSE',
payload: 'toggle',
});
}

// TODO: useEffectEvent here once it's supported in all versions DevTools supports.
// For now we just exclude it from deps since we don't lint those anyway.
function changeTimelineIndex(newIndex: number) {
// Synchronize timeline index with what is resuspended.
if (rootID === null) {
return;
}
const rendererID = store.getRendererIDForElement(rootID);
if (rendererID === null) {
console.error(
`No renderer ID found for root element ${rootID} in suspense timeline.`,
);
return;
}
// We suspend everything after the current selection. The root isn't showing
// anything suspended in the root. The step after that should have one less
// thing suspended. I.e. the first suspense boundary should be unsuspended
// when it's selected. This also lets you show everything in the last step.
const suspendedSet = timeline.slice(timelineIndex + 1);
Copy link
Collaborator Author

@sebmarkbage sebmarkbage Sep 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also fixed this issue so that you can see everything in the last step and so that going from first to second step actually does something.

bridge.send('overrideSuspenseMilestone', {
rendererID,
rootID,
suspendedSet,
});
}

useEffect(() => {
changeTimelineIndex(timelineIndex);
}, [timelineIndex]);

useEffect(() => {
if (!playing) {
return undefined;
}
// While playing, advance one step every second.
const PLAY_SPEED_INTERVAL = 1000;
const timer = setInterval(() => {
suspenseTreeDispatch({
type: 'SUSPENSE_PLAY_TICK',
});
}, PLAY_SPEED_INTERVAL);
return () => {
clearInterval(timer);
};
}, [playing]);

return (
<>
{timelineIndex}/{max}
<div className={styles.SuspenseTimelineInput}>
<Button
disabled={timelineIndex === 0}
title={'Previous'}
onClick={skipPrevious}>
<ButtonIcon type={'skip-previous'} />
</Button>
<Button
disabled={max === 0 && !playing}
title={playing ? 'Pause' : 'Play'}
onClick={togglePlaying}>
<ButtonIcon type={playing ? 'pause' : 'play'} />
</Button>
<Button
disabled={timelineIndex === max}
title={'Next'}
onClick={skipForward}>
<ButtonIcon type={'skip-next'} />
</Button>
<div
className={styles.SuspenseTimelineInput}
title={timelineIndex + '/' + max}>
<input
className={styles.SuspenseTimelineSlider}
type="range"
Expand Down
Loading
Loading