Skip to content

Commit 02a8811

Browse files
authored
[SuspenseTab] Scuffed version of Suspense rects (facebook#34188)
1 parent 379a083 commit 02a8811

File tree

4 files changed

+225
-7
lines changed

4 files changed

+225
-7
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBo
1919
import InspectedElement from '../Components/InspectedElement';
2020
import portaledContent from '../portaledContent';
2121
import styles from './SuspenseTab.css';
22+
import SuspenseRects from './SuspenseRects';
2223
import SuspenseTreeList from './SuspenseTreeList';
2324
import Button from '../Button';
2425

@@ -48,10 +49,6 @@ function SuspenseTimeline() {
4849
return <div className={styles.Timeline}>timeline</div>;
4950
}
5051

51-
function SuspenseRects() {
52-
return <div>rects</div>;
53-
}
54-
5552
function ToggleTreeList({
5653
dispatch,
5754
state,

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ import {
1717
useMemo,
1818
useReducer,
1919
} from 'react';
20+
import type {SuspenseNode} from '../../../frontend/types';
2021
import {StoreContext} from '../context';
2122

22-
export type SuspenseTreeState = {};
23+
export type SuspenseTreeState = {
24+
shells: $ReadOnlyArray<SuspenseNode['id']>,
25+
};
2326

2427
type ACTION_HANDLE_SUSPENSE_TREE_MUTATION = {
2528
type: 'HANDLE_SUSPENSE_TREE_MUTATION',
@@ -56,15 +59,18 @@ function SuspenseTreeContextController({children}: Props): React.Node {
5659
const {type} = action;
5760
switch (type) {
5861
case 'HANDLE_SUSPENSE_TREE_MUTATION':
59-
return {...state};
62+
return {...state, shells: store.roots};
6063
default:
6164
throw new Error(`Unrecognized action "${type}"`);
6265
}
6366
},
6467
[],
6568
);
6669

67-
const [state, dispatch] = useReducer(reducer, {});
70+
const initialState: SuspenseTreeState = {
71+
shells: store.roots,
72+
};
73+
const [state, dispatch] = useReducer(reducer, initialState);
6874
const transitionDispatch = useMemo(
6975
() => (action: SuspenseTreeAction) =>
7076
startTransition(() => {

0 commit comments

Comments
 (0)