[DevTools] Show list of named Activities in Suspense tab (#35092)

This commit is contained in:
Sebastian "Sebbie" Silbermann
2025-11-18 09:52:44 +01:00
committed by GitHub
parent ea4899e13f
commit 7f1a085b28
7 changed files with 221 additions and 99 deletions

View File

@@ -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<Element['id']>,
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) {

View File

@@ -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:

View File

@@ -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);

View File

@@ -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<Element>,
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 (
<ol
role="listbox"
className={styles.ActivityList}
data-pending-activity-slice-selection={isPendingActivitySliceSelection}
tabIndex={0}
onKeyDown={handleKeyDown}>
{activities.map(activity => (
<li
key={activity.id}
role="option"
aria-selected={activity.id === selectedActivityID ? 'true' : 'false'}
className={styles.ActivityListItem}
onClick={handleClick.bind(null, activity.id)}
onDoubleClick={handleDoubleClick}
onPointerOver={highlightHostInstance.bind(null, activity.id, false)}
onPointerLeave={clearHighlightHostInstance}>
{activity.nameProp}
</li>
))}
</ol>
<div className={styles.ActivityListContaier}>
<div className={styles.ActivityListHeader}>
{activityID !== null && (
// TODO: Obsolete once filtered Activities are included in this list.
<Button
onClick={startActivitySliceSelection.bind(
null,
changeActivitySliceAction.bind(null, null),
)}
title="Back to full tree view">
<ButtonIcon type="previous" />
</Button>
)}
</div>
<ol
role="listbox"
className={styles.ActivityListList}
data-pending-activity-slice-selection={isPendingActivitySliceSelection}
tabIndex={0}
onKeyDown={handleKeyDown}>
{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 (
<li
key={activity.id}
role="option"
aria-selected={
activity.id === selectedActivityID ? 'true' : 'false'
}
className={styles.ActivityListItem}
onClick={handleClick.bind(null, activity.id)}
onDoubleClick={handleDoubleClick}
onPointerOver={highlightHostInstance.bind(
null,
activity.id,
false,
)}
onPointerLeave={clearHighlightHostInstance}>
{'\u00A0'.repeat(depth) + name}
</li>
);
})}
</ol>
</div>
);
}

View File

@@ -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;
}

View File

@@ -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({
<Button
onClick={() =>
dispatch({
type: 'ACTION_SET_TREE_LIST_TOGGLE',
type: 'ACTION_SET_ACTIVITY_LIST_TOGGLE',
payload: null,
})
}
title={state.treeListHidden ? 'Show Tree List' : 'Hide Tree List'}>
title={
state.activityListHidden ? 'Show Activity List' : 'Hide Activity List'
}>
<ButtonIcon
type={state.treeListHidden ? 'panel-left-open' : 'panel-left-close'}
type={state.activityListHidden ? 'panel-left-open' : 'panel-left-close'}
/>
</Button>
);
@@ -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<Element> {
const activities = useMemo(() => {
const items: Array<Element> = [];
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 | HTMLElement>(null);
const resizeTreeRef = useRef<null | HTMLElement>(null);
const resizeTreeListRef = useRef<null | HTMLElement>(null);
const resizeActivityListRef = useRef<null | HTMLElement>(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(_: {}) {
<SettingsModalContextController>
<div className={styles.SuspenseTab} ref={wrapperTreeRef}>
<div className={styles.TreeWrapper} ref={resizeTreeRef}>
{treeListDisabled ? null : (
{activityListDisabled ? null : (
<div
className={styles.ActivityList}
hidden={treeListHidden}
ref={resizeTreeListRef}>
hidden={activityListHidden}
ref={resizeActivityListRef}>
<ActivityList activities={activities} />
</div>
)}
{treeListDisabled ? null : (
<div className={styles.ResizeBarWrapper} hidden={treeListHidden}>
{activityListDisabled ? null : (
<div
className={styles.ResizeBarWrapper}
hidden={activityListHidden}>
<div
onPointerDown={onResizeStart}
onPointerMove={onResizeTreeList}
onPointerMove={onResizeActivityList}
onPointerUp={onResizeEnd}
className={styles.ResizeBar}
/>
@@ -493,10 +489,10 @@ function SuspenseTab(_: {}) {
)}
<div className={styles.TreeView}>
<header className={styles.SuspenseTreeViewHeader}>
{treeListDisabled ? (
{activityListDisabled ? (
<div />
) : (
<ToggleTreeList dispatch={dispatch} state={state} />
<ToggleActivityList dispatch={dispatch} state={state} />
)}
{store.supportsClickToInspect && (
<Fragment>
@@ -559,19 +555,19 @@ function SuspenseTab(_: {}) {
const LOCAL_STORAGE_KEY = 'React::DevTools::SuspenseTab::layout';
const VERTICAL_TREE_MODE_MAX_WIDTH = 600;
const MINIMUM_TREE_SIZE = 100;
const MINIMUM_TREE_LIST_SIZE = 100;
const MINIMUM_ACTIVITY_LIST_SIZE = 100;
function layoutReducer(state: LayoutState, action: LayoutAction): LayoutState {
switch (action.type) {
case 'ACTION_SET_TREE_LIST_TOGGLE':
case 'ACTION_SET_ACTIVITY_LIST_TOGGLE':
return {
...state,
treeListHidden: !state.treeListHidden,
activityListHidden: !state.activityListHidden,
};
case 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION':
case 'ACTION_SET_ACTIVITY_LIST_HORIZONTAL_FRACTION':
return {
...state,
treeListHorizontalFraction: action.payload,
activityListHorizontalFraction: action.payload,
};
case 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE':
return {
@@ -597,8 +593,8 @@ function initLayoutState(): LayoutState {
let inspectedElementHidden = false;
let inspectedElementHorizontalFraction = 0.65;
let inspectedElementVerticalFraction = 0.5;
let treeListHidden = false;
let treeListHorizontalFraction = 0.35;
let activityListHidden = false;
let activityListHorizontalFraction = 0.35;
try {
let data = localStorageGetItem(LOCAL_STORAGE_KEY);
@@ -608,8 +604,8 @@ function initLayoutState(): LayoutState {
inspectedElementHorizontalFraction =
data.inspectedElementHorizontalFraction;
inspectedElementVerticalFraction = data.inspectedElementVerticalFraction;
treeListHidden = data.treeListHidden;
treeListHorizontalFraction = data.treeListHorizontalFraction;
activityListHidden = data.activityListHidden;
activityListHorizontalFraction = data.activityListHorizontalFraction;
}
} catch (error) {}
@@ -617,8 +613,8 @@ function initLayoutState(): LayoutState {
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
treeListHidden,
treeListHorizontalFraction,
activityListHidden,
activityListHorizontalFraction,
};
}
@@ -634,7 +630,7 @@ function getTreeOrientation(
function setResizeCSSVariable(
resizeElement: null | HTMLElement,
name: 'tree' | 'tree-list',
name: 'tree' | 'activity-list',
orientation: null | Orientation,
percentage: number,
): void {

View File

@@ -77,13 +77,13 @@ function Root({children}: {children: React.Node}): React.Node {
export default function Segments(): React.Node {
return (
<React.Activity name="/" mode="visible">
<React.Activity name="root" mode="visible">
<Root>
<React.Activity name="/outer/" mode="visible">
<React.Activity name="outer" mode="visible">
<OuterSegment>
<React.Activity name="/outer/inner" mode="visible">
<React.Activity name="inner" mode="visible">
<InnerSegment>
<React.Activity name="/outer/inner/page" mode="visible">
<React.Activity name="slot" mode="visible">
<Page />
</React.Activity>
</InnerSegment>