[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-scroll-track': '#fafafa',
'--color-tooltip-background': 'rgba(0, 0, 0, 0.9)', '--color-tooltip-background': 'rgba(0, 0, 0, 0.9)',
'--color-tooltip-text': '#ffffff', '--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: { dark: {
'--color-attribute-name': '#9d87d2', '--color-attribute-name': '#9d87d2',
@ -315,6 +318,9 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-scroll-track': '#313640', '--color-scroll-track': '#313640',
'--color-tooltip-background': 'rgba(255, 255, 255, 0.95)', '--color-tooltip-background': 'rgba(255, 255, 255, 0.95)',
'--color-tooltip-text': '#000000', '--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: { compact: {
'--font-size-monospace-small': '9px', '--font-size-monospace-small': '9px',

View File

@ -2,14 +2,40 @@
padding: .25rem; padding: .25rem;
} }
.SuspenseRect { .SuspenseRectsViewBox {
fill: transparent; position: relative;
stroke: var(--color-background-selected);
stroke-width: 1px;
vector-effect: non-scaling-stroke;
paint-order: stroke;
} }
[data-highlighted='true'] > .SuspenseRect { .SuspenseRectsBoundary {
fill: var(--color-selected-tree-highlight-active); 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, SuspenseNode,
Rect, Rect,
} from 'react-devtools-shared/src/frontend/types'; } from 'react-devtools-shared/src/frontend/types';
import typeof {
SyntheticMouseEvent,
SyntheticPointerEvent,
} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react'; import * as React from 'react';
import {useContext} from 'react'; import {createContext, useContext} from 'react';
import { import {
TreeDispatcherContext, TreeDispatcherContext,
TreeStateContext, TreeStateContext,
@ -26,19 +30,32 @@ import {
SuspenseTreeStateContext, SuspenseTreeStateContext,
SuspenseTreeDispatcherContext, SuspenseTreeDispatcherContext,
} from './SuspenseTreeContext'; } 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 ( return (
<rect <div
className={styles.SuspenseRect} {...props}
x={rect.x} className={styles.SuspenseRectsScaledRect + ' ' + className}
y={rect.y} style={{
width={rect.width} width,
height={rect.height} height,
top: y,
left: x,
}}
/> />
); );
} }
@ -97,24 +114,67 @@ function SuspenseRects({
// TODO: Use the nearest Suspense boundary // TODO: Use the nearest Suspense boundary
const selected = inspectedElementID === suspenseID; const selected = inspectedElementID === suspenseID;
const boundingBox = getBoundingBox(suspense.rects);
return ( return (
<g <ScaledRect rect={boundingBox} className={styles.SuspenseRectsBoundary}>
data-highlighted={selected} <ViewBox.Provider value={boundingBox}>
onClick={handleClick} {suspense.rects !== null &&
onPointerOver={handlePointerOver} suspense.rects.map((rect, index) => {
onPointerLeave={handlePointerLeave}> return (
<title>{suspense.name}</title> <ScaledRect
{suspense.rects !== null && key={index}
suspense.rects.map((rect, index) => { className={styles.SuspenseRectsRect}
return <SuspenseRect key={index} rect={rect} />; rect={rect}
})} data-highlighted={selected}
{suspense.children.map(childID => { onClick={handleClick}
return <SuspenseRects key={childID} suspenseID={childID} />; onPointerOver={handlePointerOver}
})} onPointerLeave={handlePointerLeave}
</g> // 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( function getDocumentBoundingRect(
store: Store, store: Store,
roots: $ReadOnlyArray<SuspenseNode['id']>, roots: $ReadOnlyArray<SuspenseNode['id']>,
@ -169,42 +229,42 @@ function SuspenseRectsShell({
const store = useContext(StoreContext); const store = useContext(StoreContext);
const root = store.getSuspenseByID(rootID); const root = store.getSuspenseByID(rootID);
if (root === null) { if (root === null) {
console.warn(`<Element> Could not find suspense node id ${rootID}`); // getSuspenseByID will have already warned
return null; return null;
} }
return ( return root.children.map(childID => {
<g> return <SuspenseRects key={childID} suspenseID={childID} />;
{root.children.map(childID => { });
return <SuspenseRects key={childID} suspenseID={childID} />;
})}
</g>
);
} }
const ViewBox = createContext<Rect>((null: any));
function SuspenseRectsContainer(): React$Node { function SuspenseRectsContainer(): React$Node {
const store = useContext(StoreContext); const store = useContext(StoreContext);
// TODO: This relies on a full re-render of all children when the Suspense tree changes. // TODO: This relies on a full re-render of all children when the Suspense tree changes.
const {roots} = useContext(SuspenseTreeStateContext); 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 width = '100%';
const boundingRectWidth = boundingRect.width; const aspectRatio = `1 / ${heightScale}`;
const height =
(boundingRectWidth === 0 ? 0 : boundingRect.height / boundingRect.width) *
100 +
'%';
return ( return (
<div className={styles.SuspenseRectsContainer}> <div className={styles.SuspenseRectsContainer}>
<svg <ViewBox.Provider value={boundingBox}>
style={{width, height}} <div
viewBox={`${boundingRect.x} ${boundingRect.y} ${boundingRect.width} ${boundingRect.height}`}> className={styles.SuspenseRectsViewBox}
{roots.map(rootID => { style={{aspectRatio, width}}>
return <SuspenseRectsShell key={rootID} rootID={rootID} />; {roots.map(rootID => {
})} return <SuspenseRectsShell key={rootID} rootID={rootID} />;
</svg> })}
</div>
</ViewBox.Provider>
</div> </div>
); );
} }