Skip to content

Commit 755ceba

Browse files
authored
[DevTools] Elevate Suspense rects to visualize hierarchy (facebook#34455)
1 parent 5813211 commit 755ceba

File tree

3 files changed

+147
-55
lines changed

3 files changed

+147
-55
lines changed

packages/react-devtools-shared/src/devtools/constants.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
163163
'--color-scroll-track': '#fafafa',
164164
'--color-tooltip-background': 'rgba(0, 0, 0, 0.9)',
165165
'--color-tooltip-text': '#ffffff',
166+
167+
'--elevation-4':
168+
'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)',
166169
},
167170
dark: {
168171
'--color-attribute-name': '#9d87d2',
@@ -315,6 +318,9 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
315318
'--color-scroll-track': '#313640',
316319
'--color-tooltip-background': 'rgba(255, 255, 255, 0.95)',
317320
'--color-tooltip-text': '#000000',
321+
322+
'--elevation-4':
323+
'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)',
318324
},
319325
compact: {
320326
'--font-size-monospace-small': '9px',

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,40 @@
22
padding: .25rem;
33
}
44

5-
.SuspenseRect {
6-
fill: transparent;
7-
stroke: var(--color-background-selected);
8-
stroke-width: 1px;
9-
vector-effect: non-scaling-stroke;
10-
paint-order: stroke;
5+
.SuspenseRectsViewBox {
6+
position: relative;
117
}
128

13-
[data-highlighted='true'] > .SuspenseRect {
14-
fill: var(--color-selected-tree-highlight-active);
9+
.SuspenseRectsBoundary {
10+
pointer-events: all;
11+
}
12+
13+
.SuspenseRectsBoundaryChildren {
14+
pointer-events: none;
15+
/**
16+
* So that the shadow of Boundaries within is clipped off.
17+
* Otherwise it would look like this boundary is further elevated.
18+
*/
19+
overflow: hidden;
20+
}
21+
22+
.SuspenseRectsRect {
23+
box-shadow: var(--elevation-4);
24+
pointer-events: all;
25+
outline-style: solid;
26+
outline-width: 1px;
27+
}
28+
29+
.SuspenseRectsScaledRect {
30+
position: absolute;
31+
outline-color: var(--color-background-selected);
32+
}
33+
34+
/* highlight this boundary */
35+
.SuspenseRectsBoundary:hover:not(:has(.SuspenseRectsBoundary:hover)) > .SuspenseRectsRect, .SuspenseRectsBoundary[data-highlighted='true'] > .SuspenseRectsRect {
36+
background-color: var(--color-background-hover);
37+
}
38+
39+
.SuspenseRectsRect[data-highlighted='true'] {
40+
background-color: var(--color-selected-tree-highlight-active);
1541
}

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js

Lines changed: 107 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,13 @@ import type {
1212
SuspenseNode,
1313
Rect,
1414
} from 'react-devtools-shared/src/frontend/types';
15+
import typeof {
16+
SyntheticMouseEvent,
17+
SyntheticPointerEvent,
18+
} from 'react-dom-bindings/src/events/SyntheticEvent';
1519

1620
import * as React from 'react';
17-
import {useContext} from 'react';
21+
import {createContext, useContext} from 'react';
1822
import {
1923
TreeDispatcherContext,
2024
TreeStateContext,
@@ -26,19 +30,32 @@ import {
2630
SuspenseTreeStateContext,
2731
SuspenseTreeDispatcherContext,
2832
} from './SuspenseTreeContext';
29-
import typeof {
30-
SyntheticMouseEvent,
31-
SyntheticPointerEvent,
32-
} from 'react-dom-bindings/src/events/SyntheticEvent';
3333

34-
function SuspenseRect({rect}: {rect: Rect}): React$Node {
34+
function ScaledRect({
35+
className,
36+
rect,
37+
...props
38+
}: {
39+
className: string,
40+
rect: Rect,
41+
...
42+
}): React$Node {
43+
const viewBox = useContext(ViewBox);
44+
const width = (rect.width / viewBox.width) * 100 + '%';
45+
const height = (rect.height / viewBox.height) * 100 + '%';
46+
const x = ((rect.x - viewBox.x) / viewBox.width) * 100 + '%';
47+
const y = ((rect.y - viewBox.y) / viewBox.height) * 100 + '%';
48+
3549
return (
36-
<rect
37-
className={styles.SuspenseRect}
38-
x={rect.x}
39-
y={rect.y}
40-
width={rect.width}
41-
height={rect.height}
50+
<div
51+
{...props}
52+
className={styles.SuspenseRectsScaledRect + ' ' + className}
53+
style={{
54+
width,
55+
height,
56+
top: y,
57+
left: x,
58+
}}
4259
/>
4360
);
4461
}
@@ -97,24 +114,67 @@ function SuspenseRects({
97114
// TODO: Use the nearest Suspense boundary
98115
const selected = inspectedElementID === suspenseID;
99116

117+
const boundingBox = getBoundingBox(suspense.rects);
118+
100119
return (
101-
<g
102-
data-highlighted={selected}
103-
onClick={handleClick}
104-
onPointerOver={handlePointerOver}
105-
onPointerLeave={handlePointerLeave}>
106-
<title>{suspense.name}</title>
107-
{suspense.rects !== null &&
108-
suspense.rects.map((rect, index) => {
109-
return <SuspenseRect key={index} rect={rect} />;
110-
})}
111-
{suspense.children.map(childID => {
112-
return <SuspenseRects key={childID} suspenseID={childID} />;
113-
})}
114-
</g>
120+
<ScaledRect rect={boundingBox} className={styles.SuspenseRectsBoundary}>
121+
<ViewBox.Provider value={boundingBox}>
122+
{suspense.rects !== null &&
123+
suspense.rects.map((rect, index) => {
124+
return (
125+
<ScaledRect
126+
key={index}
127+
className={styles.SuspenseRectsRect}
128+
rect={rect}
129+
data-highlighted={selected}
130+
onClick={handleClick}
131+
onPointerOver={handlePointerOver}
132+
onPointerLeave={handlePointerLeave}
133+
// Reach-UI tooltip will go out of bounds of parent scroll container.
134+
title={suspense.name}
135+
/>
136+
);
137+
})}
138+
{suspense.children.length > 0 && (
139+
<ScaledRect
140+
className={styles.SuspenseRectsBoundaryChildren}
141+
rect={boundingBox}>
142+
{suspense.children.map(childID => {
143+
return <SuspenseRects key={childID} suspenseID={childID} />;
144+
})}
145+
</ScaledRect>
146+
)}
147+
</ViewBox.Provider>
148+
</ScaledRect>
115149
);
116150
}
117151

152+
function getBoundingBox(rects: $ReadOnlyArray<Rect> | null): Rect {
153+
if (rects === null || rects.length === 0) {
154+
return {x: 0, y: 0, width: 0, height: 0};
155+
}
156+
157+
let minX = Number.POSITIVE_INFINITY;
158+
let minY = Number.POSITIVE_INFINITY;
159+
let maxX = Number.NEGATIVE_INFINITY;
160+
let maxY = Number.NEGATIVE_INFINITY;
161+
162+
for (let i = 0; i < rects.length; i++) {
163+
const rect = rects[i];
164+
minX = Math.min(minX, rect.x);
165+
minY = Math.min(minY, rect.y);
166+
maxX = Math.max(maxX, rect.x + rect.width);
167+
maxY = Math.max(maxY, rect.y + rect.height);
168+
}
169+
170+
return {
171+
x: minX,
172+
y: minY,
173+
width: maxX - minX,
174+
height: maxY - minY,
175+
};
176+
}
177+
118178
function getDocumentBoundingRect(
119179
store: Store,
120180
roots: $ReadOnlyArray<SuspenseNode['id']>,
@@ -169,42 +229,42 @@ function SuspenseRectsShell({
169229
const store = useContext(StoreContext);
170230
const root = store.getSuspenseByID(rootID);
171231
if (root === null) {
172-
console.warn(`<Element> Could not find suspense node id ${rootID}`);
232+
// getSuspenseByID will have already warned
173233
return null;
174234
}
175235

176-
return (
177-
<g>
178-
{root.children.map(childID => {
179-
return <SuspenseRects key={childID} suspenseID={childID} />;
180-
})}
181-
</g>
182-
);
236+
return root.children.map(childID => {
237+
return <SuspenseRects key={childID} suspenseID={childID} />;
238+
});
183239
}
184240

241+
const ViewBox = createContext<Rect>((null: any));
242+
185243
function SuspenseRectsContainer(): React$Node {
186244
const store = useContext(StoreContext);
187245
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
188246
const {roots} = useContext(SuspenseTreeStateContext);
189247

190-
const boundingRect = getDocumentBoundingRect(store, roots);
248+
const boundingBox = getDocumentBoundingRect(store, roots);
191249

250+
const boundingBoxWidth = boundingBox.width;
251+
const heightScale =
252+
boundingBoxWidth === 0 ? 1 : boundingBox.height / boundingBoxWidth;
253+
// Scales the inspected document to fit into the available width
192254
const width = '100%';
193-
const boundingRectWidth = boundingRect.width;
194-
const height =
195-
(boundingRectWidth === 0 ? 0 : boundingRect.height / boundingRect.width) *
196-
100 +
197-
'%';
255+
const aspectRatio = `1 / ${heightScale}`;
198256

199257
return (
200258
<div className={styles.SuspenseRectsContainer}>
201-
<svg
202-
style={{width, height}}
203-
viewBox={`${boundingRect.x} ${boundingRect.y} ${boundingRect.width} ${boundingRect.height}`}>
204-
{roots.map(rootID => {
205-
return <SuspenseRectsShell key={rootID} rootID={rootID} />;
206-
})}
207-
</svg>
259+
<ViewBox.Provider value={boundingBox}>
260+
<div
261+
className={styles.SuspenseRectsViewBox}
262+
style={{aspectRatio, width}}>
263+
{roots.map(rootID => {
264+
return <SuspenseRectsShell key={rootID} rootID={rootID} />;
265+
})}
266+
</div>
267+
</ViewBox.Provider>
208268
</div>
209269
);
210270
}

0 commit comments

Comments
 (0)