diff --git a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
index 1f3c339608098..a3f7b0701ef17 100644
--- a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
+++ b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
@@ -40,6 +40,12 @@ export type IconType =
| 'panel-right-open'
| 'panel-bottom-open'
| 'panel-bottom-close'
+ | 'filter-on'
+ | 'filter-off'
+ | 'play'
+ | 'pause'
+ | 'skip-previous'
+ | 'skip-next'
| 'error'
| 'suspend'
| 'undo'
@@ -153,6 +159,30 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
pathData = PATH_MATERIAL_PANEL_BOTTOM_CLOSE;
viewBox = panelIcons;
break;
+ case 'filter-on':
+ pathData = PATH_MATERIAL_FILTER_ALT;
+ viewBox = panelIcons;
+ break;
+ case 'filter-off':
+ 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;
@@ -338,3 +368,33 @@ const PATH_MATERIAL_PANEL_BOTTOM_OPEN = `
const PATH_MATERIAL_PANEL_BOTTOM_CLOSE = `
m506-508 102-110q8-8.82 3.5-19.41T595-648H365q-12.25 0-16.62 10.5Q344-627 352-618l102 110q11.18 11 26.09 11T506-508Zm243-308q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538ZM216-336v120h528v-120H216Zm528-72v-336H216v336h528Zm-528 72v120-120Z
`;
+
+// Source: Material Design Icons filter_alt
+const PATH_MATERIAL_FILTER_ALT = `
+ M440-160q-17 0-28.5-11.5T400-200v-240L168-736q-15-20-4.5-42t36.5-22h560q26 0 36.5 22t-4.5 42L560-440v240q0 17-11.5 28.5T520-160h-80Zm40-308 198-252H282l198 252Zm0 0Z
+`;
+
+// Source: Material Design Icons filter_alt_off
+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/SuspenseScrubber.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css
new file mode 100644
index 0000000000000..94e51ef63d330
--- /dev/null
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css
@@ -0,0 +1,57 @@
+.SuspenseScrubber {
+ position: relative;
+ width: 100%;
+ height: 1.5rem;
+ border-radius: 0.75rem;
+ padding: 0.25rem;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+}
+
+.SuspenseScrubber:has(.SuspenseScrubberInput:focus-visible) {
+ outline: 2px solid var(--color-button-background-focus);
+}
+
+.SuspenseScrubberInput {
+ position: absolute;
+ width: 100%;
+ opacity: 0;
+ height: 0px;
+ overflow: hidden;
+}
+
+.SuspenseScrubberInput:focus {
+ outline: none;
+}
+
+.SuspenseScrubberStep {
+ cursor: pointer;
+ flex: 1;
+ height: 100%;
+ padding-right: 1px; /* we use this instead of flex gap to make every pixel clickable */
+ display: flex;
+ align-items: center;
+}
+.SuspenseScrubberStep:last-child {
+ padding-right: 0;
+}
+
+.SuspenseScrubberBead, .SuspenseScrubberBeadSelected {
+ flex: 1;
+ height: 0.5rem;
+ background: var(--color-background-selected);
+ border-radius: 0.5rem;
+ background: var(--color-selected-tree-highlight-active);
+ transition: all 0.3s ease-in-out;
+}
+
+.SuspenseScrubberBeadSelected {
+ height: 1rem;
+ background: var(--color-background-selected);
+}
+
+.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
new file mode 100644
index 0000000000000..f1f96a33e00e2
--- /dev/null
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js
@@ -0,0 +1,86 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
+
+import * as React from 'react';
+import {useRef} from 'react';
+
+import styles from './SuspenseScrubber.css';
+
+export default function SuspenseScrubber({
+ min,
+ max,
+ value,
+ onBlur,
+ onChange,
+ onFocus,
+ onHoverSegment,
+ onHoverLeave,
+}: {
+ min: number,
+ max: number,
+ value: number,
+ onBlur: () => void,
+ onChange: (index: number) => void,
+ onFocus: () => void,
+ onHoverSegment: (index: number) => void,
+ onHoverLeave: () => void,
+}): React$Node {
+ const inputRef = useRef();
+ function handleChange(event: SyntheticEvent) {
+ const newValue = +event.currentTarget.value;
+ onChange(newValue);
+ }
+ function handlePress(index: number, event: SyntheticEvent) {
+ event.preventDefault();
+ if (inputRef.current == null) {
+ throw new Error(
+ 'The input should always be mounted while we can click things.',
+ );
+ }
+ inputRef.current.focus();
+ onChange(index);
+ }
+ const steps = [];
+ for (let index = min; index <= max; index++) {
+ steps.push(
+
,
+ );
+ }
+
+ return (
+
+
+ {steps}
+
+ );
+}
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 3e042402a4c3a..c718be7ea9499 100644
--- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
@@ -14,6 +14,14 @@
-webkit-font-smoothing: var(--font-smoothing);
}
+.VRule {
+ height: 20px;
+ width: 1px;
+ flex: 0 0 1px;
+ margin: 0 0.5rem;
+ background-color: var(--color-border);
+}
+
.TreeWrapper {
border-top: 1px solid var(--color-border);
flex: 1 1 65%;
@@ -107,13 +115,13 @@
.SuspenseTreeViewHeader {
flex: 0 0 42px;
padding: 0.5rem;
- display: grid;
- grid-template-columns: auto 1fr auto auto auto;
- align-items: flex-start;
+ display: flex;
+ align-items: center;
border-bottom: 1px solid var(--color-border);
}
.SuspenseBreadcrumbs {
+ flex: 1;
/**
* TODO: Switch to single item view on overflow like OwnerStack does.
* OwnerStack has more constraints that make it easier so it won't be a 1:1 port.
@@ -129,5 +137,9 @@
padding: 0.5rem;
display: grid;
grid-template-columns: 1fr auto;
- align-items: flex-start;
+ 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 c7b53605b70a1..b4ed7ec1f93c8 100644
--- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
@@ -33,14 +33,17 @@ import {
SuspenseTreeDispatcherContext,
SuspenseTreeStateContext,
} from './SuspenseTreeContext';
-import {StoreContext} from '../context';
+import {StoreContext, OptionsContext} from '../context';
import {TreeDispatcherContext} from '../Components/TreeContext';
import Button from '../Button';
-import Tooltip from '../Components/reach-ui/tooltip';
+import Toggle from '../Toggle';
import typeof {
SyntheticEvent,
SyntheticPointerEvent,
} from 'react-dom-bindings/src/events/SyntheticEvent';
+import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal';
+import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle';
+import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext';
type Orientation = 'horizontal' | 'vertical';
@@ -72,9 +75,8 @@ function ToggleUniqueSuspenders() {
SuspenseTreeStateContext,
);
- function handleToggleUniqueSuspenders(event: SyntheticEvent) {
- const nextUniqueSuspendersOnly = (event.currentTarget as HTMLInputElement)
- .checked;
+ function handleToggleUniqueSuspenders() {
+ const nextUniqueSuspendersOnly = !uniqueSuspendersOnly;
const nextTimeline =
rootID === null
? []
@@ -90,13 +92,12 @@ function ToggleUniqueSuspenders() {
}
return (
-
-
-
+
+
+
);
}
@@ -212,6 +213,7 @@ function ToggleInspectedElement({
}
function SuspenseTab(_: {}) {
+ const {hideSettings} = useContext(OptionsContext);
const [state, dispatch] = useReducer(
layoutReducer,
null,
@@ -394,75 +396,82 @@ function SuspenseTab(_: {}) {
};
return (
-
-
- {treeListDisabled ? null : (
-
-
-
- )}
- {treeListDisabled ? null : (
-
+
+
+
+ {treeListDisabled ? null : (
-
- )}
-
-
-
-
-
-
+ className={styles.ResizeBarWrapper}
+ hidden={inspectedElementHidden}>
+
+
+
+
+
+
+
+
-
+
);
}
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..77bb01c5fa32b 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;
@@ -10,11 +9,6 @@
display: flex;
flex-direction: column;
flex-grow: 1;
- /*
- * `overflow: auto` will add scrollbars but the input will not actually grow beyond visible content.
- * `overflow: hidden` will constrain the input to its visible content.
- */
- overflow: hidden;
}
.SuspenseTimelineRootSwitcher {
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..d618bfcea63b3 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, useEffect} from 'react';
import {BridgeContext, StoreContext} from '../context';
import {TreeDispatcherContext} from '../Components/TreeContext';
import {useHighlightHostInstance} from '../hooks';
@@ -17,10 +17,9 @@ import {
SuspenseTreeStateContext,
} from './SuspenseTreeContext';
import styles from './SuspenseTimeline.css';
-import typeof {
- SyntheticEvent,
- SyntheticPointerEvent,
-} from 'react-dom-bindings/src/events/SyntheticEvent';
+import SuspenseScrubber from './SuspenseScrubber';
+import Button from '../Button';
+import ButtonIcon from '../ButtonIcon';
function SuspenseTimelineInput() {
const bridge = useContext(BridgeContext);
@@ -34,31 +33,9 @@ function SuspenseTimelineInput() {
selectedRootID: rootID,
timeline,
timelineIndex,
+ playing,
} = useContext(SuspenseTreeStateContext);
- const inputRef = useRef
(null);
- const inputBBox = useRef(null);
- useLayoutEffect(() => {
- if (timeline.length === 0) {
- return;
- }
-
- const input = inputRef.current;
- if (input === null) {
- throw new Error('Expected an input HTML element to be present.');
- }
-
- inputBBox.current = input.getBoundingClientRect();
- const observer = new ResizeObserver(entries => {
- inputBBox.current = input.getBoundingClientRect();
- });
- observer.observe(input);
- return () => {
- inputBBox.current = null;
- observer.disconnect();
- };
- }, [timeline.length]);
-
const min = 0;
const max = timeline.length > 0 ? timeline.length - 1 : 0;
@@ -97,7 +74,65 @@ function SuspenseTimelineInput() {
});
}
- function handleChange(event: SyntheticEvent) {
+ function handleChange(pendingTimelineIndex: number) {
+ switchSuspenseNode(pendingTimelineIndex);
+ }
+
+ function handleBlur() {
+ clearHighlightHostInstance();
+ }
+
+ function handleFocus() {
+ switchSuspenseNode(timelineIndex);
+ }
+
+ function handleHoverSegment(hoveredValue: number) {
+ const suspenseID = timeline[hoveredValue];
+ if (suspenseID === undefined) {
+ throw new Error(
+ `Suspense node not found for value ${hoveredValue} in timeline.`,
+ );
+ }
+ 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;
}
@@ -108,67 +143,70 @@ function SuspenseTimelineInput() {
);
return;
}
-
- const pendingTimelineIndex = +event.currentTarget.value;
- const suspendedSet = timeline.slice(pendingTimelineIndex);
-
+ // 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,
});
-
- switchSuspenseNode(pendingTimelineIndex);
- }
-
- function handleBlur() {
- clearHighlightHostInstance();
}
- function handleFocus() {
- switchSuspenseNode(timelineIndex);
- }
-
- function handlePointerMove(event: SyntheticPointerEvent) {
- const bbox = inputBBox.current;
- if (bbox === null) {
- throw new Error('Bounding box of slider is unknown.');
- }
+ useEffect(() => {
+ changeTimelineIndex(timelineIndex);
+ }, [timelineIndex]);
- const hoveredValue = Math.max(
- min,
- Math.min(
- Math.round(
- min + ((event.clientX - bbox.left) / bbox.width) * (max - min),
- ),
- max,
- ),
- );
- const suspenseID = timeline[hoveredValue];
- if (suspenseID === undefined) {
- throw new Error(
- `Suspense node not found for value ${hoveredValue} in timeline when on ${event.clientX} in bounding box ${JSON.stringify(bbox)}.`,
- );
+ useEffect(() => {
+ if (!playing) {
+ return undefined;
}
- highlightHostInstance(suspenseID);
- }
+ // 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}
-
-
+
+
+
+
+
+
>
diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
index 3f0c5fd41ae2b..d97b88952e5ae 100644
--- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
@@ -32,6 +32,7 @@ export type SuspenseTreeState = {
timeline: $ReadOnlyArray
,
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: