packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js JAVASCRIPT 622 lines View on github.com → Search inside
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.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.