| 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 {SuspenseNode} from 'react-devtools-shared/src/frontend/types'; |
| 11 | import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/SyntheticEvent'; |
| 12 | |
| 13 | import * as React from 'react'; |
| 14 | import {Fragment, useContext, useLayoutEffect, useRef, useState} from 'react'; |
| 15 | import Button from '../Button'; |
| 16 | import ButtonIcon from '../ButtonIcon'; |
| 17 | import Tooltip from '../Components/reach-ui/tooltip'; |
| 18 | import { |
| 19 | Menu, |
| 20 | MenuList, |
| 21 | MenuButton, |
| 22 | MenuItem, |
| 23 | } from '../Components/reach-ui/menu-button'; |
| 24 | import { |
| 25 | TreeDispatcherContext, |
| 26 | TreeStateContext, |
| 27 | } from '../Components/TreeContext'; |
| 28 | import {StoreContext} from '../context'; |
| 29 | import {useHighlightHostInstance, useIsOverflowing} from '../hooks'; |
| 30 | import styles from './SuspenseBreadcrumbs.css'; |
| 31 | import { |
| 32 | SuspenseTreeStateContext, |
| 33 | SuspenseTreeDispatcherContext, |
| 34 | } from './SuspenseTreeContext'; |
| 35 | |
| 36 | type SuspenseBreadcrumbsFlatListProps = { |
| 37 | onItemClick: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void, |
| 38 | onItemPointerEnter: ( |
| 39 | id: SuspenseNode['id'], |
| 40 | scrollIntoView?: boolean, |
| 41 | ) => void, |
| 42 | onItemPointerLeave: (event: SyntheticMouseEvent) => void, |
| 43 | setElementsTotalWidth: (width: number) => void, |
| 44 | }; |
| 45 | |
| 46 | function SuspenseBreadcrumbsFlatList({ |
| 47 | onItemClick, |
| 48 | onItemPointerEnter, |
| 49 | onItemPointerLeave, |
| 50 | setElementsTotalWidth, |
| 51 | }: SuspenseBreadcrumbsFlatListProps): React$Node { |
| 52 | const store = useContext(StoreContext); |
| 53 | const {activityID} = useContext(TreeStateContext); |
| 54 | const {selectedSuspenseID, lineage, roots} = useContext( |
| 55 | SuspenseTreeStateContext, |
| 56 | ); |
| 57 | |
| 58 | const containerRef = useRef<HTMLDivElement | null>(null); |
| 59 | useLayoutEffect(() => { |
| 60 | const container = containerRef.current; |
| 61 | if (container === null) { |
| 62 | return; |
| 63 | } |
| 64 | |
| 65 | const ResizeObserver = container.ownerDocument.defaultView.ResizeObserver; |
| 66 | const observer = new ResizeObserver(entries => { |
| 67 | const entry = entries[0]; |
| 68 | setElementsTotalWidth(entry.contentRect.width); |
| 69 | }); |
| 70 | |
| 71 | observer.observe(container); |
| 72 | return observer.disconnect.bind(observer); |
| 73 | }, []); |
| 74 | |
| 75 | return ( |
| 76 | <ol className={styles.SuspenseBreadcrumbsList} ref={containerRef}> |
| 77 | {lineage === null ? null : lineage.length === 0 ? ( |
| 78 | // We selected the root. This means that we're currently viewing the Transition |
| 79 | // that rendered the whole screen. In laymans terms this is really "Initial Paint" . |
| 80 | // When we're looking at a subtree selection, then the equivalent is a |
| 81 | // "Transition" since in that case it's really about a Transition within the page. |
| 82 | roots.length > 0 ? ( |
| 83 | <li |
| 84 | className={styles.SuspenseBreadcrumbsListItem} |
| 85 | aria-current="true"> |
| 86 | <button |
| 87 | className={styles.SuspenseBreadcrumbsButton} |
| 88 | onClick={onItemClick.bind( |
| 89 | null, |
| 90 | activityID === null ? roots[0] : activityID, |
| 91 | )} |
| 92 | type="button"> |
| 93 | {activityID === null ? 'Initial Paint' : 'Transition'} |
| 94 | </button> |
| 95 | </li> |
| 96 | ) : null |
| 97 | ) : ( |
| 98 | lineage.map((id, index) => { |
| 99 | const node = store.getSuspenseByID(id); |
| 100 | |
| 101 | return ( |
| 102 | <Fragment key={id}> |
| 103 | <li |
| 104 | className={styles.SuspenseBreadcrumbsListItem} |
| 105 | aria-current={selectedSuspenseID === id} |
| 106 | onPointerEnter={onItemPointerEnter.bind(null, id, false)} |
| 107 | onPointerLeave={onItemPointerLeave}> |
| 108 | <button |
| 109 | className={styles.SuspenseBreadcrumbsButton} |
| 110 | onClick={onItemClick.bind(null, id)} |
| 111 | type="button"> |
| 112 | {node === null ? 'Unknown' : node.name || 'Unknown'} |
| 113 | </button> |
| 114 | </li> |
| 115 | {index < lineage.length - 1 && ( |
| 116 | <span className={styles.SuspenseBreadcrumbsListItemSeparator}> |
| 117 | » |
| 118 | </span> |
| 119 | )} |
| 120 | </Fragment> |
| 121 | ); |
| 122 | }) |
| 123 | )} |
| 124 | </ol> |
| 125 | ); |
| 126 | } |
| 127 | |
| 128 | type SuspenseBreadcrumbsMenuProps = { |
| 129 | onItemClick: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void, |
| 130 | onItemPointerEnter: ( |
| 131 | id: SuspenseNode['id'], |
| 132 | scrollIntoView?: boolean, |
| 133 | ) => void, |
| 134 | onItemPointerLeave: (event: SyntheticMouseEvent) => void, |
| 135 | }; |
| 136 | |
| 137 | function SuspenseBreadcrumbsMenu({ |
| 138 | onItemClick, |
| 139 | onItemPointerEnter, |
| 140 | onItemPointerLeave, |
| 141 | }: SuspenseBreadcrumbsMenuProps): React$Node { |
| 142 | const store = useContext(StoreContext); |
| 143 | const {activityID} = useContext(TreeStateContext); |
| 144 | const {selectedSuspenseID, lineage, roots} = useContext( |
| 145 | SuspenseTreeStateContext, |
| 146 | ); |
| 147 | const selectedSuspenseNode = |
| 148 | selectedSuspenseID !== null |
| 149 | ? store.getSuspenseByID(selectedSuspenseID) |
| 150 | : null; |
| 151 | |
| 152 | return ( |
| 153 | <> |
| 154 | {lineage === null ? null : lineage.length === 0 ? ( |
| 155 | // We selected the root. This means that we're currently viewing the Transition |
| 156 | // that rendered the whole screen. In laymans terms this is really "Initial Paint" . |
| 157 | // When we're looking at a subtree selection, then the equivalent is a |
| 158 | // "Transition" since in that case it's really about a Transition within the page. |
| 159 | roots.length > 0 ? ( |
| 160 | <button |
| 161 | className={styles.SuspenseBreadcrumbsButton} |
| 162 | onClick={onItemClick.bind( |
| 163 | null, |
| 164 | activityID === null ? roots[0] : activityID, |
| 165 | )} |
| 166 | type="button"> |
| 167 | {activityID === null ? 'Initial Paint' : 'Transition'} |
| 168 | </button> |
| 169 | ) : null |
| 170 | ) : ( |
| 171 | <> |
| 172 | <SuspenseBreadcrumbsDropdown |
| 173 | lineage={lineage} |
| 174 | selectElement={onItemClick} |
| 175 | /> |
| 176 | <SuspenseBreadcrumbsToParentButton |
| 177 | lineage={lineage} |
| 178 | selectedSuspenseID={selectedSuspenseID} |
| 179 | selectElement={onItemClick} |
| 180 | /> |
| 181 | {selectedSuspenseNode != null && ( |
| 182 | <button |
| 183 | className={styles.SuspenseBreadcrumbsButton} |
| 184 | onClick={onItemClick.bind(null, selectedSuspenseNode.id)} |
| 185 | onPointerEnter={onItemPointerEnter.bind( |
| 186 | null, |
| 187 | selectedSuspenseNode.id, |
| 188 | false, |
| 189 | )} |
| 190 | onPointerLeave={onItemPointerLeave} |
| 191 | type="button"> |
| 192 | {/* $FlowFixMe[invalid-compare] */} |
| 193 | {selectedSuspenseNode === null |
| 194 | ? 'Unknown' |
| 195 | : selectedSuspenseNode.name || 'Unknown'} |
| 196 | </button> |
| 197 | )} |
| 198 | </> |
| 199 | )} |
| 200 | </> |
| 201 | ); |
| 202 | } |
| 203 | |
| 204 | type SuspenseBreadcrumbsDropdownProps = { |
| 205 | lineage: $ReadOnlyArray<SuspenseNode['id']>, |
| 206 | selectedIndex: number, |
| 207 | selectElement: (id: SuspenseNode['id']) => void, |
| 208 | }; |
| 209 | function SuspenseBreadcrumbsDropdown({ |
| 210 | lineage, |
| 211 | selectElement, |
| 212 | }: SuspenseBreadcrumbsDropdownProps) { |
| 213 | const store = useContext(StoreContext); |
| 214 | |
| 215 | const menuItems = []; |
| 216 | for (let index = lineage.length - 1; index >= 0; index--) { |
| 217 | const suspenseNodeID = lineage[index]; |
| 218 | const node = store.getSuspenseByID(suspenseNodeID); |
| 219 | menuItems.push( |
| 220 | <MenuItem |
| 221 | key={suspenseNodeID} |
| 222 | className={`${styles.Component}`} |
| 223 | onSelect={selectElement.bind(null, suspenseNodeID)}> |
| 224 | {node === null ? 'Unknown' : node.name || 'Unknown'} |
| 225 | </MenuItem>, |
| 226 | ); |
| 227 | } |
| 228 | |
| 229 | return ( |
| 230 | <Menu> |
| 231 | <MenuButton className={styles.SuspenseBreadcrumbsMenuButton}> |
| 232 | <Tooltip label="Open elements dropdown"> |
| 233 | <span |
| 234 | className={styles.SuspenseBreadcrumbsMenuButtonContent} |
| 235 | tabIndex={-1}> |
| 236 | <ButtonIcon type="more" /> |
| 237 | </span> |
| 238 | </Tooltip> |
| 239 | </MenuButton> |
| 240 | <MenuList className={styles.SuspenseBreadcrumbsModal}> |
| 241 | {menuItems} |
| 242 | </MenuList> |
| 243 | </Menu> |
| 244 | ); |
| 245 | } |
| 246 | |
| 247 | type SuspenseBreadcrumbsToParentButtonProps = { |
| 248 | lineage: $ReadOnlyArray<SuspenseNode['id']>, |
| 249 | selectedSuspenseID: SuspenseNode['id'] | null, |
| 250 | selectElement: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void, |
| 251 | }; |
| 252 | function SuspenseBreadcrumbsToParentButton({ |
| 253 | lineage, |
| 254 | selectedSuspenseID, |
| 255 | selectElement, |
| 256 | }: SuspenseBreadcrumbsToParentButtonProps) { |
| 257 | const store = useContext(StoreContext); |
| 258 | const selectedIndex = |
| 259 | selectedSuspenseID === null |
| 260 | ? lineage.length - 1 |
| 261 | : lineage.indexOf(selectedSuspenseID); |
| 262 | |
| 263 | if (selectedIndex <= 0) { |
| 264 | return null; |
| 265 | } |
| 266 | |
| 267 | const parentID = lineage[selectedIndex - 1]; |
| 268 | const parent = store.getSuspenseByID(parentID); |
| 269 | |
| 270 | return ( |
| 271 | <Button |
| 272 | className={parent !== null ? undefined : styles.NotInStore} |
| 273 | onClick={parent !== null ? selectElement.bind(null, parentID) : null} |
| 274 | title={`Up to ${parent === null ? 'Unknown' : parent.name || 'Unknown'}`}> |
| 275 | <ButtonIcon type="previous" /> |
| 276 | </Button> |
| 277 | ); |
| 278 | } |
| 279 | |
| 280 | export default function SuspenseBreadcrumbs(): React$Node { |
| 281 | const treeDispatch = useContext(TreeDispatcherContext); |
| 282 | const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); |
| 283 | |
| 284 | const {highlightHostInstance, clearHighlightHostInstance} = |
| 285 | useHighlightHostInstance(); |
| 286 | |
| 287 | function handleClick(id: SuspenseNode['id'], event?: SyntheticMouseEvent) { |
| 288 | if (event !== undefined) { |
| 289 | // E.g. 3rd party component libraries might omit the event and already prevent default |
| 290 | // like Reach's MenuItem does. |
| 291 | event.preventDefault(); |
| 292 | } |
| 293 | treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id}); |
| 294 | suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id}); |
| 295 | } |
| 296 | |
| 297 | const [elementsTotalWidth, setElementsTotalWidth] = useState(0); |
| 298 | const containerRef = useRef<HTMLDivElement | null>(null); |
| 299 | const isOverflowing = useIsOverflowing(containerRef, elementsTotalWidth); |
| 300 | |
| 301 | return ( |
| 302 | <div className={styles.SuspenseBreadcrumbsContainer} ref={containerRef}> |
| 303 | {isOverflowing ? ( |
| 304 | <SuspenseBreadcrumbsMenu |
| 305 | onItemClick={handleClick} |
| 306 | onItemPointerEnter={highlightHostInstance} |
| 307 | onItemPointerLeave={clearHighlightHostInstance} |
| 308 | /> |
| 309 | ) : ( |
| 310 | <SuspenseBreadcrumbsFlatList |
| 311 | onItemClick={handleClick} |
| 312 | onItemPointerEnter={highlightHostInstance} |
| 313 | onItemPointerLeave={clearHighlightHostInstance} |
| 314 | setElementsTotalWidth={setElementsTotalWidth} |
| 315 | /> |
| 316 | )} |
| 317 | </div> |
| 318 | ); |
| 319 | } |