main
js 210 lines 5.81 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 * 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;