From 142fd27bf6e1b46c554d436509bdf9b70f7ef042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 25 Jul 2025 10:16:43 -0400 Subject: [PATCH 1/8] [DevTools] Add Option to Open Local Files directly in External Editor (#33983) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2025-07-24 at 4 09 09 PM I prominently surface this option in the Source pane to make it discoverable. Screenshot 2025-07-24 at 4 03 48 PM 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. --- .../react-devtools-core/src/standalone.js | 10 +-- .../src/main/index.js | 2 +- .../react-devtools-fusebox/src/frontend.d.ts | 17 +++- .../inspectedElementSerializer.js | 3 +- .../src/backend/fiber/renderer.js | 5 -- .../src/backend/legacy/renderer.js | 2 - .../src/backend/types.js | 3 - .../react-devtools-shared/src/backendAPI.js | 2 - .../react-devtools-shared/src/constants.js | 2 + .../views/Components/InspectedElement.js | 37 +++++--- .../Components/InspectedElementSourcePanel.js | 22 +---- .../InspectedElementViewSourceButton.js | 45 ++-------- .../src/devtools/views/DevTools.js | 10 +-- .../src/devtools/views/Editor/EditorPane.css | 18 +++- .../src/devtools/views/Editor/EditorPane.js | 47 ++++++---- .../src/devtools/views/Editor/utils.js | 4 +- .../views/Profiler/SidebarEventInfo.js | 18 ++-- .../views/Settings/CodeEditorByDefault.js | 35 ++++++++ .../views/Settings/CodeEditorOptions.js | 2 +- .../views/Settings/GeneralSettings.js | 17 ++++ .../src/devtools/views/hooks.js | 16 +++- .../src/devtools/views/useOpenResource.js | 85 +++++++++++++++++++ .../src/frontend/types.js | 3 - packages/react-devtools-shared/src/utils.js | 9 ++ 24 files changed, 274 insertions(+), 140 deletions(-) create mode 100644 packages/react-devtools-shared/src/devtools/views/Settings/CodeEditorByDefault.js create mode 100644 packages/react-devtools-shared/src/devtools/views/useOpenResource.js diff --git a/packages/react-devtools-core/src/standalone.js b/packages/react-devtools-core/src/standalone.js index 369dc43a242f9..f8286783a9b44 100644 --- a/packages/react-devtools-core/src/standalone.js +++ b/packages/react-devtools-core/src/standalone.js @@ -26,7 +26,7 @@ import { import {localStorageSetItem} from 'react-devtools-shared/src/storage'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; -import type {ReactFunctionLocation} from 'shared/ReactTypes'; +import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes'; export type StatusTypes = 'server-connected' | 'devtools-connected' | 'error'; export type StatusListener = (message: string, status: StatusTypes) => void; @@ -144,8 +144,8 @@ async function fetchFileWithCaching(url: string) { } function canViewElementSourceFunction( - _source: ReactFunctionLocation, - symbolicatedSource: ReactFunctionLocation | null, + _source: ReactFunctionLocation | ReactCallSite, + symbolicatedSource: ReactFunctionLocation | ReactCallSite | null, ): boolean { if (symbolicatedSource == null) { return false; @@ -156,8 +156,8 @@ function canViewElementSourceFunction( } function viewElementSourceFunction( - _source: ReactFunctionLocation, - symbolicatedSource: ReactFunctionLocation | null, + _source: ReactFunctionLocation | ReactCallSite, + symbolicatedSource: ReactFunctionLocation | ReactCallSite | null, ): void { if (symbolicatedSource == null) { return; diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js index d7756ca991bef..5bb94ea1ccd3c 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -326,7 +326,7 @@ function createSourcesEditorPanel() { editorPane = createdPane; createdPane.setPage('panel.html'); - createdPane.setHeight('42px'); + createdPane.setHeight('75px'); createdPane.onShown.addListener(portal => { editorPortalContainer = portal.container; diff --git a/packages/react-devtools-fusebox/src/frontend.d.ts b/packages/react-devtools-fusebox/src/frontend.d.ts index 5a06aec7e49c4..a1142178f47a7 100644 --- a/packages/react-devtools-fusebox/src/frontend.d.ts +++ b/packages/react-devtools-fusebox/src/frontend.d.ts @@ -34,17 +34,26 @@ export type ReactFunctionLocation = [ number, // enclosing line number number, // enclosing column number ]; +export type ReactCallSite = [ + string, // function name + string, // file name TODO: model nested eval locations as nested arrays + number, // line number + number, // column number + number, // enclosing line number + number, // enclosing column number + boolean, // async resume +]; export type ViewElementSource = ( - source: ReactFunctionLocation, - symbolicatedSource: ReactFunctionLocation | null, + source: ReactFunctionLocation | ReactCallSite, + symbolicatedSource: ReactFunctionLocation | ReactCallSite | null, ) => void; export type ViewAttributeSource = ( id: number, path: Array, ) => void; export type CanViewElementSource = ( - source: ReactFunctionLocation, - symbolicatedSource: ReactFunctionLocation | null, + source: ReactFunctionLocation | ReactCallSite, + symbolicatedSource: ReactFunctionLocation | ReactCallSite | null, ) => boolean; export type InitializationOptions = { diff --git a/packages/react-devtools-shared/src/__tests__/__serializers__/inspectedElementSerializer.js b/packages/react-devtools-shared/src/__tests__/__serializers__/inspectedElementSerializer.js index 870ad35e4b03c..55029252843c8 100644 --- a/packages/react-devtools-shared/src/__tests__/__serializers__/inspectedElementSerializer.js +++ b/packages/react-devtools-shared/src/__tests__/__serializers__/inspectedElementSerializer.js @@ -15,8 +15,7 @@ export function test(maybeInspectedElement) { hasOwnProperty('canEditFunctionProps') && hasOwnProperty('canEditHooks') && hasOwnProperty('canToggleSuspense') && - hasOwnProperty('canToggleError') && - hasOwnProperty('canViewSource') + hasOwnProperty('canToggleError') ); } diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 937fe8d352fd9..96c7a38863b2e 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -4374,8 +4374,6 @@ export function attach( (fiber.alternate !== null && forceFallbackForFibers.has(fiber.alternate))), - // Can view component source location. - canViewSource, source, // Does the component have legacy context attached to it. @@ -4416,7 +4414,6 @@ export function attach( function inspectVirtualInstanceRaw( virtualInstance: VirtualInstance, ): InspectedElement | null { - const canViewSource = true; const source = getSourceForInstance(virtualInstance); const componentInfo = virtualInstance.data; @@ -4470,8 +4467,6 @@ export function attach( canToggleSuspense: supportsTogglingSuspense && hasSuspenseBoundary, - // Can view component source location. - canViewSource, source, // Does the component have legacy context attached to it. diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index cbcc37e319ee6..cc097c8379090 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -830,8 +830,6 @@ export function attach( // Suspense did not exist in legacy versions canToggleSuspense: false, - // Can view component source location. - canViewSource: type === ElementTypeClass || type === ElementTypeFunction, source: null, // Only legacy context exists in legacy versions. diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index c86298850973e..c9d6284b2f424 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -264,9 +264,6 @@ export type InspectedElement = { // Is this Suspense, and can its value be overridden now? canToggleSuspense: boolean, - // Can view component source location. - canViewSource: boolean, - // Does the component have legacy context attached to it. hasLegacyContext: boolean, diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index bbb171bce0da0..20b4e99a101e7 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -222,7 +222,6 @@ export function convertInspectedElementBackendToFrontend( canToggleError, isErrored, canToggleSuspense, - canViewSource, hasLegacyContext, id, type, @@ -252,7 +251,6 @@ export function convertInspectedElementBackendToFrontend( canToggleError, isErrored, canToggleSuspense, - canViewSource, hasLegacyContext, id, key, diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index b08738165906c..fa32ead1e934a 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -37,6 +37,8 @@ export const LOCAL_STORAGE_OPEN_IN_EDITOR_URL = 'React::DevTools::openInEditorUrl'; export const LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET = 'React::DevTools::openInEditorUrlPreset'; +export const LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR = + 'React::DevTools::alwaysOpenInEditor'; export const LOCAL_STORAGE_PARSE_HOOK_NAMES_KEY = 'React::DevTools::parseHookNames'; export const SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js index 8210e12331b1b..0c980f0db484a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -18,8 +18,11 @@ import Toggle from '../Toggle'; import {ElementTypeSuspense} from 'react-devtools-shared/src/frontend/types'; import InspectedElementView from './InspectedElementView'; import {InspectedElementContext} from './InspectedElementContext'; -import {getOpenInEditorURL} from '../../../utils'; -import {LOCAL_STORAGE_OPEN_IN_EDITOR_URL} from '../../../constants'; +import {getOpenInEditorURL, getAlwaysOpenInEditor} from '../../../utils'; +import { + LOCAL_STORAGE_OPEN_IN_EDITOR_URL, + LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR, +} from '../../../constants'; import FetchFileWithCachingContext from './FetchFileWithCachingContext'; import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource'; import OpenInEditorButton from './OpenInEditorButton'; @@ -118,18 +121,26 @@ export default function InspectedElementWrapper(_: Props): React.Node { inspectedElement != null && inspectedElement.canToggleSuspense; - const editorURL = useSyncExternalStore( - function subscribe(callback) { - window.addEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback); + const alwaysOpenInEditor = useSyncExternalStore( + useCallback(function subscribe(callback) { + window.addEventListener(LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR, callback); return function unsubscribe() { - window.removeEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback); + window.removeEventListener( + LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR, + callback, + ); }; - }, - function getState() { - return getOpenInEditorURL(); - }, + }, []), + getAlwaysOpenInEditor, ); + const editorURL = useSyncExternalStore(function subscribe(callback) { + window.addEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback); + return function unsubscribe() { + window.removeEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback); + }; + }, getOpenInEditorURL); + const toggleErrored = useCallback(() => { if (inspectedElement == null) { return; @@ -217,7 +228,8 @@ export default function InspectedElementWrapper(_: Props): React.Node { - {!!editorURL && + {!alwaysOpenInEditor && + !!editorURL && inspectedElement != null && inspectedElement.source != null && symbolicatedSourcePromise != null && ( @@ -271,8 +283,7 @@ export default function InspectedElementWrapper(_: Props): React.Node { {!hideViewSourceAction && ( )} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js index 91780cdc13d75..585361cedcf43 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSourcePanel.js @@ -8,7 +8,6 @@ */ import * as React from 'react'; -import {useCallback, useContext} from 'react'; import {copy} from 'clipboard-js'; import {toNormalUrl} from 'jsc-safe-url'; @@ -17,7 +16,7 @@ import ButtonIcon from '../ButtonIcon'; import Skeleton from './Skeleton'; import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck'; -import ViewElementSourceContext from './ViewElementSourceContext'; +import useOpenResource from '../useOpenResource'; import type {ReactFunctionLocation} from 'shared/ReactTypes'; import styles from './InspectedElementSourcePanel.css'; @@ -91,24 +90,11 @@ function CopySourceButton({source, symbolicatedSourcePromise}: Props) { function FormattedSourceString({source, symbolicatedSourcePromise}: Props) { const symbolicatedSource = React.use(symbolicatedSourcePromise); - const {canViewElementSourceFunction, viewElementSourceFunction} = useContext( - ViewElementSourceContext, + const [linkIsEnabled, viewSource] = useOpenResource( + source, + symbolicatedSource, ); - // In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source. - // To detect this case, we defer to an injected helper function (if present). - const linkIsEnabled = - viewElementSourceFunction != null && - source != null && - (canViewElementSourceFunction == null || - canViewElementSourceFunction(source, symbolicatedSource)); - - const viewSource = useCallback(() => { - if (viewElementSourceFunction != null && source != null) { - viewElementSourceFunction(source, symbolicatedSource); - } - }, [source, symbolicatedSource]); - const [, sourceURL, line] = symbolicatedSource == null ? source : symbolicatedSource; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js index 6043bb61df92f..23d4cf96c8277 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementViewSourceButton.js @@ -11,79 +11,48 @@ import * as React from 'react'; import ButtonIcon from '../ButtonIcon'; import Button from '../Button'; -import ViewElementSourceContext from './ViewElementSourceContext'; import Skeleton from './Skeleton'; import type {ReactFunctionLocation} from 'shared/ReactTypes'; -import type { - CanViewElementSource, - ViewElementSource, -} from 'react-devtools-shared/src/devtools/views/DevTools'; -const {useCallback, useContext} = React; +import useOpenResource from '../useOpenResource'; type Props = { - canViewSource: ?boolean, - source: ?ReactFunctionLocation, + source: null | ReactFunctionLocation, symbolicatedSourcePromise: Promise | null, }; function InspectedElementViewSourceButton({ - canViewSource, source, symbolicatedSourcePromise, }: Props): React.Node { - const {canViewElementSourceFunction, viewElementSourceFunction} = useContext( - ViewElementSourceContext, - ); - return ( }> ); } type ActualSourceButtonProps = { - canViewSource: ?boolean, - source: ?ReactFunctionLocation, + source: null | ReactFunctionLocation, symbolicatedSourcePromise: Promise | null, - canViewElementSourceFunction: CanViewElementSource | null, - viewElementSourceFunction: ViewElementSource | null, }; function ActualSourceButton({ - canViewSource, source, symbolicatedSourcePromise, - canViewElementSourceFunction, - viewElementSourceFunction, }: ActualSourceButtonProps): React.Node { const symbolicatedSource = symbolicatedSourcePromise == null ? null : React.use(symbolicatedSourcePromise); - // In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source. - // To detect this case, we defer to an injected helper function (if present). - const buttonIsEnabled = - !!canViewSource && - viewElementSourceFunction != null && - source != null && - (canViewElementSourceFunction == null || - canViewElementSourceFunction(source, symbolicatedSource)); - - const viewSource = useCallback(() => { - if (viewElementSourceFunction != null && source != null) { - viewElementSourceFunction(source, symbolicatedSource); - } - }, [source, symbolicatedSource]); - + const [buttonIsEnabled, viewSource] = useOpenResource( + source, + symbolicatedSource, + ); return ( ); + } else { + editorToolbar = ( +
+ +
+ +
+ ); } return (
- -
- + {editorToolbar} +
+ {editorURL ? ( + + ) : ( + 'Configure an external editor to open local files.' + )} +
); } diff --git a/packages/react-devtools-shared/src/devtools/views/Editor/utils.js b/packages/react-devtools-shared/src/devtools/views/Editor/utils.js index 89e697bd60c8d..b239c9d213a65 100644 --- a/packages/react-devtools-shared/src/devtools/views/Editor/utils.js +++ b/packages/react-devtools-shared/src/devtools/views/Editor/utils.js @@ -7,11 +7,11 @@ * @flow */ -import type {ReactFunctionLocation} from 'shared/ReactTypes'; +import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes'; export function checkConditions( editorURL: string, - source: ReactFunctionLocation, + source: ReactFunctionLocation | ReactCallSite, ): {url: URL | null, shouldDisableButton: boolean} { try { const url = new URL(editorURL); diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js index b7b031a990caf..77f0d13feb72f 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/SidebarEventInfo.js @@ -12,7 +12,6 @@ import type {SchedulingEvent} from 'react-devtools-timeline/src/types'; import * as React from 'react'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; -import ViewElementSourceContext from '../Components/ViewElementSourceContext'; import {useContext} from 'react'; import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext'; import { @@ -22,6 +21,7 @@ import { import {stackToComponentLocations} from 'react-devtools-shared/src/devtools/utils'; import {copy} from 'clipboard-js'; import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck'; +import useOpenResource from '../useOpenResource'; import styles from './SidebarEventInfo.css'; @@ -32,9 +32,6 @@ type SchedulingEventProps = { }; function SchedulingEventInfo({eventInfo}: SchedulingEventProps) { - const {canViewElementSourceFunction, viewElementSourceFunction} = useContext( - ViewElementSourceContext, - ); const {componentName, timestamp} = eventInfo; const componentStack = eventInfo.componentStack || null; @@ -79,15 +76,10 @@ function SchedulingEventInfo({eventInfo}: SchedulingEventProps) { // TODO: We should support symbolication here as well, but // symbolicating the whole stack can be expensive - const canViewSource = - canViewElementSourceFunction == null || - canViewElementSourceFunction(location, null); - - const viewSource = - !canViewSource || viewElementSourceFunction == null - ? () => null - : () => viewElementSourceFunction(location, null); - + const [canViewSource, viewSource] = useOpenResource( + location, + null, + ); return (
  • +
    + +
    + +
    +
    Display density
    + +
    + {supportsTraceUpdates && (