Files
react/packages/react-devtools-shared/src/devtools/views/hooks.js
Sebastian Markbåge d160aa0fbb [DevTools] Use Unicode Atom Symbol instead of Atom Emoji (#30832)
This reverts #19603.

Before:
<img width="724" alt="Screenshot 2024-08-28 at 12 07 29 AM"
src="https://github.com/user-attachments/assets/0613088f-c013-4f1c-92c3-fbdae8c1f109">

After:
<img width="771" alt="Screenshot 2024-08-28 at 12 08 13 AM"
src="https://github.com/user-attachments/assets/eef21bee-d11f-4f0a-9147-053a163f720f">

Consensus seems to be that while the purple on is a bit clearer and
easier to read. The purple is not on brand so it doesn't look like
React. It looks ugly. It's distracting (too eye catching). Taking away
attention from other tabs in an unfair way.

It also gets worse with more tabs added. We plan on both adding another
tab and panes inside other tabs (elements/sources) soon. Each needs to
be marked somehow as part of React but spelling it out is too long.
Putting inside a second tab means two clicks and takes away real-estate
from our extension and doesn't solve the problem with extension panes in
other tabs. We also plan on adding multiple different tracks to the
Performance tab which also needs a name other than just React and
spelling out React as a prefix is too long. The Emoji is too
distracting. So it seems best to uniformly apply the symbol - albeit it
might just look like a dot to many.

Dark mode looks close to on brand:

<img width="1089" alt="Screenshot 2024-08-28 at 12 32 50 AM"
src="https://github.com/user-attachments/assets/7175a540-4241-4c26-9e4d-4d367873af57">
2024-09-10 00:09:42 -04:00

370 lines
10 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import {
useCallback,
useEffect,
useLayoutEffect,
useReducer,
useState,
useContext,
} from 'react';
import {
localStorageGetItem,
localStorageSetItem,
} from 'react-devtools-shared/src/storage';
import {StoreContext, BridgeContext} from './context';
import {sanitizeForParse, smartParse, smartStringify} from '../utils';
type ACTION_RESET = {
type: 'RESET',
externalValue: any,
};
type ACTION_UPDATE = {
type: 'UPDATE',
editableValue: any,
externalValue: any,
};
type UseEditableValueAction = ACTION_RESET | ACTION_UPDATE;
type UseEditableValueDispatch = (action: UseEditableValueAction) => void;
type UseEditableValueState = {
editableValue: any,
externalValue: any,
hasPendingChanges: boolean,
isValid: boolean,
parsedValue: any,
};
function useEditableValueReducer(
state: UseEditableValueState,
action: UseEditableValueAction,
) {
switch (action.type) {
case 'RESET':
return {
...state,
editableValue: smartStringify(action.externalValue),
externalValue: action.externalValue,
hasPendingChanges: false,
isValid: true,
parsedValue: action.externalValue,
};
case 'UPDATE':
let isNewValueValid = false;
let newParsedValue;
try {
newParsedValue = smartParse(action.editableValue);
isNewValueValid = true;
} catch (error) {}
return {
...state,
editableValue: sanitizeForParse(action.editableValue),
externalValue: action.externalValue,
hasPendingChanges:
smartStringify(action.externalValue) !== action.editableValue,
isValid: isNewValueValid,
parsedValue: isNewValueValid ? newParsedValue : state.parsedValue,
};
default:
throw new Error(`Invalid action "${action.type}"`);
}
}
// Convenience hook for working with an editable value that is validated via JSON.parse.
export function useEditableValue(
externalValue: any,
): [UseEditableValueState, UseEditableValueDispatch] {
const [state, dispatch] = useReducer<
UseEditableValueState,
UseEditableValueState,
UseEditableValueAction,
>(useEditableValueReducer, {
editableValue: smartStringify(externalValue),
externalValue,
hasPendingChanges: false,
isValid: true,
parsedValue: externalValue,
});
if (!Object.is(state.externalValue, externalValue)) {
if (!state.hasPendingChanges) {
dispatch({
type: 'RESET',
externalValue,
});
} else {
dispatch({
type: 'UPDATE',
editableValue: state.editableValue,
externalValue,
});
}
}
return [state, dispatch];
}
export function useIsOverflowing(
containerRef: {current: HTMLDivElement | null, ...},
totalChildWidth: number,
): boolean {
const [isOverflowing, setIsOverflowing] = useState<boolean>(false);
// It's important to use a layout effect, so that we avoid showing a flash of overflowed content.
useLayoutEffect(() => {
if (containerRef.current === null) {
return () => {};
}
const container = ((containerRef.current: any): HTMLDivElement);
const handleResize = () =>
setIsOverflowing(container.clientWidth <= totalChildWidth);
handleResize();
// It's important to listen to the ownerDocument.defaultView to support the browser extension.
// Here we use portals to render individual tabs (e.g. Profiler),
// and the root document might belong to a different window.
const ownerWindow = container.ownerDocument.defaultView;
ownerWindow.addEventListener('resize', handleResize);
return () => ownerWindow.removeEventListener('resize', handleResize);
}, [containerRef, totalChildWidth]);
return isOverflowing;
}
// Forked from https://usehooks.com/useLocalStorage/
export function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
onValueSet?: (any, string) => void,
): [T, (value: T | (() => T)) => void] {
const getValueFromLocalStorage = useCallback(() => {
try {
const item = localStorageGetItem(key);
if (item != null) {
return JSON.parse(item);
}
} catch (error) {
console.log(error);
}
if (typeof initialValue === 'function') {
return ((initialValue: any): () => T)();
} else {
return initialValue;
}
}, [initialValue, key]);
const [storedValue, setStoredValue] = useState<any>(getValueFromLocalStorage);
const setValue = useCallback(
(value: $FlowFixMe) => {
try {
const valueToStore =
value instanceof Function ? (value: any)(storedValue) : value;
setStoredValue(valueToStore);
localStorageSetItem(key, JSON.stringify(valueToStore));
// Notify listeners that this setting has changed.
window.dispatchEvent(new Event(key));
if (onValueSet != null) {
onValueSet(valueToStore, key);
}
} catch (error) {
console.log(error);
}
},
[key, storedValue],
);
// Listen for changes to this local storage value made from other windows.
// This enables the e.g. "⚛ Elements" tab to update in response to changes from "⚛ Settings".
useLayoutEffect(() => {
// $FlowFixMe[missing-local-annot]
const onStorage = event => {
const newValue = getValueFromLocalStorage();
if (key === event.key && storedValue !== newValue) {
setValue(newValue);
}
};
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener('storage', onStorage);
};
}, [getValueFromLocalStorage, key, storedValue, setValue]);
return [storedValue, setValue];
}
export function useModalDismissSignal(
modalRef: {current: HTMLDivElement | null, ...},
dismissCallback: () => void,
dismissOnClickOutside?: boolean = true,
): void {
useEffect(() => {
if (modalRef.current === null) {
return () => {};
}
const handleRootNodeKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
dismissCallback();
}
};
const handleRootNodeClick: MouseEventHandler = event => {
if (
modalRef.current !== null &&
/* $FlowExpectedError[incompatible-call] Instead of dealing with possibly multiple realms
and multiple Node references to comply with Flow (e.g. checking with `event.target instanceof Node`)
just delegate it to contains call */
!modalRef.current.contains(event.target)
) {
event.stopPropagation();
event.preventDefault();
dismissCallback();
}
};
let modalRootNode = null;
// Delay until after the current call stack is empty,
// in case this effect is being run while an event is currently bubbling.
// In that case, we don't want to listen to the pre-existing event.
let timeoutID: null | TimeoutID = setTimeout(() => {
timeoutID = null;
// It's important to listen to the ownerDocument to support the browser extension.
// Here we use portals to render individual tabs (e.g. Profiler),
// and the root document might belong to a different window.
const modalDOMElement = modalRef.current;
if (modalDOMElement != null) {
modalRootNode = modalDOMElement.getRootNode();
modalRootNode.addEventListener('keydown', handleRootNodeKeyDown);
if (dismissOnClickOutside) {
modalRootNode.addEventListener('click', handleRootNodeClick, true);
}
}
}, 0);
return () => {
if (timeoutID !== null) {
clearTimeout(timeoutID);
}
if (modalRootNode !== null) {
modalRootNode.removeEventListener('keydown', handleRootNodeKeyDown);
modalRootNode.removeEventListener('click', handleRootNodeClick, true);
}
};
}, [modalRef, dismissCallback, dismissOnClickOutside]);
}
// Copied from https://github.com/facebook/react/pull/15022
export function useSubscription<Value>({
getCurrentValue,
subscribe,
}: {
getCurrentValue: () => Value,
subscribe: (callback: Function) => () => void,
}): Value {
const [state, setState] = useState(() => ({
getCurrentValue,
subscribe,
value: getCurrentValue(),
}));
if (
state.getCurrentValue !== getCurrentValue ||
state.subscribe !== subscribe
) {
setState({
getCurrentValue,
subscribe,
value: getCurrentValue(),
});
}
useEffect(() => {
let didUnsubscribe = false;
const checkForUpdates = () => {
if (didUnsubscribe) {
return;
}
setState(prevState => {
if (
prevState.getCurrentValue !== getCurrentValue ||
prevState.subscribe !== subscribe
) {
return prevState;
}
const value = getCurrentValue();
if (prevState.value === value) {
return prevState;
}
return {...prevState, value};
});
};
const unsubscribe = subscribe(checkForUpdates);
checkForUpdates();
return () => {
didUnsubscribe = true;
unsubscribe();
};
}, [getCurrentValue, subscribe]);
return state.value;
}
export function useHighlightHostInstance(): {
clearHighlightHostInstance: () => void,
highlightHostInstance: (id: number) => void,
} {
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
const highlightHostInstance = useCallback(
(id: number) => {
const element = store.getElementByID(id);
const rendererID = store.getRendererIDForElement(id);
if (element !== null && rendererID !== null) {
bridge.send('highlightHostInstance', {
displayName: element.displayName,
hideAfterTimeout: false,
id,
openBuiltinElementsPanel: false,
rendererID,
scrollIntoView: false,
});
}
},
[store, bridge],
);
const clearHighlightHostInstance = useCallback(() => {
bridge.send('clearHostInstanceHighlight');
}, [bridge]);
return {
highlightHostInstance,
clearHighlightHostInstance,
};
}