Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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 (
<rect
className={styles.SuspenseRect}
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
/>
);
}

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(`<Element> 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 (
<g
data-highlighted={selected}
onClick={handleClick}
onPointerOver={handlePointerOver}
onPointerLeave={handlePointerLeave}>
<title>{suspense.name}</title>
{suspense.rects !== null &&
suspense.rects.map((rect, index) => {
return <SuspenseRect key={index} rect={rect} />;
})}
{suspense.children.map(childID => {
return <SuspenseRects key={childID} suspenseID={childID} />;
})}
</g>
);
}

function getDocumentBoundingRect(
store: Store,
shells: $ReadOnlyArray<SuspenseNode['id']>,
): 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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only picks the rects from the roots but the suspense boundaries within the root can overflow the root and the inner suspense boundaries themselves and overflow.

For example, if you just have an absolutely positioned child inside body it's likely that the body element is smaller than the child.

So if the last boundary is below the size of the root then it's not clickable in this mode. This should really be the max of every rect recursively.

However, another way is to just make it like:

<div className="document" onClick={clickedDocument} style={{ width: '100%', height: '100%', overflow: 'auto' }}>
  <div className="root" style={{ width: root1.width, height: root1.height, left: root1.x, top: root1.y, position: 'absolute' }}></div>
  <div className="root" style={{ width: root2.width, height: root2.height, left: root2.x, top: root2.y, position: 'absolute' }}></div>
  <div className="suspense" style={{ width: suspense1.width, height: suspense1.height, left: suspense1.x, top: suspense1.y, position: 'absolute' }} onClick={clickedSuspense.bind(null, suspense1.id)}></div>
  ...
</div>

Since then the overflow of the "document" div will account for the max of all the children automatically.

Another reason to maybe favor divs.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although maybe we need to keep track of the min/max of the whole document anyway to allow the UI to scale down to fit the width of the smaller devtools window automatically.

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(`<Element> Could not find suspense node id ${shellID}`);
return null;
}

return (
<g>
{shell.children.map(childID => {
return <SuspenseRects key={childID} suspenseID={childID} />;
})}
</g>
);
}

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 (
<div className={styles.SuspenseRectsContainer}>
<svg
style={{width, height}}
viewBox={`${boundingRect.x} ${boundingRect.y} ${boundingRect.width} ${boundingRect.height}`}>
{shells.map(shellID => {
return <SuspenseRectsShell key={shellID} shellID={shellID} />;
})}
</svg>
</div>
);
}

export default SuspenseRectsContainer;
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -48,10 +49,6 @@ function SuspenseTimeline() {
return <div className={styles.Timeline}>timeline</div>;
}

function SuspenseRects() {
return <div>rects</div>;
}

function ToggleTreeList({
dispatch,
state,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SuspenseNode['id']>,
};

type ACTION_HANDLE_SUSPENSE_TREE_MUTATION = {
type: 'HANDLE_SUSPENSE_TREE_MUTATION',
Expand Down Expand Up @@ -56,15 +59,18 @@ 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}"`);
}
},
[],
);

const [state, dispatch] = useReducer(reducer, {});
const initialState: SuspenseTreeState = {
shells: store.roots,
};
const [state, dispatch] = useReducer(reducer, initialState);
const transitionDispatch = useMemo(
() => (action: SuspenseTreeAction) =>
startTransition(() => {
Expand Down
Loading