@samitouri / QOS-React / commits / 02a8811864

[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(() => {