From a7d8dddaf321c581a4128897123127486163eb8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sun, 28 Sep 2025 19:09:52 -0400 Subject: [PATCH 1/4] [DevTools] Add Settings button on Suspense Tab (#34624) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings dialog appears on all tabs and should be reachable from Suspense tab too. It's a bit weird because it's not contextual to the tab and it shows you whatever your last settings tab was opened. Maybe it should default to opening to the current tab's settings? There aren't any Suspense specific settings yet but there definitely will be. We could move the "Show all" into settings but it might be frequently that you want to check why something isn't suspending a Suspense boundary or test SSR streaming. However, the general settings still apply to the Suspense tab. E.g. switching dark/light mode. Screenshot 2025-09-27 at 12 35 05 PM --- .../views/SuspenseTab/SuspenseTab.css | 12 +- .../devtools/views/SuspenseTab/SuspenseTab.js | 139 ++++++++++-------- 2 files changed, 85 insertions(+), 66 deletions(-) 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..227478649f9d7 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; + display: flex; align-items: flex-start; 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. 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..fb4235e2991e2 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -33,7 +33,7 @@ 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'; @@ -41,6 +41,9 @@ 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'; @@ -212,6 +215,7 @@ function ToggleInspectedElement({ } function SuspenseTab(_: {}) { + const {hideSettings} = useContext(OptionsContext); const [state, dispatch] = useReducer( layoutReducer, null, @@ -394,75 +398,82 @@ function SuspenseTab(_: {}) { }; return ( -
-
- {treeListDisabled ? null : ( - - )} - {treeListDisabled ? null : ( - 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..08e7723ec0de4 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; 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..59bc6c8506308 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, useLayoutEffect, useEffect, useRef} from 'react'; import {BridgeContext, StoreContext} from '../context'; import {TreeDispatcherContext} from '../Components/TreeContext'; import {useHighlightHostInstance} from '../hooks'; @@ -21,6 +21,8 @@ import typeof { SyntheticEvent, SyntheticPointerEvent, } from 'react-dom-bindings/src/events/SyntheticEvent'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; function SuspenseTimelineInput() { const bridge = useContext(BridgeContext); @@ -34,6 +36,7 @@ function SuspenseTimelineInput() { selectedRootID: rootID, timeline, timelineIndex, + playing, } = useContext(SuspenseTreeStateContext); const inputRef = useRef(null); @@ -98,26 +101,7 @@ function SuspenseTimelineInput() { } function handleChange(event: SyntheticEvent) { - if (rootID === null) { - return; - } - const rendererID = store.getRendererIDForElement(rootID); - if (rendererID === null) { - console.error( - `No renderer ID found for root element ${rootID} in suspense timeline.`, - ); - return; - } - const pendingTimelineIndex = +event.currentTarget.value; - const suspendedSet = timeline.slice(pendingTimelineIndex); - - bridge.send('overrideSuspenseMilestone', { - rendererID, - rootID, - suspendedSet, - }); - switchSuspenseNode(pendingTimelineIndex); } @@ -153,10 +137,108 @@ function SuspenseTimelineInput() { 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; + } + const rendererID = store.getRendererIDForElement(rootID); + if (rendererID === null) { + console.error( + `No renderer ID found for root element ${rootID} in suspense timeline.`, + ); + return; + } + // 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, + }); + } + + useEffect(() => { + changeTimelineIndex(timelineIndex); + }, [timelineIndex]); + + useEffect(() => { + if (!playing) { + return undefined; + } + // 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} -
+ + + +
, 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: From dce1f6cd5dd97ed93249f210a74c212d19e6900b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Sun, 28 Sep 2025 20:00:09 -0400 Subject: [PATCH 4/4] [DevTools] Custom Scrubber Design (#34627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2025-09-27 at 4 50 21 PM Screenshot 2025-09-27 at 4 50 45 PM https://github.com/user-attachments/assets/bc725f01-f0b5-40a8-bbb5-24cc4e84e86d --- .../views/SuspenseTab/SuspenseScrubber.css | 57 ++++++++++++ .../views/SuspenseTab/SuspenseScrubber.js | 86 +++++++++++++++++++ .../views/SuspenseTab/SuspenseTimeline.css | 5 -- .../views/SuspenseTab/SuspenseTimeline.js | 60 ++----------- 4 files changed, 151 insertions(+), 57 deletions(-) create mode 100644 packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css create mode 100644 packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js 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() {
-