main
js 621 lines 17.5 KB
Raw
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 Element,
13 SuspenseNode,
14 Rect,
15 } from 'react-devtools-shared/src/frontend/types';
16 import typeof {
17 SyntheticMouseEvent,
18 SyntheticPointerEvent,
19 } from 'react-dom-bindings/src/events/SyntheticEvent';
20
21 import * as React from 'react';
22 import {createContext, useContext, useLayoutEffect, useMemo} from 'react';
23 import {
24 TreeDispatcherContext,
25 TreeStateContext,
26 } from '../Components/TreeContext';
27 import {StoreContext} from '../context';
28 import {useHighlightHostInstance} from '../hooks';
29 import styles from './SuspenseRects.css';
30 import {
31 SuspenseTreeStateContext,
32 SuspenseTreeDispatcherContext,
33 } from './SuspenseTreeContext';
34 import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
35 import type RBush from 'rbush';
36
37 function ScaledRect({
38 className,
39 rect,
40 visible,
41 suspended,
42 selected,
43 hovered,
44 adjust,
45 ...props
46 }: {
47 className: string,
48 rect: Rect,
49 visible: boolean,
50 suspended: boolean,
51 selected?: boolean,
52 hovered?: boolean,
53 adjust?: boolean,
54 ...
55 }): React$Node {
56 const viewBox = useContext(ViewBox);
57 const width = (rect.width / viewBox.width) * 100 + '%';
58 const height = (rect.height / viewBox.height) * 100 + '%';
59 const x = ((rect.x - viewBox.x) / viewBox.width) * 100 + '%';
60 const y = ((rect.y - viewBox.y) / viewBox.height) * 100 + '%';
61
62 return (
63 <div
64 {...props}
65 className={styles.SuspenseRectsScaledRect + ' ' + className}
66 data-visible={visible}
67 data-suspended={suspended}
68 data-selected={selected}
69 data-hovered={hovered}
70 style={{
71 // Shrink one pixel so that the bottom outline will line up with the top outline of the next one.
72 width: adjust ? 'calc(' + width + ' - 1px)' : width,
73 height: adjust ? 'calc(' + height + ' - 1px)' : height,
74 top: y,
75 left: x,
76 }}
77 />
78 );
79 }
80
81 function SuspenseRects({
82 suspenseID,
83 parentRects,
84 }: {
85 suspenseID: SuspenseNode['id'],
86 parentRects: null | Array<Rect>,
87 }): React$Node {
88 const store = useContext(StoreContext);
89 const treeDispatch = useContext(TreeDispatcherContext);
90 const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
91 const {uniqueSuspendersOnly, timeline, hoveredTimelineIndex} = useContext(
92 SuspenseTreeStateContext,
93 );
94
95 const {inspectedElementID} = useContext(TreeStateContext);
96
97 const {highlightHostInstance, clearHighlightHostInstance} =
98 useHighlightHostInstance();
99
100 const suspense = store.getSuspenseByID(suspenseID);
101 if (suspense === null) {
102 // getSuspenseByID will have already warned
103 return null;
104 }
105 const visible = suspense.hasUniqueSuspenders || !uniqueSuspendersOnly;
106
107 function handleClick(event: SyntheticMouseEvent) {
108 if (event.defaultPrevented) {
109 // Already clicked on an inner rect
110 return;
111 }
112 event.preventDefault();
113 treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID});
114 suspenseTreeDispatch({
115 type: 'SET_SUSPENSE_LINEAGE',
116 payload: suspenseID,
117 });
118 }
119
120 function handleDoubleClick(event: SyntheticMouseEvent) {
121 if (event.defaultPrevented) {
122 // Already clicked on an inner rect
123 return;
124 }
125 event.preventDefault();
126 suspenseTreeDispatch({
127 type: 'TOGGLE_TIMELINE_FOR_ID',
128 payload: suspenseID,
129 });
130 }
131
132 function handlePointerOver(event: SyntheticPointerEvent) {
133 if (event.defaultPrevented) {
134 // Already hovered an inner rect
135 return;
136 }
137 event.preventDefault();
138 highlightHostInstance(suspenseID);
139 suspenseTreeDispatch({
140 type: 'HOVER_TIMELINE_FOR_ID',
141 payload: suspenseID,
142 });
143 }
144
145 function handlePointerLeave(event: SyntheticPointerEvent) {
146 if (event.defaultPrevented) {
147 // Already hovered an inner rect
148 return;
149 }
150 event.preventDefault();
151 clearHighlightHostInstance();
152 suspenseTreeDispatch({
153 type: 'HOVER_TIMELINE_FOR_ID',
154 payload: -1,
155 });
156 }
157
158 // TODO: Use the nearest Suspense boundary
159 const selected = inspectedElementID === suspenseID;
160
161 const hovered =
162 hoveredTimelineIndex > -1 &&
163 timeline[hoveredTimelineIndex].id === suspenseID;
164
165 let environment: null | string = null;
166 for (let i = 0; i < timeline.length; i++) {
167 const timelineStep = timeline[i];
168 if (timelineStep.id === suspenseID) {
169 environment = timelineStep.environment;
170 break;
171 }
172 }
173
174 const rects = suspense.rects;
175 const boundingBox = getBoundingBox(rects);
176
177 // Next we'll try to find a rect within one of our rects that isn't intersecting with
178 // other rects.
179 // TODO: This should probably be memoized based on if any changes to the rtree has been made.
180 const titleBox: null | Rect =
181 rects === null ? null : findTitleBox(store._rtree, rects, parentRects);
182 const nextRects =
183 rects === null || rects.length === 0
184 ? parentRects
185 : parentRects === null || parentRects.length === 0
186 ? rects
187 : parentRects.concat(rects);
188
189 return (
190 <ScaledRect
191 rect={boundingBox}
192 className={
193 styles.SuspenseRectsBoundary +
194 ' ' +
195 getClassNameForEnvironment(environment)
196 }
197 visible={visible}
198 selected={selected}
199 suspended={suspense.isSuspended}
200 hovered={hovered}>
201 <ViewBox.Provider value={boundingBox}>
202 {visible &&
203 suspense.rects !== null &&
204 suspense.rects.map((rect, index) => {
205 return (
206 <ScaledRect
207 key={index}
208 className={styles.SuspenseRectsRect}
209 rect={rect}
210 adjust={true}
211 onClick={handleClick}
212 onDoubleClick={handleDoubleClick}
213 onPointerOver={handlePointerOver}
214 onPointerLeave={handlePointerLeave}
215 // Reach-UI tooltip will go out of bounds of parent scroll container.
216 title={suspense.name || 'Unknown'}
217 />
218 );
219 })}
220 {suspense.children.length > 0 && (
221 <ScaledRect
222 className={styles.SuspenseRectsBoundaryChildren}
223 rect={boundingBox}>
224 {suspense.children.map(childID => {
225 return (
226 <SuspenseRects
227 key={childID}
228 suspenseID={childID}
229 parentRects={nextRects}
230 />
231 );
232 })}
233 </ScaledRect>
234 )}
235 {titleBox && suspense.name && visible ? (
236 <ScaledRect className={styles.SuspenseRectsTitle} rect={titleBox}>
237 <span>{suspense.name}</span>
238 </ScaledRect>
239 ) : null}
240 </ViewBox.Provider>
241 </ScaledRect>
242 );
243 }
244
245 function getBoundingBox(rects: $ReadOnlyArray<Rect> | null): Rect {
246 if (rects === null || rects.length === 0) {
247 return {x: 0, y: 0, width: 0, height: 0};
248 }
249
250 let minX = Number.POSITIVE_INFINITY;
251 let minY = Number.POSITIVE_INFINITY;
252 let maxX = Number.NEGATIVE_INFINITY;
253 let maxY = Number.NEGATIVE_INFINITY;
254
255 for (let i = 0; i < rects.length; i++) {
256 const rect = rects[i];
257 minX = Math.min(minX, rect.x);
258 minY = Math.min(minY, rect.y);
259 maxX = Math.max(maxX, rect.x + rect.width);
260 maxY = Math.max(maxY, rect.y + rect.height);
261 }
262
263 return {
264 x: minX,
265 y: minY,
266 width: maxX - minX,
267 height: maxY - minY,
268 };
269 }
270
271 function computeBoundingRectRecursively(
272 store: Store,
273 node: SuspenseNode,
274 bounds: {
275 minX: number,
276 minY: number,
277 maxX: number,
278 maxY: number,
279 },
280 ): void {
281 const rects = node.rects;
282 if (rects !== null) {
283 for (let j = 0; j < rects.length; j++) {
284 const rect = rects[j];
285 if (rect.x < bounds.minX) {
286 bounds.minX = rect.x;
287 }
288 if (rect.x + rect.width > bounds.maxX) {
289 bounds.maxX = rect.x + rect.width;
290 }
291 if (rect.y < bounds.minY) {
292 bounds.minY = rect.y;
293 }
294 if (rect.y + rect.height > bounds.maxY) {
295 bounds.maxY = rect.y + rect.height;
296 }
297 }
298 }
299 for (let i = 0; i < node.children.length; i++) {
300 const child = store.getSuspenseByID(node.children[i]);
301 if (child !== null) {
302 computeBoundingRectRecursively(store, child, bounds);
303 }
304 }
305 }
306
307 function getDocumentBoundingRect(
308 store: Store,
309 roots: $ReadOnlyArray<SuspenseNode['id']>,
310 ): Rect {
311 if (roots.length === 0) {
312 return {x: 0, y: 0, width: 0, height: 0};
313 }
314
315 const bounds = {
316 minX: Number.POSITIVE_INFINITY,
317 minY: Number.POSITIVE_INFINITY,
318 maxX: Number.NEGATIVE_INFINITY,
319 maxY: Number.NEGATIVE_INFINITY,
320 };
321
322 for (let i = 0; i < roots.length; i++) {
323 const rootID = roots[i];
324 const root = store.getSuspenseByID(rootID);
325 if (root === null) {
326 continue;
327 }
328 computeBoundingRectRecursively(store, root, bounds);
329 }
330
331 if (bounds.minX === Number.POSITIVE_INFINITY) {
332 // No rects found, return empty rect
333 return {x: 0, y: 0, width: 0, height: 0};
334 }
335
336 return {
337 x: bounds.minX,
338 y: bounds.minY,
339 width: bounds.maxX - bounds.minX,
340 height: bounds.maxY - bounds.minY,
341 };
342 }
343
344 function findTitleBox(
345 rtree: RBush<Rect>,
346 rects: Array<Rect>,
347 parentRects: null | Array<Rect>,
348 ): null | Rect {
349 for (let i = 0; i < rects.length; i++) {
350 const rect = rects[i];
351 if (rect.width < 20 || rect.height < 10) {
352 // Skip small rects. They're likely not able to be contain anything useful anyway.
353 continue;
354 }
355 // Find all overlapping rects elsewhere in the tree to limit our rect.
356 const overlappingRects = rtree.search({
357 minX: rect.x,
358 minY: rect.y,
359 maxX: rect.x + rect.width,
360 maxY: rect.y + rect.height,
361 });
362 if (
363 overlappingRects.length === 0 ||
364 (overlappingRects.length === 1 && overlappingRects[0] === rect)
365 ) {
366 // There are no overlapping rects that isn't our own rect, so we can just use
367 // the full space of the rect.
368 return rect;
369 }
370 // We have some overlapping rects but they might not overlap everything. Let's
371 // shrink it up toward the top left corner until it has no more overlap.
372 const minX = rect.x;
373 const minY = rect.y;
374 let maxX = rect.x + rect.width;
375 let maxY = rect.y + rect.height;
376 for (let j = 0; j < overlappingRects.length; j++) {
377 const overlappingRect = overlappingRects[j];
378 if (overlappingRect === rect) {
379 continue;
380 }
381 const x = overlappingRect.x;
382 const y = overlappingRect.y;
383 if (y < maxY && x < maxX) {
384 if (
385 parentRects !== null &&
386 parentRects.indexOf(overlappingRect) !== -1
387 ) {
388 // This rect overlaps but it's part of a parent boundary. We let
389 // title content render if it's on top and not a sibling.
390 continue;
391 }
392 // This rect cuts into the remaining space. Let's figure out if we're
393 // better off cutting on the x or y axis to maximize remaining space.
394 const remainderX = x - minX;
395 const remainderY = y - minY;
396 if (remainderX > remainderY) {
397 maxX = x;
398 } else {
399 maxY = y;
400 }
401 }
402 }
403 if (maxX > minX && maxY > minY) {
404 return {
405 x: minX,
406 y: minY,
407 width: maxX - minX,
408 height: maxY - minY,
409 };
410 }
411 }
412 return null;
413 }
414
415 function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node {
416 const store = useContext(StoreContext);
417 const root = store.getSuspenseByID(rootID);
418 if (root === null) {
419 // getSuspenseByID will have already warned
420 return null;
421 }
422
423 return root.children.map(childID => {
424 return (
425 <SuspenseRects key={childID} suspenseID={childID} parentRects={null} />
426 );
427 });
428 }
429
430 function SuspenseRectsInitialPaint(): React$Node {
431 const {roots} = useContext(SuspenseTreeStateContext);
432 return roots.map(rootID => {
433 return <SuspenseRectsRoot key={rootID} rootID={rootID} />;
434 });
435 }
436
437 function SuspenseRectsTransition({id}: {id: Element['id']}): React$Node {
438 const store = useContext(StoreContext);
439 const children = useMemo(() => {
440 return store.getSuspenseChildren(id);
441 }, [id, store]);
442
443 return children.map(suspenseID => {
444 return (
445 <SuspenseRects
446 key={suspenseID}
447 suspenseID={suspenseID}
448 parentRects={null}
449 />
450 );
451 });
452 }
453
454 const ViewBox = createContext<Rect>(null as any);
455
456 function SuspenseRectsContainer({
457 scaleRef,
458 }: {
459 scaleRef: {current: number},
460 }): React$Node {
461 const store = useContext(StoreContext);
462 const {activityID, inspectedElementID} = useContext(TreeStateContext);
463 const treeDispatch = useContext(TreeDispatcherContext);
464 const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
465 // TODO: This relies on a full re-render of all children when the Suspense tree changes.
466 const {roots, timeline, hoveredTimelineIndex, uniqueSuspendersOnly} =
467 useContext(SuspenseTreeStateContext);
468
469 const activityChildren: $ReadOnlyArray<SuspenseNode['id']> | null =
470 useMemo(() => {
471 if (activityID === null) {
472 return null;
473 }
474 return store.getSuspenseChildren(activityID);
475 }, [activityID, store]);
476 const transitionChildren =
477 activityChildren === null ? roots : activityChildren;
478
479 // We're using the bounding box of the entire document to anchor the Transition
480 // in the actual document.
481 const boundingBox = getDocumentBoundingRect(store, roots);
482
483 const boundingBoxWidth = boundingBox.width;
484 const heightScale =
485 boundingBoxWidth === 0 ? 1 : boundingBox.height / boundingBoxWidth;
486 // Scales the inspected document to fit into the available width
487 const width = '100%';
488 const aspectRatio = `1 / ${heightScale}`;
489
490 function handleClick(event: SyntheticMouseEvent) {
491 if (event.defaultPrevented) {
492 // Already clicked on an inner rect
493 return;
494 }
495 if (transitionChildren.length === 0) {
496 // Nothing to select
497 return;
498 }
499 const arbitraryRootID = roots[0];
500 const transitionRoot = activityID === null ? arbitraryRootID : activityID;
501
502 event.preventDefault();
503 treeDispatch({
504 type: 'SELECT_ELEMENT_BY_ID',
505 payload: transitionRoot,
506 });
507 suspenseTreeDispatch({
508 type: 'SET_SUSPENSE_LINEAGE',
509 payload: arbitraryRootID,
510 });
511 }
512
513 function handleDoubleClick(event: SyntheticMouseEvent) {
514 if (event.defaultPrevented) {
515 // Already clicked on an inner rect
516 return;
517 }
518 event.preventDefault();
519 suspenseTreeDispatch({
520 type: 'SUSPENSE_SET_TIMELINE_INDEX',
521 payload: 0,
522 });
523 }
524
525 // $FlowFixMe[incompatible-type]
526 const isRootSelected = roots.includes(inspectedElementID);
527 // When we're focusing a Transition, the first timeline step will not be a root.
528 const isRootHovered = activityID === null && hoveredTimelineIndex === 0;
529
530 let hasRootSuspenders = false;
531 if (!uniqueSuspendersOnly) {
532 hasRootSuspenders = true;
533 } else {
534 for (let i = 0; i < roots.length; i++) {
535 const rootID = roots[i];
536 const root = store.getSuspenseByID(rootID);
537 if (root !== null && root.hasUniqueSuspenders) {
538 hasRootSuspenders = true;
539 break;
540 }
541 }
542 }
543
544 const rootEnvironment =
545 timeline.length === 0 ? null : timeline[0].environment;
546
547 useLayoutEffect(() => {
548 // 100% of the width represents this many pixels in the real document.
549 scaleRef.current = boundingBoxWidth;
550 }, [boundingBoxWidth]);
551
552 let selectedBoundingBox = null;
553 let selectedEnvironment = null;
554 if (isRootSelected) {
555 selectedEnvironment = rootEnvironment;
556 } else if (
557 inspectedElementID !== null &&
558 // TODO: Separate inspected element and inspected Suspense and use the inspected Suspense ID here.
559 store.containsSuspense(inspectedElementID)
560 ) {
561 const selectedSuspenseNode = store.getSuspenseByID(inspectedElementID);
562 if (
563 selectedSuspenseNode !== null &&
564 (selectedSuspenseNode.hasUniqueSuspenders || !uniqueSuspendersOnly)
565 ) {
566 selectedBoundingBox = getBoundingBox(selectedSuspenseNode.rects);
567 for (let i = 0; i < timeline.length; i++) {
568 const timelineStep = timeline[i];
569 if (timelineStep.id === inspectedElementID) {
570 selectedEnvironment = timelineStep.environment;
571 break;
572 }
573 }
574 }
575 }
576
577 return (
578 <div
579 className={
580 styles.SuspenseRectsContainer +
581 (hasRootSuspenders &&
582 // We don't want to draw attention to the root if we're looking at a Transition.
583 // TODO: Draw bounding rect of Transition and check if the Transition
584 // has unique suspenders.
585 activityID === null
586 ? ' ' + styles.SuspenseRectsRoot
587 : '') +
588 (isRootSelected ? ' ' + styles.SuspenseRectsRootOutline : '') +
589 ' ' +
590 getClassNameForEnvironment(rootEnvironment)
591 }
592 onClick={handleClick}
593 onDoubleClick={handleDoubleClick}
594 data-hovered={isRootHovered}>
595 <ViewBox.Provider value={boundingBox}>
596 <div
597 className={styles.SuspenseRectsViewBox}
598 style={{aspectRatio, width}}>
599 {activityID === null ? (
600 <SuspenseRectsInitialPaint />
601 ) : (
602 <SuspenseRectsTransition id={activityID} />
603 )}
604 {selectedBoundingBox !== null ? (
605 <ScaledRect
606 className={
607 styles.SuspenseRectOutline +
608 ' ' +
609 getClassNameForEnvironment(selectedEnvironment)
610 }
611 rect={selectedBoundingBox}
612 adjust={true}
613 />
614 ) : null}
615 </div>
616 </ViewBox.Provider>
617 </div>
618 );
619 }
620
621 export default SuspenseRectsContainer;