| 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 {copy} from 'clipboard-js'; |
| 11 | import * as React from 'react'; |
| 12 | import {useCallback, useContext, useState} from 'react'; |
| 13 | import {BridgeContext, StoreContext} from '../context'; |
| 14 | import Button from '../Button'; |
| 15 | import ButtonIcon from '../ButtonIcon'; |
| 16 | import Toggle from '../Toggle'; |
| 17 | import ExpandCollapseToggle from './ExpandCollapseToggle'; |
| 18 | import KeyValue from './KeyValue'; |
| 19 | import {getMetaValueLabel, serializeHooksForCopy} from '../utils'; |
| 20 | import Store from '../../store'; |
| 21 | import styles from './InspectedElementHooksTree.css'; |
| 22 | import {meta} from '../../../hydration'; |
| 23 | import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookSourceLocation'; |
| 24 | import HookNamesModuleLoaderContext from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext'; |
| 25 | import isArray from 'react-devtools-shared/src/isArray'; |
| 26 | |
| 27 | import type {InspectedElement} from 'react-devtools-shared/src/frontend/types'; |
| 28 | import type {HooksNode, HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; |
| 29 | import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; |
| 30 | import type {HookNames} from 'react-devtools-shared/src/frontend/types'; |
| 31 | import type {Element} from 'react-devtools-shared/src/frontend/types'; |
| 32 | import type {ToggleParseHookNames} from './InspectedElementContext'; |
| 33 | |
| 34 | type HooksTreeViewProps = { |
| 35 | bridge: FrontendBridge, |
| 36 | element: Element, |
| 37 | hookNames: HookNames | null, |
| 38 | inspectedElement: InspectedElement, |
| 39 | parseHookNames: boolean, |
| 40 | store: Store, |
| 41 | toggleParseHookNames: ToggleParseHookNames, |
| 42 | }; |
| 43 | |
| 44 | export function InspectedElementHooksTree({ |
| 45 | bridge, |
| 46 | element, |
| 47 | hookNames, |
| 48 | inspectedElement, |
| 49 | parseHookNames, |
| 50 | store, |
| 51 | toggleParseHookNames, |
| 52 | }: HooksTreeViewProps): React.Node { |
| 53 | const {hooks, id} = inspectedElement; |
| 54 | |
| 55 | // Changing parseHookNames is done in a transition, because it suspends. |
| 56 | // This value is done outside of the transition, so the UI toggle feels responsive. |
| 57 | const [parseHookNamesOptimistic, setParseHookNamesOptimistic] = |
| 58 | useState(parseHookNames); |
| 59 | const handleChange = () => { |
| 60 | setParseHookNamesOptimistic(!parseHookNames); |
| 61 | toggleParseHookNames(); |
| 62 | }; |
| 63 | |
| 64 | const hookNamesModuleLoader = useContext(HookNamesModuleLoaderContext); |
| 65 | |
| 66 | const hookParsingFailed = parseHookNames && hookNames === null; |
| 67 | |
| 68 | let toggleTitle; |
| 69 | if (hookParsingFailed) { |
| 70 | toggleTitle = 'Hook parsing failed'; |
| 71 | } else if (parseHookNames) { |
| 72 | toggleTitle = 'Parsing hook names ...'; |
| 73 | } else { |
| 74 | toggleTitle = 'Parse hook names (may be slow)'; |
| 75 | } |
| 76 | |
| 77 | const handleCopy = () => copy(serializeHooksForCopy(hooks)); |
| 78 | |
| 79 | if (hooks === null) { |
| 80 | return null; |
| 81 | } else { |
| 82 | return ( |
| 83 | <div data-testname="InspectedElementHooksTree"> |
| 84 | <div className={styles.HeaderRow}> |
| 85 | <div className={styles.Header}>hooks</div> |
| 86 | {typeof hookNamesModuleLoader === 'function' && |
| 87 | (!parseHookNames || hookParsingFailed) && ( |
| 88 | <Toggle |
| 89 | className={hookParsingFailed ? styles.ToggleError : null} |
| 90 | isChecked={parseHookNamesOptimistic} |
| 91 | isDisabled={parseHookNamesOptimistic || hookParsingFailed} |
| 92 | onChange={handleChange} |
| 93 | testName="LoadHookNamesButton" |
| 94 | title={toggleTitle}> |
| 95 | <ButtonIcon type="parse-hook-names" /> |
| 96 | </Toggle> |
| 97 | )} |
| 98 | <Button onClick={handleCopy} title="Copy to clipboard"> |
| 99 | <ButtonIcon type="copy" /> |
| 100 | </Button> |
| 101 | </div> |
| 102 | <InnerHooksTreeView |
| 103 | hookNames={hookNames} |
| 104 | hooks={hooks} |
| 105 | id={id} |
| 106 | element={element} |
| 107 | inspectedElement={inspectedElement} |
| 108 | path={[]} |
| 109 | /> |
| 110 | </div> |
| 111 | ); |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | type InnerHooksTreeViewProps = { |
| 116 | element: Element, |
| 117 | hookNames: HookNames | null, |
| 118 | hooks: HooksTree, |
| 119 | id: number, |
| 120 | inspectedElement: InspectedElement, |
| 121 | path: Array<string | number>, |
| 122 | }; |
| 123 | |
| 124 | export function InnerHooksTreeView({ |
| 125 | element, |
| 126 | hookNames, |
| 127 | hooks, |
| 128 | id, |
| 129 | inspectedElement, |
| 130 | path, |
| 131 | }: InnerHooksTreeViewProps): React.Node { |
| 132 | return hooks.map((hook, index) => ( |
| 133 | <HookView |
| 134 | key={index} |
| 135 | element={element} |
| 136 | hook={hooks[index]} |
| 137 | hookNames={hookNames} |
| 138 | id={id} |
| 139 | inspectedElement={inspectedElement} |
| 140 | path={path.concat([index])} |
| 141 | /> |
| 142 | )); |
| 143 | } |
| 144 | |
| 145 | type HookViewProps = { |
| 146 | element: Element, |
| 147 | hook: HooksNode, |
| 148 | hookNames: HookNames | null, |
| 149 | id: number, |
| 150 | inspectedElement: InspectedElement, |
| 151 | path: Array<string | number>, |
| 152 | }; |
| 153 | |
| 154 | function HookView({ |
| 155 | element, |
| 156 | hook, |
| 157 | hookNames, |
| 158 | id, |
| 159 | inspectedElement, |
| 160 | path, |
| 161 | }: HookViewProps) { |
| 162 | const {canEditHooks, canEditHooksAndDeletePaths, canEditHooksAndRenamePaths} = |
| 163 | inspectedElement; |
| 164 | const {id: hookID, isStateEditable, subHooks, value} = hook; |
| 165 | |
| 166 | const isReadOnly = hookID == null || !isStateEditable; |
| 167 | |
| 168 | const canDeletePaths = !isReadOnly && canEditHooksAndDeletePaths; |
| 169 | const canEditValues = !isReadOnly && canEditHooks; |
| 170 | const canRenamePaths = !isReadOnly && canEditHooksAndRenamePaths; |
| 171 | |
| 172 | const bridge = useContext(BridgeContext); |
| 173 | const store = useContext(StoreContext); |
| 174 | |
| 175 | const [isOpen, setIsOpen] = useState<boolean>(false); |
| 176 | |
| 177 | const toggleIsOpen = useCallback( |
| 178 | () => setIsOpen(prevIsOpen => !prevIsOpen), |
| 179 | [], |
| 180 | ); |
| 181 | |
| 182 | if (hook.hasOwnProperty(meta.inspected)) { |
| 183 | // This Hook is too deep and hasn't been hydrated. |
| 184 | if (__DEV__) { |
| 185 | console.warn('Unexpected dehydrated hook; this is a DevTools error.'); |
| 186 | } |
| 187 | return ( |
| 188 | <div className={styles.Hook}> |
| 189 | <div className={styles.NameValueRow}> |
| 190 | <span className={styles.TruncationIndicator}>...</span> |
| 191 | </div> |
| 192 | </div> |
| 193 | ); |
| 194 | } |
| 195 | |
| 196 | // Certain hooks are not editable at all (as identified by react-debug-tools). |
| 197 | // Primitive hook names (e.g. the "State" name for useState) are also never editable. |
| 198 | // $FlowFixMe[missing-local-annot] |
| 199 | const canRenamePathsAtDepth = depth => isStateEditable && depth > 1; |
| 200 | |
| 201 | const isCustomHook = subHooks.length > 0; |
| 202 | |
| 203 | let name = hook.name; |
| 204 | if (hookID !== null) { |
| 205 | name = ( |
| 206 | <> |
| 207 | <span className={styles.PrimitiveHookNumber}>{hookID + 1}</span> |
| 208 | {name} |
| 209 | </> |
| 210 | ); |
| 211 | } |
| 212 | |
| 213 | const type = typeof value; |
| 214 | |
| 215 | let displayValue; |
| 216 | let isComplexDisplayValue = false; |
| 217 | |
| 218 | const hookSource = hook.hookSource; |
| 219 | const hookName = |
| 220 | hookNames != null && hookSource != null |
| 221 | ? hookNames.get(getHookSourceLocationKey(hookSource)) |
| 222 | : null; |
| 223 | const hookDisplayName = hookName ? ( |
| 224 | <> |
| 225 | {name} |
| 226 | { |
| 227 | // $FlowFixMe[constant-condition] |
| 228 | !!hookName && <span className={styles.HookName}>({hookName})</span> |
| 229 | } |
| 230 | </> |
| 231 | ) : ( |
| 232 | name |
| 233 | ); |
| 234 | |
| 235 | // Format data for display to mimic the props/state/context for now. |
| 236 | if (type === 'string') { |
| 237 | displayValue = `"${value as any as string}"`; |
| 238 | } else if (type === 'boolean') { |
| 239 | displayValue = value ? 'true' : 'false'; |
| 240 | } else if (type === 'number') { |
| 241 | displayValue = value; |
| 242 | } else if (value === null) { |
| 243 | displayValue = 'null'; |
| 244 | } else if (value === undefined) { |
| 245 | displayValue = null; |
| 246 | } else if (isArray(value)) { |
| 247 | isComplexDisplayValue = true; |
| 248 | displayValue = 'Array'; |
| 249 | } else if (type === 'object') { |
| 250 | isComplexDisplayValue = true; |
| 251 | displayValue = 'Object'; |
| 252 | } |
| 253 | |
| 254 | if (isCustomHook) { |
| 255 | const subHooksView = isArray(subHooks) ? ( |
| 256 | <InnerHooksTreeView |
| 257 | element={element} |
| 258 | hooks={subHooks} |
| 259 | hookNames={hookNames} |
| 260 | id={id} |
| 261 | inspectedElement={inspectedElement} |
| 262 | path={path.concat(['subHooks'])} |
| 263 | /> |
| 264 | ) : ( |
| 265 | <KeyValue |
| 266 | alphaSort={false} |
| 267 | bridge={bridge} |
| 268 | canDeletePaths={canDeletePaths} |
| 269 | canEditValues={canEditValues} |
| 270 | canRenamePaths={canRenamePaths} |
| 271 | canRenamePathsAtDepth={canRenamePathsAtDepth} |
| 272 | depth={1} |
| 273 | element={element} |
| 274 | hookID={hookID} |
| 275 | hookName={hookName} |
| 276 | inspectedElement={inspectedElement} |
| 277 | name="subHooks" |
| 278 | path={path.concat(['subHooks'])} |
| 279 | store={store} |
| 280 | type="hooks" |
| 281 | value={subHooks} |
| 282 | /> |
| 283 | ); |
| 284 | |
| 285 | if (isComplexDisplayValue) { |
| 286 | return ( |
| 287 | <div className={styles.Hook}> |
| 288 | <div className={styles.NameValueRow}> |
| 289 | <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} /> |
| 290 | <span |
| 291 | onClick={toggleIsOpen} |
| 292 | className={name !== '' ? styles.Name : styles.NameAnonymous}> |
| 293 | {hookDisplayName || 'Anonymous'} |
| 294 | </span> |
| 295 | <span className={styles.Value} onClick={toggleIsOpen}> |
| 296 | {isOpen || getMetaValueLabel(value)} |
| 297 | </span> |
| 298 | </div> |
| 299 | <div className={styles.Children} hidden={!isOpen}> |
| 300 | <KeyValue |
| 301 | alphaSort={false} |
| 302 | bridge={bridge} |
| 303 | canDeletePaths={canDeletePaths} |
| 304 | canEditValues={canEditValues} |
| 305 | canRenamePaths={canRenamePaths} |
| 306 | canRenamePathsAtDepth={canRenamePathsAtDepth} |
| 307 | depth={1} |
| 308 | element={element} |
| 309 | hookID={hookID} |
| 310 | hookName={hookName} |
| 311 | inspectedElement={inspectedElement} |
| 312 | name="DebugValue" |
| 313 | path={path.concat(['value'])} |
| 314 | pathRoot="hooks" |
| 315 | store={store} |
| 316 | value={value} |
| 317 | /> |
| 318 | {subHooksView} |
| 319 | </div> |
| 320 | </div> |
| 321 | ); |
| 322 | } else { |
| 323 | return ( |
| 324 | <div className={styles.Hook}> |
| 325 | <div className={styles.NameValueRow}> |
| 326 | <ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} /> |
| 327 | <span |
| 328 | onClick={toggleIsOpen} |
| 329 | className={name !== '' ? styles.Name : styles.NameAnonymous}> |
| 330 | {hookDisplayName || 'Anonymous'} |
| 331 | </span>{' '} |
| 332 | <span className={styles.Value} onClick={toggleIsOpen}> |
| 333 | {displayValue} |
| 334 | </span> |
| 335 | </div> |
| 336 | <div className={styles.Children} hidden={!isOpen}> |
| 337 | {subHooksView} |
| 338 | </div> |
| 339 | </div> |
| 340 | ); |
| 341 | } |
| 342 | } else { |
| 343 | if (isComplexDisplayValue) { |
| 344 | return ( |
| 345 | <div className={styles.Hook}> |
| 346 | <KeyValue |
| 347 | alphaSort={false} |
| 348 | bridge={bridge} |
| 349 | canDeletePaths={canDeletePaths} |
| 350 | canEditValues={canEditValues} |
| 351 | canRenamePaths={canRenamePaths} |
| 352 | canRenamePathsAtDepth={canRenamePathsAtDepth} |
| 353 | depth={1} |
| 354 | element={element} |
| 355 | hookID={hookID} |
| 356 | hookName={hookName} |
| 357 | inspectedElement={inspectedElement} |
| 358 | name={name} |
| 359 | path={path.concat(['value'])} |
| 360 | pathRoot="hooks" |
| 361 | store={store} |
| 362 | value={value} |
| 363 | /> |
| 364 | </div> |
| 365 | ); |
| 366 | } else { |
| 367 | return ( |
| 368 | <div className={styles.Hook}> |
| 369 | <KeyValue |
| 370 | alphaSort={false} |
| 371 | bridge={bridge} |
| 372 | canDeletePaths={false} |
| 373 | canEditValues={canEditValues} |
| 374 | canRenamePaths={false} |
| 375 | depth={1} |
| 376 | element={element} |
| 377 | hookID={hookID} |
| 378 | hookName={hookName} |
| 379 | inspectedElement={inspectedElement} |
| 380 | name={name} |
| 381 | path={path.concat(['value'])} |
| 382 | pathRoot="hooks" |
| 383 | store={store} |
| 384 | value={value} |
| 385 | /> |
| 386 | </div> |
| 387 | ); |
| 388 | } |
| 389 | } |
| 390 | } |
| 391 | |
| 392 | export default React.memo(InspectedElementHooksTree) as component( |
| 393 | ...props: HookViewProps |
| 394 | ); |