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/SuspenseTimeline.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css
index 08e7723ec0de4..77bb01c5fa32b 100644
--- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css
@@ -9,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 59bc6c8506308..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, useEffect, 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,7 @@ 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';
@@ -39,29 +36,6 @@ function SuspenseTimelineInput() {
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;
@@ -100,8 +74,7 @@ function SuspenseTimelineInput() {
});
}
- function handleChange(event: SyntheticEvent) {
- const pendingTimelineIndex = +event.currentTarget.value;
+ function handleChange(pendingTimelineIndex: number) {
switchSuspenseNode(pendingTimelineIndex);
}
@@ -113,25 +86,11 @@ function SuspenseTimelineInput() {
switchSuspenseNode(timelineIndex);
}
- function handlePointerMove(event: SyntheticPointerEvent) {
- const bbox = inputBBox.current;
- if (bbox === null) {
- throw new Error('Bounding box of slider is unknown.');
- }
-
- const hoveredValue = Math.max(
- min,
- Math.min(
- Math.round(
- min + ((event.clientX - bbox.left) / bbox.width) * (max - min),
- ),
- max,
- ),
- );
+ function handleHoverSegment(hoveredValue: number) {
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)}.`,
+ `Suspense node not found for value ${hoveredValue} in timeline.`,
);
}
highlightHostInstance(suspenseID);
@@ -239,18 +198,15 @@ function SuspenseTimelineInput() {
-
>