Files
react/packages/react-devtools-shared/src/devtools/views/hooks.js
Sebastian Markbåge 142fd27bf6 [DevTools] Add Option to Open Local Files directly in External Editor (#33983)
The `useOpenResource` hook is now used to open links. Currently, the
`<>` icon for the component stacks and the link in the bottom of the
components stack. But it'll also be used for many new links like stacks.
If this new option is configured, and this is a local file then this is
opened directly in the external editor. Otherwise it fallbacks to open
in the Sources tab or whatever the standalone or inline is configured to
use.

<img width="453" height="252" alt="Screenshot 2025-07-24 at 4 09 09 PM"
src="https://github.com/user-attachments/assets/04cae170-dd30-4485-a9ee-e8fe1612978e"
/>

I prominently surface this option in the Source pane to make it
discoverable.

<img width="588" height="144" alt="Screenshot 2025-07-24 at 4 03 48 PM"
src="https://github.com/user-attachments/assets/0f3a7da9-2fae-4b5b-90ec-769c5a9c5361"
/>

When this is configured, the "Open in Editor" is hidden since that's
just the default. I plan on deprecating this button to avoid having the
two buttons going forward.

Notably there's one exception where this doesn't work. When you click an
Action or Event listener it takes you to the Sources tab and you have to
open in editor from there. That's because we use the `inspect()`
mechanism instead of extracting the source location. That's because we
can't do the "throw trick" since these can have side-effects. The Chrome
debugger protocol would solve this but it pops up an annoying dialog. We
could maybe only attach the debugger only for that case. Especially if
the dialog disappears before you focus on the browser again.
2025-07-25 10:16:43 -04:00

380 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,
useSyncExternalStore,
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 = useSyncExternalStore(
useCallback(
function subscribe(callback) {
window.addEventListener(key, callback);
return function unsubscribe() {
window.removeEventListener(key, callback);
};
},
[key],
),
getValueFromLocalStorage,
);
const setValue = useCallback(
(value: $FlowFixMe) => {
try {
const valueToStore =
value instanceof Function ? (value: any)(storedValue) : value;
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,
};
}