| 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 * as React from 'react'; |
| 11 | import { |
| 12 | useContext, |
| 13 | useMemo, |
| 14 | useCallback, |
| 15 | memo, |
| 16 | useState, |
| 17 | useEffect, |
| 18 | } from 'react'; |
| 19 | import styles from './HookChangeSummary.css'; |
| 20 | import ButtonIcon from '../ButtonIcon'; |
| 21 | import {InspectedElementContext} from '../Components/InspectedElementContext'; |
| 22 | import {StoreContext} from '../context'; |
| 23 | |
| 24 | import {getAlreadyLoadedHookNames} from 'react-devtools-shared/src/hookNamesCache'; |
| 25 | import {getHookSourceLocationKey} from 'react-devtools-shared/src/hookSourceLocation'; |
| 26 | import Toggle from '../Toggle'; |
| 27 | import type {HooksNode} from 'react-debug-tools/src/ReactDebugHooks'; |
| 28 | import type {ChangeDescription} from './types'; |
| 29 | |
| 30 | // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about Intl.ListFormat |
| 31 | const hookListFormatter = new Intl.ListFormat('en', { |
| 32 | style: 'long', |
| 33 | type: 'conjunction', |
| 34 | }); |
| 35 | |
| 36 | type HookProps = { |
| 37 | hook: HooksNode, |
| 38 | hookNames: Map<string, string> | null, |
| 39 | }; |
| 40 | |
| 41 | const Hook: component(...props: HookProps) = memo( |
| 42 | ({hook, hookNames}: HookProps) => { |
| 43 | const hookSource = hook.hookSource; |
| 44 | const hookName = useMemo(() => { |
| 45 | if (!hookSource || !hookNames) return null; |
| 46 | const key = getHookSourceLocationKey(hookSource); |
| 47 | return hookNames.get(key) || null; |
| 48 | }, [hookSource, hookNames]); |
| 49 | |
| 50 | return ( |
| 51 | <ul className={styles.Hook}> |
| 52 | <li> |
| 53 | {hook.id !== null && ( |
| 54 | <span className={styles.PrimitiveHookNumber}> |
| 55 | {String(hook.id + 1)} |
| 56 | </span> |
| 57 | )} |
| 58 | <span |
| 59 | className={ |
| 60 | hook.id !== null ? styles.PrimitiveHookName : styles.Name |
| 61 | }> |
| 62 | {hook.name} |
| 63 | {hookName && <span className={styles.HookName}>({hookName})</span>} |
| 64 | </span> |
| 65 | {hook.subHooks?.map((subHook, index) => ( |
| 66 | <Hook key={hook.id} hook={subHook} hookNames={hookNames} /> |
| 67 | ))} |
| 68 | </li> |
| 69 | </ul> |
| 70 | ); |
| 71 | }, |
| 72 | ); |
| 73 | |
| 74 | const shouldKeepHook = ( |
| 75 | hook: HooksNode, |
| 76 | hooksArray: Array<number>, |
| 77 | ): boolean => { |
| 78 | if (hook.id !== null && hooksArray.includes(hook.id)) { |
| 79 | return true; |
| 80 | } |
| 81 | const subHooks = hook.subHooks; |
| 82 | if (subHooks == null) { |
| 83 | return false; |
| 84 | } |
| 85 | |
| 86 | return subHooks.some(subHook => shouldKeepHook(subHook, hooksArray)); |
| 87 | }; |
| 88 | |
| 89 | const filterHooks = ( |
| 90 | hook: HooksNode, |
| 91 | hooksArray: Array<number>, |
| 92 | ): HooksNode | null => { |
| 93 | if (!shouldKeepHook(hook, hooksArray)) { |
| 94 | return null; |
| 95 | } |
| 96 | |
| 97 | const subHooks = hook.subHooks; |
| 98 | if (subHooks == null) { |
| 99 | return hook; |
| 100 | } |
| 101 | |
| 102 | const filteredSubHooks = subHooks |
| 103 | .map(subHook => filterHooks(subHook, hooksArray)) |
| 104 | .filter(Boolean); |
| 105 | return filteredSubHooks.length > 0 |
| 106 | ? {...hook, subHooks: filteredSubHooks} |
| 107 | : hook; |
| 108 | }; |
| 109 | |
| 110 | type Props = {| |
| 111 | fiberID: number, |
| 112 | hooks: ChangeDescription['hooks'], |
| 113 | state: ChangeDescription['state'], |
| 114 | displayMode?: 'detailed' | 'compact', |
| 115 | |}; |
| 116 | |
| 117 | const HookChangeSummary: component(...props: Props) = memo( |
| 118 | ({hooks, fiberID, state, displayMode = 'detailed'}: Props) => { |
| 119 | const {parseHookNames, toggleParseHookNames, inspectedElement} = useContext( |
| 120 | InspectedElementContext, |
| 121 | ); |
| 122 | const store = useContext(StoreContext); |
| 123 | |
| 124 | const [parseHookNamesOptimistic, setParseHookNamesOptimistic] = |
| 125 | useState<boolean>(parseHookNames); |
| 126 | |
| 127 | useEffect(() => { |
| 128 | setParseHookNamesOptimistic(parseHookNames); |
| 129 | }, [inspectedElement?.id, parseHookNames]); |
| 130 | |
| 131 | const handleOnChange = useCallback(() => { |
| 132 | setParseHookNamesOptimistic(!parseHookNames); |
| 133 | toggleParseHookNames(); |
| 134 | }, [toggleParseHookNames, parseHookNames]); |
| 135 | |
| 136 | // $FlowFixMe[invalid-compare] |
| 137 | const element = fiberID !== null ? store.getElementByID(fiberID) : null; |
| 138 | const hookNames = |
| 139 | element != null ? getAlreadyLoadedHookNames(element) : null; |
| 140 | |
| 141 | const filteredHooks = useMemo(() => { |
| 142 | if (!hooks || !inspectedElement?.hooks) return null; |
| 143 | return inspectedElement.hooks |
| 144 | .map(hook => filterHooks(hook, hooks)) |
| 145 | .filter(Boolean); |
| 146 | }, [inspectedElement?.hooks, hooks]); |
| 147 | |
| 148 | const hookParsingFailed = parseHookNames && hookNames === null; |
| 149 | |
| 150 | if (!hooks?.length) { |
| 151 | return <span>No hooks changed</span>; |
| 152 | } |
| 153 | |
| 154 | if ( |
| 155 | inspectedElement?.id !== element?.id || |
| 156 | filteredHooks?.length !== hooks.length || |
| 157 | displayMode === 'compact' |
| 158 | ) { |
| 159 | const hookIds = hooks.map(hookId => String(hookId + 1)); |
| 160 | const hookWord = hookIds.length === 1 ? '• Hook' : '• Hooks'; |
| 161 | return ( |
| 162 | <span> |
| 163 | {hookWord} {hookListFormatter.format(hookIds)} changed |
| 164 | </span> |
| 165 | ); |
| 166 | } |
| 167 | |
| 168 | let toggleTitle: string; |
| 169 | if (hookParsingFailed) { |
| 170 | toggleTitle = 'Hook parsing failed'; |
| 171 | } else if (parseHookNamesOptimistic) { |
| 172 | toggleTitle = 'Parsing hook names ...'; |
| 173 | } else { |
| 174 | toggleTitle = 'Parse hook names (may be slow)'; |
| 175 | } |
| 176 | |
| 177 | if (filteredHooks == null) { |
| 178 | return null; |
| 179 | } |
| 180 | |
| 181 | return ( |
| 182 | <div> |
| 183 | {filteredHooks.length > 1 ? '• Hooks changed:' : '• Hook changed:'} |
| 184 | {(!parseHookNames || hookParsingFailed) && ( |
| 185 | <Toggle |
| 186 | className={ |
| 187 | hookParsingFailed |
| 188 | ? styles.ToggleError |
| 189 | : styles.LoadHookNamesToggle |
| 190 | } |
| 191 | isChecked={parseHookNamesOptimistic} |
| 192 | isDisabled={parseHookNamesOptimistic || hookParsingFailed} |
| 193 | onChange={handleOnChange} |
| 194 | title={toggleTitle}> |
| 195 | <ButtonIcon type="parse-hook-names" /> |
| 196 | </Toggle> |
| 197 | )} |
| 198 | {filteredHooks.map(hook => ( |
| 199 | <Hook |
| 200 | key={`${inspectedElement?.id ?? 'unknown'}-${hook.id}`} |
| 201 | hook={hook} |
| 202 | hookNames={hookNames} |
| 203 | /> |
| 204 | ))} |
| 205 | </div> |
| 206 | ); |
| 207 | }, |
| 208 | ); |
| 209 | |
| 210 | export default HookChangeSummary; |