[DevTools] Layout for Suspense tab (#34042)

This commit is contained in:
Sebastian "Sebbie" Silbermann
2025-07-30 07:12:18 +02:00
committed by GitHub
parent 88b40f6e41
commit 36c63d4f9c
3 changed files with 622 additions and 6 deletions
@@ -34,6 +34,12 @@ export type IconType =
| 'save'
| 'search'
| 'settings'
| 'panel-left-close'
| 'panel-left-open'
| 'panel-right-close'
| 'panel-right-open'
| 'panel-bottom-open'
| 'panel-bottom-close'
| 'error'
| 'suspend'
| 'undo'
@@ -46,8 +52,10 @@ type Props = {
type: IconType,
};
const materialIconsViewBox = '0 -960 960 960';
export default function ButtonIcon({className = '', type}: Props): React.Node {
let pathData = null;
let viewBox = '0 0 24 24';
switch (type) {
case 'add':
pathData = PATH_ADD;
@@ -121,6 +129,30 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
case 'error':
pathData = PATH_ERROR;
break;
case 'panel-left-close':
pathData = PATH_MATERIAL_PANEL_LEFT_CLOSE;
viewBox = materialIconsViewBox;
break;
case 'panel-left-open':
pathData = PATH_MATERIAL_PANEL_LEFT_OPEN;
viewBox = materialIconsViewBox;
break;
case 'panel-right-close':
pathData = PATH_MATERIAL_PANEL_RIGHT_CLOSE;
viewBox = materialIconsViewBox;
break;
case 'panel-right-open':
pathData = PATH_MATERIAL_PANEL_RIGHT_OPEN;
viewBox = materialIconsViewBox;
break;
case 'panel-bottom-open':
pathData = PATH_MATERIAL_PANEL_BOTTOM_OPEN;
viewBox = materialIconsViewBox;
break;
case 'panel-bottom-close':
pathData = PATH_MATERIAL_PANEL_BOTTOM_CLOSE;
viewBox = materialIconsViewBox;
break;
case 'suspend':
pathData = PATH_SUSPEND;
break;
@@ -147,7 +179,7 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
className={`${styles.ButtonIcon} ${className}`}
width="24"
height="24"
viewBox="0 0 24 24">
viewBox={viewBox}>
<path d="M0 0h24v24H0z" fill="none" />
{typeof pathData === 'string' ? (
<path fill="currentColor" d={pathData} />
@@ -276,3 +308,33 @@ const PATH_VIEW_SOURCE = `
const PATH_EDITOR = `
M7 5h10v2h2V3c0-1.1-.9-1.99-2-1.99L7 1c-1.1 0-2 .9-2 2v4h2V5zm8.41 11.59L20 12l-4.59-4.59L14 8.83 17.17 12 14 15.17l1.41 1.42zM10 15.17L6.83 12 10 8.83 8.59 7.41 4 12l4.59 4.59L10 15.17zM17 19H7v-2H5v4c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2v-4h-2v2z
`;
// Source: Material Design Icons left_panel_close
const PATH_MATERIAL_PANEL_LEFT_CLOSE = `
M648-324v-312L480-480l168 156ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm125-72v-528H216v528h120Zm72 0h336v-528H408v528Zm-72 0H216h120Z
`;
// Source: Material Design Icons left_panel_open
const PATH_MATERIAL_PANEL_LEFT_OPEN = `
M504-595v230q0 12.25 10.5 16.62Q525-344 534-352l110-102q11-11.18 11-26.09T644-506L534-608q-8.82-8-19.41-3.5T504-595ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm125-72v-528H216v528h120Zm72 0h336v-528H408v528Zm-72 0H216h120Z
`;
// Source: Material Design Icons right_panel_close
const PATH_MATERIAL_PANEL_RIGHT_CLOSE = `
M312-365q0 12.25 10.5 16.62Q333-344 342-352l110-102q11-11.18 11-26.09T452-506L342-608q-8.82-8-19.41-3.5T312-595v230ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm413-72h120v-528H624v528Zm-72 0v-528H216v528h336Zm72 0h120-120Z
`;
// Source: Material Design Icons right_panel_open
const PATH_MATERIAL_PANEL_RIGHT_OPEN = `
M456-365v-230q0-12.25-10.5-16.63Q435-616 426-608L316-506q-11 11.18-11 26.09T316-454l110 102q8.82 8 19.41 3.5T456-365ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm413-72h120v-528H624v528Zm-72 0v-528H216v528h336Zm72 0h120-120Z
`;
// Source: Material Design Icons bottom_panel_open
const PATH_MATERIAL_PANEL_BOTTOM_OPEN = `
M365-504h230q12.25 0 16.63-10.5Q616-525 608-534L506-644q-11.18-11-26.09-11T454-644L352-534q-8 8.82-3.5 19.41T365-504ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm5-192v120h528v-120H216Zm0-72h528v-336H216v336Zm0 72v120-120Z
`;
// Source: Material Design Icons bottom_panel_close
const PATH_MATERIAL_PANEL_BOTTOM_CLOSE = `
m506-508 102-110q8-8.82 3.5-19.41T595-648H365q-12.25 0-16.62 10.5Q344-627 352-618l102 110q11.18 11 26.09 11T506-508Zm243-308q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538ZM216-336v120h528v-120H216Zm528-72v-336H216v336h528Zm-528 72v120-120Z
`;
@@ -0,0 +1,117 @@
.SuspenseTab {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
background-color: var(--color-background);
color: var(--color-text);
font-family: var(--font-family-sans);
}
.SuspenseTab, .SuspenseTab * {
box-sizing: border-box;
-webkit-font-smoothing: var(--font-smoothing);
}
.TreeWrapper {
flex: 1 1 var(--horizontal-resize-tree-percentage);
display: flex;
flex-direction: row;
overflow: auto;
border-top: 1px solid var(--color-border);
}
.InspectedElementWrapper {
flex: 1 1 calc(100% - var(--horizontal-resize-tree-percentage));
overflow-x: hidden;
overflow-y: auto;
}
.ResizeBarWrapper {
flex: 0 0 0px;
position: relative;
}
.ResizeBar {
position: absolute;
/*
* moving the bar out of its bounding box might cause its hitbox to overlap
* with another scrollbar creating disorienting UX where you both resize and scroll
* at the same time.
* If you adjust this value, double check that starting resize right on this edge
* doesn't also cause scroll
*/
left: 1px;
width: 5px;
height: 100%;
cursor: ew-resize;
}
.TreeView footer {
display: none;
}
@container devtools (width < 600px) {
.SuspenseTab {
flex-direction: column;
}
.TreeWrapper {
border-top: 1px solid var(--color-border);
flex: 1 0 var(--vertical-resize-tree-percentage);
}
.InspectedElementWrapper {
flex: 1 1 50%;
}
.TreeWrapper + .ResizeBarWrapper .ResizeBar {
top: 1px;
left: 0;
width: 100%;
height: 5px;
cursor: ns-resize;
}
.TreeView footer {
display: flex;
justify-content: end;
}
.ToggleInspectedElement[data-orientation="horizontal"] {
display: none;
}
}
.TreeList {
flex: 0 0 var(--horizontal-resize-tree-list-percentage);
border-right: 1px solid var(--color-border);
padding: 0.25rem
}
.TreeView {
flex: 1 1 35%;
display: flex;
flex-direction: column;
}
.Rects {
border-top: 1px solid var(--color-border);
padding: 0.25rem;
flex-grow: 1;
}
.TimelineWrapper {
padding: 0.25rem;
display: flex;
flex-direction: row;
}
.Timeline {
flex-grow: 1;
}
@@ -1,8 +1,445 @@
import * as React from 'react';
import portaledContent from '../portaledContent';
/**
* 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
*/
function SuspenseTab() {
return 'Under construction';
import * as React from 'react';
import {useEffect, useLayoutEffect, useReducer, useRef} from 'react';
import {
localStorageGetItem,
localStorageSetItem,
} from 'react-devtools-shared/src/storage';
import ButtonIcon, {type IconType} from '../ButtonIcon';
import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBoundary';
import InspectedElement from '../Components/InspectedElement';
import portaledContent from '../portaledContent';
import styles from './SuspenseTab.css';
import Button from '../Button';
type Orientation = 'horizontal' | 'vertical';
type LayoutActionType =
| 'ACTION_SET_TREE_LIST_TOGGLE'
| 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION'
| 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE'
| 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION'
| 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION';
type LayoutAction = {
type: LayoutActionType,
payload: any,
};
type LayoutState = {
treeListHidden: boolean,
treeListHorizontalFraction: number,
inspectedElementHidden: boolean,
inspectedElementHorizontalFraction: number,
inspectedElementVerticalFraction: number,
};
type LayoutDispatch = (action: LayoutAction) => void;
function SuspenseTreeList() {
return <div>tree list</div>;
}
export default (portaledContent(SuspenseTab): React.ComponentType<{}>);
function SuspenseTimeline() {
return <div className={styles.Timeline}>timeline</div>;
}
function SuspenseRects() {
return <div>rects</div>;
}
function ToggleTreeList({
dispatch,
state,
}: {
dispatch: LayoutDispatch,
state: LayoutState,
}) {
return (
<Button
onClick={() =>
dispatch({
type: 'ACTION_SET_TREE_LIST_TOGGLE',
payload: null,
})
}
title={state.treeListHidden ? 'Show Tree List' : 'Hide Tree List'}>
<ButtonIcon
type={state.treeListHidden ? 'panel-left-open' : 'panel-left-close'}
/>
</Button>
);
}
function ToggleInspectedElement({
dispatch,
state,
orientation,
}: {
dispatch: LayoutDispatch,
state: LayoutState,
orientation: 'horizontal' | 'vertical',
}) {
let iconType: IconType;
if (orientation === 'horizontal') {
iconType = state.inspectedElementHidden
? 'panel-right-open'
: 'panel-right-close';
} else {
iconType = state.inspectedElementHidden
? 'panel-bottom-open'
: 'panel-bottom-close';
}
return (
<Button
className={styles.ToggleInspectedElement}
data-orientation={orientation}
onClick={() =>
dispatch({
type: 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE',
payload: null,
})
}
title={
state.inspectedElementHidden
? 'Show Inspected Element'
: 'Hide Inspected Element'
}>
<ButtonIcon type={iconType} />
</Button>
);
}
function SuspenseTab(_: {}) {
const [state, dispatch] = useReducer<LayoutState, null, LayoutAction>(
layoutReducer,
null,
initLayoutState,
);
const wrapperTreeRef = useRef<null | HTMLElement>(null);
const resizeTreeRef = useRef<null | HTMLElement>(null);
const resizeTreeListRef = 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.
const {
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
treeListHidden,
treeListHorizontalFraction,
} = state;
useLayoutEffect(() => {
const wrapperElement = wrapperTreeRef.current;
setResizeCSSVariable(
wrapperElement,
'tree',
'horizontal',
inspectedElementHorizontalFraction * 100,
);
setResizeCSSVariable(
wrapperElement,
'tree',
'vertical',
inspectedElementVerticalFraction * 100,
);
const resizeTreeListElement = resizeTreeListRef.current;
setResizeCSSVariable(
resizeTreeListElement,
'tree-list',
'horizontal',
treeListHorizontalFraction * 100,
);
}, []);
useEffect(() => {
const timeoutID = setTimeout(() => {
localStorageSetItem(
LOCAL_STORAGE_KEY,
JSON.stringify({
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
treeListHidden,
treeListHorizontalFraction,
}),
);
}, 500);
return () => clearTimeout(timeoutID);
}, [
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
treeListHidden,
treeListHorizontalFraction,
]);
const onResizeStart = (event: SyntheticPointerEvent<HTMLElement>) => {
const element = event.currentTarget;
element.setPointerCapture(event.pointerId);
};
const onResizeEnd = (event: SyntheticPointerEvent<HTMLElement>) => {
const element = event.currentTarget;
element.releasePointerCapture(event.pointerId);
};
const onResizeTree = (event: SyntheticPointerEvent<HTMLElement>) => {
const element = event.currentTarget;
const isResizing = element.hasPointerCapture(event.pointerId);
if (!isResizing) {
return;
}
const resizeElement = resizeTreeRef.current;
const wrapperElement = wrapperTreeRef.current;
if (wrapperElement === null || resizeElement === null) {
return;
}
event.preventDefault();
const orientation = getTreeOrientation(wrapperElement);
const {height, width, left, top} = wrapperElement.getBoundingClientRect();
const currentMousePosition =
orientation === 'horizontal' ? event.clientX - left : event.clientY - top;
const boundaryMin = MINIMUM_TREE_SIZE;
const boundaryMax =
orientation === 'horizontal'
? width - MINIMUM_TREE_SIZE
: height - MINIMUM_TREE_SIZE;
const isMousePositionInBounds =
currentMousePosition > boundaryMin && currentMousePosition < boundaryMax;
if (isMousePositionInBounds) {
const resizedElementDimension =
orientation === 'horizontal' ? width : height;
const actionType =
orientation === 'horizontal'
? 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION'
: 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION';
const fraction = currentMousePosition / resizedElementDimension;
const percentage = fraction * 100;
setResizeCSSVariable(wrapperElement, 'tree', orientation, percentage);
dispatch({
type: actionType,
payload: fraction,
});
}
};
const onResizeTreeList = (event: SyntheticPointerEvent<HTMLElement>) => {
const element = event.currentTarget;
const isResizing = element.hasPointerCapture(event.pointerId);
if (!isResizing) {
return;
}
const resizeElement = resizeTreeListRef.current;
const wrapperElement = resizeTreeRef.current;
if (wrapperElement === null || resizeElement === null) {
return;
}
event.preventDefault();
const orientation = 'horizontal';
const {height, width, left, top} = wrapperElement.getBoundingClientRect();
const currentMousePosition =
orientation === 'horizontal' ? event.clientX - left : event.clientY - top;
const boundaryMin = MINIMUM_TREE_LIST_SIZE;
const boundaryMax =
orientation === 'horizontal'
? width - MINIMUM_TREE_LIST_SIZE
: height - MINIMUM_TREE_LIST_SIZE;
const isMousePositionInBounds =
currentMousePosition > boundaryMin && currentMousePosition < boundaryMax;
if (isMousePositionInBounds) {
const resizedElementDimension =
orientation === 'horizontal' ? width : height;
const actionType = 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION';
const percentage = (currentMousePosition / resizedElementDimension) * 100;
setResizeCSSVariable(resizeElement, 'tree-list', orientation, percentage);
dispatch({
type: actionType,
payload: currentMousePosition / resizedElementDimension,
});
}
};
return (
<div className={styles.SuspenseTab} ref={wrapperTreeRef}>
<div className={styles.TreeWrapper} ref={resizeTreeRef}>
<div
className={styles.TreeList}
hidden={treeListHidden}
ref={resizeTreeListRef}>
<SuspenseTreeList />
</div>
<div className={styles.ResizeBarWrapper}>
<div
onPointerDown={onResizeStart}
onPointerMove={onResizeTreeList}
onPointerUp={onResizeEnd}
className={styles.ResizeBar}
/>
</div>
<div className={styles.TreeView}>
<div className={styles.TimelineWrapper}>
<ToggleTreeList dispatch={dispatch} state={state} />
<SuspenseTimeline />
<ToggleInspectedElement
dispatch={dispatch}
state={state}
orientation="horizontal"
/>
</div>
<div className={styles.Rects}>
<SuspenseRects />
</div>
<footer>
<ToggleInspectedElement
dispatch={dispatch}
state={state}
orientation="vertical"
/>
</footer>
</div>
</div>
<div className={styles.ResizeBarWrapper}>
<div
onPointerDown={onResizeStart}
onPointerMove={onResizeTree}
onPointerUp={onResizeEnd}
className={styles.ResizeBar}
/>
</div>
<div
className={styles.InspectedElementWrapper}
hidden={inspectedElementHidden}>
<InspectedElementErrorBoundary>
<InspectedElement />
</InspectedElementErrorBoundary>
</div>
</div>
);
}
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;
function layoutReducer(state: LayoutState, action: LayoutAction): LayoutState {
switch (action.type) {
case 'ACTION_SET_TREE_LIST_TOGGLE':
return {
...state,
treeListHidden: !state.treeListHidden,
};
case 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION':
return {
...state,
treeListHorizontalFraction: action.payload,
};
case 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE':
return {
...state,
inspectedElementHidden: !state.inspectedElementHidden,
};
case 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION':
return {
...state,
inspectedElementHorizontalFraction: action.payload,
};
case 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION':
return {
...state,
inspectedElementVerticalFraction: action.payload,
};
default:
return state;
}
}
function initLayoutState(): LayoutState {
let inspectedElementHidden = false;
let inspectedElementHorizontalFraction = 0.65;
let inspectedElementVerticalFraction = 0.5;
let treeListHidden = false;
let treeListHorizontalFraction = 0.35;
try {
let data = localStorageGetItem(LOCAL_STORAGE_KEY);
if (data != null) {
data = JSON.parse(data);
inspectedElementHidden = data.inspectedElementHidden;
inspectedElementHorizontalFraction =
data.inspectedElementHorizontalFraction;
inspectedElementVerticalFraction = data.inspectedElementVerticalFraction;
treeListHidden = data.treeListHidden;
treeListHorizontalFraction = data.treeListHorizontalFraction;
}
} catch (error) {}
return {
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
treeListHidden,
treeListHorizontalFraction,
};
}
function getTreeOrientation(
wrapperElement: null | HTMLElement,
): null | Orientation {
if (wrapperElement != null) {
const {width} = wrapperElement.getBoundingClientRect();
return width > VERTICAL_TREE_MODE_MAX_WIDTH ? 'horizontal' : 'vertical';
}
return null;
}
function setResizeCSSVariable(
resizeElement: null | HTMLElement,
name: 'tree' | 'tree-list',
orientation: null | Orientation,
percentage: number,
): void {
if (resizeElement !== null && orientation !== null) {
resizeElement.style.setProperty(
`--${orientation}-resize-${name}-percentage`,
`${percentage}%`,
);
}
}
export default (portaledContent(SuspenseTab): React$ComponentType<{}>);