mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
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" />
354 lines
9.4 KiB
JavaScript
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;
|