diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css new file mode 100644 index 0000000000000..d9ecf2cad75d6 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css @@ -0,0 +1,15 @@ +.SuspenseRectsContainer { + padding: .25rem; +} + +.SuspenseRect { + fill: transparent; + stroke: var(--color-background-selected); + stroke-width: 1px; + vector-effect: non-scaling-stroke; + paint-order: stroke; +} + +[data-highlighted='true'] > .SuspenseRect { + fill: var(--color-selected-tree-highlight-active); +} diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js new file mode 100644 index 0000000000000..793db5f97e0df --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -0,0 +1,200 @@ +/** + * 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 type Store from 'react-devtools-shared/src/devtools/store'; +import type { + SuspenseNode, + Rect, +} from 'react-devtools-shared/src/frontend/types'; + +import * as React from 'react'; +import {useContext} from 'react'; +import { + TreeDispatcherContext, + TreeStateContext, +} from '../Components/TreeContext'; +import {StoreContext} from '../context'; +import {useHighlightHostInstance} from '../hooks'; +import styles from './SuspenseRects.css'; +import {SuspenseTreeStateContext} from './SuspenseTreeContext'; + +function SuspenseRect({rect}: {rect: Rect}): React$Node { + return ( + + ); +} + +function SuspenseRects({ + suspenseID, +}: { + suspenseID: SuspenseNode['id'], +}): React$Node { + const dispatch = useContext(TreeDispatcherContext); + const store = useContext(StoreContext); + + const {inspectedElementID} = useContext(TreeStateContext); + + const {highlightHostInstance, clearHighlightHostInstance} = + useHighlightHostInstance(); + + const suspense = store.getSuspenseByID(suspenseID); + if (suspense === null) { + console.warn(` Could not find suspense node id ${suspenseID}`); + return null; + } + + function handleClick(event: SyntheticMouseEvent<>) { + if (event.defaultPrevented) { + // Already clicked on an inner rect + return; + } + event.preventDefault(); + dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID}); + } + + function handlePointerOver(event: SyntheticPointerEvent<>) { + if (event.defaultPrevented) { + // Already hovered an inner rect + return; + } + event.preventDefault(); + highlightHostInstance(suspenseID); + } + + function handlePointerLeave(event: SyntheticPointerEvent<>) { + if (event.defaultPrevented) { + // Already hovered an inner rect + return; + } + event.preventDefault(); + clearHighlightHostInstance(); + } + + // TODO: Use the nearest Suspense boundary + const selected = inspectedElementID === suspenseID; + + return ( + + {suspense.name} + {suspense.rects !== null && + suspense.rects.map((rect, index) => { + return ; + })} + {suspense.children.map(childID => { + return ; + })} + + ); +} + +function getDocumentBoundingRect( + store: Store, + shells: $ReadOnlyArray, +): Rect { + if (shells.length === 0) { + return {x: 0, y: 0, width: 0, height: 0}; + } + + let minX = Number.POSITIVE_INFINITY; + let minY = Number.POSITIVE_INFINITY; + let maxX = Number.NEGATIVE_INFINITY; + let maxY = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < shells.length; i++) { + const shellID = shells[i]; + const shell = store.getSuspenseByID(shellID); + if (shell === null) { + continue; + } + + const rects = shell.rects; + if (rects === null) { + continue; + } + for (let j = 0; j < rects.length; j++) { + const rect = rects[j]; + minX = Math.min(minX, rect.x); + minY = Math.min(minY, rect.y); + maxX = Math.max(maxX, rect.x + rect.width); + maxY = Math.max(maxY, rect.y + rect.height); + } + } + + if (minX === Number.POSITIVE_INFINITY) { + // No rects found, return empty rect + return {x: 0, y: 0, width: 0, height: 0}; + } + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; +} + +function SuspenseRectsShell({ + shellID, +}: { + shellID: SuspenseNode['id'], +}): React$Node { + const store = useContext(StoreContext); + const shell = store.getSuspenseByID(shellID); + if (shell === null) { + console.warn(` Could not find suspense node id ${shellID}`); + return null; + } + + return ( + + {shell.children.map(childID => { + return ; + })} + + ); +} + +function SuspenseRectsContainer(): React$Node { + const store = useContext(StoreContext); + // TODO: This relies on a full re-render of all children when the Suspense tree changes. + const {shells} = useContext(SuspenseTreeStateContext); + + const boundingRect = getDocumentBoundingRect(store, shells); + + const width = '100%'; + const boundingRectWidth = boundingRect.width; + const height = + (boundingRectWidth === 0 ? 0 : boundingRect.height / boundingRect.width) * + 100 + + '%'; + + return ( +
+ + {shells.map(shellID => { + return ; + })} + +
+ ); +} + +export default SuspenseRectsContainer; 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 d113fd3901f0c..dc0816bc2c285 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -19,6 +19,7 @@ import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBo import InspectedElement from '../Components/InspectedElement'; import portaledContent from '../portaledContent'; import styles from './SuspenseTab.css'; +import SuspenseRects from './SuspenseRects'; import SuspenseTreeList from './SuspenseTreeList'; import Button from '../Button'; @@ -48,10 +49,6 @@ function SuspenseTimeline() { return
timeline
; } -function SuspenseRects() { - return
rects
; -} - function ToggleTreeList({ dispatch, state, diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js index 8441a994972cd..a16c93a4573e7 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js @@ -17,9 +17,12 @@ import { useMemo, useReducer, } from 'react'; +import type {SuspenseNode} from '../../../frontend/types'; import {StoreContext} from '../context'; -export type SuspenseTreeState = {}; +export type SuspenseTreeState = { + shells: $ReadOnlyArray, +}; type ACTION_HANDLE_SUSPENSE_TREE_MUTATION = { type: 'HANDLE_SUSPENSE_TREE_MUTATION', @@ -56,7 +59,7 @@ function SuspenseTreeContextController({children}: Props): React.Node { const {type} = action; switch (type) { case 'HANDLE_SUSPENSE_TREE_MUTATION': - return {...state}; + return {...state, shells: store.roots}; default: throw new Error(`Unrecognized action "${type}"`); } @@ -64,7 +67,10 @@ function SuspenseTreeContextController({children}: Props): React.Node { [], ); - const [state, dispatch] = useReducer(reducer, {}); + const initialState: SuspenseTreeState = { + shells: store.roots, + }; + const [state, dispatch] = useReducer(reducer, initialState); const transitionDispatch = useMemo( () => (action: SuspenseTreeAction) => startTransition(() => {