main
js 436 lines 12.1 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 {
11 useCallback,
12 useEffect,
13 useLayoutEffect,
14 useReducer,
15 useState,
16 useSyncExternalStore,
17 useContext,
18 } from 'react';
19 import {
20 localStorageGetItem,
21 localStorageSetItem,
22 } from 'react-devtools-shared/src/storage';
23 import {StoreContext, BridgeContext} from './context';
24 import {sanitizeForParse, smartParse, smartStringify} from '../utils';
25
26 type ACTION_RESET = {
27 type: 'RESET',
28 externalValue: any,
29 };
30 type ACTION_UPDATE = {
31 type: 'UPDATE',
32 editableValue: any,
33 externalValue: any,
34 };
35
36 type UseEditableValueAction = ACTION_RESET | ACTION_UPDATE;
37 type UseEditableValueDispatch = (action: UseEditableValueAction) => void;
38 type UseEditableValueState = {
39 editableValue: any,
40 externalValue: any,
41 hasPendingChanges: boolean,
42 isValid: boolean,
43 parsedValue: any,
44 };
45
46 function useEditableValueReducer(
47 state: UseEditableValueState,
48 action: UseEditableValueAction,
49 ) {
50 switch (action.type) {
51 case 'RESET':
52 return {
53 ...state,
54 editableValue: smartStringify(action.externalValue),
55 externalValue: action.externalValue,
56 hasPendingChanges: false,
57 isValid: true,
58 parsedValue: action.externalValue,
59 };
60 case 'UPDATE':
61 let isNewValueValid = false;
62 let newParsedValue;
63 try {
64 newParsedValue = smartParse(action.editableValue);
65 isNewValueValid = true;
66 } catch (error) {}
67 return {
68 ...state,
69 editableValue: sanitizeForParse(action.editableValue),
70 externalValue: action.externalValue,
71 hasPendingChanges:
72 smartStringify(action.externalValue) !== action.editableValue,
73 isValid: isNewValueValid,
74 parsedValue: isNewValueValid ? newParsedValue : state.parsedValue,
75 };
76 default:
77 throw new Error(`Invalid action "${action.type}"`);
78 }
79 }
80
81 // Convenience hook for working with an editable value that is validated via JSON.parse.
82 export function useEditableValue(
83 externalValue: any,
84 ): [UseEditableValueState, UseEditableValueDispatch] {
85 const [state, dispatch] = useReducer<
86 UseEditableValueState,
87 UseEditableValueState,
88 UseEditableValueAction,
89 >(useEditableValueReducer, {
90 editableValue: smartStringify(externalValue),
91 externalValue,
92 hasPendingChanges: false,
93 isValid: true,
94 parsedValue: externalValue,
95 });
96 if (!Object.is(state.externalValue, externalValue)) {
97 if (!state.hasPendingChanges) {
98 dispatch({
99 type: 'RESET',
100 externalValue,
101 });
102 } else {
103 dispatch({
104 type: 'UPDATE',
105 editableValue: state.editableValue,
106 externalValue,
107 });
108 }
109 }
110
111 return [state, dispatch];
112 }
113
114 export function useIsOverflowing(
115 containerRef: {current: HTMLDivElement | null},
116 totalChildWidth: number,
117 ): boolean {
118 const [isOverflowing, setIsOverflowing] = useState<boolean>(false);
119
120 // It's important to use a layout effect, so that we avoid showing a flash of overflowed content.
121 useLayoutEffect(() => {
122 const container = containerRef.current;
123 if (container === null) {
124 return;
125 }
126
127 // ResizeObserver on the global did not fire for the extension.
128 // We need to grab the ResizeObserver from the container's window.
129 const ResizeObserver = container.ownerDocument.defaultView.ResizeObserver;
130 const observer = new ResizeObserver(entries => {
131 const entry = entries[0];
132 const contentWidth = entry.contentRect.width;
133 setIsOverflowing(
134 contentWidth <=
135 // We need to treat the box as overflowing when you're just
136 // about to overflow.
137 // Otherwise you won't be able to resize panes with custom resize handles.
138 // Previously we were relying on clientWidth which is already rounded.
139 // We don't want to read that again since that would trigger another layout.
140 totalChildWidth + 1,
141 );
142 });
143
144 observer.observe(container);
145
146 return observer.disconnect.bind(observer);
147 }, [containerRef, totalChildWidth]);
148
149 return isOverflowing;
150 }
151
152 // Forked from https://usehooks.com/useLocalStorage/
153 export function useLocalStorage<T>(
154 key: string,
155 initialValue: T | (() => T),
156 onValueSet?: (any, string) => void,
157 ): [T, (value: T | (() => T)) => void] {
158 const getValueFromLocalStorage = useCallback(() => {
159 try {
160 const item = localStorageGetItem(key);
161 if (item != null) {
162 return JSON.parse(item);
163 }
164 } catch (error) {
165 console.log(error);
166 }
167 if (typeof initialValue === 'function') {
168 return (initialValue as any as () => T)();
169 } else {
170 return initialValue;
171 }
172 }, [initialValue, key]);
173
174 const storedValue = useSyncExternalStore(
175 useCallback(
176 function subscribe(callback) {
177 window.addEventListener(key, callback);
178 return function unsubscribe() {
179 window.removeEventListener(key, callback);
180 };
181 },
182 [key],
183 ),
184 getValueFromLocalStorage,
185 );
186
187 const setValue = useCallback(
188 (value: $FlowFixMe) => {
189 try {
190 const valueToStore =
191 value instanceof Function ? (value as any)(storedValue) : value;
192 localStorageSetItem(key, JSON.stringify(valueToStore));
193
194 // Notify listeners that this setting has changed.
195 window.dispatchEvent(new Event(key));
196
197 if (onValueSet != null) {
198 onValueSet(valueToStore, key);
199 }
200 } catch (error) {
201 console.log(error);
202 }
203 },
204 [key, storedValue],
205 );
206
207 // Listen for changes to this local storage value made from other windows.
208 // This enables the e.g. "⚛ Elements" tab to update in response to changes from "⚛ Settings".
209 useLayoutEffect(() => {
210 // $FlowFixMe[missing-local-annot]
211 const onStorage = event => {
212 const newValue = getValueFromLocalStorage();
213 if (key === event.key && storedValue !== newValue) {
214 setValue(newValue);
215 }
216 };
217
218 window.addEventListener('storage', onStorage);
219 return () => {
220 window.removeEventListener('storage', onStorage);
221 };
222 }, [getValueFromLocalStorage, key, storedValue, setValue]);
223
224 return [storedValue, setValue];
225 }
226
227 export function useModalDismissSignal(
228 modalRef: {current: HTMLDivElement | null, ...},
229 dismissCallback: () => void,
230 dismissOnClickOutside?: boolean = true,
231 ): void {
232 useEffect(() => {
233 if (modalRef.current === null) {
234 return () => {};
235 }
236
237 const handleRootNodeKeyDown = (event: KeyboardEvent) => {
238 if (event.key === 'Escape') {
239 dismissCallback();
240 }
241 };
242
243 const handleRootNodeClick: MouseEventHandler = event => {
244 if (
245 modalRef.current !== null &&
246 /* $FlowExpectedError[incompatible-type] Instead of dealing with possibly multiple realms
247 and multiple Node references to comply with Flow (e.g. checking with `event.target instanceof Node`)
248 just delegate it to contains call */
249 !modalRef.current.contains(event.target)
250 ) {
251 event.stopPropagation();
252 event.preventDefault();
253
254 dismissCallback();
255 }
256 };
257
258 let modalRootNode = null;
259
260 // Delay until after the current call stack is empty,
261 // in case this effect is being run while an event is currently bubbling.
262 // In that case, we don't want to listen to the pre-existing event.
263 let timeoutID: null | TimeoutID = setTimeout(() => {
264 timeoutID = null;
265
266 // It's important to listen to the ownerDocument to support the browser extension.
267 // Here we use portals to render individual tabs (e.g. Profiler),
268 // and the root document might belong to a different window.
269 const modalDOMElement = modalRef.current;
270 if (modalDOMElement != null) {
271 modalRootNode = modalDOMElement.getRootNode();
272 modalRootNode.addEventListener('keydown', handleRootNodeKeyDown);
273 if (dismissOnClickOutside) {
274 modalRootNode.addEventListener('click', handleRootNodeClick, true);
275 }
276 }
277 }, 0);
278
279 return () => {
280 if (timeoutID !== null) {
281 clearTimeout(timeoutID);
282 }
283
284 if (modalRootNode !== null) {
285 modalRootNode.removeEventListener('keydown', handleRootNodeKeyDown);
286 modalRootNode.removeEventListener('click', handleRootNodeClick, true);
287 }
288 };
289 }, [modalRef, dismissCallback, dismissOnClickOutside]);
290 }
291
292 // Copied from https://github.com/facebook/react/pull/15022
293 export function useSubscription<Value>({
294 getCurrentValue,
295 subscribe,
296 }: {
297 getCurrentValue: () => Value,
298 subscribe: (callback: Function) => () => void,
299 }): Value {
300 const [state, setState] = useState(() => ({
301 getCurrentValue,
302 subscribe,
303 value: getCurrentValue(),
304 }));
305
306 if (
307 state.getCurrentValue !== getCurrentValue ||
308 state.subscribe !== subscribe
309 ) {
310 setState({
311 getCurrentValue,
312 subscribe,
313 value: getCurrentValue(),
314 });
315 }
316
317 useEffect(() => {
318 let didUnsubscribe = false;
319
320 const checkForUpdates = () => {
321 if (didUnsubscribe) {
322 return;
323 }
324
325 setState(prevState => {
326 if (
327 prevState.getCurrentValue !== getCurrentValue ||
328 prevState.subscribe !== subscribe
329 ) {
330 return prevState;
331 }
332
333 const value = getCurrentValue();
334 if (prevState.value === value) {
335 return prevState;
336 }
337
338 return {...prevState, value};
339 });
340 };
341 const unsubscribe = subscribe(checkForUpdates);
342
343 checkForUpdates();
344
345 return () => {
346 didUnsubscribe = true;
347 unsubscribe();
348 };
349 }, [getCurrentValue, subscribe]);
350
351 return state.value;
352 }
353
354 export function useHighlightHostInstance(): {
355 clearHighlightHostInstance: () => void,
356 highlightHostInstance: (id: number, scrollIntoView?: boolean) => void,
357 } {
358 const bridge = useContext(BridgeContext);
359 const store = useContext(StoreContext);
360
361 const highlightHostInstance = useCallback(
362 (id: number, scrollIntoView?: boolean = false) => {
363 const element = store.getElementByID(id);
364 if (element !== null) {
365 const isRoot = element.parentID === 0;
366 let displayName = element.displayName;
367 if (displayName !== null && element.nameProp !== null) {
368 displayName += ` name="${element.nameProp}"`;
369 }
370 if (isRoot) {
371 // Inspect screen
372 const elements: Array<{rendererID: number, id: number}> = [];
373
374 for (let i = 0; i < store.roots.length; i++) {
375 const rootID = store.roots[i];
376 const rendererID = store.getRendererIDForElement(rootID);
377 if (rendererID === null) {
378 continue;
379 }
380 elements.push({rendererID, id: rootID});
381 }
382
383 bridge.send('highlightHostInstances', {
384 displayName,
385 hideAfterTimeout: false,
386 elements,
387 scrollIntoView: scrollIntoView,
388 });
389 } else {
390 const rendererID = store.getRendererIDForElement(id);
391 if (rendererID !== null) {
392 bridge.send('highlightHostInstance', {
393 displayName,
394 hideAfterTimeout: false,
395 id,
396 openBuiltinElementsPanel: false,
397 rendererID,
398 scrollIntoView: scrollIntoView,
399 });
400 }
401 }
402 }
403 },
404 [store, bridge],
405 );
406
407 const clearHighlightHostInstance = useCallback(() => {
408 bridge.send('clearHostInstanceHighlight');
409 }, [bridge]);
410
411 return {
412 highlightHostInstance,
413 clearHighlightHostInstance,
414 };
415 }
416
417 export function useScrollToHostInstance(): (id: number) => void {
418 const bridge = useContext(BridgeContext);
419 const store = useContext(StoreContext);
420
421 const scrollToHostInstance = useCallback(
422 (id: number) => {
423 const element = store.getElementByID(id);
424 const rendererID = store.getRendererIDForElement(id);
425 if (element !== null && rendererID !== null) {
426 bridge.send('scrollToHostInstance', {
427 id,
428 rendererID,
429 });
430 }
431 },
432 [store, bridge],
433 );
434
435 return scrollToHostInstance;
436 }