Repository: facebook/react
Pull request: [DevTools] Layout for Suspense tab

Based on the Components tab UI but with an additional column on the left.

With the additional left column it got a bit more crowded so I'm using the same layout Chrome's Sources panel uses: Navigator (here: tree list) and content (here: rects) are always two columns. Debugger (here: inspected element) is the rightmost column if enough horizontal space is available. Otherwise it gets pushed below navigator/content.

Icons and borders aren't aligned yet. I'll adjust those once these panes actually get content. I just needed something that allows me to easily make room for the pane I'm currently focusing on. Might move the toggle icons to the rects pane since timeline pane has very little space to deal with.


https://github.com/user-attachments/assets/4d9e53a6-4534-4b17-846e-862e9197969a



Changed files:
filename: packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
patch: @@ -34,6 +34,12 @@ export type IconType =
   | 'save'
   | 'search'
   | 'settings'
+  | 'panel-left-close'
+  | 'panel-left-open'
+  | 'panel-right-close'
+  | 'panel-right-open'
+  | 'panel-bottom-open'
+  | 'panel-bottom-close'
   | 'error'
   | 'suspend'
   | 'undo'
@@ -46,8 +52,10 @@ type Props = {
   type: IconType,
 };
 
+const materialIconsViewBox = '0 -960 960 960';
 export default function ButtonIcon({className = '', type}: Props): React.Node {
   let pathData = null;
+  let viewBox = '0 0 24 24';
   switch (type) {
     case 'add':
       pathData = PATH_ADD;
@@ -121,6 +129,30 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
     case 'error':
       pathData = PATH_ERROR;
       break;
+    case 'panel-left-close':
+      pathData = PATH_MATERIAL_PANEL_LEFT_CLOSE;
+      viewBox = materialIconsViewBox;
+      break;
+    case 'panel-left-open':
+      pathData = PATH_MATERIAL_PANEL_LEFT_OPEN;
+      viewBox = materialIconsViewBox;
+      break;
+    case 'panel-right-close':
+      pathData = PATH_MATERIAL_PANEL_RIGHT_CLOSE;
+      viewBox = materialIconsViewBox;
+      break;
+    case 'panel-right-open':
+      pathData = PATH_MATERIAL_PANEL_RIGHT_OPEN;
+      viewBox = materialIconsViewBox;
+      break;
+    case 'panel-bottom-open':
+      pathData = PATH_MATERIAL_PANEL_BOTTOM_OPEN;
+      viewBox = materialIconsViewBox;
+      break;
+    case 'panel-bottom-close':
+      pathData = PATH_MATERIAL_PANEL_BOTTOM_CLOSE;
+      viewBox = materialIconsViewBox;
+      break;
     case 'suspend':
       pathData = PATH_SUSPEND;
       break;
@@ -147,7 +179,7 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
       className={`${styles.ButtonIcon} ${className}`}
       width="24"
       height="24"
-      viewBox="0 0 24 24">
+      viewBox={viewBox}>
       <path d="M0 0h24v24H0z" fill="none" />
       {typeof pathData === 'string' ? (
         <path fill="currentColor" d={pathData} />
@@ -276,3 +308,33 @@ const PATH_VIEW_SOURCE = `
 const PATH_EDITOR = `
   M7 5h10v2h2V3c0-1.1-.9-1.99-2-1.99L7 1c-1.1 0-2 .9-2 2v4h2V5zm8.41 11.59L20 12l-4.59-4.59L14 8.83 17.17 12 14 15.17l1.41 1.42zM10 15.17L6.83 12 10 8.83 8.59 7.41 4 12l4.59 4.59L10 15.17zM17 19H7v-2H5v4c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2v-4h-2v2z
 `;
+
+// Source: Material Design Icons left_panel_close
+const PATH_MATERIAL_PANEL_LEFT_CLOSE = `
+  M648-324v-312L480-480l168 156ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm125-72v-528H216v528h120Zm72 0h336v-528H408v528Zm-72 0H216h120Z
+`;
+
+// Source: Material Design Icons left_panel_open
+const PATH_MATERIAL_PANEL_LEFT_OPEN = `
+  M504-595v230q0 12.25 10.5 16.62Q525-344 534-352l110-102q11-11.18 11-26.09T644-506L534-608q-8.82-8-19.41-3.5T504-595ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm125-72v-528H216v528h120Zm72 0h336v-528H408v528Zm-72 0H216h120Z
+`;
+
+// Source: Material Design Icons right_panel_close
+const PATH_MATERIAL_PANEL_RIGHT_CLOSE = `
+  M312-365q0 12.25 10.5 16.62Q333-344 342-352l110-102q11-11.18 11-26.09T452-506L342-608q-8.82-8-19.41-3.5T312-595v230ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm413-72h120v-528H624v528Zm-72 0v-528H216v528h336Zm72 0h120-120Z
+`;
+
+// Source: Material Design Icons right_panel_open
+const PATH_MATERIAL_PANEL_RIGHT_OPEN = `
+  M456-365v-230q0-12.25-10.5-16.63Q435-616 426-608L316-506q-11 11.18-11 26.09T316-454l110 102q8.82 8 19.41 3.5T456-365ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm413-72h120v-528H624v528Zm-72 0v-528H216v528h336Zm72 0h120-120Z
+`;
+
+// Source: Material Design Icons bottom_panel_open
+const PATH_MATERIAL_PANEL_BOTTOM_OPEN = `
+  M365-504h230q12.25 0 16.63-10.5Q616-525 608-534L506-644q-11.18-11-26.09-11T454-644L352-534q-8 8.82-3.5 19.41T365-504ZM211-144q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211Zm5-192v120h528v-120H216Zm0-72h528v-336H216v336Zm0 72v120-120Z
+`;
+
+// Source: Material Design Icons bottom_panel_close
+const PATH_MATERIAL_PANEL_BOTTOM_CLOSE = `
+  m506-508 102-110q8-8.82 3.5-19.41T595-648H365q-12.25 0-16.62 10.5Q344-627 352-618l102 110q11.18 11 26.09 11T506-508Zm243-308q27.64 0 47.32 19.68T816-749v538q0 27.64-19.68 47.32T749-144H211q-27.64 0-47.32-19.68T144-211v-538q0-27.64 19.68-47.32T211-816h538ZM216-336v120h528v-120H216Zm528-72v-336H216v336h528Zm-528 72v120-120Z
+`;

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
patch: @@ -0,0 +1,117 @@
+.SuspenseTab {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: row;
+  background-color: var(--color-background);
+  color: var(--color-text);
+  font-family: var(--font-family-sans);
+}
+
+.SuspenseTab, .SuspenseTab * {
+  box-sizing: border-box;
+  -webkit-font-smoothing: var(--font-smoothing);
+}
+
+.TreeWrapper {
+  flex: 1 1 var(--horizontal-resize-tree-percentage);
+  display: flex;
+  flex-direction: row;
+  overflow: auto;
+  border-top: 1px solid var(--color-border);
+}
+
+.InspectedElementWrapper {
+  flex: 1 1 calc(100% - var(--horizontal-resize-tree-percentage));
+  overflow-x: hidden;
+  overflow-y: auto;
+}
+
+.ResizeBarWrapper {
+  flex: 0 0 0px;
+  position: relative;
+}
+
+.ResizeBar {
+  position: absolute;
+  /*
+   * moving the bar out of its bounding box might cause its hitbox to overlap
+   * with another scrollbar creating disorienting UX where you both resize and scroll
+   * at the same time.
+   * If you adjust this value, double check that starting resize right on this edge
+   * doesn't also cause scroll
+   */
+  left: 1px;
+  width: 5px;
+  height: 100%;
+  cursor: ew-resize;
+}
+
+.TreeView footer {
+  display: none;
+}
+
+
+
+@container devtools (width < 600px) {
+  .SuspenseTab {
+    flex-direction: column;
+  }
+
+  .TreeWrapper {
+    border-top: 1px solid var(--color-border);
+    flex: 1 0 var(--vertical-resize-tree-percentage);
+  }
+
+  .InspectedElementWrapper {
+    flex: 1 1 50%;
+  }
+
+  .TreeWrapper + .ResizeBarWrapper .ResizeBar {
+    top: 1px;
+    left: 0;
+    width: 100%;
+    height: 5px;
+    cursor: ns-resize;
+  }
+
+  .TreeView footer {
+    display: flex;
+    justify-content: end;
+  }
+
+  .ToggleInspectedElement[data-orientation="horizontal"] {
+    display: none;
+  }
+}
+
+.TreeList {
+  flex: 0 0 var(--horizontal-resize-tree-list-percentage);
+  border-right: 1px solid var(--color-border);
+  padding: 0.25rem
+}
+
+.TreeView {
+  flex: 1 1 35%;
+  display: flex;
+  flex-direction: column;
+}
+
+
+
+.Rects {
+  border-top: 1px solid var(--color-border);
+  padding: 0.25rem;
+  flex-grow: 1;
+}
+
+.TimelineWrapper {
+  padding: 0.25rem;
+  display: flex;
+  flex-direction: row;
+}
+
+.Timeline {
+  flex-grow: 1;
+}

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
patch: @@ -1,8 +1,445 @@
+/**
+ * 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 * as React from 'react';
+import {useEffect, useLayoutEffect, useReducer, useRef} from 'react';
+
+import {
+  localStorageGetItem,
+  localStorageSetItem,
+} from 'react-devtools-shared/src/storage';
+import ButtonIcon, {type IconType} from '../ButtonIcon';
+import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBoundary';
+import InspectedElement from '../Components/InspectedElement';
 import portaledContent from '../portaledContent';
+import styles from './SuspenseTab.css';
+import Button from '../Button';
+
+type Orientation = 'horizontal' | 'vertical';
+
+type LayoutActionType =
+  | 'ACTION_SET_TREE_LIST_TOGGLE'
+  | 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION'
+  | 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE'
+  | 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION'
+  | 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION';
+type LayoutAction = {
+  type: LayoutActionType,
+  payload: any,
+};
+
+type LayoutState = {
+  treeListHidden: boolean,
+  treeListHorizontalFraction: number,
+  inspectedElementHidden: boolean,
+  inspectedElementHorizontalFraction: number,
+  inspectedElementVerticalFraction: number,
+};
+type LayoutDispatch = (action: LayoutAction) => void;
+
+function SuspenseTreeList() {
+  return <div>tree list</div>;
+}
+
+function SuspenseTimeline() {
+  return <div className={styles.Timeline}>timeline</div>;
+}
+
+function SuspenseRects() {
+  return <div>rects</div>;
+}
+
+function ToggleTreeList({
+  dispatch,
+  state,
+}: {
+  dispatch: LayoutDispatch,
+  state: LayoutState,
+}) {
+  return (
+    <Button
+      onClick={() =>
+        dispatch({
+          type: 'ACTION_SET_TREE_LIST_TOGGLE',
+          payload: null,
+        })
+      }
+      title={state.treeListHidden ? 'Show Tree List' : 'Hide Tree List'}>
+      <ButtonIcon
+        type={state.treeListHidden ? 'panel-left-open' : 'panel-left-close'}
+      />
+    </Button>
+  );
+}
+
+function ToggleInspectedElement({
+  dispatch,
+  state,
+  orientation,
+}: {
+  dispatch: LayoutDispatch,
+  state: LayoutState,
+  orientation: 'horizontal' | 'vertical',
+}) {
+  let iconType: IconType;
+  if (orientation === 'horizontal') {
+    iconType = state.inspectedElementHidden
+      ? 'panel-right-open'
+      : 'panel-right-close';
+  } else {
+    iconType = state.inspectedElementHidden
+      ? 'panel-bottom-open'
+      : 'panel-bottom-close';
+  }
+  return (
+    <Button
+      className={styles.ToggleInspectedElement}
+      data-orientation={orientation}
+      onClick={() =>
+        dispatch({
+          type: 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE',
+          payload: null,
+        })
+      }
+      title={
+        state.inspectedElementHidden
+          ? 'Show Inspected Element'
+          : 'Hide Inspected Element'
+      }>
+      <ButtonIcon type={iconType} />
+    </Button>
+  );
+}
+
+function SuspenseTab(_: {}) {
+  const [state, dispatch] = useReducer<LayoutState, null, LayoutAction>(
+    layoutReducer,
+    null,
+    initLayoutState,
+  );
+
+  const wrapperTreeRef = useRef<null | HTMLElement>(null);
+  const resizeTreeRef = useRef<null | HTMLElement>(null);
+  const resizeTreeListRef = useRef<null | HTMLElement>(null);
+
+  // TODO: We'll show the recently inspected element in this tab when it should probably
+  // switch to the nearest Suspense boundary when we switch into this tab.
+
+  const {
+    inspectedElementHidden,
+    inspectedElementHorizontalFraction,
+    inspectedElementVerticalFraction,
+    treeListHidden,
+    treeListHorizontalFraction,
+  } = state;
+
+  useLayoutEffect(() => {
+    const wrapperElement = wrapperTreeRef.current;
+
+    setResizeCSSVariable(
+      wrapperElement,
+      'tree',
+      'horizontal',
+      inspectedElementHorizontalFraction * 100,
+    );
+    setResizeCSSVariable(
+      wrapperElement,
+      'tree',
+      'vertical',
+      inspectedElementVerticalFraction * 100,
+    );
+
+    const resizeTreeListElement = resizeTreeListRef.current;
+    setResizeCSSVariable(
+      resizeTreeListElement,
+      'tree-list',
+      'horizontal',
+      treeListHorizontalFraction * 100,
+    );
+  }, []);
+  useEffect(() => {
+    const timeoutID = setTimeout(() => {
+      localStorageSetItem(
+        LOCAL_STORAGE_KEY,
+        JSON.stringify({
+          inspectedElementHidden,
+          inspectedElementHorizontalFraction,
+          inspectedElementVerticalFraction,
+          treeListHidden,
+          treeListHorizontalFraction,
+        }),
+      );
+    }, 500);
+
+    return () => clearTimeout(timeoutID);
+  }, [
+    inspectedElementHidden,
+    inspectedElementHorizontalFraction,
+    inspectedElementVerticalFraction,
+    treeListHidden,
+    treeListHorizontalFraction,
+  ]);
+
+  const onResizeStart = (event: SyntheticPointerEvent<HTMLElement>) => {
+    const element = event.currentTarget;
+    element.setPointerCapture(event.pointerId);
+  };
+
+  const onResizeEnd = (event: SyntheticPointerEvent<HTMLElement>) => {
+    const element = event.currentTarget;
+    element.releasePointerCapture(event.pointerId);
+  };
+
+  const onResizeTree = (event: SyntheticPointerEvent<HTMLElement>) => {
+    const element = event.currentTarget;
+    const isResizing = element.hasPointerCapture(event.pointerId);
+    if (!isResizing) {
+      return;
+    }
+
+    const resizeElement = resizeTreeRef.current;
+    const wrapperElement = wrapperTreeRef.current;
+
+    if (wrapperElement === null || resizeElement === null) {
+      return;
+    }
+
+    event.preventDefault();
+
+    const orientation = getTreeOrientation(wrapperElement);
+
+    const {height, width, left, top} = wrapperElement.getBoundingClientRect();
+
+    const currentMousePosition =
+      orientation === 'horizontal' ? event.clientX - left : event.clientY - top;
+
+    const boundaryMin = MINIMUM_TREE_SIZE;
+    const boundaryMax =
+      orientation === 'horizontal'
+        ? width - MINIMUM_TREE_SIZE
+        : height - MINIMUM_TREE_SIZE;
+
+    const isMousePositionInBounds =
+      currentMousePosition > boundaryMin && currentMousePosition < boundaryMax;
+
+    if (isMousePositionInBounds) {
+      const resizedElementDimension =
+        orientation === 'horizontal' ? width : height;
+      const actionType =
+        orientation === 'horizontal'
+          ? 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION'
+          : 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION';
+      const fraction = currentMousePosition / resizedElementDimension;
+      const percentage = fraction * 100;
+
+      setResizeCSSVariable(wrapperElement, 'tree', orientation, percentage);
+
+      dispatch({
+        type: actionType,
+        payload: fraction,
+      });
+    }
+  };
+
+  const onResizeTreeList = (event: SyntheticPointerEvent<HTMLElement>) => {
+    const element = event.currentTarget;
+    const isResizing = element.hasPointerCapture(event.pointerId);
+    if (!isResizing) {
+      return;
+    }
+
+    const resizeElement = resizeTreeListRef.current;
+    const wrapperElement = resizeTreeRef.current;
+
+    if (wrapperElement === null || resizeElement === null) {
+      return;
+    }
+
+    event.preventDefault();
+
+    const orientation = 'horizontal';
+
+    const {height, width, left, top} = wrapperElement.getBoundingClientRect();
+
+    const currentMousePosition =
+      orientation === 'horizontal' ? event.clientX - left : event.clientY - top;
+
+    const boundaryMin = MINIMUM_TREE_LIST_SIZE;
+    const boundaryMax =
+      orientation === 'horizontal'
+        ? width - MINIMUM_TREE_LIST_SIZE
+        : height - MINIMUM_TREE_LIST_SIZE;
+
+    const isMousePositionInBounds =
+      currentMousePosition > boundaryMin && currentMousePosition < boundaryMax;
+
+    if (isMousePositionInBounds) {
+      const resizedElementDimension =
+        orientation === 'horizontal' ? width : height;
+      const actionType = 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION';
+      const percentage = (currentMousePosition / resizedElementDimension) * 100;
+
+      setResizeCSSVariable(resizeElement, 'tree-list', orientation, percentage);
+
+      dispatch({
+        type: actionType,
+        payload: currentMousePosition / resizedElementDimension,
+      });
+    }
+  };
+
+  return (
+    <div className={styles.SuspenseTab} ref={wrapperTreeRef}>
+      <div className={styles.TreeWrapper} ref={resizeTreeRef}>
+        <div
+          className={styles.TreeList}
+          hidden={treeListHidden}
+          ref={resizeTreeListRef}>
+          <SuspenseTreeList />
+        </div>
+        <div className={styles.ResizeBarWrapper}>
+          <div
+            onPointerDown={onResizeStart}
+            onPointerMove={onResizeTreeList}
+            onPointerUp={onResizeEnd}
+            className={styles.ResizeBar}
+          />
+        </div>
+        <div className={styles.TreeView}>
+          <div className={styles.TimelineWrapper}>
+            <ToggleTreeList dispatch={dispatch} state={state} />
+            <SuspenseTimeline />
+            <ToggleInspectedElement
+              dispatch={dispatch}
+              state={state}
+              orientation="horizontal"
+            />
+          </div>
+          <div className={styles.Rects}>
+            <SuspenseRects />
+          </div>
+          <footer>
+            <ToggleInspectedElement
+              dispatch={dispatch}
+              state={state}
+              orientation="vertical"
+            />
+          </footer>
+        </div>
+      </div>
+      <div className={styles.ResizeBarWrapper}>
+        <div
+          onPointerDown={onResizeStart}
+          onPointerMove={onResizeTree}
+          onPointerUp={onResizeEnd}
+          className={styles.ResizeBar}
+        />
+      </div>
+      <div
+        className={styles.InspectedElementWrapper}
+        hidden={inspectedElementHidden}>
+        <InspectedElementErrorBoundary>
+          <InspectedElement />
+        </InspectedElementErrorBoundary>
+      </div>
+    </div>
+  );
+}
+
+const LOCAL_STORAGE_KEY = 'React::DevTools::SuspenseTab::layout';
+const VERTICAL_TREE_MODE_MAX_WIDTH = 600;
+const MINIMUM_TREE_SIZE = 100;
+const MINIMUM_TREE_LIST_SIZE = 100;
+
+function layoutReducer(state: LayoutState, action: LayoutAction): LayoutState {
+  switch (action.type) {
+    case 'ACTION_SET_TREE_LIST_TOGGLE':
+      return {
+        ...state,
+        treeListHidden: !state.treeListHidden,
+      };
+    case 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION':
+      return {
+        ...state,
+        treeListHorizontalFraction: action.payload,
+      };
+    case 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE':
+      return {
+        ...state,
+        inspectedElementHidden: !state.inspectedElementHidden,
+      };
+    case 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION':
+      return {
+        ...state,
+        inspectedElementHorizontalFraction: action.payload,
+      };
+    case 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION':
+      return {
+        ...state,
+        inspectedElementVerticalFraction: action.payload,
+      };
+    default:
+      return state;
+  }
+}
+
+function initLayoutState(): LayoutState {
+  let inspectedElementHidden = false;
+  let inspectedElementHorizontalFraction = 0.65;
+  let inspectedElementVerticalFraction = 0.5;
+  let treeListHidden = false;
+  let treeListHorizontalFraction = 0.35;
+
+  try {
+    let data = localStorageGetItem(LOCAL_STORAGE_KEY);
+    if (data != null) {
+      data = JSON.parse(data);
+      inspectedElementHidden = data.inspectedElementHidden;
+      inspectedElementHorizontalFraction =
+        data.inspectedElementHorizontalFraction;
+      inspectedElementVerticalFraction = data.inspectedElementVerticalFraction;
+      treeListHidden = data.treeListHidden;
+      treeListHorizontalFraction = data.treeListHorizontalFraction;
+    }
+  } catch (error) {}
+
+  return {
+    inspectedElementHidden,
+    inspectedElementHorizontalFraction,
+    inspectedElementVerticalFraction,
+    treeListHidden,
+    treeListHorizontalFraction,
+  };
+}
+
+function getTreeOrientation(
+  wrapperElement: null | HTMLElement,
+): null | Orientation {
+  if (wrapperElement != null) {
+    const {width} = wrapperElement.getBoundingClientRect();
+    return width > VERTICAL_TREE_MODE_MAX_WIDTH ? 'horizontal' : 'vertical';
+  }
+  return null;
+}
 
-function SuspenseTab() {
-  return 'Under construction';
+function setResizeCSSVariable(
+  resizeElement: null | HTMLElement,
+  name: 'tree' | 'tree-list',
+  orientation: null | Orientation,
+  percentage: number,
+): void {
+  if (resizeElement !== null && orientation !== null) {
+    resizeElement.style.setProperty(
+      `--${orientation}-resize-${name}-percentage`,
+      `${percentage}%`,
+    );
+  }
 }
 
-export default (portaledContent(SuspenseTab): React.ComponentType<{}>);
+export default (portaledContent(SuspenseTab): React$ComponentType<{}>);