import * as React from 'react';
import {
useContext,
useEffect,
useLayoutEffect,
useReducer,
useRef,
Fragment,
} from 'react';
import {enableActivitySlices} from 'react-devtools-feature-flags';
import {
localStorageGetItem,
localStorageSetItem,
} from 'react-devtools-shared/src/storage';
import ButtonIcon, {type IconType} from '../ButtonIcon';
import InspectHostNodesToggle from '../Components/InspectHostNodesToggle';
import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBoundary';
import InspectedElement from '../Components/InspectedElement';
import portaledContent from '../portaledContent';
import styles from './SuspenseTab.css';
import SuspenseBreadcrumbs from './SuspenseBreadcrumbs';
import SuspenseRects from './SuspenseRects';
import SuspenseTimeline from './SuspenseTimeline';
import ActivityList from './ActivityList';
import {
SuspenseTreeDispatcherContext,
SuspenseTreeStateContext,
} from './SuspenseTreeContext';
import {BridgeContext, StoreContext, OptionsContext} from '../context';
import Button from '../Button';
import Toggle from '../Toggle';
import typeof {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';
import {TreeStateContext} from '../Components/TreeContext';
type Orientation = 'horizontal' | 'vertical';
type LayoutActionType =
| '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';
type LayoutAction = {
type: LayoutActionType,
payload: any,
};
type LayoutState = {
activityListHidden: boolean,
activityListHorizontalFraction: number,
inspectedElementHidden: boolean,
inspectedElementHorizontalFraction: number,
inspectedElementVerticalFraction: number,
};
type LayoutDispatch = (action: LayoutAction) => void;
function ToggleUniqueSuspenders() {
const store = useContext(StoreContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {uniqueSuspendersOnly} = useContext(SuspenseTreeStateContext);
function handleToggleUniqueSuspenders() {
const nextUniqueSuspendersOnly = !uniqueSuspendersOnly;
const nextTimeline = store.getEndTimeOrDocumentOrderSuspense(
nextUniqueSuspendersOnly,
);
suspenseTreeDispatch({
type: 'SET_SUSPENSE_TIMELINE',
payload: [nextTimeline, null, nextUniqueSuspendersOnly],
});
}
return (
<Toggle
isChecked={uniqueSuspendersOnly}
onChange={handleToggleUniqueSuspenders}
title={
'Filter Suspense which does not suspend, or if the parent also suspend on the same.'
}>
<ButtonIcon type={uniqueSuspendersOnly ? 'filter-on' : 'filter-off'} />
</Toggle>
);
}
function ToggleActivityList({
dispatch,
state,
}: {
dispatch: LayoutDispatch,
state: LayoutState,
}) {
return (
<Button
onClick={() =>
dispatch({
type: 'ACTION_SET_ACTIVITY_LIST_TOGGLE',
payload: null,
})
}
title={
state.activityListHidden ? 'Show Activity List' : 'Hide Activity List'
}>
<ButtonIcon
type={state.activityListHidden ? '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 SynchronizedScrollContainer({
className,
children,
scaleRef,
}: {
className?: string,
children?: React.Node,
scaleRef: {current: number},
}) {
const bridge = useContext(BridgeContext);
const ref = useRef(null);
const applyingScrollRef = useRef(false);
function scrollContainerTo({
left,
top,
right,
bottom,
}: {
left: number,
top: number,
right: number,
bottom: number,
}): void {
const element = ref.current;
if (element === null) {
return;
}
const scale = scaleRef.current / element.clientWidth;
const targetLeft = Math.round(left / scale);
const targetTop = Math.round(top / scale);
if (
targetLeft !== Math.round(element.scrollLeft) ||
targetTop !== Math.round(element.scrollTop)
) {
applyingScrollRef.current = true;
element.scrollTo({
left: targetLeft,
top: targetTop,
behavior: 'smooth',
});
}
}
useEffect(() => {
const callback = scrollContainerTo;
bridge.addListener('scrollTo', callback);
bridge.send('requestScrollPosition');
return () => bridge.removeListener('scrollTo', callback);
}, [bridge]);
const scrollTimer = useRef<null | TimeoutID>(null);
function sendScroll() {
if (scrollTimer.current) {
clearTimeout(scrollTimer.current);
scrollTimer.current = null;
}
if (applyingScrollRef.current) {
return;
}
const element = ref.current;
if (element === null) {
return;
}
const scale = scaleRef.current / element.clientWidth;
const left = element.scrollLeft * scale;
const top = element.scrollTop * scale;
const right = left + element.clientWidth * scale;
const bottom = top + element.clientHeight * scale;
bridge.send('scrollTo', {left, top, right, bottom});
}
function throttleScroll() {
if (!scrollTimer.current) {
scrollTimer.current = setTimeout(sendScroll, 400);
}
}
function scrollEnd() {
sendScroll();
applyingScrollRef.current = false;
}
useEffect(() => {
const element = ref.current;
if (element === null) {
return;
}
const scrollCallback = throttleScroll;
const scrollEndCallback = scrollEnd;
element.addEventListener('scroll', scrollCallback);
element.addEventListener('scrollend', scrollEndCallback);
return () => {
element.removeEventListener('scroll', scrollCallback);
element.removeEventListener('scrollend', scrollEndCallback);
};
}, [ref]);
return (
<div className={className} ref={ref}>
{children}
</div>
);
}
function SuspenseTab(_: {}) {
const store = useContext(StoreContext);
const {hideSettings} = useContext(OptionsContext);
const [state, dispatch] = useReducer<LayoutState, null, LayoutAction>(
layoutReducer,
null,
initLayoutState,
);
const {activities} = useContext(TreeStateContext);
const activityListDisabled = !enableActivitySlices || activities.length === 0;
const wrapperTreeRef = useRef<null | HTMLElement>(null);
const resizeTreeRef = useRef<null | HTMLElement>(null);
const resizeActivityListRef = useRef<null | HTMLElement>(null);
const {
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
activityListHidden,
activityListHorizontalFraction,
} = state;
useLayoutEffect(() => {
const wrapperElement = wrapperTreeRef.current;
setResizeCSSVariable(
wrapperElement,
'tree',
'horizontal',
inspectedElementHorizontalFraction * 100,
);
setResizeCSSVariable(
wrapperElement,
'tree',
'vertical',
inspectedElementVerticalFraction * 100,
);
const resizeActivityListElement = resizeActivityListRef.current;
setResizeCSSVariable(
resizeActivityListElement,
'activity-list',
'horizontal',
activityListHorizontalFraction * 100,
);
}, []);
useEffect(() => {
const timeoutID = setTimeout(() => {
localStorageSetItem(
LOCAL_STORAGE_KEY,
JSON.stringify({
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
activityListHidden,
activityListHorizontalFraction,
}),
);
}, 500);
return () => clearTimeout(timeoutID);
}, [
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
activityListHidden,
activityListHorizontalFraction,
]);
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 onResizeActivityList = (event: SyntheticPointerEvent) => {
const element = event.currentTarget;
const isResizing = element.hasPointerCapture(event.pointerId);
if (!isResizing) {
return;
}
const resizeElement = resizeActivityListRef.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_ACTIVITY_LIST_SIZE;
const boundaryMax =
orientation === 'horizontal'
? width - MINIMUM_ACTIVITY_LIST_SIZE
: height - MINIMUM_ACTIVITY_LIST_SIZE;
const isMousePositionInBounds =
currentMousePosition > boundaryMin && currentMousePosition < boundaryMax;
if (isMousePositionInBounds) {
const resizedElementDimension =
orientation === 'horizontal' ? width : height;
const actionType = 'ACTION_SET_ACTIVITY_LIST_HORIZONTAL_FRACTION';
const percentage = (currentMousePosition / resizedElementDimension) * 100;
setResizeCSSVariable(
resizeElement,
'activity-list',
orientation,
percentage,
);
dispatch({
type: actionType,
payload: currentMousePosition / resizedElementDimension,
});
}
};
const scaleRef = useRef(0);
return (
<SettingsModalContextController>
<div className={styles.SuspenseTab} ref={wrapperTreeRef}>
<div className={styles.TreeWrapper} ref={resizeTreeRef}>
{activityListDisabled ? null : (
<div
className={styles.ActivityList}
hidden={activityListHidden}
ref={resizeActivityListRef}>
<ActivityList activities={activities} />
</div>
)}
{activityListDisabled ? null : (
<div
className={styles.ResizeBarWrapper}
hidden={activityListHidden}>
<div
onPointerDown={onResizeStart}
onPointerMove={onResizeActivityList}
onPointerUp={onResizeEnd}
className={styles.ResizeBar}
/>
</div>
)}
<div className={styles.TreeView}>
<header className={styles.SuspenseTreeViewHeader}>
{activityListDisabled ? (
<div />
) : (
<ToggleActivityList dispatch={dispatch} state={state} />
)}
{store.supportsClickToInspect && (
<Fragment>
<InspectHostNodesToggle onlySuspenseNodes={true} />
<div className={styles.VRule} />
</Fragment>
)}
<div className={styles.SuspenseBreadcrumbs}>
<SuspenseBreadcrumbs />
</div>
<div className={styles.VRule} />
<ToggleUniqueSuspenders />
{!hideSettings && <SettingsModalContextToggle />}
<ToggleInspectedElement
dispatch={dispatch}
state={state}
orientation="horizontal"
/>
</header>
<SynchronizedScrollContainer
className={styles.Rects}
scaleRef={scaleRef}>
<SuspenseRects scaleRef={scaleRef} />
</SynchronizedScrollContainer>
<footer className={styles.SuspenseTreeViewFooter}>
<SuspenseTimeline />
<div className={styles.SuspenseTreeViewFooterButtons}>
<ToggleInspectedElement
dispatch={dispatch}
state={state}
orientation="vertical"
/>
</div>
</footer>
</div>
</div>
<div
className={styles.ResizeBarWrapper}
hidden={inspectedElementHidden}>
<div
onPointerDown={onResizeStart}
onPointerMove={onResizeTree}
onPointerUp={onResizeEnd}
className={styles.ResizeBar}
/>
</div>
<div
className={styles.InspectedElementWrapper}
hidden={inspectedElementHidden}>
<InspectedElementErrorBoundary>
<InspectedElement
fallbackEmpty={
'No React element selected. Select a Suspense boundary in the minimap to inspect.'
}
/>
</InspectedElementErrorBoundary>
</div>
<SettingsModal />
</div>
</SettingsModalContextController>
);
}
const LOCAL_STORAGE_KEY = 'React::DevTools::SuspenseTab::layout';
const VERTICAL_TREE_MODE_MAX_WIDTH = 600;
const MINIMUM_TREE_SIZE = 100;
const MINIMUM_ACTIVITY_LIST_SIZE = 100;
function layoutReducer(state: LayoutState, action: LayoutAction): LayoutState {
switch (action.type) {
case 'ACTION_SET_ACTIVITY_LIST_TOGGLE':
return {
...state,
activityListHidden: !state.activityListHidden,
};
case 'ACTION_SET_ACTIVITY_LIST_HORIZONTAL_FRACTION':
return {
...state,
activityListHorizontalFraction: 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 activityListHidden = false;
let activityListHorizontalFraction = 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;
activityListHidden = data.activityListHidden;
activityListHorizontalFraction = data.activityListHorizontalFraction;
}
} catch (error) {}
return {
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
activityListHidden,
activityListHorizontalFraction,
};
}
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' | 'activity-list',
orientation: null | Orientation,
percentage: number,
): void {
if (resizeElement !== null && orientation !== null) {
resizeElement.style.setProperty(
`--${orientation}-resize-${name}-percentage`,
`${percentage}%`,
);
}
}
export default (portaledContent(SuspenseTab): component());