Repository: facebook/react
Pull request: [SuspenseTab] Scuffed version of Suspense rects

For now we scale the width of the bounding box to the available Suspense tab width. The scaling lacks nuance but basic cases should be viewable. svg might not be the best choice for the final scaling heuristics.

Next up is investigating backend renderer bugs so that we can start testing it on non-trivial surfaces 


https://github.com/user-attachments/assets/ce205fc4-9d77-450a-9cf4-ac7f691d70a7



Changed files:
filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css
patch: @@ -0,0 +1,15 @@
+.SuspenseRectsContainer {
+  padding: .25rem;
+}
+
+.SuspenseRect {
+  fill: transparent;
+  stroke: var(--color-background-selected);
+  stroke-width: 1px;
+  vector-effect: non-scaling-stroke;
+  paint-order: stroke;
+}
+
+[data-highlighted='true'] > .SuspenseRect {
+  fill: var(--color-selected-tree-highlight-active);
+}

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
patch: @@ -0,0 +1,200 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import type Store from 'react-devtools-shared/src/devtools/store';
+import type {
+  SuspenseNode,
+  Rect,
+} from 'react-devtools-shared/src/frontend/types';
+
+import * as React from 'react';
+import {useContext} from 'react';
+import {
+  TreeDispatcherContext,
+  TreeStateContext,
+} from '../Components/TreeContext';
+import {StoreContext} from '../context';
+import {useHighlightHostInstance} from '../hooks';
+import styles from './SuspenseRects.css';
+import {SuspenseTreeStateContext} from './SuspenseTreeContext';
+
+function SuspenseRect({rect}: {rect: Rect}): React$Node {
+  return (
+    <rect
+      className={styles.SuspenseRect}
+      x={rect.x}
+      y={rect.y}
+      width={rect.width}
+      height={rect.height}
+    />
+  );
+}
+
+function SuspenseRects({
+  suspenseID,
+}: {
+  suspenseID: SuspenseNode['id'],
+}): React$Node {
+  const dispatch = useContext(TreeDispatcherContext);
+  const store = useContext(StoreContext);
+
+  const {inspectedElementID} = useContext(TreeStateContext);
+
+  const {highlightHostInstance, clearHighlightHostInstance} =
+    useHighlightHostInstance();
+
+  const suspense = store.getSuspenseByID(suspenseID);
+  if (suspense === null) {
+    console.warn(`<Element> Could not find suspense node id ${suspenseID}`);
+    return null;
+  }
+
+  function handleClick(event: SyntheticMouseEvent<>) {
+    if (event.defaultPrevented) {
+      // Already clicked on an inner rect
+      return;
+    }
+    event.preventDefault();
+    dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: suspenseID});
+  }
+
+  function handlePointerOver(event: SyntheticPointerEvent<>) {
+    if (event.defaultPrevented) {
+      // Already hovered an inner rect
+      return;
+    }
+    event.preventDefault();
+    highlightHostInstance(suspenseID);
+  }
+
+  function handlePointerLeave(event: SyntheticPointerEvent<>) {
+    if (event.defaultPrevented) {
+      // Already hovered an inner rect
+      return;
+    }
+    event.preventDefault();
+    clearHighlightHostInstance();
+  }
+
+  // TODO: Use the nearest Suspense boundary
+  const selected = inspectedElementID === suspenseID;
+
+  return (
+    <g
+      data-highlighted={selected}
+      onClick={handleClick}
+      onPointerOver={handlePointerOver}
+      onPointerLeave={handlePointerLeave}>
+      <title>{suspense.name}</title>
+      {suspense.rects !== null &&
+        suspense.rects.map((rect, index) => {
+          return <SuspenseRect key={index} rect={rect} />;
+        })}
+      {suspense.children.map(childID => {
+        return <SuspenseRects key={childID} suspenseID={childID} />;
+      })}
+    </g>
+  );
+}
+
+function getDocumentBoundingRect(
+  store: Store,
+  shells: $ReadOnlyArray<SuspenseNode['id']>,
+): Rect {
+  if (shells.length === 0) {
+    return {x: 0, y: 0, width: 0, height: 0};
+  }
+
+  let minX = Number.POSITIVE_INFINITY;
+  let minY = Number.POSITIVE_INFINITY;
+  let maxX = Number.NEGATIVE_INFINITY;
+  let maxY = Number.NEGATIVE_INFINITY;
+
+  for (let i = 0; i < shells.length; i++) {
+    const shellID = shells[i];
+    const shell = store.getSuspenseByID(shellID);
+    if (shell === null) {
+      continue;
+    }
+
+    const rects = shell.rects;
+    if (rects === null) {
+      continue;
+    }
+    for (let j = 0; j < rects.length; j++) {
+      const rect = rects[j];
+      minX = Math.min(minX, rect.x);
+      minY = Math.min(minY, rect.y);
+      maxX = Math.max(maxX, rect.x + rect.width);
+      maxY = Math.max(maxY, rect.y + rect.height);
+    }
+  }
+
+  if (minX === Number.POSITIVE_INFINITY) {
+    // No rects found, return empty rect
+    return {x: 0, y: 0, width: 0, height: 0};
+  }
+
+  return {
+    x: minX,
+    y: minY,
+    width: maxX - minX,
+    height: maxY - minY,
+  };
+}
+
+function SuspenseRectsShell({
+  shellID,
+}: {
+  shellID: SuspenseNode['id'],
+}): React$Node {
+  const store = useContext(StoreContext);
+  const shell = store.getSuspenseByID(shellID);
+  if (shell === null) {
+    console.warn(`<Element> Could not find suspense node id ${shellID}`);
+    return null;
+  }
+
+  return (
+    <g>
+      {shell.children.map(childID => {
+        return <SuspenseRects key={childID} suspenseID={childID} />;
+      })}
+    </g>
+  );
+}
+
+function SuspenseRectsContainer(): React$Node {
+  const store = useContext(StoreContext);
+  // TODO: This relies on a full re-render of all children when the Suspense tree changes.
+  const {shells} = useContext(SuspenseTreeStateContext);
+
+  const boundingRect = getDocumentBoundingRect(store, shells);
+
+  const width = '100%';
+  const boundingRectWidth = boundingRect.width;
+  const height =
+    (boundingRectWidth === 0 ? 0 : boundingRect.height / boundingRect.width) *
+      100 +
+    '%';
+
+  return (
+    <div className={styles.SuspenseRectsContainer}>
+      <svg
+        style={{width, height}}
+        viewBox={`${boundingRect.x} ${boundingRect.y} ${boundingRect.width} ${boundingRect.height}`}>
+        {shells.map(shellID => {
+          return <SuspenseRectsShell key={shellID} shellID={shellID} />;
+        })}
+      </svg>
+    </div>
+  );
+}
+
+export default SuspenseRectsContainer;

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
patch: @@ -19,6 +19,7 @@ import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBo
 import InspectedElement from '../Components/InspectedElement';
 import portaledContent from '../portaledContent';
 import styles from './SuspenseTab.css';
+import SuspenseRects from './SuspenseRects';
 import SuspenseTreeList from './SuspenseTreeList';
 import Button from '../Button';
 
@@ -48,10 +49,6 @@ function SuspenseTimeline() {
   return <div className={styles.Timeline}>timeline</div>;
 }
 
-function SuspenseRects() {
-  return <div>rects</div>;
-}
-
 function ToggleTreeList({
   dispatch,
   state,

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
patch: @@ -17,9 +17,12 @@ import {
   useMemo,
   useReducer,
 } from 'react';
+import type {SuspenseNode} from '../../../frontend/types';
 import {StoreContext} from '../context';
 
-export type SuspenseTreeState = {};
+export type SuspenseTreeState = {
+  shells: $ReadOnlyArray<SuspenseNode['id']>,
+};
 
 type ACTION_HANDLE_SUSPENSE_TREE_MUTATION = {
   type: 'HANDLE_SUSPENSE_TREE_MUTATION',
@@ -56,15 +59,18 @@ function SuspenseTreeContextController({children}: Props): React.Node {
         const {type} = action;
         switch (type) {
           case 'HANDLE_SUSPENSE_TREE_MUTATION':
-            return {...state};
+            return {...state, shells: store.roots};
           default:
             throw new Error(`Unrecognized action "${type}"`);
         }
       },
     [],
   );
 
-  const [state, dispatch] = useReducer(reducer, {});
+  const initialState: SuspenseTreeState = {
+    shells: store.roots,
+  };
+  const [state, dispatch] = useReducer(reducer, initialState);
   const transitionDispatch = useMemo(
     () => (action: SuspenseTreeAction) =>
       startTransition(() => {