[DevTools] Elevate Suspense rects to visualize hierarchy (#34455)

This commit is contained in:
Sebastian "Sebbie" Silbermann 2025-09-18 18:37:00 +02:00 committed by GitHub
parent 581321160f
commit 755cebad6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 147 additions and 55 deletions

View File

@ -163,6 +163,9 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-scroll-track': '#fafafa',
'--color-tooltip-background': 'rgba(0, 0, 0, 0.9)',
'--color-tooltip-text': '#ffffff',
'--elevation-4':
'0 2px 4px -1px rgba(0,0,0,.2),0 4px 5px 0 rgba(0,0,0,.14),0 1px 10px 0 rgba(0,0,0,.12)',
},
dark: {
'--color-attribute-name': '#9d87d2',
@ -315,6 +318,9 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-scroll-track': '#313640',
'--color-tooltip-background': 'rgba(255, 255, 255, 0.95)',
'--color-tooltip-text': '#000000',
'--elevation-4':
'0 2px 8px 0 rgba(0,0,0,0.32),0 4px 12px 0 rgba(0,0,0,0.24),0 1px 10px 0 rgba(0,0,0,0.18)',
},
compact: {
'--font-size-monospace-small': '9px',

View File

@ -2,14 +2,40 @@
padding: .25rem;
}
.SuspenseRect {
fill: transparent;
stroke: var(--color-background-selected);
stroke-width: 1px;
vector-effect: non-scaling-stroke;
paint-order: stroke;
.SuspenseRectsViewBox {
position: relative;
}
[data-highlighted='true'] > .SuspenseRect {
fill: var(--color-selected-tree-highlight-active);
.SuspenseRectsBoundary {
pointer-events: all;
}
.SuspenseRectsBoundaryChildren {
pointer-events: none;
/**
* So that the shadow of Boundaries within is clipped off.
* Otherwise it would look like this boundary is further elevated.
*/
overflow: hidden;
}
.SuspenseRectsRect {
box-shadow: var(--elevation-4);
pointer-events: all;
outline-style: solid;
outline-width: 1px;
}
.SuspenseRectsScaledRect {
position: absolute;
outline-color: var(--color-background-selected);
}
/* highlight this boundary */
.SuspenseRectsBoundary:hover:not(:has(.SuspenseRectsBoundary:hover)) > .SuspenseRectsRect, .SuspenseRectsBoundary[data-highlighted='true'] > .SuspenseRectsRect {
background-color: var(--color-background-hover);
}
.SuspenseRectsRect[data-highlighted='true'] {
background-color: var(--color-selected-tree-highlight-active);
}

View File

@ -12,9 +12,13 @@ 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 {useContext} from 'react';
import {createContext, useContext} from 'react';
import {
TreeDispatcherContext,
TreeStateContext,
@ -26,19 +30,32 @@ import {
SuspenseTreeStateContext,
SuspenseTreeDispatcherContext,
} from './SuspenseTreeContext';
import typeof {
SyntheticMouseEvent,
SyntheticPointerEvent,
} from 'react-dom-bindings/src/events/SyntheticEvent';
function SuspenseRect({rect}: {rect: Rect}): React$Node {
function ScaledRect({
className,
rect,
...props
}: {
className: string,
rect: Rect,
...
}): 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 (
<rect
className={styles.SuspenseRect}
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
<div
{...props}
className={styles.SuspenseRectsScaledRect + ' ' + className}
style={{
width,
height,
top: y,
left: x,
}}
/>
);
}
@ -97,24 +114,67 @@ function SuspenseRects({
// TODO: Use the nearest Suspense boundary
const selected = inspectedElementID === suspenseID;
const boundingBox = getBoundingBox(suspense.rects);
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>
<ScaledRect rect={boundingBox} className={styles.SuspenseRectsBoundary}>
<ViewBox.Provider value={boundingBox}>
{suspense.rects !== null &&
suspense.rects.map((rect, index) => {
return (
<ScaledRect
key={index}
className={styles.SuspenseRectsRect}
rect={rect}
data-highlighted={selected}
onClick={handleClick}
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 getDocumentBoundingRect(
store: Store,
roots: $ReadOnlyArray<SuspenseNode['id']>,
@ -169,42 +229,42 @@ function SuspenseRectsShell({
const store = useContext(StoreContext);
const root = store.getSuspenseByID(rootID);
if (root === null) {
console.warn(`<Element> Could not find suspense node id ${rootID}`);
// getSuspenseByID will have already warned
return null;
}
return (
<g>
{root.children.map(childID => {
return <SuspenseRects key={childID} suspenseID={childID} />;
})}
</g>
);
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);
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
const {roots} = useContext(SuspenseTreeStateContext);
const boundingRect = getDocumentBoundingRect(store, roots);
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 boundingRectWidth = boundingRect.width;
const height =
(boundingRectWidth === 0 ? 0 : boundingRect.height / boundingRect.width) *
100 +
'%';
const aspectRatio = `1 / ${heightScale}`;
return (
<div className={styles.SuspenseRectsContainer}>
<svg
style={{width, height}}
viewBox={`${boundingRect.x} ${boundingRect.y} ${boundingRect.width} ${boundingRect.height}`}>
{roots.map(rootID => {
return <SuspenseRectsShell key={rootID} rootID={rootID} />;
})}
</svg>
<ViewBox.Provider value={boundingBox}>
<div
className={styles.SuspenseRectsViewBox}
style={{aspectRatio, width}}>
{roots.map(rootID => {
return <SuspenseRectsShell key={rootID} rootID={rootID} />;
})}
</div>
</ViewBox.Provider>
</div>
);
}