[DevTools] Elevate Suspense rects to visualize hierarchy (#34455)
Sebastian "Sebbie" Silbermann committed
Sep 18, 2025 at 18:37 UTC
755cebad6b80ef3d248d5ee38e925dceedd8f217
3 files changed
+147
-55
packages/react-devtools-shared/src/devtools/constants.js
+6
@@ -163,6 +163,9 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
163
'--color-scroll-track': '#fafafa',
164
'--color-tooltip-background': 'rgba(0, 0, 0, 0.9)',
165
'--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)',
169
},
170
dark: {
171
'--color-attribute-name': '#9d87d2',
@@ -315,6 +318,9 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
318
'--color-scroll-track': '#313640',
319
'--color-tooltip-background': 'rgba(255, 255, 255, 0.95)',
320
'--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)',
324
},
325
compact: {
326
'--font-size-monospace-small': '9px',
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css
+34
-8
@@ -2,14 +2,40 @@
2
padding: .25rem;
3
}
4
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;
7
}
8
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);
41
}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
+107
-47
@@ -12,9 +12,13 @@ import type {
12
SuspenseNode,
13
Rect,
14
} from 'react-devtools-shared/src/frontend/types';
15
+import typeof {
16
+ SyntheticMouseEvent,
17
+ SyntheticPointerEvent,
18
+} from 'react-dom-bindings/src/events/SyntheticEvent';
19
20
import * as React from 'react';
17
-import {useContext} from 'react';
21
+import {createContext, useContext} from 'react';
22
import {
23
TreeDispatcherContext,
24
TreeStateContext,
@@ -26,19 +30,32 @@ import {
30
SuspenseTreeStateContext,
31
SuspenseTreeDispatcherContext,
32
} from './SuspenseTreeContext';
29
-import typeof {
30
- SyntheticMouseEvent,
31
- SyntheticPointerEvent,
32
-} from 'react-dom-bindings/src/events/SyntheticEvent';
33
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
+
49
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
+ }}
59
/>
60
);
61
}
@@ -97,24 +114,67 @@ function SuspenseRects({
114
// TODO: Use the nearest Suspense boundary
115
const selected = inspectedElementID === suspenseID;
116
117
+ const boundingBox = getBoundingBox(suspense.rects);
118
+
119
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>
149
);
150
}
151
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
+
178
function getDocumentBoundingRect(
179
store: Store,
180
roots: $ReadOnlyArray<SuspenseNode['id']>,
@@ -169,42 +229,42 @@ function SuspenseRectsShell({
229
const store = useContext(StoreContext);
230
const root = store.getSuspenseByID(rootID);
231
if (root === null) {
172
- console.warn(`<Element> Could not find suspense node id ${rootID}`);
232
+ // getSuspenseByID will have already warned
233
return null;
234
}
235
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
+ });
239
}
240
241
+const ViewBox = createContext<Rect>((null: any));
242
+
243
function SuspenseRectsContainer(): React$Node {
244
const store = useContext(StoreContext);
245
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
246
const {roots} = useContext(SuspenseTreeStateContext);
247
190
- const boundingRect = getDocumentBoundingRect(store, roots);
248
+ const boundingBox = getDocumentBoundingRect(store, roots);
249
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
254
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}`;
256
257
return (
258
<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>
268
</div>
269
);
270
}