main
js 394 lines 11.4 KB
Raw
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 );