main
js 552 lines 18.7 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 useCallback,
13 useContext,
14 useEffect,
15 useMemo,
16 useRef,
17 useState,
18 use,
19 } from 'react';
20 import {useSubscription} from '../hooks';
21 import {StoreContext} from '../context';
22 import Button from '../Button';
23 import ButtonIcon from '../ButtonIcon';
24 import Toggle from '../Toggle';
25 import {SettingsContext} from '../Settings/SettingsContext';
26 import {
27 ComponentFilterDisplayName,
28 ComponentFilterElementType,
29 ComponentFilterHOC,
30 ComponentFilterLocation,
31 ComponentFilterEnvironmentName,
32 ComponentFilterActivitySlice,
33 ElementTypeClass,
34 ElementTypeContext,
35 ElementTypeFunction,
36 ElementTypeForwardRef,
37 ElementTypeHostComponent,
38 ElementTypeMemo,
39 ElementTypeOtherOrUnknown,
40 ElementTypeProfiler,
41 ElementTypeSuspense,
42 ElementTypeActivity,
43 ElementTypeViewTransition,
44 } from 'react-devtools-shared/src/frontend/types';
45
46 import styles from './SettingsShared.css';
47
48 import type {
49 BooleanComponentFilter,
50 ComponentFilter,
51 ComponentFilterType,
52 ElementType,
53 ElementTypeComponentFilter,
54 RegExpComponentFilter,
55 EnvironmentNameComponentFilter,
56 } from 'react-devtools-shared/src/frontend/types';
57 import {isInternalFacebookBuild} from 'react-devtools-feature-flags';
58
59 export default function ComponentsSettings({
60 environmentNames,
61 }: {
62 environmentNames: Promise<Array<string>>,
63 }): React.Node {
64 const store = useContext(StoreContext);
65 const {parseHookNames, setParseHookNames} = useContext(SettingsContext);
66
67 const collapseNodesByDefaultSubscription = useMemo(
68 () => ({
69 getCurrentValue: () => store.collapseNodesByDefault,
70 subscribe: (callback: Function) => {
71 store.addListener('collapseNodesByDefault', callback);
72 return () => store.removeListener('collapseNodesByDefault', callback);
73 },
74 }),
75 [store],
76 );
77 const collapseNodesByDefault = useSubscription<boolean>(
78 collapseNodesByDefaultSubscription,
79 );
80
81 const updateCollapseNodesByDefault = useCallback(
82 ({currentTarget}: $FlowFixMe) => {
83 store.collapseNodesByDefault = !currentTarget.checked;
84 },
85 [store],
86 );
87
88 const updateParseHookNames = useCallback(
89 ({currentTarget}: $FlowFixMe) => {
90 setParseHookNames(currentTarget.checked);
91 },
92 [setParseHookNames],
93 );
94
95 const [componentFilters, setComponentFilters] = useState<
96 Array<ComponentFilter>,
97 >(() => [...store.componentFilters]);
98
99 const usedEnvironmentNames = use(environmentNames);
100
101 const resolvedEnvironmentNames = useMemo(() => {
102 const set = new Set(usedEnvironmentNames);
103 // If there are other filters already specified but are not currently
104 // on the page, we still allow them as options.
105 for (let i = 0; i < componentFilters.length; i++) {
106 const filter = componentFilters[i];
107 if (filter.type === ComponentFilterEnvironmentName) {
108 set.add(filter.value);
109 }
110 }
111 // Client is special and is always available as a default.
112 if (set.size > 0) {
113 // Only show any options at all if there's any other option already
114 // used by a filter or if any environments are used by the page.
115 // Note that "Client" can have been added above which would mean
116 // that we should show it as an option regardless if it's the only
117 // option.
118 set.add('Client');
119 }
120 return Array.from(set).sort();
121 }, [usedEnvironmentNames, componentFilters]);
122
123 const addFilter = useCallback(() => {
124 setComponentFilters(prevComponentFilters => {
125 return [
126 ...prevComponentFilters,
127 {
128 type: ComponentFilterElementType,
129 value: ElementTypeHostComponent,
130 isEnabled: true,
131 },
132 ];
133 });
134 }, []);
135
136 const changeFilterType = useCallback(
137 (componentFilter: ComponentFilter, type: ComponentFilterType) => {
138 setComponentFilters(prevComponentFilters => {
139 const cloned: Array<ComponentFilter> = [...prevComponentFilters];
140 const index = prevComponentFilters.indexOf(componentFilter);
141 if (index >= 0) {
142 if (type === ComponentFilterElementType) {
143 cloned[index] = {
144 type: ComponentFilterElementType,
145 isEnabled: componentFilter.isEnabled,
146 value: ElementTypeHostComponent,
147 };
148 } else if (type === ComponentFilterDisplayName) {
149 cloned[index] = {
150 type: ComponentFilterDisplayName,
151 isEnabled: componentFilter.isEnabled,
152 isValid: true,
153 value: '',
154 };
155 } else if (type === ComponentFilterLocation) {
156 cloned[index] = {
157 type: ComponentFilterLocation,
158 isEnabled: componentFilter.isEnabled,
159 isValid: true,
160 value: '',
161 };
162 } else if (type === ComponentFilterHOC) {
163 cloned[index] = {
164 type: ComponentFilterHOC,
165 isEnabled: componentFilter.isEnabled,
166 isValid: true,
167 };
168 } else if (type === ComponentFilterEnvironmentName) {
169 cloned[index] = {
170 type: ComponentFilterEnvironmentName,
171 isEnabled: componentFilter.isEnabled,
172 isValid: true,
173 value: 'Client',
174 };
175 } else if (type === ComponentFilterActivitySlice) {
176 // TODO: Allow changing type
177 }
178 }
179 return cloned;
180 });
181 },
182 [],
183 );
184
185 const updateFilterValueElementType = useCallback(
186 (componentFilter: ComponentFilter, value: ElementType) => {
187 if (componentFilter.type !== ComponentFilterElementType) {
188 throw Error('Invalid value for element type filter');
189 }
190
191 setComponentFilters(prevComponentFilters => {
192 const cloned: Array<ComponentFilter> = [...prevComponentFilters];
193 if (componentFilter.type === ComponentFilterElementType) {
194 const index = prevComponentFilters.indexOf(componentFilter);
195 if (index >= 0) {
196 cloned[index] = {
197 ...componentFilter,
198 value,
199 };
200 }
201 }
202 return cloned;
203 });
204 },
205 [],
206 );
207
208 const updateFilterValueRegExp = useCallback(
209 (componentFilter: ComponentFilter, value: string) => {
210 if (componentFilter.type === ComponentFilterElementType) {
211 throw Error('Invalid value for element type filter');
212 }
213
214 setComponentFilters(prevComponentFilters => {
215 const cloned: Array<ComponentFilter> = [...prevComponentFilters];
216 if (
217 componentFilter.type === ComponentFilterDisplayName ||
218 componentFilter.type === ComponentFilterLocation
219 ) {
220 const index = prevComponentFilters.indexOf(componentFilter);
221 if (index >= 0) {
222 let isValid = true;
223 try {
224 new RegExp(value); // eslint-disable-line no-new
225 } catch (error) {
226 isValid = false;
227 }
228 cloned[index] = {
229 ...componentFilter,
230 isValid,
231 value,
232 };
233 }
234 }
235 return cloned;
236 });
237 },
238 [],
239 );
240
241 const updateFilterValueEnvironmentName = useCallback(
242 (componentFilter: ComponentFilter, value: string) => {
243 if (componentFilter.type !== ComponentFilterEnvironmentName) {
244 throw Error('Invalid value for environment name filter');
245 }
246
247 setComponentFilters(prevComponentFilters => {
248 const cloned: Array<ComponentFilter> = [...prevComponentFilters];
249 if (componentFilter.type === ComponentFilterEnvironmentName) {
250 const index = prevComponentFilters.indexOf(componentFilter);
251 if (index >= 0) {
252 cloned[index] = {
253 ...componentFilter,
254 value,
255 };
256 }
257 }
258 return cloned;
259 });
260 },
261 [],
262 );
263
264 const removeFilter = useCallback((index: number) => {
265 setComponentFilters(prevComponentFilters => {
266 const cloned: Array<ComponentFilter> = [...prevComponentFilters];
267 cloned.splice(index, 1);
268 return cloned;
269 });
270 }, []);
271
272 const removeAllFilter = () => {
273 setComponentFilters([]);
274 };
275
276 const toggleFilterIsEnabled = useCallback(
277 (componentFilter: ComponentFilter, isEnabled: boolean) => {
278 setComponentFilters(prevComponentFilters => {
279 const cloned: Array<ComponentFilter> = [...prevComponentFilters];
280 const index = prevComponentFilters.indexOf(componentFilter);
281 if (index >= 0) {
282 if (componentFilter.type === ComponentFilterElementType) {
283 cloned[index] = {
284 ...(cloned[index] as any as ElementTypeComponentFilter),
285 isEnabled,
286 };
287 } else if (
288 componentFilter.type === ComponentFilterDisplayName ||
289 componentFilter.type === ComponentFilterLocation
290 ) {
291 cloned[index] = {
292 ...(cloned[index] as any as RegExpComponentFilter),
293 isEnabled,
294 };
295 } else if (componentFilter.type === ComponentFilterHOC) {
296 cloned[index] = {
297 ...(cloned[index] as any as BooleanComponentFilter),
298 isEnabled,
299 };
300 } else if (componentFilter.type === ComponentFilterEnvironmentName) {
301 cloned[index] = {
302 ...(cloned[index] as any as EnvironmentNameComponentFilter),
303 isEnabled,
304 };
305 }
306 }
307 return cloned;
308 });
309 },
310 [],
311 );
312
313 // Filter updates are expensive to apply (since they impact the entire tree).
314 // Only apply them on unmount.
315 // The Store will avoid doing any expensive work unless they've changed.
316 // We just want to batch the work in the event that they do change.
317 const componentFiltersRef = useRef<Array<ComponentFilter>>(componentFilters);
318 useEffect(() => {
319 componentFiltersRef.current = componentFilters;
320 return () => {};
321 }, [componentFilters]);
322 useEffect(
323 () => () => {
324 store.componentFilters = [...componentFiltersRef.current];
325 },
326 [store],
327 );
328
329 return (
330 <div className={styles.SettingList}>
331 <div className={styles.SettingWrapper}>
332 <label className={styles.SettingRow}>
333 <input
334 type="checkbox"
335 checked={!collapseNodesByDefault}
336 onChange={updateCollapseNodesByDefault}
337 className={styles.SettingRowCheckbox}
338 />
339 Expand component tree by default
340 </label>
341 </div>
342
343 <div className={styles.SettingWrapper}>
344 <label className={styles.SettingRow}>
345 <input
346 type="checkbox"
347 checked={parseHookNames}
348 onChange={updateParseHookNames}
349 className={styles.SettingRowCheckbox}
350 />
351 Always parse hook names from source&nbsp;
352 <span className={styles.Warning}>(may be slow)</span>
353 </label>
354 </div>
355
356 <div className={styles.Header}>Hide components where...</div>
357
358 <table className={styles.Table}>
359 <tbody>
360 {componentFilters.length === 0 && (
361 <tr className={styles.TableRow}>
362 <td className={styles.NoFiltersCell}>
363 No filters have been added.
364 </td>
365 </tr>
366 )}
367 {componentFilters.map((componentFilter, index) => (
368 <tr className={styles.TableRow} key={index}>
369 <td className={styles.TableCell}>
370 {componentFilter.type !== ComponentFilterActivitySlice && (
371 <Toggle
372 className={
373 componentFilter.isValid !== false
374 ? ''
375 : styles.InvalidRegExp
376 }
377 isChecked={componentFilter.isEnabled}
378 onChange={isEnabled =>
379 toggleFilterIsEnabled(componentFilter, isEnabled)
380 }
381 title={
382 componentFilter.isValid === false
383 ? 'Filter invalid'
384 : componentFilter.isEnabled
385 ? 'Filter enabled'
386 : 'Filter disabled'
387 }>
388 <ToggleIcon
389 isEnabled={componentFilter.isEnabled}
390 isValid={
391 componentFilter.isValid == null ||
392 componentFilter.isValid === true
393 }
394 />
395 </Toggle>
396 )}
397 </td>
398 <td className={styles.TableCell}>
399 <select
400 disabled={
401 componentFilter.type === ComponentFilterActivitySlice
402 }
403 value={componentFilter.type}
404 onChange={({currentTarget}) =>
405 changeFilterType(
406 componentFilter,
407 parseInt(
408 currentTarget.value,
409 10,
410 ) as any as ComponentFilterType,
411 )
412 }>
413 {/* TODO: currently disabled, need find a new way of doing this
414 <option value={ComponentFilterLocation}>location</option>
415 */}
416 <option value={ComponentFilterDisplayName}>name</option>
417 <option value={ComponentFilterElementType}>type</option>
418 <option value={ComponentFilterHOC}>hoc</option>
419 {resolvedEnvironmentNames.length > 0 && (
420 <option value={ComponentFilterEnvironmentName}>
421 environment
422 </option>
423 )}
424 {componentFilter.type === ComponentFilterActivitySlice && (
425 <option value={ComponentFilterActivitySlice}>
426 component
427 </option>
428 )}
429 </select>
430 </td>
431 <td className={styles.TableCell}>
432 {(componentFilter.type === ComponentFilterElementType ||
433 componentFilter.type === ComponentFilterEnvironmentName) &&
434 'equals'}
435 {(componentFilter.type === ComponentFilterLocation ||
436 componentFilter.type === ComponentFilterDisplayName) &&
437 'matches'}
438 {componentFilter.type === ComponentFilterActivitySlice &&
439 'within'}
440 </td>
441 <td className={styles.TableCell}>
442 {componentFilter.type === ComponentFilterElementType && (
443 <select
444 value={componentFilter.value}
445 onChange={({currentTarget}) =>
446 updateFilterValueElementType(
447 componentFilter,
448 parseInt(currentTarget.value, 10) as any as ElementType,
449 )
450 }>
451 {isInternalFacebookBuild && (
452 <option value={ElementTypeActivity}>activity</option>
453 )}
454 <option value={ElementTypeClass}>class</option>
455 <option value={ElementTypeContext}>context</option>
456 <option value={ElementTypeFunction}>function</option>
457 <option value={ElementTypeForwardRef}>forward ref</option>
458 <option value={ElementTypeHostComponent}>
459 {__IS_NATIVE__
460 ? 'host components (e.g. <RCTText>)'
461 : 'dom nodes (e.g. <div>)'}
462 </option>
463 <option value={ElementTypeMemo}>memo</option>
464 <option value={ElementTypeOtherOrUnknown}>other</option>
465 <option value={ElementTypeProfiler}>profiler</option>
466 <option value={ElementTypeSuspense}>suspense</option>
467 {isInternalFacebookBuild && (
468 <option value={ElementTypeViewTransition}>
469 view transition
470 </option>
471 )}
472 </select>
473 )}
474 {(componentFilter.type === ComponentFilterLocation ||
475 componentFilter.type === ComponentFilterDisplayName) && (
476 <input
477 className={styles.Input}
478 type="text"
479 placeholder="Regular expression"
480 onChange={({currentTarget}) =>
481 updateFilterValueRegExp(
482 componentFilter,
483 currentTarget.value,
484 )
485 }
486 value={componentFilter.value}
487 />
488 )}
489 {componentFilter.type === ComponentFilterEnvironmentName && (
490 <select
491 value={componentFilter.value}
492 onChange={({currentTarget}) =>
493 updateFilterValueEnvironmentName(
494 componentFilter,
495 currentTarget.value,
496 )
497 }>
498 {resolvedEnvironmentNames.map(name => (
499 <option key={name} value={name}>
500 {name}
501 </option>
502 ))}
503 </select>
504 )}
505 {componentFilter.type === ComponentFilterActivitySlice && (
506 <span>Activity Slice</span>
507 )}
508 </td>
509 <td className={styles.TableCell}>
510 <Button
511 onClick={() => removeFilter(index)}
512 title="Delete filter">
513 <ButtonIcon type="delete" />
514 </Button>
515 </td>
516 </tr>
517 ))}
518 </tbody>
519 </table>
520 <Button onClick={addFilter} title="Add filter">
521 <ButtonIcon className={styles.ButtonIcon} type="add" />
522 Add filter
523 </Button>
524 {componentFilters.length > 0 && (
525 <Button onClick={removeAllFilter} title="Delete all filters">
526 <ButtonIcon className={styles.ButtonIcon} type="delete" />
527 Delete all filters
528 </Button>
529 )}
530 </div>
531 );
532 }
533
534 type ToggleIconProps = {
535 isEnabled: boolean,
536 isValid: boolean,
537 };
538 function ToggleIcon({isEnabled, isValid}: ToggleIconProps) {
539 let className;
540 if (isValid) {
541 className = isEnabled ? styles.ToggleOn : styles.ToggleOff;
542 } else {
543 className = isEnabled ? styles.ToggleOnInvalid : styles.ToggleOffInvalid;
544 }
545 return (
546 <div className={className}>
547 <div
548 className={isEnabled ? styles.ToggleInsideOn : styles.ToggleInsideOff}
549 />
550 </div>
551 );
552 }