From 7f1a085b282d30dbb151f40c41bd53fc9045deb1 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Tue, 18 Nov 2025 09:52:44 +0100 Subject: [PATCH 1/2] [DevTools] Show list of named Activities in Suspense tab (#35092) --- .../src/devtools/store.js | 27 ++++ .../devtools/views/Components/TreeContext.js | 7 +- .../views/SuspenseTab/ActivityList.css | 21 ++- .../views/SuspenseTab/ActivityList.js | 135 ++++++++++++++---- .../views/SuspenseTab/SuspenseTab.css | 2 +- .../devtools/views/SuspenseTab/SuspenseTab.js | 120 ++++++++-------- .../src/app/Segments/index.js | 8 +- 7 files changed, 221 insertions(+), 99 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 2631893a543..b68dbdc9523 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -1058,6 +1058,33 @@ export default class Store extends EventEmitter<{ return timeline; } + getActivities(): Array<{id: Element['id'], depth: number}> { + const target: Array<{id: Element['id'], depth: number}> = []; + // TODO: Keep a live tree in the backend so we don't need to recalculate + // this each time while also including filtered Activities. + this._pushActivitiesInDocumentOrder(this.roots, target, 0); + return target; + } + + _pushActivitiesInDocumentOrder( + children: $ReadOnlyArray, + target: Array<{id: Element['id'], depth: number}>, + depth: number, + ): void { + for (let i = 0; i < children.length; i++) { + const child = this._idToElement.get(children[i]); + if (child === undefined) { + continue; + } + if (child.type === ElementTypeActivity && child.nameProp !== null) { + target.push({id: child.id, depth}); + this._pushActivitiesInDocumentOrder(child.children, target, depth + 1); + } else { + this._pushActivitiesInDocumentOrder(child.children, target, depth); + } + } + } + getRendererIDForElement(id: number): number | null { let current = this._idToElement.get(id); while (current !== undefined) { diff --git a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js index 4ad28c38935..2a1477ddb57 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js @@ -59,6 +59,7 @@ export type StateContext = { // Activity slice activityID: Element['id'] | null, + activities: $ReadOnlyArray<{id: Element['id'], depth: number}>, // Inspection element panel inspectedElementID: number | null, @@ -172,6 +173,7 @@ type State = { // Activity slice activityID: Element['id'] | null, + activities: $ReadOnlyArray<{id: Element['id'], depth: number}>, // Inspection element panel inspectedElementID: number | null, @@ -809,6 +811,7 @@ function reduceActivityState( case 'HANDLE_STORE_MUTATION': let {activityID} = state; const [, , activitySliceIDChange] = action.payload; + const activities = store.getActivities(); if (activitySliceIDChange === 0 && activityID !== null) { activityID = null; } else if ( @@ -817,10 +820,11 @@ function reduceActivityState( ) { activityID = activitySliceIDChange; } - if (activityID !== state.activityID) { + if (activityID !== state.activityID || activities !== state.activities) { return { ...state, activityID, + activities, }; } } @@ -863,6 +867,7 @@ function getInitialState({ // Activity slice activityID: null, + activities: store.getActivities(), // Inspection element panel inspectedElementID: diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/ActivityList.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/ActivityList.css index 0d1180b570c..4781f6899b8 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/ActivityList.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/ActivityList.css @@ -1,20 +1,33 @@ -.ActivityList { +.ActivityListContaier { + display: flex; + flex-direction: column; +} + +.ActivityListHeader { + /* even if empty, provides layout alignment with the main view */ + display: flex; + flex: 0 0 42px; + border-bottom: 1px solid var(--color-border); +} + +.ActivityListList { cursor: default; list-style-type: none; margin: 0; padding: 0; } -.ActivityList[data-pending-activity-slice-selection="true"] { +.ActivityListList[data-pending-activity-slice-selection="true"] { cursor: wait; } -.ActivityList:focus { +.ActivityListList:focus { outline: none; } .ActivityListItem { color: var(--color-component-name); + line-height: var(--line-height-data); padding: 0 0.25rem; user-select: none; } @@ -27,7 +40,7 @@ background-color: var(--color-background-inactive); } -.ActivityList:focus .ActivityListItem[aria-selected="true"] { +.ActivityListList:focus .ActivityListItem[aria-selected="true"] { background-color: var(--color-background-selected); color: var(--color-text-selected); diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/ActivityList.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/ActivityList.js index 97218fbbe4c..3a6935b4d00 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/ActivityList.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/ActivityList.js @@ -15,10 +15,14 @@ import typeof { SyntheticMouseEvent, SyntheticKeyboardEvent, } from 'react-dom-bindings/src/events/SyntheticEvent'; +import type Store from 'react-devtools-shared/src/devtools/store'; import * as React from 'react'; -import {useContext, useTransition} from 'react'; -import {ComponentFilterActivitySlice} from 'react-devtools-shared/src/frontend/types'; +import {useContext, useMemo, useTransition} from 'react'; +import { + ComponentFilterActivitySlice, + ElementTypeActivity, +} from 'react-devtools-shared/src/frontend/types'; import styles from './ActivityList.css'; import { TreeStateContext, @@ -26,6 +30,8 @@ import { } from '../Components/TreeContext'; import {useHighlightHostInstance} from '../hooks'; import {StoreContext} from '../context'; +import ButtonIcon from '../ButtonIcon'; +import Button from '../Button'; export function useChangeActivitySliceAction(): ( id: Element['id'] | null, @@ -62,15 +68,49 @@ export function useChangeActivitySliceAction(): ( return changeActivitySliceAction; } +function findNearestActivityParentID( + elementID: Element['id'], + store: Store, +): Element['id'] | null { + let currentID: null | Element['id'] = elementID; + while (currentID !== null) { + const element = store.getElementByID(currentID); + if (element === null) { + return null; + } + if (element.type === ElementTypeActivity) { + return element.id; + } + currentID = element.parentID; + } + + return currentID; +} + +function useSelectedActivityID(): Element['id'] | null { + const {inspectedElementID} = useContext(TreeStateContext); + const store = useContext(StoreContext); + return useMemo(() => { + if (inspectedElementID === null) { + return null; + } + const nearestActivityID = findNearestActivityParentID( + inspectedElementID, + store, + ); + return nearestActivityID; + }, [inspectedElementID, store]); +} + export default function ActivityList({ activities, }: { - activities: $ReadOnlyArray, + activities: $ReadOnlyArray<{id: Element['id'], depth: number}>, }): React$Node { - const {inspectedElementID} = useContext(TreeStateContext); + const {activityID, inspectedElementID} = useContext(TreeStateContext); const treeDispatch = useContext(TreeDispatcherContext); - // TODO: Derive from inspected element - const selectedActivityID = inspectedElementID; + const store = useContext(StoreContext); + const selectedActivityID = useSelectedActivityID(); const {highlightHostInstance, clearHighlightHostInstance} = useHighlightHostInstance(); @@ -79,8 +119,13 @@ export default function ActivityList({ const changeActivitySliceAction = useChangeActivitySliceAction(); function handleKeyDown(event: SyntheticKeyboardEvent) { - // TODO: Implement keyboard navigation switch (event.key) { + case 'Escape': + startActivitySliceSelection(() => { + changeActivitySliceAction(null); + }); + event.preventDefault(); + break; case 'Enter': case ' ': if (inspectedElementID !== null) { @@ -149,25 +194,61 @@ export default function ActivityList({ } return ( -
    - {activities.map(activity => ( -
  1. - {activity.nameProp} -
  2. - ))} -
+
+
+ {activityID !== null && ( + // TODO: Obsolete once filtered Activities are included in this list. + + )} +
+
    + {activities.map(({id, depth}) => { + const activity = store.getElementByID(id); + if (activity === null) { + return null; + } + const name = activity.nameProp; + if (name === null) { + // This shouldn't actually happen. We only want to show activities with a name. + // And hide the whole list if no named Activities are present. + return null; + } + + // TODO: Filtered Activities should have dedicated styles once we include + // filtered Activities in this list. + return ( +
  1. + {'\u00A0'.repeat(depth) + name} +
  2. + ); + })} +
+
); } 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 0dec577ea9a..0b1d85b1f48 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css @@ -92,7 +92,7 @@ } .ActivityList { - flex: 0 0 var(--horizontal-resize-tree-list-percentage); + flex: 0 0 var(--horizontal-resize-activity-list-percentage);; border-right: 1px solid var(--color-border); overflow: auto; } 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 f3929dec94c..36351f82bb8 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -6,14 +6,11 @@ * * @flow */ -import type {Element} from 'react-devtools-shared/src/frontend/types'; - import * as React from 'react'; import { useContext, useEffect, useLayoutEffect, - useMemo, useReducer, useRef, Fragment, @@ -44,12 +41,13 @@ import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/Synthe 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'; +import {TreeStateContext} from '../Components/TreeContext'; type Orientation = 'horizontal' | 'vertical'; type LayoutActionType = - | 'ACTION_SET_TREE_LIST_TOGGLE' - | 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION' + | 'ACTION_SET_ACTIVITY_LIST_TOGGLE' + | 'ACTION_SET_ACTIVITY_LIST_HORIZONTAL_FRACTION' | 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE' | 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION' | 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION'; @@ -59,8 +57,8 @@ type LayoutAction = { }; type LayoutState = { - treeListHidden: boolean, - treeListHorizontalFraction: number, + activityListHidden: boolean, + activityListHorizontalFraction: number, inspectedElementHidden: boolean, inspectedElementHorizontalFraction: number, inspectedElementVerticalFraction: number, @@ -97,7 +95,7 @@ function ToggleUniqueSuspenders() { ); } -function ToggleTreeList({ +function ToggleActivityList({ dispatch, state, }: { @@ -108,13 +106,15 @@ function ToggleTreeList({ ); @@ -272,17 +272,6 @@ function SynchronizedScrollContainer({ ); } -// TODO: Get this from the store directly. -// The backend needs to keep a separate tree so that resuspending keeps Activity around. -function useActivities(): $ReadOnlyArray { - const activities = useMemo(() => { - const items: Array = []; - return items; - }, []); - - return activities; -} - function SuspenseTab(_: {}) { const store = useContext(StoreContext); const {hideSettings} = useContext(OptionsContext); @@ -292,14 +281,14 @@ function SuspenseTab(_: {}) { initLayoutState, ); - const activities = useActivities(); + const {activities} = useContext(TreeStateContext); // If there are no named Activity boundaries, we don't have any tree list and we should hide // both the panel and the button to toggle it. - const treeListDisabled = activities.length === 0; + const activityListDisabled = activities.length === 0; const wrapperTreeRef = useRef(null); const resizeTreeRef = useRef(null); - const resizeTreeListRef = useRef(null); + const resizeActivityListRef = useRef(null); // TODO: We'll show the recently inspected element in this tab when it should probably // switch to the nearest Suspense boundary when we switch into this tab. @@ -308,8 +297,8 @@ function SuspenseTab(_: {}) { inspectedElementHidden, inspectedElementHorizontalFraction, inspectedElementVerticalFraction, - treeListHidden, - treeListHorizontalFraction, + activityListHidden, + activityListHorizontalFraction, } = state; useLayoutEffect(() => { @@ -328,12 +317,12 @@ function SuspenseTab(_: {}) { inspectedElementVerticalFraction * 100, ); - const resizeTreeListElement = resizeTreeListRef.current; + const resizeActivityListElement = resizeActivityListRef.current; setResizeCSSVariable( - resizeTreeListElement, - 'tree-list', + resizeActivityListElement, + 'activity-list', 'horizontal', - treeListHorizontalFraction * 100, + activityListHorizontalFraction * 100, ); }, []); useEffect(() => { @@ -344,8 +333,8 @@ function SuspenseTab(_: {}) { inspectedElementHidden, inspectedElementHorizontalFraction, inspectedElementVerticalFraction, - treeListHidden, - treeListHorizontalFraction, + activityListHidden, + activityListHorizontalFraction, }), ); }, 500); @@ -355,8 +344,8 @@ function SuspenseTab(_: {}) { inspectedElementHidden, inspectedElementHorizontalFraction, inspectedElementVerticalFraction, - treeListHidden, - treeListHorizontalFraction, + activityListHidden, + activityListHorizontalFraction, ]); const onResizeStart = (event: SyntheticPointerEvent) => { @@ -420,14 +409,14 @@ function SuspenseTab(_: {}) { } }; - const onResizeTreeList = (event: SyntheticPointerEvent) => { + const onResizeActivityList = (event: SyntheticPointerEvent) => { const element = event.currentTarget; const isResizing = element.hasPointerCapture(event.pointerId); if (!isResizing) { return; } - const resizeElement = resizeTreeListRef.current; + const resizeElement = resizeActivityListRef.current; const wrapperElement = resizeTreeRef.current; if (wrapperElement === null || resizeElement === null) { @@ -443,11 +432,11 @@ function SuspenseTab(_: {}) { const currentMousePosition = orientation === 'horizontal' ? event.clientX - left : event.clientY - top; - const boundaryMin = MINIMUM_TREE_LIST_SIZE; + const boundaryMin = MINIMUM_ACTIVITY_LIST_SIZE; const boundaryMax = orientation === 'horizontal' - ? width - MINIMUM_TREE_LIST_SIZE - : height - MINIMUM_TREE_LIST_SIZE; + ? width - MINIMUM_ACTIVITY_LIST_SIZE + : height - MINIMUM_ACTIVITY_LIST_SIZE; const isMousePositionInBounds = currentMousePosition > boundaryMin && currentMousePosition < boundaryMax; @@ -455,10 +444,15 @@ function SuspenseTab(_: {}) { if (isMousePositionInBounds) { const resizedElementDimension = orientation === 'horizontal' ? width : height; - const actionType = 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION'; + const actionType = 'ACTION_SET_ACTIVITY_LIST_HORIZONTAL_FRACTION'; const percentage = (currentMousePosition / resizedElementDimension) * 100; - setResizeCSSVariable(resizeElement, 'tree-list', orientation, percentage); + setResizeCSSVariable( + resizeElement, + 'activity-list', + orientation, + percentage, + ); dispatch({ type: actionType, @@ -473,19 +467,21 @@ function SuspenseTab(_: {}) {
- {treeListDisabled ? null : ( + {activityListDisabled ? null : ( )} - {treeListDisabled ? null : ( -