Files
react/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
T
Sebastian MarkbågeandGitHub 2e68dc76a4 [DevTools] Give a distinct color to the root (#34690)
Stacked on #34654.

The root is special since it represents "Initial Paint" (or a
"Transition" when an Activity is selected). This gives it a different
color in the timeline as well as gives it an outline that's clickable.
Hovering the timeline now shows "Initial Paint" or "Suspense".

Also made the cursor a pointer to invite you to try to click things and
some rounded corners.

<img width="1219" height="420" alt="Screenshot 2025-10-02 at 1 26 38 PM"
src="https://github.com/user-attachments/assets/12451f93-8917-4f3b-8f01-930129e5fc13"
/>

<img width="1217" height="419" alt="Screenshot 2025-10-02 at 1 26 54 PM"
src="https://github.com/user-attachments/assets/02b5e94c-3fbe-488d-b0f2-225b73578608"
/>

<img width="1215" height="419" alt="Screenshot 2025-10-02 at 1 27 24 PM"
src="https://github.com/user-attachments/assets/c24e8861-e74a-4ccc-8643-ee9d04bef43c"
/>

<img width="1216" height="419" alt="Screenshot 2025-10-02 at 1 27 10 PM"
src="https://github.com/user-attachments/assets/d5cc2b62-fa64-41bf-b485-116b1cd67467"
/>
2025-10-02 14:37:03 -04:00

354 lines
9.4 KiB
JavaScript

/**
* 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 typeof {
SyntheticMouseEvent,
SyntheticPointerEvent,
} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react';
import {createContext, useContext} from 'react';
import {
TreeDispatcherContext,
TreeStateContext,
} from '../Components/TreeContext';
import {StoreContext} from '../context';
import {useHighlightHostInstance} from '../hooks';
import styles from './SuspenseRects.css';
import {
SuspenseTreeStateContext,
SuspenseTreeDispatcherContext,
} from './SuspenseTreeContext';
function ScaledRect({
className,
rect,
visible,
...props
}: {
className: string,
rect: Rect,
visible: boolean,
...
}): React$Node {
const viewBox = useContext(ViewBox);
const width = (rect.width / viewBox.width) * 100 + '%';
const height = (rect.height / viewBox.height) * 100 + '%';
const x = ((rect.x - viewBox.x) / viewBox.width) * 100 + '%';
const y = ((rect.y - viewBox.y) / viewBox.height) * 100 + '%';
return (
<div
{...props}
className={styles.SuspenseRectsScaledRect + ' ' + className}
data-visible={visible}
style={{
width,
height,
top: y,
left: x,
}}
/>
);
}
function SuspenseRects({
suspenseID,
}: {
suspenseID: SuspenseNode['id'],
}): React$Node {
const store = useContext(StoreContext);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {uniqueSuspendersOnly} = useContext(SuspenseTreeStateContext);
const {inspectedElementID} = useContext(TreeStateContext);
const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();
const suspense = store.getSuspenseByID(suspenseID);
if (suspense === null) {
// getSuspenseByID will have already warned
return null;
}
const visible = suspense.hasUniqueSuspenders || !uniqueSuspendersOnly;
function handleClick(event: SyntheticMouseEvent) {
if (event.defaultPrevented) {
// Already clicked on an inner rect
return;
}
event.preventDefault();
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID});
suspenseTreeDispatch({
type: 'SET_SUSPENSE_LINEAGE',
payload: suspenseID,
});
}
function handleDoubleClick(event: SyntheticMouseEvent) {
if (event.defaultPrevented) {
// Already clicked on an inner rect
return;
}
event.preventDefault();
suspenseTreeDispatch({
type: 'TOGGLE_TIMELINE_FOR_ID',
payload: suspenseID,
});
}
function handlePointerOver(event: SyntheticPointerEvent) {
if (event.defaultPrevented) {
// Already hovered an inner rect
return;
}
event.preventDefault();
highlightHostInstance(suspenseID);
suspenseTreeDispatch({
type: 'HOVER_TIMELINE_FOR_ID',
payload: suspenseID,
});
}
function handlePointerLeave(event: SyntheticPointerEvent) {
if (event.defaultPrevented) {
// Already hovered an inner rect
return;
}
event.preventDefault();
clearHighlightHostInstance();
suspenseTreeDispatch({
type: 'HOVER_TIMELINE_FOR_ID',
payload: -1,
});
}
// TODO: Use the nearest Suspense boundary
const selected = inspectedElementID === suspenseID;
const boundingBox = getBoundingBox(suspense.rects);
return (
<ScaledRect
rect={boundingBox}
className={styles.SuspenseRectsBoundary}
visible={visible}>
<ViewBox.Provider value={boundingBox}>
{visible &&
suspense.rects !== null &&
suspense.rects.map((rect, index) => {
return (
<ScaledRect
key={index}
className={styles.SuspenseRectsRect}
rect={rect}
data-highlighted={selected}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onPointerOver={handlePointerOver}
onPointerLeave={handlePointerLeave}
// Reach-UI tooltip will go out of bounds of parent scroll container.
title={suspense.name}
/>
);
})}
{suspense.children.length > 0 && (
<ScaledRect
className={styles.SuspenseRectsBoundaryChildren}
rect={boundingBox}>
{suspense.children.map(childID => {
return <SuspenseRects key={childID} suspenseID={childID} />;
})}
</ScaledRect>
)}
</ViewBox.Provider>
</ScaledRect>
);
}
function getBoundingBox(rects: $ReadOnlyArray<Rect> | null): Rect {
if (rects === null || rects.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 < rects.length; i++) {
const rect = rects[i];
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);
}
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}
function computeBoundingRectRecursively(
store: Store,
node: SuspenseNode,
bounds: {
minX: number,
minY: number,
maxX: number,
maxY: number,
},
): void {
const rects = node.rects;
if (rects !== null) {
for (let j = 0; j < rects.length; j++) {
const rect = rects[j];
if (rect.x < bounds.minX) {
bounds.minX = rect.x;
}
if (rect.x + rect.width > bounds.maxX) {
bounds.maxX = rect.x + rect.width;
}
if (rect.y < bounds.minY) {
bounds.minY = rect.y;
}
if (rect.y + rect.height > bounds.maxY) {
bounds.maxY = rect.y + rect.height;
}
}
}
for (let i = 0; i < node.children.length; i++) {
const child = store.getSuspenseByID(node.children[i]);
if (child !== null) {
computeBoundingRectRecursively(store, child, bounds);
}
}
}
function getDocumentBoundingRect(
store: Store,
roots: $ReadOnlyArray<SuspenseNode['id']>,
): Rect {
if (roots.length === 0) {
return {x: 0, y: 0, width: 0, height: 0};
}
const bounds = {
minX: Number.POSITIVE_INFINITY,
minY: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxY: Number.NEGATIVE_INFINITY,
};
for (let i = 0; i < roots.length; i++) {
const rootID = roots[i];
const root = store.getSuspenseByID(rootID);
if (root === null) {
continue;
}
computeBoundingRectRecursively(store, root, bounds);
}
if (bounds.minX === Number.POSITIVE_INFINITY) {
// No rects found, return empty rect
return {x: 0, y: 0, width: 0, height: 0};
}
return {
x: bounds.minX,
y: bounds.minY,
width: bounds.maxX - bounds.minX,
height: bounds.maxY - bounds.minY,
};
}
function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node {
const store = useContext(StoreContext);
const root = store.getSuspenseByID(rootID);
if (root === null) {
// getSuspenseByID will have already warned
return null;
}
return root.children.map(childID => {
return <SuspenseRects key={childID} suspenseID={childID} />;
});
}
const ViewBox = createContext<Rect>((null: any));
function SuspenseRectsContainer(): React$Node {
const store = useContext(StoreContext);
const {inspectedElementID} = useContext(TreeStateContext);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
const {roots} = useContext(SuspenseTreeStateContext);
// TODO: bbox does not consider uniqueSuspendersOnly filter
const boundingBox = getDocumentBoundingRect(store, roots);
const boundingBoxWidth = boundingBox.width;
const heightScale =
boundingBoxWidth === 0 ? 1 : boundingBox.height / boundingBoxWidth;
// Scales the inspected document to fit into the available width
const width = '100%';
const aspectRatio = `1 / ${heightScale}`;
function handleClick(event: SyntheticMouseEvent) {
if (event.defaultPrevented) {
// Already clicked on an inner rect
return;
}
if (roots.length === 0) {
// Nothing to select
return;
}
const arbitraryRootID = roots[0];
event.preventDefault();
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: arbitraryRootID});
suspenseTreeDispatch({
type: 'SET_SUSPENSE_LINEAGE',
payload: arbitraryRootID,
});
}
const isRootSelected = roots.includes(inspectedElementID);
return (
<div
className={styles.SuspenseRectsContainer}
onClick={handleClick}
data-highlighted={isRootSelected}>
<ViewBox.Provider value={boundingBox}>
<div
className={styles.SuspenseRectsViewBox}
style={{aspectRatio, width}}>
{roots.map(rootID => {
return <SuspenseRectsRoot key={rootID} rootID={rootID} />;
})}
</div>
</ViewBox.Provider>
</div>
);
}
export default SuspenseRectsContainer;