Skip to content

Commit f6a4882

Browse files
authored
[DevTools] Show the Suspense boundary name in the rect if there's no overlap (facebook#34918)
This shows the title in the top corner of the rect if there's enough space. The complex bit here is that it can be noisy if too many boundaries occupy the same space to overlap or partially overlap. This uses an R-tree to store all the rects to find overlapping boundaries to cut the available space to draw inside the rect. We use this to compute the rectangle within the rect which doesn't have any overlapping boundaries. The roots don't count as overlapping. Similarly, a parent rect is not consider overlapping a child. However, if two sibling boundaries occupy the same space, no title will be drawn. <img width="734" height="813" alt="Screenshot 2025-10-19 at 5 34 49 PM" src="https://github.com/user-attachments/assets/2b848b9c-3b78-48e5-9476-dd59a7baf6bf" /> We might also consider drawing the "Initial Paint" title at the root but that's less interesting. It's interesting in the beginning before you know about the special case at the root but after that it's just always the same value so just adds noise.
1 parent b485f7c commit f6a4882

File tree

7 files changed

+387
-91
lines changed

7 files changed

+387
-91
lines changed

packages/react-devtools-shared/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"json5": "^2.2.3",
2424
"local-storage-fallback": "^4.1.1",
2525
"react-virtualized-auto-sizer": "^1.0.23",
26-
"react-window": "^1.8.10"
26+
"react-window": "^1.8.10",
27+
"rbush": "4.0.1"
2728
}
2829
}

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

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,31 @@ import type {
6262
import UnsupportedBridgeOperationError from 'react-devtools-shared/src/UnsupportedBridgeOperationError';
6363
import type {DevToolsHookSettings} from '../backend/types';
6464

65+
import RBush from 'rbush';
66+
67+
// Custom version which works with our Rect data structure.
68+
class RectRBush extends RBush<Rect> {
69+
toBBox(rect: Rect): {
70+
minX: number,
71+
minY: number,
72+
maxX: number,
73+
maxY: number,
74+
} {
75+
return {
76+
minX: rect.x,
77+
minY: rect.y,
78+
maxX: rect.x + rect.width,
79+
maxY: rect.y + rect.height,
80+
};
81+
}
82+
compareMinX(a: Rect, b: Rect): number {
83+
return a.x - b.x;
84+
}
85+
compareMinY(a: Rect, b: Rect): number {
86+
return a.y - b.y;
87+
}
88+
}
89+
6590
const debug = (methodName: string, ...args: Array<string>) => {
6691
if (__DEBUG__) {
6792
console.log(
@@ -194,6 +219,9 @@ export default class Store extends EventEmitter<{
194219
// Renderer ID is needed to support inspection fiber props, state, and hooks.
195220
_rootIDToRendererID: Map<Element['id'], number> = new Map();
196221

222+
// Stores all the SuspenseNode rects in an R-tree to make it fast to find overlaps.
223+
_rtree: RBush<Rect> = new RectRBush();
224+
197225
// These options may be initially set by a configuration option when constructing the Store.
198226
_supportsInspectMatchingDOMElement: boolean = false;
199227
_supportsClickToInspect: boolean = false;
@@ -1622,7 +1650,12 @@ export default class Store extends EventEmitter<{
16221650
const y = operations[i + 1] / 1000;
16231651
const width = operations[i + 2] / 1000;
16241652
const height = operations[i + 3] / 1000;
1625-
rects.push({x, y, width, height});
1653+
const rect = {x, y, width, height};
1654+
if (parentID !== 0) {
1655+
// Track all rects except the root.
1656+
this._rtree.insert(rect);
1657+
}
1658+
rects.push(rect);
16261659
i += 4;
16271660
}
16281661
}
@@ -1680,13 +1713,20 @@ export default class Store extends EventEmitter<{
16801713

16811714
i += 1;
16821715

1683-
const {children, parentID} = suspense;
1716+
const {children, parentID, rects} = suspense;
16841717
if (children.length > 0) {
16851718
this._throwAndEmitError(
16861719
Error(`Suspense node "${id}" was removed before its children.`),
16871720
);
16881721
}
16891722

1723+
if (rects !== null && parentID !== 0) {
1724+
// Delete all the existing rects from the R-tree
1725+
for (let j = 0; j < rects.length; j++) {
1726+
this._rtree.remove(rects[j]);
1727+
}
1728+
}
1729+
16901730
this._idToSuspense.delete(id);
16911731
removedSuspenseIDs.set(id, parentID);
16921732

@@ -1785,6 +1825,14 @@ export default class Store extends EventEmitter<{
17851825
break;
17861826
}
17871827

1828+
const prevRects = suspense.rects;
1829+
if (prevRects !== null && suspense.parentID !== 0) {
1830+
// Delete all the existing rects from the R-tree
1831+
for (let j = 0; j < prevRects.length; j++) {
1832+
this._rtree.remove(prevRects[j]);
1833+
}
1834+
}
1835+
17881836
let nextRects: SuspenseNode['rects'];
17891837
if (numRects === -1) {
17901838
nextRects = null;
@@ -1796,7 +1844,12 @@ export default class Store extends EventEmitter<{
17961844
const width = operations[i + 2] / 1000;
17971845
const height = operations[i + 3] / 1000;
17981846

1799-
nextRects.push({x, y, width, height});
1847+
const rect = {x, y, width, height};
1848+
if (suspense.parentID !== 0) {
1849+
// Track all rects except the root.
1850+
this._rtree.insert(rect);
1851+
}
1852+
nextRects.push(rect);
18001853

18011854
i += 4;
18021855
}

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,24 @@
3939
pointer-events: none;
4040
}
4141

42+
.SuspenseRectsTitle {
43+
pointer-events: none;
44+
color: var(--color-text);
45+
overflow: hidden;
46+
text-overflow: ellipsis;
47+
font-size: var(--font-size-sans-small);
48+
line-height: var(--font-size-sans-small);
49+
padding: .25rem;
50+
container-type: size;
51+
container-name: title;
52+
}
53+
54+
@container title (width < 30px) or (height < 12px) {
55+
.SuspenseRectsTitle > span {
56+
display: none;
57+
}
58+
}
59+
4260
.SuspenseRectsScaledRect[data-visible='false'] > .SuspenseRectsBoundaryChildren {
4361
overflow: initial;
4462
}
@@ -75,7 +93,7 @@
7593
transition: background-color 0.2s ease-out;
7694
}
7795

78-
.SuspenseRectsBoundary[data-selected='true'] {
96+
.SuspenseRectsBoundary[data-selected='true'][data-visible='true'] {
7997
box-shadow: var(--elevation-4);
8098
}
8199

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

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
SuspenseTreeDispatcherContext,
3232
} from './SuspenseTreeContext';
3333
import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
34+
import type RBush from 'rbush';
3435

3536
function ScaledRect({
3637
className,
@@ -78,8 +79,10 @@ function ScaledRect({
7879

7980
function SuspenseRects({
8081
suspenseID,
82+
parentRects,
8183
}: {
8284
suspenseID: SuspenseNode['id'],
85+
parentRects: null | Array<Rect>,
8386
}): React$Node {
8487
const store = useContext(StoreContext);
8588
const treeDispatch = useContext(TreeDispatcherContext);
@@ -167,7 +170,20 @@ function SuspenseRects({
167170
}
168171
}
169172

170-
const boundingBox = getBoundingBox(suspense.rects);
173+
const rects = suspense.rects;
174+
const boundingBox = getBoundingBox(rects);
175+
176+
// Next we'll try to find a rect within one of our rects that isn't intersecting with
177+
// other rects.
178+
// TODO: This should probably be memoized based on if any changes to the rtree has been made.
179+
const titleBox: null | Rect =
180+
rects === null ? null : findTitleBox(store._rtree, rects, parentRects);
181+
const nextRects =
182+
rects === null || rects.length === 0
183+
? parentRects
184+
: parentRects === null || parentRects.length === 0
185+
? rects
186+
: parentRects.concat(rects);
171187

172188
return (
173189
<ScaledRect
@@ -205,11 +221,22 @@ function SuspenseRects({
205221
className={styles.SuspenseRectsBoundaryChildren}
206222
rect={boundingBox}>
207223
{suspense.children.map(childID => {
208-
return <SuspenseRects key={childID} suspenseID={childID} />;
224+
return (
225+
<SuspenseRects
226+
key={childID}
227+
suspenseID={childID}
228+
parentRects={nextRects}
229+
/>
230+
);
209231
})}
210232
</ScaledRect>
211233
)}
212-
{selected ? (
234+
{titleBox && suspense.name && visible ? (
235+
<ScaledRect className={styles.SuspenseRectsTitle} rect={titleBox}>
236+
<span>{suspense.name}</span>
237+
</ScaledRect>
238+
) : null}
239+
{selected && visible ? (
213240
<ScaledRect
214241
className={styles.SuspenseRectOutline}
215242
rect={boundingBox}
@@ -320,6 +347,77 @@ function getDocumentBoundingRect(
320347
};
321348
}
322349

350+
function findTitleBox(
351+
rtree: RBush<Rect>,
352+
rects: Array<Rect>,
353+
parentRects: null | Array<Rect>,
354+
): null | Rect {
355+
for (let i = 0; i < rects.length; i++) {
356+
const rect = rects[i];
357+
if (rect.width < 20 || rect.height < 10) {
358+
// Skip small rects. They're likely not able to be contain anything useful anyway.
359+
continue;
360+
}
361+
// Find all overlapping rects elsewhere in the tree to limit our rect.
362+
const overlappingRects = rtree.search({
363+
minX: rect.x,
364+
minY: rect.y,
365+
maxX: rect.x + rect.width,
366+
maxY: rect.y + rect.height,
367+
});
368+
if (
369+
overlappingRects.length === 0 ||
370+
(overlappingRects.length === 1 && overlappingRects[0] === rect)
371+
) {
372+
// There are no overlapping rects that isn't our own rect, so we can just use
373+
// the full space of the rect.
374+
return rect;
375+
}
376+
// We have some overlapping rects but they might not overlap everything. Let's
377+
// shrink it up toward the top left corner until it has no more overlap.
378+
const minX = rect.x;
379+
const minY = rect.y;
380+
let maxX = rect.x + rect.width;
381+
let maxY = rect.y + rect.height;
382+
for (let j = 0; j < overlappingRects.length; j++) {
383+
const overlappingRect = overlappingRects[j];
384+
if (overlappingRect === rect) {
385+
continue;
386+
}
387+
const x = overlappingRect.x;
388+
const y = overlappingRect.y;
389+
if (y < maxY && x < maxX) {
390+
if (
391+
parentRects !== null &&
392+
parentRects.indexOf(overlappingRect) !== -1
393+
) {
394+
// This rect overlaps but it's part of a parent boundary. We let
395+
// title content render if it's on top and not a sibling.
396+
continue;
397+
}
398+
// This rect cuts into the remaining space. Let's figure out if we're
399+
// better off cutting on the x or y axis to maximize remaining space.
400+
const remainderX = x - minX;
401+
const remainderY = y - minY;
402+
if (remainderX > remainderY) {
403+
maxX = x;
404+
} else {
405+
maxY = y;
406+
}
407+
}
408+
}
409+
if (maxX > minX && maxY > minY) {
410+
return {
411+
x: minX,
412+
y: minY,
413+
width: maxX - minX,
414+
height: maxY - minY,
415+
};
416+
}
417+
}
418+
return null;
419+
}
420+
323421
function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node {
324422
const store = useContext(StoreContext);
325423
const root = store.getSuspenseByID(rootID);
@@ -329,7 +427,9 @@ function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node {
329427
}
330428

331429
return root.children.map(childID => {
332-
return <SuspenseRects key={childID} suspenseID={childID} />;
430+
return (
431+
<SuspenseRects key={childID} suspenseID={childID} parentRects={null} />
432+
);
333433
});
334434
}
335435

0 commit comments

Comments
 (0)