[SuspenseTab] Scuffed version of Suspense rects (#34188)
Sebastian "Sebbie" Silbermann committed
Aug 14, 2025 at 18:24 UTC
02a8811864caf09ed1229e939742f793a8ed5791
4 files changed
+225
-7
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css
new
+15
@@ -0,0 +1,15 @@
1
+.SuspenseRectsContainer {
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;
11
+}
12
+
13
+[data-highlighted='true'] > .SuspenseRect {
14
+ fill: var(--color-selected-tree-highlight-active);
15
+}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
new
+200
@@ -0,0 +1,200 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import type Store from 'react-devtools-shared/src/devtools/store';
11
+import type {
12
+ SuspenseNode,
13
+ Rect,
14
+} from 'react-devtools-shared/src/frontend/types';
15
+
16
+import * as React from 'react';
17
+import {useContext} from 'react';
18
+import {
19
+ TreeDispatcherContext,
20
+ TreeStateContext,
21
+} from '../Components/TreeContext';
22
+import {StoreContext} from '../context';
23
+import {useHighlightHostInstance} from '../hooks';
24
+import styles from './SuspenseRects.css';
25
+import {SuspenseTreeStateContext} from './SuspenseTreeContext';
26
+
27
+function SuspenseRect({rect}: {rect: Rect}): React$Node {
28
+ return (
29
+ <rect
30
+ className={styles.SuspenseRect}
31
+ x={rect.x}
32
+ y={rect.y}
33
+ width={rect.width}
34
+ height={rect.height}
35
+ />
36
+ );
37
+}
38
+
39
+function SuspenseRects({
40
+ suspenseID,
41
+}: {
42
+ suspenseID: SuspenseNode['id'],
43
+}): React$Node {
44
+ const dispatch = useContext(TreeDispatcherContext);
45
+ const store = useContext(StoreContext);
46
+
47
+ const {inspectedElementID} = useContext(TreeStateContext);
48
+
49
+ const {highlightHostInstance, clearHighlightHostInstance} =
50
+ useHighlightHostInstance();
51
+
52
+ const suspense = store.getSuspenseByID(suspenseID);
53
+ if (suspense === null) {
54
+ console.warn(`<Element> Could not find suspense node id ${suspenseID}`);
55
+ return null;
56
+ }
57
+
58
+ function handleClick(event: SyntheticMouseEvent<>) {
59
+ if (event.defaultPrevented) {
60
+ // Already clicked on an inner rect
61
+ return;
62
+ }
63
+ event.preventDefault();
64
+ dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID});
65
+ }
66
+
67
+ function handlePointerOver(event: SyntheticPointerEvent<>) {
68
+ if (event.defaultPrevented) {
69
+ // Already hovered an inner rect
70
+ return;
71
+ }
72
+ event.preventDefault();
73
+ highlightHostInstance(suspenseID);
74
+ }
75
+
76
+ function handlePointerLeave(event: SyntheticPointerEvent<>) {
77
+ if (event.defaultPrevented) {
78
+ // Already hovered an inner rect
79
+ return;
80
+ }
81
+ event.preventDefault();
82
+ clearHighlightHostInstance();
83
+ }
84
+
85
+ // TODO: Use the nearest Suspense boundary
86
+ const selected = inspectedElementID === suspenseID;
87
+
88
+ return (
89
+ <g
90
+ data-highlighted={selected}
91
+ onClick={handleClick}
92
+ onPointerOver={handlePointerOver}
93
+ onPointerLeave={handlePointerLeave}>
94
+ <title>{suspense.name}</title>
95
+ {suspense.rects !== null &&
96
+ suspense.rects.map((rect, index) => {
97
+ return <SuspenseRect key={index} rect={rect} />;
98
+ })}
99
+ {suspense.children.map(childID => {
100
+ return <SuspenseRects key={childID} suspenseID={childID} />;
101
+ })}
102
+ </g>
103
+ );
104
+}
105
+
106
+function getDocumentBoundingRect(
107
+ store: Store,
108
+ shells: $ReadOnlyArray<SuspenseNode['id']>,
109
+): Rect {
110
+ if (shells.length === 0) {
111
+ return {x: 0, y: 0, width: 0, height: 0};
112
+ }
113
+
114
+ let minX = Number.POSITIVE_INFINITY;
115
+ let minY = Number.POSITIVE_INFINITY;
116
+ let maxX = Number.NEGATIVE_INFINITY;
117
+ let maxY = Number.NEGATIVE_INFINITY;
118
+
119
+ for (let i = 0; i < shells.length; i++) {
120
+ const shellID = shells[i];
121
+ const shell = store.getSuspenseByID(shellID);
122
+ if (shell === null) {
123
+ continue;
124
+ }
125
+
126
+ const rects = shell.rects;
127
+ if (rects === null) {
128
+ continue;
129
+ }
130
+ for (let j = 0; j < rects.length; j++) {
131
+ const rect = rects[j];
132
+ minX = Math.min(minX, rect.x);
133
+ minY = Math.min(minY, rect.y);
134
+ maxX = Math.max(maxX, rect.x + rect.width);
135
+ maxY = Math.max(maxY, rect.y + rect.height);
136
+ }
137
+ }
138
+
139
+ if (minX === Number.POSITIVE_INFINITY) {
140
+ // No rects found, return empty rect
141
+ return {x: 0, y: 0, width: 0, height: 0};
142
+ }
143
+
144
+ return {
145
+ x: minX,
146
+ y: minY,
147
+ width: maxX - minX,
148
+ height: maxY - minY,
149
+ };
150
+}
151
+
152
+function SuspenseRectsShell({
153
+ shellID,
154
+}: {
155
+ shellID: SuspenseNode['id'],
156
+}): React$Node {
157
+ const store = useContext(StoreContext);
158
+ const shell = store.getSuspenseByID(shellID);
159
+ if (shell === null) {
160
+ console.warn(`<Element> Could not find suspense node id ${shellID}`);
161
+ return null;
162
+ }
163
+
164
+ return (
165
+ <g>
166
+ {shell.children.map(childID => {
167
+ return <SuspenseRects key={childID} suspenseID={childID} />;
168
+ })}
169
+ </g>
170
+ );
171
+}
172
+
173
+function SuspenseRectsContainer(): React$Node {
174
+ const store = useContext(StoreContext);
175
+ // TODO: This relies on a full re-render of all children when the Suspense tree changes.
176
+ const {shells} = useContext(SuspenseTreeStateContext);
177
+
178
+ const boundingRect = getDocumentBoundingRect(store, shells);
179
+
180
+ const width = '100%';
181
+ const boundingRectWidth = boundingRect.width;
182
+ const height =
183
+ (boundingRectWidth === 0 ? 0 : boundingRect.height / boundingRect.width) *
184
+ 100 +
185
+ '%';
186
+
187
+ return (
188
+ <div className={styles.SuspenseRectsContainer}>
189
+ <svg
190
+ style={{width, height}}
191
+ viewBox={`${boundingRect.x} ${boundingRect.y} ${boundingRect.width} ${boundingRect.height}`}>
192
+ {shells.map(shellID => {
193
+ return <SuspenseRectsShell key={shellID} shellID={shellID} />;
194
+ })}
195
+ </svg>
196
+ </div>
197
+ );
198
+}
199
+
200
+export default SuspenseRectsContainer;
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+1
-4
@@ -19,6 +19,7 @@ import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBo
19
import InspectedElement from '../Components/InspectedElement';
20
import portaledContent from '../portaledContent';
21
import styles from './SuspenseTab.css';
22
+import SuspenseRects from './SuspenseRects';
23
import SuspenseTreeList from './SuspenseTreeList';
24
import Button from '../Button';
25
@@ -48,10 +49,6 @@ function SuspenseTimeline() {
49
return <div className={styles.Timeline}>timeline</div>;
50
}
51
51
-function SuspenseRects() {
52
- return <div>rects</div>;
53
-}
54
-
52
function ToggleTreeList({
53
dispatch,
54
state,
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+9
-3
@@ -17,9 +17,12 @@ import {
17
useMemo,
18
useReducer,
19
} from 'react';
20
+import type {SuspenseNode} from '../../../frontend/types';
21
import {StoreContext} from '../context';
22
22
-export type SuspenseTreeState = {};
23
+export type SuspenseTreeState = {
24
+ shells: $ReadOnlyArray<SuspenseNode['id']>,
25
+};
26
27
type ACTION_HANDLE_SUSPENSE_TREE_MUTATION = {
28
type: 'HANDLE_SUSPENSE_TREE_MUTATION',
@@ -56,7 +59,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
59
const {type} = action;
60
switch (type) {
61
case 'HANDLE_SUSPENSE_TREE_MUTATION':
59
- return {...state};
62
+ return {...state, shells: store.roots};
63
default:
64
throw new Error(`Unrecognized action "${type}"`);
65
}
@@ -64,7 +67,10 @@ function SuspenseTreeContextController({children}: Props): React.Node {
67
[],
68
);
69
67
- const [state, dispatch] = useReducer(reducer, {});
70
+ const initialState: SuspenseTreeState = {
71
+ shells: store.roots,
72
+ };
73
+ const [state, dispatch] = useReducer(reducer, initialState);
74
const transitionDispatch = useMemo(
75
() => (action: SuspenseTreeAction) =>
76
startTransition(() => {