From 53daaf5aba1827c2f6c6e01ae1318c36f5a1902e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82a=C5=BCej=20Kustra?= <46095609+blazejkustra@users.noreply.github.com> Date: Thu, 15 Jan 2026 12:06:14 +0100 Subject: [PATCH 1/3] Improve the detection of changed hooks (#35123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary cc @hoxyq Fixes https://github.com/facebook/react/issues/28584. Follow up to PR: https://github.com/facebook/react/pull/34547 This PR updates getChangedHooksIndices to account for the fact that `useSyncExternalStore`, `useTransition`, `useActionState`, `useFormState` internally mounts more than one hook while DevTools should treat it as a single user-facing hook. Approach idea came from [this](https://github.com/facebook/react/pull/34547#issuecomment-3504113776) comment 😄 Before: https://github.com/user-attachments/assets/6bd5ce80-8b52-4bb8-8bb1-5e91b1e65043 After: https://github.com/user-attachments/assets/47f56898-ab34-46b6-be7a-a54024dcefee ## How did you test this change? I used this component to reproduce this issue locally (I followed instructions in `packages/react-devtools/CONTRIBUTING.md`).
Details ```ts import * as React from 'react'; function useDeepNestedHook() { React.useState(0); // 1 return React.useState(1); // 2 } function useNestedHook() { const deepState = useDeepNestedHook(); React.useState(2); // 3 React.useState(3); // 4 return deepState; } // Create a simple store for useSyncExternalStore function createStore(initialValue) { let value = initialValue; const listeners = new Set(); return { getSnapshot: () => value, subscribe: listener => { listeners.add(listener); return () => { listeners.delete(listener); }; }, update: newValue => { value = newValue; listeners.forEach(listener => listener()); }, }; } const syncExternalStore = createStore(0); export default function InspectableElements(): React.Node { const [nestedState, setNestedState] = useNestedHook(); // 5 const syncExternalValue = React.useSyncExternalStore( syncExternalStore.subscribe, syncExternalStore.getSnapshot, ); // 6 const [isPending, startTransition] = React.useTransition(); // 7 const [formState, formAction, formPending] = React.useActionState( async (prevState, formData) => { return {count: (prevState?.count || 0) + 1}; }, {count: 0}, ); const handleTransition = () => { startTransition(() => { setState(Math.random()); }); }; // 8 const [state, setState] = React.useState('test'); return ( <>
setNestedState(Math.random())} style={{backgroundColor: 'red', padding: '10px', cursor: 'pointer'}}> State: {nestedState}
Value: {syncExternalValue}
Count: {formState.count}
setState(Math.random())} style={{backgroundColor: 'red', padding: '10px', cursor: 'pointer'}}> State: {state}
); } ```
--------- Co-authored-by: Ruslan Lesiutin <28902667+hoxyq@users.noreply.github.com> --- .../react-debug-tools/src/ReactDebugHooks.js | 6 +- .../profilerChangeDescriptions-test.js | 8 +- .../src/__tests__/profilingCache-test.js | 249 +++++++++++++++++- .../src/backend/fiber/renderer.js | 95 +++---- 4 files changed, 290 insertions(+), 68 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index db9495a97dd..b4eb6c2b596 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -467,9 +467,11 @@ function useSyncExternalStore( // useSyncExternalStore() composes multiple hooks internally. // Advance the current hook index the same number of times // so that subsequent hooks have the right memoized state. - nextHook(); // SyncExternalStore + const hook = nextHook(); // SyncExternalStore nextHook(); // Effect - const value = getSnapshot(); + // Read from hook.memoizedState to get the value that was used during render, + // not the current value from getSnapshot() which may have changed. + const value = hook !== null ? hook.memoizedState : getSnapshot(); hookLog.push({ displayName: null, primitive: 'SyncExternalStore', diff --git a/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js b/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js index 87d8132e50e..4710f5cd1b2 100644 --- a/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js +++ b/packages/react-devtools-shared/src/__tests__/profilerChangeDescriptions-test.js @@ -90,7 +90,7 @@ describe('Profiler change descriptions', () => { { "context": true, "didHooksChange": false, - "hooks": null, + "hooks": [], "isFirstMount": false, "props": [], "state": null, @@ -110,7 +110,7 @@ describe('Profiler change descriptions', () => { { "context": true, "didHooksChange": false, - "hooks": null, + "hooks": [], "isFirstMount": false, "props": [], "state": null, @@ -125,7 +125,7 @@ describe('Profiler change descriptions', () => { { "context": false, "didHooksChange": false, - "hooks": null, + "hooks": [], "isFirstMount": false, "props": [], "state": null, @@ -140,7 +140,7 @@ describe('Profiler change descriptions', () => { { "context": true, "didHooksChange": false, - "hooks": null, + "hooks": [], "isFirstMount": false, "props": [], "state": null, diff --git a/packages/react-devtools-shared/src/__tests__/profilingCache-test.js b/packages/react-devtools-shared/src/__tests__/profilingCache-test.js index 428b22d466d..c17a59c2925 100644 --- a/packages/react-devtools-shared/src/__tests__/profilingCache-test.js +++ b/packages/react-devtools-shared/src/__tests__/profilingCache-test.js @@ -378,6 +378,12 @@ describe('ProfilingCache', () => { ), ); + // Save references to the real dispatch/setState functions. + // inspectHooks() re-runs the component with a mock dispatcher, + // which would overwrite these variables with mock functions that do nothing. + const realDispatch = dispatch; + const realSetState = setState; + // Second render has no changed hooks, only changed props. utils.act(() => render( @@ -388,10 +394,10 @@ describe('ProfilingCache', () => { ); // Third render has a changed reducer hook. - utils.act(() => dispatch({type: 'invert'})); + utils.act(() => realDispatch({type: 'invert'})); // Fourth render has a changed state hook. - utils.act(() => setState('def')); + utils.act(() => realSetState('def')); // Fifth render has a changed context value, but no changed hook. utils.act(() => @@ -521,6 +527,238 @@ describe('ProfilingCache', () => { } }); + // @reactVersion >= 19.0 + it('should detect what hooks changed in a render with custom and composite hooks', () => { + let snapshot = 0; + let syncExternalStoreCallback; + + function subscribe(callback) { + syncExternalStoreCallback = callback; + return () => {}; + } + + function getSnapshot() { + return snapshot; + } + + // Custom hook wrapping multiple primitive hooks + function useCustomHook() { + const [value, setValue] = React.useState('custom'); + React.useEffect(() => {}, [value]); + return [value, setValue]; + } + + let setState = null; + let startTransition = null; + let actionStateDispatch = null; + let setCustomValue = null; + let setFinalState = null; + + const Component = () => { + // Hook 0: useState + const [state, _setState] = React.useState('initial'); + setState = _setState; + + // Hook 1: useSyncExternalStore (composite hook - internally uses multiple hooks) + const storeValue = React.useSyncExternalStore( + subscribe, + getSnapshot, + getSnapshot, + ); + + // Hook 2: useTransition (composite hook - internally uses multiple hooks) + const [isPending, _startTransition] = React.useTransition(); + startTransition = _startTransition; + + // Hook 3: useActionState (composite hook - internally uses multiple hooks) + const [actionState, _actionStateDispatch] = React.useActionState( + (_prev, action) => action, + 'action-initial', + ); + actionStateDispatch = _actionStateDispatch; + + // Hook 4: useState inside custom hook (flattened) + // Hook 5: useEffect inside custom hook (not stateful, won't show in changes) + const [customValue, _setCustomValue] = useCustomHook(); + setCustomValue = _setCustomValue; + + // Hook 6: direct useState at the end + const [finalState, _setFinalState] = React.useState('final'); + setFinalState = _setFinalState; + + return `${state}-${storeValue}-${isPending}-${actionState}-${customValue}-${finalState}`; + }; + + utils.act(() => store.profilerStore.startProfiling()); + utils.act(() => render()); + + // Save references before inspectHooks() overwrites them + const realSetState = setState; + const realStartTransition = startTransition; + const realActionStateDispatch = actionStateDispatch; + const realSetCustomValue = setCustomValue; + const realSetFinalState = setFinalState; + + // 2nd render: change useState (hook 0) + utils.act(() => realSetState('changed')); + + // 3rd render: change useSyncExternalStore (hook 1) + utils.act(() => { + snapshot = 1; + syncExternalStoreCallback(); + }); + + // 4th render: trigger useTransition (hook 2) + // Note: useTransition triggers two renders - one when isPending becomes true, + // and another when isPending becomes false after the transition completes + utils.act(() => { + realStartTransition(() => {}); + }); + + // 6th render: change useActionState (hook 3) + utils.act(() => realActionStateDispatch('action-changed')); + + // 7th render: change custom hook's useState (hook 4) + utils.act(() => realSetCustomValue('custom-changed')); + + // 8th render: change final useState (hook 6) + utils.act(() => realSetFinalState('final-changed')); + + utils.act(() => store.profilerStore.stopProfiling()); + + const rootID = store.roots[0]; + + const changeDescriptions = store.profilerStore + .getDataForRoot(rootID) + .commitData.map(commitData => commitData.changeDescriptions); + expect(changeDescriptions).toHaveLength(8); + + // 1st render: Initial mount + expect(changeDescriptions[0]).toMatchInlineSnapshot(` + Map { + 2 => { + "context": null, + "didHooksChange": false, + "isFirstMount": true, + "props": null, + "state": null, + }, + } + `); + + // 2nd render: Changed hook 0 (useState) + expect(changeDescriptions[1]).toMatchInlineSnapshot(` + Map { + 2 => { + "context": false, + "didHooksChange": true, + "hooks": [ + 0, + ], + "isFirstMount": false, + "props": [], + "state": null, + }, + } + `); + + // 3rd render: Changed hook 1 (useSyncExternalStore) + expect(changeDescriptions[2]).toMatchInlineSnapshot(` + Map { + 2 => { + "context": false, + "didHooksChange": true, + "hooks": [ + 1, + ], + "isFirstMount": false, + "props": [], + "state": null, + }, + } + `); + + // 4th render: Changed hook 2 (useTransition - isPending becomes true) + expect(changeDescriptions[3]).toMatchInlineSnapshot(` + Map { + 2 => { + "context": false, + "didHooksChange": true, + "hooks": [ + 2, + ], + "isFirstMount": false, + "props": [], + "state": null, + }, + } + `); + + // 5th render: Changed hook 2 (useTransition - isPending becomes false) + expect(changeDescriptions[4]).toMatchInlineSnapshot(` + Map { + 2 => { + "context": false, + "didHooksChange": true, + "hooks": [ + 2, + ], + "isFirstMount": false, + "props": [], + "state": null, + }, + } + `); + + // 6th render: Changed hook 3 (useActionState) + expect(changeDescriptions[5]).toMatchInlineSnapshot(` + Map { + 2 => { + "context": false, + "didHooksChange": true, + "hooks": [ + 3, + ], + "isFirstMount": false, + "props": [], + "state": null, + }, + } + `); + + // 7th render: Changed hook 4 (useState inside useCustomHook) + expect(changeDescriptions[6]).toMatchInlineSnapshot(` + Map { + 2 => { + "context": false, + "didHooksChange": true, + "hooks": [ + 4, + ], + "isFirstMount": false, + "props": [], + "state": null, + }, + } + `); + + // 8th render: Changed hook 6 (final useState) + expect(changeDescriptions[7]).toMatchInlineSnapshot(` + Map { + 2 => { + "context": false, + "didHooksChange": true, + "hooks": [ + 6, + ], + "isFirstMount": false, + "props": [], + "state": null, + }, + } + `); + }); + // @reactVersion >= 19.0 it('should detect context changes or lack of changes with conditional use()', () => { const ContextA = React.createContext(0); @@ -553,6 +791,11 @@ describe('ProfilingCache', () => { ), ); + // Save reference to the real setState function before profiling starts. + // inspectHooks() re-runs the component with a mock dispatcher, + // which would overwrite setState with a mock function that does nothing. + const realSetState = setState; + utils.act(() => store.profilerStore.startProfiling()); // First render changes Context. @@ -567,7 +810,7 @@ describe('ProfilingCache', () => { ); // Second render has no changed Context, only changed state. - utils.act(() => setState('def')); + utils.act(() => realSetState('def')); utils.act(() => store.profilerStore.stopProfiling()); diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 6848e327f67..892a3c6fc77 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -18,7 +18,7 @@ import type { Wakeable, } from 'shared/ReactTypes'; -import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; +import type {HooksNode, HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; import { ComponentFilterDisplayName, @@ -127,7 +127,6 @@ import {enableStyleXFeatures} from 'react-devtools-feature-flags'; import {componentInfoToComponentLogsMap} from '../shared/DevToolsServerComponentLogs'; import is from 'shared/objectIs'; -import hasOwnProperty from 'shared/hasOwnProperty'; import {getIODescription} from 'shared/ReactIODescription'; @@ -1976,10 +1975,9 @@ export function attach( state: null, }; } else { - const indices = getChangedHooksIndices( - prevFiber.memoizedState, - nextFiber.memoizedState, - ); + const prevHooks = inspectHooks(prevFiber); + const nextHooks = inspectHooks(nextFiber); + const indices = getChangedHooksIndices(prevHooks, nextHooks); const data: ChangeDescription = { context: getContextChanged(prevFiber, nextFiber), didHooksChange: indices !== null && indices.length > 0, @@ -2028,74 +2026,53 @@ export function attach( return false; } - function isUseSyncExternalStoreHook(hookObject: any): boolean { - const queue = hookObject.queue; - if (!queue) { - return false; - } - - const boundHasOwnProperty = hasOwnProperty.bind(queue); - return ( - boundHasOwnProperty('value') && - boundHasOwnProperty('getSnapshot') && - typeof queue.getSnapshot === 'function' - ); - } - - function isHookThatCanScheduleUpdate(hookObject: any) { - const queue = hookObject.queue; - if (!queue) { - return false; - } - - const boundHasOwnProperty = hasOwnProperty.bind(queue); - - // Detect the shape of useState() / useReducer() / useTransition() - // using the attributes that are unique to these hooks - // but also stable (e.g. not tied to current Lanes implementation) - // We don't check for dispatch property, because useTransition doesn't have it - if (boundHasOwnProperty('pending')) { - return true; - } - - return isUseSyncExternalStoreHook(hookObject); - } - - function didStatefulHookChange(prev: any, next: any): boolean { - const prevMemoizedState = prev.memoizedState; - const nextMemoizedState = next.memoizedState; + function didStatefulHookChange(prev: HooksNode, next: HooksNode): boolean { + // Detect the shape of useState() / useReducer() / useTransition() / useSyncExternalStore() / useActionState() + const isStatefulHook = + prev.isStateEditable === true || + prev.name === 'SyncExternalStore' || + prev.name === 'Transition' || + prev.name === 'ActionState' || + prev.name === 'FormState'; - if (isHookThatCanScheduleUpdate(prev)) { - return prevMemoizedState !== nextMemoizedState; + // Compare the values to see if they changed + if (isStatefulHook) { + return prev.value !== next.value; } return false; } - function getChangedHooksIndices(prev: any, next: any): null | Array { - if (prev == null || next == null) { + function getChangedHooksIndices( + prevHooks: HooksTree | null, + nextHooks: HooksTree | null, + ): null | Array { + if (prevHooks == null || nextHooks == null) { return null; } - const indices = []; + const indices: Array = []; let index = 0; - while (next !== null) { - if (didStatefulHookChange(prev, next)) { - indices.push(index); - } + function traverse(prevTree: HooksTree, nextTree: HooksTree): void { + for (let i = 0; i < prevTree.length; i++) { + const prevHook = prevTree[i]; + const nextHook = nextTree[i]; - // useSyncExternalStore creates 2 internal hooks, but we only count it as 1 user-facing hook - if (isUseSyncExternalStoreHook(next)) { - next = next.next; - prev = prev.next; - } + if (prevHook.subHooks.length > 0 && nextHook.subHooks.length > 0) { + traverse(prevHook.subHooks, nextHook.subHooks); + continue; + } - next = next.next; - prev = prev.next; - index++; + if (didStatefulHookChange(prevHook, nextHook)) { + indices.push(index); + } + + index++; + } } + traverse(prevHooks, nextHooks); return indices; } From fae15df40ef878fda68756176d501a4cb83cc9bc Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 15 Jan 2026 13:24:06 +0100 Subject: [PATCH 2/3] [DevTools] Add React Element pane to browser Elements panel (#35240) --- .../src/main/elementSelection.js | 2 +- .../src/main/index.js | 49 +++++++++++++++++++ .../views/Components/InspectedElement.css | 8 +++ .../views/Components/InspectedElement.js | 15 +++++- .../src/devtools/views/DevTools.js | 11 +++++ .../InspectedElement/InspectedElementPane.css | 15 ++++++ .../InspectedElement/InspectedElementPane.js | 34 +++++++++++++ 7 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 packages/react-devtools-shared/src/devtools/views/InspectedElement/InspectedElementPane.css create mode 100644 packages/react-devtools-shared/src/devtools/views/InspectedElement/InspectedElementPane.js diff --git a/packages/react-devtools-extensions/src/main/elementSelection.js b/packages/react-devtools-extensions/src/main/elementSelection.js index 315482e8eec..10dd81dd634 100644 --- a/packages/react-devtools-extensions/src/main/elementSelection.js +++ b/packages/react-devtools-extensions/src/main/elementSelection.js @@ -32,7 +32,7 @@ export function setReactSelectionFromBrowser(bridge) { return; } - // Remember to sync the selection next time we show Components tab. + // Remember to sync the selection next time we show inspected element bridge.send('syncSelectionFromBuiltinElementsPanel'); } }, diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js index 03f9c2b7733..5d7bd37ee42 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -206,6 +206,7 @@ function createBridgeAndStore() { bridge, browserTheme: getBrowserTheme(), componentsPortalContainer, + inspectedElementPortalContainer, profilerPortalContainer, editorPortalContainer, currentSelectedSource, @@ -278,6 +279,51 @@ function createComponentsPanel() { ); } +function createElementsInspectPanel() { + if (inspectedElementPortalContainer) { + // Panel is created and user opened it at least once + ensureInitialHTMLIsCleared(inspectedElementPortalContainer); + render(); + + return; + } + + if (inspectedElementPane) { + // Panel is created, but wasn't opened yet, so no document is present for it + return; + } + + const elementsPanel = chrome.devtools.panels.elements; + if (__IS_FIREFOX__ || !elementsPanel || !elementsPanel.createSidebarPane) { + // Firefox will not pass the window to the onShown listener despite setPage + // being called. + // See https://bugzilla.mozilla.org/show_bug.cgi?id=2010549 + + // May not be supported in some browsers. + // See https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/devtools/panels/ElementsPanel/createSidebarPane#browser_compatibility + return; + } + + elementsPanel.createSidebarPane('React Element âš›', createdPane => { + inspectedElementPane = createdPane; + + createdPane.setPage('panel.html'); + createdPane.setHeight('75px'); + + createdPane.onShown.addListener(portal => { + inspectedElementPortalContainer = portal.container; + if (inspectedElementPortalContainer != null && render) { + ensureInitialHTMLIsCleared(inspectedElementPortalContainer); + + render(); + portal.injectStyles(cloneStyleTags); + + logEvent({event_name: 'selected-inspected-element-pane'}); + } + }); + }); +} + function createProfilerPanel() { if (profilerPortalContainer) { // Panel is created and user opened it at least once @@ -508,6 +554,7 @@ function mountReactDevTools() { createComponentsPanel(); createProfilerPanel(); createSourcesEditorPanel(); + createElementsInspectPanel(); // Suspense Tab is created via the hook // TODO(enableSuspenseTab): Create eagerly once Suspense tab is stable } @@ -556,10 +603,12 @@ let componentsPanel = null; let profilerPanel = null; let suspensePanel = null; let editorPane = null; +let inspectedElementPane = null; let componentsPortalContainer = null; let profilerPortalContainer = null; let suspensePortalContainer = null; let editorPortalContainer = null; +let inspectedElementPortalContainer = null; let mostRecentOverrideTab = null; let render = null; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.css index 156b28f2cad..7b00c0c0b39 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.css @@ -77,3 +77,11 @@ padding: 0.25rem; color: var(--color-console-error-icon); } + +.VRule { + height: 20px; + width: 1px; + flex: 0 0 1px; + margin: 0 0.5rem; + background-color: var(--color-border); +} 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 633a4d382eb..7ba70678af0 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -34,13 +34,17 @@ import useEditorURL from '../useEditorURL'; import styles from './InspectedElement.css'; import Tooltip from './reach-ui/tooltip'; -export type Props = {}; +export type Props = { + actionButtons?: React.Node, +}; // TODO Make edits and deletes also use transition API! const noSourcePromise = Promise.resolve(null); -export default function InspectedElementWrapper(_: Props): React.Node { +export default function InspectedElementWrapper({ + actionButtons, +}: Props): React.Node { const {inspectedElementID} = useContext(TreeStateContext); const bridge = useContext(BridgeContext); const store = useContext(StoreContext); @@ -305,6 +309,13 @@ export default function InspectedElementWrapper(_: Props): React.Node { symbolicatedSourcePromise={symbolicatedSourcePromise} /> )} + + {actionButtons && ( + <> +
+ {actionButtons} + + )}
{inspectedElement === null && ( diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js index 6ddacef5637..dc471574978 100644 --- a/packages/react-devtools-shared/src/devtools/views/DevTools.js +++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js @@ -26,6 +26,7 @@ import Profiler from './Profiler/Profiler'; import SuspenseTab from './SuspenseTab/SuspenseTab'; import TabBar from './TabBar'; import EditorPane from './Editor/EditorPane'; +import InspectedElementPane from './InspectedElement/InspectedElementPane'; import {SettingsContextController} from './Settings/SettingsContext'; import {TreeContextController} from './Components/TreeContext'; import ViewElementSourceContext from './Components/ViewElementSourceContext'; @@ -100,6 +101,7 @@ export type Props = { // The root app is rendered in the top-level extension window, // but individual tabs (e.g. Components, Profiling) can be rendered into portals within their browser panels. componentsPortalContainer?: Element, + inspectedElementPortalContainer?: Element, profilerPortalContainer?: Element, suspensePortalContainer?: Element, editorPortalContainer?: Element, @@ -155,6 +157,7 @@ export default function DevTools({ canViewElementSourceFunction, componentsPortalContainer, editorPortalContainer, + inspectedElementPortalContainer, profilerPortalContainer, suspensePortalContainer, currentSelectedSource, @@ -379,6 +382,14 @@ export default function DevTools({ portalContainer={editorPortalContainer} /> ) : null} + {inspectedElementPortalContainer ? ( + + ) : null} diff --git a/packages/react-devtools-shared/src/devtools/views/InspectedElement/InspectedElementPane.css b/packages/react-devtools-shared/src/devtools/views/InspectedElement/InspectedElementPane.css new file mode 100644 index 00000000000..5d0cd2e40ae --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/InspectedElement/InspectedElementPane.css @@ -0,0 +1,15 @@ +.InspectedElementPane, .InspectedElementPane * { + box-sizing: border-box; + -webkit-font-smoothing: var(--font-smoothing); +} + +.InspectedElementPane { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: row; + background-color: var(--color-background); + color: var(--color-text); + font-family: var(--font-family-sans); +} diff --git a/packages/react-devtools-shared/src/devtools/views/InspectedElement/InspectedElementPane.js b/packages/react-devtools-shared/src/devtools/views/InspectedElement/InspectedElementPane.js new file mode 100644 index 00000000000..0d5b523e1ad --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/InspectedElement/InspectedElementPane.js @@ -0,0 +1,34 @@ +/** + * 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 * as React from 'react'; +import {useContext} from 'react'; + +import portaledContent from 'react-devtools-shared/src/devtools/views/portaledContent'; +import {OptionsContext} from 'react-devtools-shared/src/devtools/views/context'; +import InspectedElement from 'react-devtools-shared/src/devtools/views/Components/InspectedElement'; +import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal'; +import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle'; +import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext'; +import styles from './InspectedElementPane.css'; + +function InspectedElementPane() { + const {hideSettings} = useContext(OptionsContext); + return ( + +
+ } + /> + +
+
+ ); +} +export default (portaledContent(InspectedElementPane): component()); From bb8a76c6cc77ea2976d690ea09f5a1b3d9b1792a Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 15 Jan 2026 14:28:02 +0100 Subject: [PATCH 3/3] [DevTools] Show fallback in inspected element pane when no element is selected (#35503) --- .../src/devtools/views/Components/Components.css | 2 ++ .../src/devtools/views/Components/Components.js | 6 +++++- .../src/devtools/views/Components/InspectedElement.css | 7 +++++-- .../src/devtools/views/Components/InspectedElement.js | 4 ++++ .../views/InspectedElement/InspectedElementPane.js | 1 + .../src/devtools/views/SuspenseTab/SuspenseTab.css | 2 ++ .../src/devtools/views/SuspenseTab/SuspenseTab.js | 6 +++++- 7 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Components.css b/packages/react-devtools-shared/src/devtools/views/Components/Components.css index 8df59f72f16..b977a368af1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Components.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/Components.css @@ -22,6 +22,7 @@ flex: 1 1 35%; overflow-x: hidden; overflow-y: auto; + border-left: 1px solid var(--color-border); } .ResizeBarWrapper { @@ -55,6 +56,7 @@ .InspectedElementWrapper { flex: 1 1 50%; + border-left: none; } .ResizeBar { diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Components.js b/packages/react-devtools-shared/src/devtools/views/Components/Components.js index 1f0927de98a..80b6d450331 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Components.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Components.js @@ -159,7 +159,11 @@ function Components(_: {}) {
- +
diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.css index 7b00c0c0b39..f9e60f0f15a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.css @@ -3,7 +3,6 @@ flex-direction: column; height: 100%; width: 100%; - border-left: 1px solid var(--color-border); border-top: 1px solid var(--color-border); } @@ -69,7 +68,11 @@ padding: 0.25rem; color: var(--color-dimmer); font-style: italic; - border-left: 1px solid var(--color-border); +} + +.NoInspectionFallback { + padding: 0.25rem; + font-style: italic; } .StrictModeNonCompliant { 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 7ba70678af0..af5c8d4b4b1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -36,6 +36,8 @@ import Tooltip from './reach-ui/tooltip'; export type Props = { actionButtons?: React.Node, + /** fallback to show when no element is inspected */ + fallbackEmpty: React.Node, }; // TODO Make edits and deletes also use transition API! @@ -44,6 +46,7 @@ const noSourcePromise = Promise.resolve(null); export default function InspectedElementWrapper({ actionButtons, + fallbackEmpty, }: Props): React.Node { const {inspectedElementID} = useContext(TreeStateContext); const bridge = useContext(BridgeContext); @@ -193,6 +196,7 @@ export default function InspectedElementWrapper({ return (
+
{fallbackEmpty}
); } diff --git a/packages/react-devtools-shared/src/devtools/views/InspectedElement/InspectedElementPane.js b/packages/react-devtools-shared/src/devtools/views/InspectedElement/InspectedElementPane.js index 0d5b523e1ad..a47bffda8d7 100644 --- a/packages/react-devtools-shared/src/devtools/views/InspectedElement/InspectedElementPane.js +++ b/packages/react-devtools-shared/src/devtools/views/InspectedElement/InspectedElementPane.js @@ -25,6 +25,7 @@ function InspectedElementPane() {
} + fallbackEmpty={"Selected element wasn't rendered with React."} />
diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css index 0b1d85b1f48..a7915d0d910 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css @@ -35,6 +35,7 @@ flex: 0 0 calc(100% - var(--horizontal-resize-tree-percentage)); overflow-x: hidden; overflow-y: auto; + border-left: 1px solid var(--color-border); } .ResizeBarWrapper { @@ -70,6 +71,7 @@ .InspectedElementWrapper { flex: 0 0 calc(100% - var(--vertical-resize-tree-percentage)); + border-left: none; } .TreeWrapper + .ResizeBarWrapper .ResizeBar { diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js index 36351f82bb8..bd5de7d13e4 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -543,7 +543,11 @@ function SuspenseTab(_: {}) { className={styles.InspectedElementWrapper} hidden={inspectedElementHidden}> - +