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/SuspenseTab.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css index cbb9e13709d72..c718be7ea9499 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css @@ -139,3 +139,7 @@ grid-template-columns: 1fr auto; align-items: center; } + +.SuspenseTreeViewFooterButtons { + padding: 0.25rem; +} \ No newline at end of file diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js index b0e08d4f4bd75..b4ed7ec1f93c8 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -441,14 +441,14 @@ function SuspenseTab(_: {}) { 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; 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..59bc6c8506308 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'; @@ -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); @@ -34,6 +36,7 @@ function SuspenseTimelineInput() { selectedRootID: rootID, timeline, timelineIndex, + playing, } = useContext(SuspenseTreeStateContext); const inputRef = useRef(null); @@ -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); } @@ -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); + 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} -
+ + + +
, timelineIndex: number | -1, uniqueSuspendersOnly: boolean, + playing: boolean, }; type ACTION_SUSPENSE_TREE_MUTATION = { @@ -60,12 +61,27 @@ type ACTION_SUSPENSE_SET_TIMELINE_INDEX = { type: 'SUSPENSE_SET_TIMELINE_INDEX', payload: number, }; +type ACTION_SUSPENSE_SKIP_TIMELINE_INDEX = { + type: 'SUSPENSE_SKIP_TIMELINE_INDEX', + payload: boolean, +}; +type ACTION_SUSPENSE_PLAY_PAUSE = { + type: 'SUSPENSE_PLAY_PAUSE', + payload: 'toggle' | 'play' | 'pause', +}; +type ACTION_SUSPENSE_PLAY_TICK = { + type: 'SUSPENSE_PLAY_TICK', +}; + export type SuspenseTreeAction = | ACTION_SUSPENSE_TREE_MUTATION | ACTION_SET_SUSPENSE_LINEAGE | ACTION_SELECT_SUSPENSE_BY_ID | ACTION_SET_SUSPENSE_TIMELINE - | ACTION_SUSPENSE_SET_TIMELINE_INDEX; + | ACTION_SUSPENSE_SET_TIMELINE_INDEX + | ACTION_SUSPENSE_SKIP_TIMELINE_INDEX + | ACTION_SUSPENSE_PLAY_PAUSE + | ACTION_SUSPENSE_PLAY_TICK; export type SuspenseTreeDispatch = (action: SuspenseTreeAction) => void; const SuspenseTreeStateContext: ReactContext = @@ -107,6 +123,7 @@ function getInitialState(store: Store): SuspenseTreeState { timeline: [], timelineIndex: -1, uniqueSuspendersOnly, + playing: false, }; } else { const timeline = store.getSuspendableDocumentOrderSuspense( @@ -128,6 +145,7 @@ function getInitialState(store: Store): SuspenseTreeState { timeline, timelineIndex, uniqueSuspendersOnly, + playing: false, }; } @@ -234,6 +252,7 @@ function SuspenseTreeContextController({children}: Props): React.Node { ...state, selectedSuspenseID, selectedRootID, + playing: false, // pause }; } case 'SET_SUSPENSE_LINEAGE': { @@ -247,6 +266,7 @@ function SuspenseTreeContextController({children}: Props): React.Node { lineage, selectedSuspenseID: suspenseID, selectedRootID, + playing: false, // pause }; } case 'SET_SUSPENSE_TIMELINE': { @@ -302,6 +322,79 @@ function SuspenseTreeContextController({children}: Props): React.Node { lineage: nextLineage, selectedSuspenseID: nextSelectedSuspenseID, timelineIndex: nextTimelineIndex, + playing: false, // pause + }; + } + 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, + playing: false, // pause + }; + } + case 'SUSPENSE_PLAY_PAUSE': { + const mode = action.payload; + + let nextTimelineIndex = state.timelineIndex; + let nextSelectedSuspenseID = state.selectedSuspenseID; + let nextLineage = state.lineage; + + if ( + !state.playing && + mode !== 'pause' && + nextTimelineIndex === state.timeline.length - 1 + ) { + // If we're restarting at the end. Then loop around and start again from the beginning. + nextTimelineIndex = 0; + nextSelectedSuspenseID = state.timeline[nextTimelineIndex]; + nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID); + } + + return { + ...state, + lineage: nextLineage, + selectedSuspenseID: nextSelectedSuspenseID, + timelineIndex: nextTimelineIndex, + playing: mode === 'toggle' ? !state.playing : mode === 'play', + }; + } + case 'SUSPENSE_PLAY_TICK': { + if (!state.playing) { + // We stopped but haven't yet cleaned up the callback. Noop. + return state; + } + // Advance time + const nextTimelineIndex = state.timelineIndex + 1; + if (nextTimelineIndex > state.timeline.length - 1) { + return state; + } + const nextSelectedSuspenseID = state.timeline[nextTimelineIndex]; + const nextLineage = store.getSuspenseLineage( + nextSelectedSuspenseID, + ); + // Stop once we reach the end. + const nextPlaying = nextTimelineIndex < state.timeline.length - 1; + return { + ...state, + lineage: nextLineage, + selectedSuspenseID: nextSelectedSuspenseID, + timelineIndex: nextTimelineIndex, + playing: nextPlaying, }; } default: