diff --git a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
index a91c7e04b5545..454497e6b02da 100644
--- a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
+++ b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
@@ -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}>
{typeof pathData === 'string' ? (
@@ -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
+`;
diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
new file mode 100644
index 0000000000000..5a153545fecf6
--- /dev/null
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
@@ -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;
+}
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 01d11b3d7e130..a920b6dabd5f1 100644
--- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
@@ -1,8 +1,445 @@
+/**
+ * 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 * 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
tree list
;
+}
+
+function SuspenseTimeline() {
+ return timeline
;
+}
+
+function SuspenseRects() {
+ return rects
;
+}
+
+function ToggleTreeList({
+ dispatch,
+ state,
+}: {
+ dispatch: LayoutDispatch,
+ state: LayoutState,
+}) {
+ return (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+function SuspenseTab(_: {}) {
+ const [state, dispatch] = useReducer(
+ layoutReducer,
+ null,
+ initLayoutState,
+ );
+
+ const wrapperTreeRef = useRef(null);
+ const resizeTreeRef = useRef(null);
+ const resizeTreeListRef = 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.
+
+ 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) => {
+ const element = event.currentTarget;
+ element.setPointerCapture(event.pointerId);
+ };
+
+ const onResizeEnd = (event: SyntheticPointerEvent) => {
+ const element = event.currentTarget;
+ element.releasePointerCapture(event.pointerId);
+ };
+
+ const onResizeTree = (event: SyntheticPointerEvent) => {
+ 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) => {
+ 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 (
+
+ );
+}
+
+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 SuspenseTab() {
- return 'Under construction';
+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<{}>);
+export default (portaledContent(SuspenseTab): React$ComponentType<{}>);