From b4fdd341bfd3b109fa39482ee89db50c7909297a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 26 Sep 2025 18:23:49 -0400 Subject: [PATCH 1/7] Add play/pause/skip buttons --- .../src/devtools/views/ButtonIcon.js | 40 +++++++++++++++++++ .../views/SuspenseTab/SuspenseTimeline.js | 23 +++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js index b4decabcba27a..a3f7b0701ef17 100644 --- a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js +++ b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js @@ -42,6 +42,10 @@ export type IconType = | 'panel-bottom-close' | 'filter-on' | 'filter-off' + | 'play' + | 'pause' + | 'skip-previous' + | 'skip-next' | 'error' | 'suspend' | 'undo' @@ -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; @@ -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 +`; diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index 51b2b9e9a065a..99776c212efb0 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -8,7 +8,7 @@ */ import * as React from 'react'; -import {useContext, useLayoutEffect, useRef} from 'react'; +import {useContext, useState, useLayoutEffect, useRef} from 'react'; import {BridgeContext, StoreContext} from '../context'; import {TreeDispatcherContext} from '../Components/TreeContext'; import {useHighlightHostInstance} from '../hooks'; @@ -21,6 +21,8 @@ import typeof { SyntheticEvent, SyntheticPointerEvent, } from 'react-dom-bindings/src/events/SyntheticEvent'; +import Button from '../Button'; +import ButtonIcon, {type IconType} from '../ButtonIcon'; function SuspenseTimelineInput() { const bridge = useContext(BridgeContext); @@ -153,10 +155,25 @@ function SuspenseTimelineInput() { highlightHostInstance(suspenseID); } + const [playing, setPlaying] = useState(false); + return ( <> - {timelineIndex}/{max} -
+ + + +
Date: Fri, 26 Sep 2025 18:44:44 -0400 Subject: [PATCH 2/7] Skip forward/backward Allow rapid clicks --- .../views/SuspenseTab/SuspenseTimeline.js | 40 +++++++++++++++++-- .../views/SuspenseTab/SuspenseTreeContext.js | 28 ++++++++++++- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index 99776c212efb0..4050158f47e8c 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -22,7 +22,7 @@ import typeof { SyntheticPointerEvent, } from 'react-dom-bindings/src/events/SyntheticEvent'; import Button from '../Button'; -import ButtonIcon, {type IconType} from '../ButtonIcon'; +import ButtonIcon from '../ButtonIcon'; function SuspenseTimelineInput() { const bridge = useContext(BridgeContext); @@ -155,20 +155,52 @@ 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, + }); + } + const [playing, setPlaying] = useState(false); return ( <> - -
void; const SuspenseTreeStateContext: ReactContext = @@ -304,6 +309,27 @@ function SuspenseTreeContextController({children}: Props): React.Node { timelineIndex: nextTimelineIndex, }; } + case 'SUSPENSE_SKIP_TIMELINE_INDEX': { + const direction = action.payload; + const nextTimelineIndex = + state.timelineIndex + (direction ? 1 : -1); + if ( + nextTimelineIndex < 0 || + nextTimelineIndex > state.timeline.length - 1 + ) { + return state; + } + const nextSelectedSuspenseID = state.timeline[nextTimelineIndex]; + const nextLineage = store.getSuspenseLineage( + nextSelectedSuspenseID, + ); + return { + ...state, + lineage: nextLineage, + selectedSuspenseID: nextSelectedSuspenseID, + timelineIndex: nextTimelineIndex, + }; + } default: throw new Error(`Unrecognized action "${action.type}"`); } From 13ad47ed6b284347f158d4f06cabb1db7c2c0680 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 26 Sep 2025 19:01:01 -0400 Subject: [PATCH 3/7] Implement playing state Pause it as soon as something is selected. If we restart at the end then loop around to the beginning. --- .../views/SuspenseTab/SuspenseTimeline.js | 14 +++++-- .../views/SuspenseTab/SuspenseTreeContext.js | 41 ++++++++++++++++++- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index 4050158f47e8c..f7fc031bfd836 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -8,7 +8,7 @@ */ import * as React from 'react'; -import {useContext, useState, useLayoutEffect, useRef} from 'react'; +import {useContext, useLayoutEffect, useRef} from 'react'; import {BridgeContext, StoreContext} from '../context'; import {TreeDispatcherContext} from '../Components/TreeContext'; import {useHighlightHostInstance} from '../hooks'; @@ -36,6 +36,7 @@ function SuspenseTimelineInput() { selectedRootID: rootID, timeline, timelineIndex, + playing, } = useContext(SuspenseTreeStateContext); const inputRef = useRef(null); @@ -181,7 +182,12 @@ function SuspenseTimelineInput() { }); } - const [playing, setPlaying] = useState(false); + function togglePlaying() { + suspenseTreeDispatch({ + type: 'SUSPENSE_PLAY_PAUSE', + payload: 'toggle', + }); + } return ( <> @@ -192,9 +198,9 @@ function SuspenseTimelineInput() {
-
- + +
+
-
diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css index 33441bcf34c00..08e7723ec0de4 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css @@ -1,5 +1,4 @@ .SuspenseTimelineContainer { - width: 100%; display: flex; flex-direction: row; padding: 0.25rem; From 767c57153cb39e419d041599f9c38492d938335f Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 26 Sep 2025 19:18:26 -0400 Subject: [PATCH 5/7] Advance one step every second while playing --- .../views/SuspenseTab/SuspenseTimeline.js | 18 ++++++++++- .../views/SuspenseTab/SuspenseTreeContext.js | 30 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index f7fc031bfd836..06c5d448c5134 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -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'; @@ -189,6 +189,22 @@ function SuspenseTimelineInput() { }); } + 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 ( <>