1/**2 * Copyright (c) Meta Platforms, Inc. and affiliates.3 *4 * This source code is licensed under the MIT license found in the5 * LICENSE file in the root directory of this source tree.6 *7 * @flow8 */910import type Store from 'react-devtools-shared/src/devtools/store';11import type {12 Element,13 SuspenseNode,14 Rect,15} from 'react-devtools-shared/src/frontend/types';16import typeof {17 SyntheticMouseEvent,18 SyntheticPointerEvent,19} from 'react-dom-bindings/src/events/SyntheticEvent';2021import * as React from 'react';22import {createContext, useContext, useLayoutEffect, useMemo} from 'react';23import {24 TreeDispatcherContext,25 TreeStateContext,26} from '../Components/TreeContext';27import {StoreContext} from '../context';28import {useHighlightHostInstance} from '../hooks';29import styles from './SuspenseRects.css';30import {31 SuspenseTreeStateContext,32 SuspenseTreeDispatcherContext,33} from './SuspenseTreeContext';34import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';35import type RBush from 'rbush';3637function ScaledRect({38 className,39 rect,40 visible,41 suspended,42 selected,43 hovered,44 adjust,45 ...props46}: {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 + '%';6162 return (63 <div64 {...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}8081function 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 );9495 const {inspectedElementID} = useContext(TreeStateContext);9697 const {highlightHostInstance, clearHighlightHostInstance} =98 useHighlightHostInstance();99100 const suspense = store.getSuspenseByID(suspenseID);101 if (suspense === null) {102 // getSuspenseByID will have already warned103 return null;104 }105 const visible = suspense.hasUniqueSuspenders || !uniqueSuspendersOnly;106107 function handleClick(event: SyntheticMouseEvent) {108 if (event.defaultPrevented) {109 // Already clicked on an inner rect110 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 }119120 function handleDoubleClick(event: SyntheticMouseEvent) {121 if (event.defaultPrevented) {122 // Already clicked on an inner rect123 return;124 }125 event.preventDefault();126 suspenseTreeDispatch({127 type: 'TOGGLE_TIMELINE_FOR_ID',128 payload: suspenseID,129 });130 }131132 function handlePointerOver(event: SyntheticPointerEvent) {133 if (event.defaultPrevented) {134 // Already hovered an inner rect135 return;136 }137 event.preventDefault();138 highlightHostInstance(suspenseID);139 suspenseTreeDispatch({140 type: 'HOVER_TIMELINE_FOR_ID',141 payload: suspenseID,142 });143 }144145 function handlePointerLeave(event: SyntheticPointerEvent) {146 if (event.defaultPrevented) {147 // Already hovered an inner rect148 return;149 }150 event.preventDefault();151 clearHighlightHostInstance();152 suspenseTreeDispatch({153 type: 'HOVER_TIMELINE_FOR_ID',154 payload: -1,155 });156 }157158 // TODO: Use the nearest Suspense boundary159 const selected = inspectedElementID === suspenseID;160161 const hovered =162 hoveredTimelineIndex > -1 &&163 timeline[hoveredTimelineIndex].id === suspenseID;164165 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 }173174 const rects = suspense.rects;175 const boundingBox = getBoundingBox(rects);176177 // Next we'll try to find a rect within one of our rects that isn't intersecting with178 // 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 === 0184 ? parentRects185 : parentRects === null || parentRects.length === 0186 ? rects187 : parentRects.concat(rects);188189 return (190 <ScaledRect191 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 <ScaledRect207 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 <ScaledRect222 className={styles.SuspenseRectsBoundaryChildren}223 rect={boundingBox}>224 {suspense.children.map(childID => {225 return (226 <SuspenseRects227 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}244245function getBoundingBox(rects: $ReadOnlyArray<Rect> | null): Rect {246 if (rects === null || rects.length === 0) {247 return {x: 0, y: 0, width: 0, height: 0};248 }249250 let minX = Number.POSITIVE_INFINITY;251 let minY = Number.POSITIVE_INFINITY;252 let maxX = Number.NEGATIVE_INFINITY;253 let maxY = Number.NEGATIVE_INFINITY;254255 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 }262263 return {264 x: minX,265 y: minY,266 width: maxX - minX,267 height: maxY - minY,268 };269}270271function 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}306307function 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 }314315 const bounds = {316 minX: Number.POSITIVE_INFINITY,317 minY: Number.POSITIVE_INFINITY,318 maxX: Number.NEGATIVE_INFINITY,319 maxY: Number.NEGATIVE_INFINITY,320 };321322 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 }330331 if (bounds.minX === Number.POSITIVE_INFINITY) {332 // No rects found, return empty rect333 return {x: 0, y: 0, width: 0, height: 0};334 }335336 return {337 x: bounds.minX,338 y: bounds.minY,339 width: bounds.maxX - bounds.minX,340 height: bounds.maxY - bounds.minY,341 };342}343344function 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 use367 // the full space of the rect.368 return rect;369 }370 // We have some overlapping rects but they might not overlap everything. Let's371 // 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) !== -1387 ) {388 // This rect overlaps but it's part of a parent boundary. We let389 // 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're393 // 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}414415function 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 warned420 return null;421 }422423 return root.children.map(childID => {424 return (425 <SuspenseRects key={childID} suspenseID={childID} parentRects={null} />426 );427 });428}429430function SuspenseRectsInitialPaint(): React$Node {431 const {roots} = useContext(SuspenseTreeStateContext);432 return roots.map(rootID => {433 return <SuspenseRectsRoot key={rootID} rootID={rootID} />;434 });435}436437function SuspenseRectsTransition({id}: {id: Element['id']}): React$Node {438 const store = useContext(StoreContext);439 const children = useMemo(() => {440 return store.getSuspenseChildren(id);441 }, [id, store]);442443 return children.map(suspenseID => {444 return (445 <SuspenseRects446 key={suspenseID}447 suspenseID={suspenseID}448 parentRects={null}449 />450 );451 });452}453454const ViewBox = createContext<Rect>(null as any);455456function 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);468469 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;478479 // We're using the bounding box of the entire document to anchor the Transition480 // in the actual document.481 const boundingBox = getDocumentBoundingRect(store, roots);482483 const boundingBoxWidth = boundingBox.width;484 const heightScale =485 boundingBoxWidth === 0 ? 1 : boundingBox.height / boundingBoxWidth;486 // Scales the inspected document to fit into the available width487 const width = '100%';488 const aspectRatio = `1 / ${heightScale}`;489490 function handleClick(event: SyntheticMouseEvent) {491 if (event.defaultPrevented) {492 // Already clicked on an inner rect493 return;494 }495 if (transitionChildren.length === 0) {496 // Nothing to select497 return;498 }499 const arbitraryRootID = roots[0];500 const transitionRoot = activityID === null ? arbitraryRootID : activityID;501502 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 }512513 function handleDoubleClick(event: SyntheticMouseEvent) {514 if (event.defaultPrevented) {515 // Already clicked on an inner rect516 return;517 }518 event.preventDefault();519 suspenseTreeDispatch({520 type: 'SUSPENSE_SET_TIMELINE_INDEX',521 payload: 0,522 });523 }524525 // $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;529530 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 }543544 const rootEnvironment =545 timeline.length === 0 ? null : timeline[0].environment;546547 useLayoutEffect(() => {548 // 100% of the width represents this many pixels in the real document.549 scaleRef.current = boundingBoxWidth;550 }, [boundingBoxWidth]);551552 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 }576577 return (578 <div579 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 Transition584 // has unique suspenders.585 activityID === null586 ? ' ' + styles.SuspenseRectsRoot587 : '') +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 <div597 className={styles.SuspenseRectsViewBox}598 style={{aspectRatio, width}}>599 {activityID === null ? (600 <SuspenseRectsInitialPaint />601 ) : (602 <SuspenseRectsTransition id={activityID} />603 )}604 {selectedBoundingBox !== null ? (605 <ScaledRect606 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}620621export default SuspenseRectsContainer;
Findings
✓ No findings reported for this file.