@samitouri / QOS-React / commits / 2622487a74

[DevTools] Move Timeline to footer instead of header (#34617)

One thing that always bothered me is that the collapse buttons on either side of the toolbar looks like left/right buttons which would conflict with some steps buttons I plan to add. Another issue is that we'll need to add more tool buttons to the top and probably eventually a Search field. Ideally this whole section should line up vertically with the height of the title row. I also realized that all UIs that have some kind of timeline control (and play/pause/skip) do that in the bottom below the content. E.g. music players and video players all do that. We're better off playing into that structure since that's the UI analogy we're going for here. Makes it clearer what the weird timeline is for. By moving it to the bottom it also frees up the top for the collapse buttons and more controls. __Horizontal__ <img width="794" height="809" alt="Screenshot 2025-09-26 at 3 40 35 PM" src="https://github.com/user-attachments/assets/dacad9c4-d52f-4b66-9585-5cc74f230e6f" /> __Vertical__ <img width="570" height="812" alt="Screenshot 2025-09-26 at 3 40 53 PM" src="https://github.com/user-attachments/assets/db225413-849e-46f1-b764-8fbd08b395c4" />

Sebastian Markbåge committed Sep 26, 2025 at 16:27 UTC 2622487a7495b2ded000e1ad8b2e292d01bdd126
3 files changed +134 -100
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
+20 -17
@@ -49,10 +49,6 @@
49 cursor: ew-resize;
50 }
51
52 -.TreeView footer {
53 - display: none;
54 -}
55 -
52 @container devtools (width < 600px) {
53 .SuspenseTab {
54 flex-direction: column;
@@ -76,13 +72,13 @@
72 cursor: ns-resize;
73 }
74
79 - .TreeView footer {
80 - display: flex;
81 - justify-content: end;
82 - border-top: 1px solid var(--color-border);
75 + .ToggleInspectedElement[data-orientation="horizontal"] {
76 + display: none;
77 }
78 +}
79
85 - .ToggleInspectedElement[data-orientation="horizontal"] {
80 +@container devtools (width >= 600px) {
81 + .ToggleInspectedElement[data-orientation="vertical"] {
82 display: none;
83 }
84 }
@@ -103,22 +99,18 @@
99 }
100
101 .Rects {
106 - border-top: 1px solid var(--color-border);
102 padding: 0.25rem;
103 flex-grow: 1;
104 overflow: auto;
105 }
106
107 .SuspenseTreeViewHeader {
113 - padding: 0.25rem;
108 + flex: 0 0 42px;
109 + padding: 0.5rem;
110 display: grid;
115 - grid-template-columns: auto 1fr auto;
111 + grid-template-columns: auto 1fr auto auto auto;
112 align-items: flex-start;
117 -}
118 -
119 -.SuspenseTreeViewHeaderMain {
120 - display: grid;
121 - grid-template-rows: auto auto;
113 + border-bottom: 1px solid var(--color-border);
114 }
115
116 .SuspenseBreadcrumbs {
@@ -128,3 +120,14 @@
120 */
121 overflow-x: auto;
122 }
123 +
124 +.SuspenseTreeViewFooter {
125 + flex: 0 0 42px;
126 + display: flex;
127 + justify-content: end;
128 + border-top: 1px solid var(--color-border);
129 + padding: 0.5rem;
130 + display: grid;
131 + grid-template-columns: 1fr auto;
132 + align-items: flex-start;
133 +}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+113 -12
@@ -8,7 +8,13 @@
8 */
9
10 import * as React from 'react';
11 -import {useEffect, useLayoutEffect, useReducer, useRef} from 'react';
11 +import {
12 + useContext,
13 + useEffect,
14 + useLayoutEffect,
15 + useReducer,
16 + useRef,
17 +} from 'react';
18
19 import {
20 localStorageGetItem,
@@ -23,8 +29,18 @@ import SuspenseBreadcrumbs from './SuspenseBreadcrumbs';
29 import SuspenseRects from './SuspenseRects';
30 import SuspenseTimeline from './SuspenseTimeline';
31 import SuspenseTreeList from './SuspenseTreeList';
32 +import {
33 + SuspenseTreeDispatcherContext,
34 + SuspenseTreeStateContext,
35 +} from './SuspenseTreeContext';
36 +import {StoreContext} from '../context';
37 +import {TreeDispatcherContext} from '../Components/TreeContext';
38 import Button from '../Button';
27 -import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
39 +import Tooltip from '../Components/reach-ui/tooltip';
40 +import typeof {
41 + SyntheticEvent,
42 + SyntheticPointerEvent,
43 +} from 'react-dom-bindings/src/events/SyntheticEvent';
44
45 type Orientation = 'horizontal' | 'vertical';
46
@@ -48,6 +64,91 @@ type LayoutState = {
64 };
65 type LayoutDispatch = (action: LayoutAction) => void;
66
67 +function ToggleUniqueSuspenders() {
68 + const store = useContext(StoreContext);
69 + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
70 +
71 + const {selectedRootID: rootID, uniqueSuspendersOnly} = useContext(
72 + SuspenseTreeStateContext,
73 + );
74 +
75 + function handleToggleUniqueSuspenders(event: SyntheticEvent) {
76 + const nextUniqueSuspendersOnly = (event.currentTarget as HTMLInputElement)
77 + .checked;
78 + const nextTimeline =
79 + rootID === null
80 + ? []
81 + : // TODO: Handle different timeline modes (e.g. random order)
82 + store.getSuspendableDocumentOrderSuspense(
83 + rootID,
84 + nextUniqueSuspendersOnly,
85 + );
86 + suspenseTreeDispatch({
87 + type: 'SET_SUSPENSE_TIMELINE',
88 + payload: [nextTimeline, null, nextUniqueSuspendersOnly],
89 + });
90 + }
91 +
92 + return (
93 + <Tooltip label="Only include boundaries with unique suspenders">
94 + <input
95 + checked={uniqueSuspendersOnly}
96 + type="checkbox"
97 + onChange={handleToggleUniqueSuspenders}
98 + />
99 + </Tooltip>
100 + );
101 +}
102 +
103 +function SelectRoot() {
104 + const store = useContext(StoreContext);
105 + const {roots, selectedRootID, uniqueSuspendersOnly} = useContext(
106 + SuspenseTreeStateContext,
107 + );
108 + const treeDispatch = useContext(TreeDispatcherContext);
109 + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
110 +
111 + function handleChange(event: SyntheticEvent) {
112 + const newRootID = +event.currentTarget.value;
113 + // TODO: scrollIntoView both suspense rects and host instance.
114 + const nextTimeline = store.getSuspendableDocumentOrderSuspense(
115 + newRootID,
116 + uniqueSuspendersOnly,
117 + );
118 + suspenseTreeDispatch({
119 + type: 'SET_SUSPENSE_TIMELINE',
120 + payload: [nextTimeline, newRootID, uniqueSuspendersOnly],
121 + });
122 + if (nextTimeline.length > 0) {
123 + const milestone = nextTimeline[nextTimeline.length - 1];
124 + treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: milestone});
125 + }
126 + }
127 + return (
128 + roots.length > 0 && (
129 + <select
130 + aria-label="Select Suspense Root"
131 + className={styles.SuspenseTimelineRootSwitcher}
132 + onChange={handleChange}
133 + value={selectedRootID === null ? -1 : selectedRootID}>
134 + <option disabled={true} value={-1}>
135 + ----
136 + </option>
137 + {roots.map(rootID => {
138 + // TODO: Use name
139 + const name = '#' + rootID;
140 + // TODO: Highlight host on hover
141 + return (
142 + <option key={rootID} value={rootID}>
143 + {name}
144 + </option>
145 + );
146 + })}
147 + </select>
148 + )
149 + );
150 +}
151 +
152 function ToggleTreeList({
153 dispatch,
154 state,
@@ -314,30 +415,30 @@ function SuspenseTab(_: {}) {
415 </div>
416 )}
417 <div className={styles.TreeView}>
317 - <div className={styles.SuspenseTreeViewHeader}>
418 + <header className={styles.SuspenseTreeViewHeader}>
419 {treeListDisabled ? (
420 <div />
421 ) : (
422 <ToggleTreeList dispatch={dispatch} state={state} />
423 )}
323 - <div className={styles.SuspenseTreeViewHeaderMain}>
324 - <div className={styles.SuspenseTimeline}>
325 - <SuspenseTimeline />
326 - </div>
327 - <div className={styles.SuspenseBreadcrumbs}>
328 - <SuspenseBreadcrumbs />
329 - </div>
424 + <div className={styles.SuspenseBreadcrumbs}>
425 + <SuspenseBreadcrumbs />
426 </div>
427 + <ToggleUniqueSuspenders />
428 + <SelectRoot />
429 <ToggleInspectedElement
430 dispatch={dispatch}
431 state={state}
432 orientation="horizontal"
433 />
336 - </div>
434 + </header>
435 <div className={styles.Rects}>
436 <SuspenseRects />
437 </div>
340 - <footer>
438 + <footer className={styles.SuspenseTreeViewFooter}>
439 + <div className={styles.SuspenseTimeline}>
440 + <SuspenseTimeline />
441 + </div>
442 <ToggleInspectedElement
443 dispatch={dispatch}
444 state={state}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+1 -71
@@ -11,7 +11,6 @@ import * as React from 'react';
11 import {useContext, useLayoutEffect, useRef} from 'react';
12 import {BridgeContext, StoreContext} from '../context';
13 import {TreeDispatcherContext} from '../Components/TreeContext';
14 -import Tooltip from '../Components/reach-ui/tooltip';
14 import {useHighlightHostInstance} from '../hooks';
15 import {
16 SuspenseTreeDispatcherContext,
@@ -35,26 +34,8 @@ function SuspenseTimelineInput() {
34 selectedRootID: rootID,
35 timeline,
36 timelineIndex,
38 - uniqueSuspendersOnly,
37 } = useContext(SuspenseTreeStateContext);
38
41 - function handleToggleUniqueSuspenders(event: SyntheticEvent) {
42 - const nextUniqueSuspendersOnly = (event.currentTarget as HTMLInputElement)
43 - .checked;
44 - const nextTimeline =
45 - rootID === null
46 - ? []
47 - : // TODO: Handle different timeline modes (e.g. random order)
48 - store.getSuspendableDocumentOrderSuspense(
49 - rootID,
50 - nextUniqueSuspendersOnly,
51 - );
52 - suspenseTreeDispatch({
53 - type: 'SET_SUSPENSE_TIMELINE',
54 - payload: [nextTimeline, null, nextUniqueSuspendersOnly],
55 - });
56 - }
57 -
39 const inputRef = useRef<HTMLElement | null>(null);
40 const inputBBox = useRef<ClientRect | null>(null);
41 useLayoutEffect(() => {
@@ -190,66 +171,15 @@ function SuspenseTimelineInput() {
171 ref={inputRef}
172 />
173 </div>
193 - <Tooltip label="Only include boundaries with unique suspenders">
194 - <input
195 - checked={uniqueSuspendersOnly}
196 - type="checkbox"
197 - onChange={handleToggleUniqueSuspenders}
198 - />
199 - </Tooltip>
174 </>
175 );
176 }
177
178 export default function SuspenseTimeline(): React$Node {
205 - const store = useContext(StoreContext);
206 - const {roots, selectedRootID, uniqueSuspendersOnly} = useContext(
207 - SuspenseTreeStateContext,
208 - );
209 - const treeDispatch = useContext(TreeDispatcherContext);
210 - const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
211 -
212 - function handleChange(event: SyntheticEvent) {
213 - const newRootID = +event.currentTarget.value;
214 - // TODO: scrollIntoView both suspense rects and host instance.
215 - const nextTimeline = store.getSuspendableDocumentOrderSuspense(
216 - newRootID,
217 - uniqueSuspendersOnly,
218 - );
219 - suspenseTreeDispatch({
220 - type: 'SET_SUSPENSE_TIMELINE',
221 - payload: [nextTimeline, newRootID, uniqueSuspendersOnly],
222 - });
223 - if (nextTimeline.length > 0) {
224 - const milestone = nextTimeline[nextTimeline.length - 1];
225 - treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: milestone});
226 - }
227 - }
228 -
179 + const {selectedRootID} = useContext(SuspenseTreeStateContext);
180 return (
181 <div className={styles.SuspenseTimelineContainer}>
182 <SuspenseTimelineInput key={selectedRootID} />
232 - {roots.length > 0 && (
233 - <select
234 - aria-label="Select Suspense Root"
235 - className={styles.SuspenseTimelineRootSwitcher}
236 - onChange={handleChange}
237 - value={selectedRootID === null ? -1 : selectedRootID}>
238 - <option disabled={true} value={-1}>
239 - ----
240 - </option>
241 - {roots.map(rootID => {
242 - // TODO: Use name
243 - const name = '#' + rootID;
244 - // TODO: Highlight host on hover
245 - return (
246 - <option key={rootID} value={rootID}>
247 - {name}
248 - </option>
249 - );
250 - })}
251 - </select>
252 - )}
183 </div>
184 );
185 }