@samitouri / QOS-React / commits / 755cebad6b

[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 }