Repository: facebook/react
Pull request: [DevTools] Move Timeline to footer instead of header

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" />


Changed files:
filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
patch: @@ -49,10 +49,6 @@
   cursor: ew-resize;
 }
 
-.TreeView footer {
-  display: none;
-}
-
 @container devtools (width < 600px) {
   .SuspenseTab {
     flex-direction: column;
@@ -76,13 +72,13 @@
     cursor: ns-resize;
   }
 
-  .TreeView footer {
-    display: flex;
-    justify-content: end;
-    border-top: 1px solid var(--color-border);
+  .ToggleInspectedElement[data-orientation="horizontal"] {
+    display: none;
   }
+}
 
-  .ToggleInspectedElement[data-orientation="horizontal"] {
+@container devtools (width >= 600px) {
+  .ToggleInspectedElement[data-orientation="vertical"] {
     display: none;
   }
 }
@@ -103,22 +99,18 @@
 }
 
 .Rects {
-  border-top: 1px solid var(--color-border);
   padding: 0.25rem;
   flex-grow: 1;
   overflow: auto;
 }
 
 .SuspenseTreeViewHeader {
-  padding: 0.25rem;
+  flex: 0 0 42px;
+  padding: 0.5rem;
   display: grid;
-  grid-template-columns: auto 1fr auto;
+  grid-template-columns: auto 1fr auto auto auto;
   align-items: flex-start;
-}
-
-.SuspenseTreeViewHeaderMain {
-  display: grid;
-  grid-template-rows: auto auto;
+  border-bottom: 1px solid var(--color-border);
 }
 
 .SuspenseBreadcrumbs {
@@ -128,3 +120,14 @@
    */
   overflow-x: auto;
 }
+
+.SuspenseTreeViewFooter {
+  flex: 0 0 42px;
+  display: flex;
+  justify-content: end;
+  border-top: 1px solid var(--color-border);
+  padding: 0.5rem;
+  display: grid;
+  grid-template-columns: 1fr auto;
+  align-items: flex-start;
+}

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
patch: @@ -8,7 +8,13 @@
  */
 
 import * as React from 'react';
-import {useEffect, useLayoutEffect, useReducer, useRef} from 'react';
+import {
+  useContext,
+  useEffect,
+  useLayoutEffect,
+  useReducer,
+  useRef,
+} from 'react';
 
 import {
   localStorageGetItem,
@@ -23,8 +29,18 @@ import SuspenseBreadcrumbs from './SuspenseBreadcrumbs';
 import SuspenseRects from './SuspenseRects';
 import SuspenseTimeline from './SuspenseTimeline';
 import SuspenseTreeList from './SuspenseTreeList';
+import {
+  SuspenseTreeDispatcherContext,
+  SuspenseTreeStateContext,
+} from './SuspenseTreeContext';
+import {StoreContext} from '../context';
+import {TreeDispatcherContext} from '../Components/TreeContext';
 import Button from '../Button';
-import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
+import Tooltip from '../Components/reach-ui/tooltip';
+import typeof {
+  SyntheticEvent,
+  SyntheticPointerEvent,
+} from 'react-dom-bindings/src/events/SyntheticEvent';
 
 type Orientation = 'horizontal' | 'vertical';
 
@@ -48,6 +64,91 @@ type LayoutState = {
 };
 type LayoutDispatch = (action: LayoutAction) => void;
 
+function ToggleUniqueSuspenders() {
+  const store = useContext(StoreContext);
+  const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
+
+  const {selectedRootID: rootID, uniqueSuspendersOnly} = useContext(
+    SuspenseTreeStateContext,
+  );
+
+  function handleToggleUniqueSuspenders(event: SyntheticEvent) {
+    const nextUniqueSuspendersOnly = (event.currentTarget as HTMLInputElement)
+      .checked;
+    const nextTimeline =
+      rootID === null
+        ? []
+        : // TODO: Handle different timeline modes (e.g. random order)
+          store.getSuspendableDocumentOrderSuspense(
+            rootID,
+            nextUniqueSuspendersOnly,
+          );
+    suspenseTreeDispatch({
+      type: 'SET_SUSPENSE_TIMELINE',
+      payload: [nextTimeline, null, nextUniqueSuspendersOnly],
+    });
+  }
+
+  return (
+    <Tooltip label="Only include boundaries with unique suspenders">
+      <input
+        checked={uniqueSuspendersOnly}
+        type="checkbox"
+        onChange={handleToggleUniqueSuspenders}
+      />
+    </Tooltip>
+  );
+}
+
+function SelectRoot() {
+  const store = useContext(StoreContext);
+  const {roots, selectedRootID, uniqueSuspendersOnly} = useContext(
+    SuspenseTreeStateContext,
+  );
+  const treeDispatch = useContext(TreeDispatcherContext);
+  const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
+
+  function handleChange(event: SyntheticEvent) {
+    const newRootID = +event.currentTarget.value;
+    // TODO: scrollIntoView both suspense rects and host instance.
+    const nextTimeline = store.getSuspendableDocumentOrderSuspense(
+      newRootID,
+      uniqueSuspendersOnly,
+    );
+    suspenseTreeDispatch({
+      type: 'SET_SUSPENSE_TIMELINE',
+      payload: [nextTimeline, newRootID, uniqueSuspendersOnly],
+    });
+    if (nextTimeline.length > 0) {
+      const milestone = nextTimeline[nextTimeline.length - 1];
+      treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: milestone});
+    }
+  }
+  return (
+    roots.length > 0 && (
+      <select
+        aria-label="Select Suspense Root"
+        className={styles.SuspenseTimelineRootSwitcher}
+        onChange={handleChange}
+        value={selectedRootID === null ? -1 : selectedRootID}>
+        <option disabled={true} value={-1}>
+          ----
+        </option>
+        {roots.map(rootID => {
+          // TODO: Use name
+          const name = '#' + rootID;
+          // TODO: Highlight host on hover
+          return (
+            <option key={rootID} value={rootID}>
+              {name}
+            </option>
+          );
+        })}
+      </select>
+    )
+  );
+}
+
 function ToggleTreeList({
   dispatch,
   state,
@@ -314,30 +415,30 @@ function SuspenseTab(_: {}) {
           </div>
         )}
         <div className={styles.TreeView}>
-          <div className={styles.SuspenseTreeViewHeader}>
+          <header className={styles.SuspenseTreeViewHeader}>
             {treeListDisabled ? (
               <div />
             ) : (
               <ToggleTreeList dispatch={dispatch} state={state} />
             )}
-            <div className={styles.SuspenseTreeViewHeaderMain}>
-              <div className={styles.SuspenseTimeline}>
-                <SuspenseTimeline />
-              </div>
-              <div className={styles.SuspenseBreadcrumbs}>
-                <SuspenseBreadcrumbs />
-              </div>
+            <div className={styles.SuspenseBreadcrumbs}>
+              <SuspenseBreadcrumbs />
             </div>
+            <ToggleUniqueSuspenders />
+            <SelectRoot />
             <ToggleInspectedElement
               dispatch={dispatch}
               state={state}
               orientation="horizontal"
             />
-          </div>
+          </header>
           <div className={styles.Rects}>
             <SuspenseRects />
           </div>
-          <footer>
+          <footer className={styles.SuspenseTreeViewFooter}>
+            <div className={styles.SuspenseTimeline}>
+              <SuspenseTimeline />
+            </div>
             <ToggleInspectedElement
               dispatch={dispatch}
               state={state}

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
patch: @@ -11,7 +11,6 @@ import * as React from 'react';
 import {useContext, useLayoutEffect, useRef} from 'react';
 import {BridgeContext, StoreContext} from '../context';
 import {TreeDispatcherContext} from '../Components/TreeContext';
-import Tooltip from '../Components/reach-ui/tooltip';
 import {useHighlightHostInstance} from '../hooks';
 import {
   SuspenseTreeDispatcherContext,
@@ -35,26 +34,8 @@ function SuspenseTimelineInput() {
     selectedRootID: rootID,
     timeline,
     timelineIndex,
-    uniqueSuspendersOnly,
   } = useContext(SuspenseTreeStateContext);
 
-  function handleToggleUniqueSuspenders(event: SyntheticEvent) {
-    const nextUniqueSuspendersOnly = (event.currentTarget as HTMLInputElement)
-      .checked;
-    const nextTimeline =
-      rootID === null
-        ? []
-        : // TODO: Handle different timeline modes (e.g. random order)
-          store.getSuspendableDocumentOrderSuspense(
-            rootID,
-            nextUniqueSuspendersOnly,
-          );
-    suspenseTreeDispatch({
-      type: 'SET_SUSPENSE_TIMELINE',
-      payload: [nextTimeline, null, nextUniqueSuspendersOnly],
-    });
-  }
-
   const inputRef = useRef<HTMLElement | null>(null);
   const inputBBox = useRef<ClientRect | null>(null);
   useLayoutEffect(() => {
@@ -190,66 +171,15 @@ function SuspenseTimelineInput() {
           ref={inputRef}
         />
       </div>
-      <Tooltip label="Only include boundaries with unique suspenders">
-        <input
-          checked={uniqueSuspendersOnly}
-          type="checkbox"
-          onChange={handleToggleUniqueSuspenders}
-        />
-      </Tooltip>
     </>
   );
 }
 
 export default function SuspenseTimeline(): React$Node {
-  const store = useContext(StoreContext);
-  const {roots, selectedRootID, uniqueSuspendersOnly} = useContext(
-    SuspenseTreeStateContext,
-  );
-  const treeDispatch = useContext(TreeDispatcherContext);
-  const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
-
-  function handleChange(event: SyntheticEvent) {
-    const newRootID = +event.currentTarget.value;
-    // TODO: scrollIntoView both suspense rects and host instance.
-    const nextTimeline = store.getSuspendableDocumentOrderSuspense(
-      newRootID,
-      uniqueSuspendersOnly,
-    );
-    suspenseTreeDispatch({
-      type: 'SET_SUSPENSE_TIMELINE',
-      payload: [nextTimeline, newRootID, uniqueSuspendersOnly],
-    });
-    if (nextTimeline.length > 0) {
-      const milestone = nextTimeline[nextTimeline.length - 1];
-      treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: milestone});
-    }
-  }
-
+  const {selectedRootID} = useContext(SuspenseTreeStateContext);
   return (
     <div className={styles.SuspenseTimelineContainer}>
       <SuspenseTimelineInput key={selectedRootID} />
-      {roots.length > 0 && (
-        <select
-          aria-label="Select Suspense Root"
-          className={styles.SuspenseTimelineRootSwitcher}
-          onChange={handleChange}
-          value={selectedRootID === null ? -1 : selectedRootID}>
-          <option disabled={true} value={-1}>
-            ----
-          </option>
-          {roots.map(rootID => {
-            // TODO: Use name
-            const name = '#' + rootID;
-            // TODO: Highlight host on hover
-            return (
-              <option key={rootID} value={rootID}>
-                {name}
-              </option>
-            );
-          })}
-        </select>
-      )}
     </div>
   );
 }