@samitouri / QOS-React / commits / dce1f6cd5d

[DevTools] Custom Scrubber Design (#34627)

Stacked on #34620. This will let us use different color for different segments of the timeline. Since we're modeling discrete steps (sometimes just a couple), a scrubber with a handle that you have to move is quite annoying and misleading. Doesn't show you how many steps there are. Therefore I went with a design that highlights each segment as its own step and you can click to jump to a step. This is still backed by an input range for accessibility and keyboard controls. <img width="1213" height="434" alt="Screenshot 2025-09-27 at 4 50 21 PM" src="https://github.com/user-attachments/assets/2c81753d-1b66-4434-8b1d-0a163fa22ab3" /> <img width="1213" height="430" alt="Screenshot 2025-09-27 at 4 50 45 PM" src="https://github.com/user-attachments/assets/07983978-a8f6-46ed-8c51-6ec96487af66" /> https://github.com/user-attachments/assets/bc725f01-f0b5-40a8-bbb5-24cc4e84e86d

Sebastian Markbåge committed Sep 28, 2025 at 20:00 UTC dce1f6cd5dd97ed93249f210a74c212d19e6900b
4 files changed +151 -57
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css new
+57
@@ -0,0 +1,57 @@
1 +.SuspenseScrubber {
2 + position: relative;
3 + width: 100%;
4 + height: 1.5rem;
5 + border-radius: 0.75rem;
6 + padding: 0.25rem;
7 + box-sizing: border-box;
8 + display: flex;
9 + align-items: center;
10 +}
11 +
12 +.SuspenseScrubber:has(.SuspenseScrubberInput:focus-visible) {
13 + outline: 2px solid var(--color-button-background-focus);
14 +}
15 +
16 +.SuspenseScrubberInput {
17 + position: absolute;
18 + width: 100%;
19 + opacity: 0;
20 + height: 0px;
21 + overflow: hidden;
22 +}
23 +
24 +.SuspenseScrubberInput:focus {
25 + outline: none;
26 +}
27 +
28 +.SuspenseScrubberStep {
29 + cursor: pointer;
30 + flex: 1;
31 + height: 100%;
32 + padding-right: 1px; /* we use this instead of flex gap to make every pixel clickable */
33 + display: flex;
34 + align-items: center;
35 +}
36 +.SuspenseScrubberStep:last-child {
37 + padding-right: 0;
38 +}
39 +
40 +.SuspenseScrubberBead, .SuspenseScrubberBeadSelected {
41 + flex: 1;
42 + height: 0.5rem;
43 + background: var(--color-background-selected);
44 + border-radius: 0.5rem;
45 + background: var(--color-selected-tree-highlight-active);
46 + transition: all 0.3s ease-in-out;
47 +}
48 +
49 +.SuspenseScrubberBeadSelected {
50 + height: 1rem;
51 + background: var(--color-background-selected);
52 +}
53 +
54 +.SuspenseScrubberStep:hover > .SuspenseScrubberBead,
55 +.SuspenseScrubberStep:hover > .SuspenseScrubberBeadSelected {
56 + height: 0.75rem;
57 +}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js new
+86
@@ -0,0 +1,86 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
11 +
12 +import * as React from 'react';
13 +import {useRef} from 'react';
14 +
15 +import styles from './SuspenseScrubber.css';
16 +
17 +export default function SuspenseScrubber({
18 + min,
19 + max,
20 + value,
21 + onBlur,
22 + onChange,
23 + onFocus,
24 + onHoverSegment,
25 + onHoverLeave,
26 +}: {
27 + min: number,
28 + max: number,
29 + value: number,
30 + onBlur: () => void,
31 + onChange: (index: number) => void,
32 + onFocus: () => void,
33 + onHoverSegment: (index: number) => void,
34 + onHoverLeave: () => void,
35 +}): React$Node {
36 + const inputRef = useRef();
37 + function handleChange(event: SyntheticEvent) {
38 + const newValue = +event.currentTarget.value;
39 + onChange(newValue);
40 + }
41 + function handlePress(index: number, event: SyntheticEvent) {
42 + event.preventDefault();
43 + if (inputRef.current == null) {
44 + throw new Error(
45 + 'The input should always be mounted while we can click things.',
46 + );
47 + }
48 + inputRef.current.focus();
49 + onChange(index);
50 + }
51 + const steps = [];
52 + for (let index = min; index <= max; index++) {
53 + steps.push(
54 + <div
55 + key={index}
56 + className={styles.SuspenseScrubberStep}
57 + onPointerDown={handlePress.bind(null, index)}
58 + onMouseEnter={onHoverSegment.bind(null, index)}>
59 + <div
60 + className={
61 + index <= value
62 + ? styles.SuspenseScrubberBeadSelected
63 + : styles.SuspenseScrubberBead
64 + }
65 + />
66 + </div>,
67 + );
68 + }
69 +
70 + return (
71 + <div className={styles.SuspenseScrubber} onMouseLeave={onHoverLeave}>
72 + <input
73 + className={styles.SuspenseScrubberInput}
74 + type="range"
75 + min={min}
76 + max={max}
77 + value={value}
78 + onBlur={onBlur}
79 + onChange={handleChange}
80 + onFocus={onFocus}
81 + ref={inputRef}
82 + />
83 + {steps}
84 + </div>
85 + );
86 +}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css
-5
@@ -9,11 +9,6 @@
9 display: flex;
10 flex-direction: column;
11 flex-grow: 1;
12 - /*
13 - * `overflow: auto` will add scrollbars but the input will not actually grow beyond visible content.
14 - * `overflow: hidden` will constrain the input to its visible content.
15 - */
16 - overflow: hidden;
12 }
13
14 .SuspenseTimelineRootSwitcher {
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+8 -52
@@ -8,7 +8,7 @@
8 */
9
10 import * as React from 'react';
11 -import {useContext, useLayoutEffect, useEffect, useRef} from 'react';
11 +import {useContext, useEffect} from 'react';
12 import {BridgeContext, StoreContext} from '../context';
13 import {TreeDispatcherContext} from '../Components/TreeContext';
14 import {useHighlightHostInstance} from '../hooks';
@@ -17,10 +17,7 @@ import {
17 SuspenseTreeStateContext,
18 } from './SuspenseTreeContext';
19 import styles from './SuspenseTimeline.css';
20 -import typeof {
21 - SyntheticEvent,
22 - SyntheticPointerEvent,
23 -} from 'react-dom-bindings/src/events/SyntheticEvent';
20 +import SuspenseScrubber from './SuspenseScrubber';
21 import Button from '../Button';
22 import ButtonIcon from '../ButtonIcon';
23
@@ -39,29 +36,6 @@ function SuspenseTimelineInput() {
36 playing,
37 } = useContext(SuspenseTreeStateContext);
38
42 - const inputRef = useRef<HTMLElement | null>(null);
43 - const inputBBox = useRef<ClientRect | null>(null);
44 - useLayoutEffect(() => {
45 - if (timeline.length === 0) {
46 - return;
47 - }
48 -
49 - const input = inputRef.current;
50 - if (input === null) {
51 - throw new Error('Expected an input HTML element to be present.');
52 - }
53 -
54 - inputBBox.current = input.getBoundingClientRect();
55 - const observer = new ResizeObserver(entries => {
56 - inputBBox.current = input.getBoundingClientRect();
57 - });
58 - observer.observe(input);
59 - return () => {
60 - inputBBox.current = null;
61 - observer.disconnect();
62 - };
63 - }, [timeline.length]);
64 -
39 const min = 0;
40 const max = timeline.length > 0 ? timeline.length - 1 : 0;
41
@@ -100,8 +74,7 @@ function SuspenseTimelineInput() {
74 });
75 }
76
103 - function handleChange(event: SyntheticEvent) {
104 - const pendingTimelineIndex = +event.currentTarget.value;
77 + function handleChange(pendingTimelineIndex: number) {
78 switchSuspenseNode(pendingTimelineIndex);
79 }
80
@@ -113,25 +86,11 @@ function SuspenseTimelineInput() {
86 switchSuspenseNode(timelineIndex);
87 }
88
116 - function handlePointerMove(event: SyntheticPointerEvent) {
117 - const bbox = inputBBox.current;
118 - if (bbox === null) {
119 - throw new Error('Bounding box of slider is unknown.');
120 - }
121 -
122 - const hoveredValue = Math.max(
123 - min,
124 - Math.min(
125 - Math.round(
126 - min + ((event.clientX - bbox.left) / bbox.width) * (max - min),
127 - ),
128 - max,
129 - ),
130 - );
89 + function handleHoverSegment(hoveredValue: number) {
90 const suspenseID = timeline[hoveredValue];
91 if (suspenseID === undefined) {
92 throw new Error(
134 - `Suspense node not found for value ${hoveredValue} in timeline when on ${event.clientX} in bounding box ${JSON.stringify(bbox)}.`,
93 + `Suspense node not found for value ${hoveredValue} in timeline.`,
94 );
95 }
96 highlightHostInstance(suspenseID);
@@ -239,18 +198,15 @@ function SuspenseTimelineInput() {
198 <div
199 className={styles.SuspenseTimelineInput}
200 title={timelineIndex + '/' + max}>
242 - <input
243 - className={styles.SuspenseTimelineSlider}
244 - type="range"
201 + <SuspenseScrubber
202 min={min}
203 max={max}
204 value={timelineIndex}
205 onBlur={handleBlur}
206 onChange={handleChange}
207 onFocus={handleFocus}
251 - onPointerMove={handlePointerMove}
252 - onPointerUp={clearHighlightHostInstance}
253 - ref={inputRef}
208 + onHoverSegment={handleHoverSegment}
209 + onHoverLeave={clearHighlightHostInstance}
210 />
211 </div>
212 </>