From 50d9451f320a9aaf94304209193562cc385567d8 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Fri, 18 Sep 2020 11:07:18 -0400 Subject: [PATCH] Improve DevTools editing interface (#19774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Improve DevTools editing interface This commit adds the ability to rename or delete keys in the props/state/hooks/context editor and adds tests to cover this functionality. DevTools will degrade gracefully for older versions of React that do not inject the new reconciler rename* or delete* methods. Specifically, this commit includes the following changes: * Adds unit tests (for modern and legacy renderers) to cover overriding props, renaming keys, and deleting keys. * Refactor backend override methods to reduce redundant Bridge/Agent listeners and methods. * Inject new (DEV-only) methods from reconciler into DevTools to rename and delete paths. * Refactor 'inspected element' UI components to improve readability. * Improve auto-size input to better mimic Chrome's Style editor panel. (See this Code Sandbox for a proof of concept.) It also contains the following code cleanup: * Additional unit tests have been added for modifying values as well as renaming or deleting paths. * Four new DEV-only methods have been added to the reconciler to be injected into the DevTools hook: overrideHookStateDeletePath, overrideHookStateRenamePath, overridePropsDeletePath, and overridePropsRenamePath. (DevTools will degrade gracefully for older renderers without these methods.) * I also took this as an opportunity to refactor some of the existing code in a few places: * Rather than the backend implementing separate methods for editing props, state, hooks, and context– there are now three methods: deletePath, renamePath, and overrideValueAtPath that accept a type argument to differentiate between props, state, context, or hooks. * The various UI components for the DevTools frontend have been refactored to remove some unnecessary repetition. This commit also adds temporary support for override* commands with mismatched backend/frontend versions: * Add message forwarding for older backend methods (overrideContext, overrideHookState, overrideProps, and overrideState) to the new overrideValueAtPath method. This was done in both the frontend Bridge (for newer frontends passing messages to older embedded backends) and in the backend Agent (for older frontends passing messages to newer backends). We do this because React Native embeds the React DevTools backend, but cannot control which version of the frontend users use. * Additional unit tests have been added as well to cover the older frontend to newer backend case. Our DevTools test infra does not make it easy to write tests for the other way around. --- packages/react-devtools-shared/package.json | 1 + .../inspectedElementContext-test.js.snap | 58 +- .../src/__tests__/editing-test.js | 1111 +++++++++++++++++ .../__tests__/inspectedElementContext-test.js | 2 +- .../src/__tests__/legacy/editing-test.js | 669 ++++++++++ .../setupNativeStyleEditor.js | 21 +- .../src/backend/agent.js | 201 ++- .../src/backend/legacy/renderer.js | 145 ++- .../src/backend/renderer.js | 224 +++- .../src/backend/types.js | 63 +- .../src/backend/utils.js | 46 + packages/react-devtools-shared/src/bridge.js | 102 +- .../src/devtools/utils.js | 4 +- .../Components/CannotSuspendWarningMessage.js | 44 + .../devtools/views/Components/Components.css | 4 +- .../devtools/views/Components/Components.js | 6 +- .../views/Components/EditableName.css | 1 - .../devtools/views/Components/EditableName.js | 41 +- .../views/Components/EditableValue.js | 8 +- .../views/Components/InspectedElement.css | 68 + .../views/Components/InspectedElement.js | 240 ++++ .../Components/InspectedElementContext.js | 8 + .../Components/InspectedElementContextTree.js | 99 ++ ...Tree.css => InspectedElementHooksTree.css} | 0 ...ksTree.js => InspectedElementHooksTree.js} | 187 +-- .../Components/InspectedElementPropsTree.js | 104 ++ ...e.css => InspectedElementSharedStyles.css} | 2 +- .../Components/InspectedElementStateTree.js | 84 ++ .../InspectedElementSuspenseToggle.js | 70 ++ .../views/Components/InspectedElementTree.js | 121 -- ...edElement.css => InspectedElementView.css} | 65 - .../views/Components/InspectedElementView.js | 285 +++++ .../devtools/views/Components/KeyValue.css | 10 +- .../src/devtools/views/Components/KeyValue.js | 287 ++++- .../NativeStyleEditor/AutoSizeInput.css | 11 - .../NativeStyleEditor/AutoSizeInput.js | 82 +- .../views/Components/NewArrayValue.css | 19 + .../views/Components/NewArrayValue.js | 107 ++ .../devtools/views/Components/NewKeyValue.css | 13 + .../devtools/views/Components/NewKeyValue.js | 100 ++ .../views/Components/SelectedElement.js | 616 --------- .../src/devtools/views/Components/types.js | 10 +- .../src/devtools/views/Components/utils.js | 28 + packages/react-devtools-shared/src/utils.js | 39 + .../src/ReactFiberReconciler.new.js | 180 ++- .../src/ReactFiberReconciler.old.js | 180 ++- yarn.lock | 2 +- 47 files changed, 4516 insertions(+), 1252 deletions(-) create mode 100644 packages/react-devtools-shared/src/__tests__/editing-test.js create mode 100644 packages/react-devtools-shared/src/__tests__/legacy/editing-test.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/CannotSuspendWarningMessage.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.css create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js rename packages/react-devtools-shared/src/devtools/views/Components/{HooksTree.css => InspectedElementHooksTree.css} (100%) rename packages/react-devtools-shared/src/devtools/views/Components/{HooksTree.js => InspectedElementHooksTree.js} (62%) create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementPropsTree.js rename packages/react-devtools-shared/src/devtools/views/Components/{InspectedElementTree.css => InspectedElementSharedStyles.css} (97%) create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStateTree.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspenseToggle.js delete mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementTree.js rename packages/react-devtools-shared/src/devtools/views/Components/{SelectedElement.css => InspectedElementView.css} (56%) create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/NewArrayValue.css create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/NewArrayValue.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/NewKeyValue.css create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/NewKeyValue.js delete mode 100644 packages/react-devtools-shared/src/devtools/views/Components/SelectedElement.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/utils.js diff --git a/packages/react-devtools-shared/package.json b/packages/react-devtools-shared/package.json index d2d72be96334a..09551aadddbaa 100644 --- a/packages/react-devtools-shared/package.json +++ b/packages/react-devtools-shared/package.json @@ -11,6 +11,7 @@ "@reach/menu-button": "^0.1.17", "@reach/tooltip": "^0.2.2", "clipboard-js": "^0.3.6", + "json5": "^2.1.3", "local-storage-fallback": "^4.1.1", "lodash.throttle": "^4.1.1", "memoize-one": "^3.1.1", diff --git a/packages/react-devtools-shared/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap b/packages/react-devtools-shared/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap index f56288c12e640..9f216fd599438 100644 --- a/packages/react-devtools-shared/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap +++ b/packages/react-devtools-shared/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap @@ -1,34 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`InspectedElementContext display complex values of useDebugValue: DisplayedComplexValue 1`] = ` -{ - "id": 2, - "owners": null, - "context": null, - "hooks": [ - { - "id": null, - "isStateEditable": false, - "name": "DebuggableHook", - "value": { - "foo": 2 - }, - "subHooks": [ - { - "id": 0, - "isStateEditable": true, - "name": "State", - "value": 1, - "subHooks": [] - } - ] - } - ], - "props": {}, - "state": null -} -`; - exports[`InspectedElementContext should dehydrate complex nested values when requested: 1: Initially inspect element 1`] = ` { "id": 2, @@ -65,6 +36,35 @@ exports[`InspectedElementContext should dehydrate complex nested values when req } `; +exports[`InspectedElementContext should display complex values of useDebugValue: DisplayedComplexValue 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "hooks": [ + { + "id": null, + "isStateEditable": false, + "name": "DebuggableHook", + "value": { + "foo": 2 + }, + "subHooks": [ + { + "id": 0, + "isStateEditable": true, + "name": "State", + "value": 1, + "subHooks": [] + } + ] + } + ], + "props": {}, + "state": null +} +`; + exports[`InspectedElementContext should include updates for nested values that were previously hydrated: 1: Initially inspect element 1`] = ` { "id": 2, diff --git a/packages/react-devtools-shared/src/__tests__/editing-test.js b/packages/react-devtools-shared/src/__tests__/editing-test.js new file mode 100644 index 0000000000000..156e4b1f391ec --- /dev/null +++ b/packages/react-devtools-shared/src/__tests__/editing-test.js @@ -0,0 +1,1111 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 type {FrontendBridge} from 'react-devtools-shared/src/bridge'; +import type Store from 'react-devtools-shared/src/devtools/store'; + +describe('editing interface', () => { + let PropTypes; + let React; + let ReactDOM; + let bridge: FrontendBridge; + let store: Store; + let utils; + + const flushPendingUpdates = () => { + jest.runOnlyPendingTimers(); + }; + + beforeEach(() => { + utils = require('./utils'); + + bridge = global.bridge; + store = global.store; + store.collapseNodesByDefault = false; + + PropTypes = require('prop-types'); + React = require('react'); + ReactDOM = require('react-dom'); + }); + + describe('props', () => { + let committedClassProps; + let committedFunctionProps; + let classID; + let functionID; + + async function mountTestApp() { + class ClassComponent extends React.Component { + componentDidMount() { + committedClassProps = this.props; + } + componentDidUpdate() { + committedClassProps = this.props; + } + render() { + return null; + } + } + + function FunctionComponent(props) { + React.useLayoutEffect(() => { + committedFunctionProps = props; + }); + return null; + } + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + <> + + , + + , + , + container, + ), + ); + + classID = ((store.getElementIDAtIndex(0): any): number); + functionID = ((store.getElementIDAtIndex(1): any): number); + + expect(committedClassProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }); + } + + it('should have editable values', async () => { + await mountTestApp(); + + function overrideProps(id, path, value) { + const rendererID = utils.getRendererID(); + bridge.send('overrideValueAtPath', { + id, + path, + rendererID, + type: 'props', + value, + }); + flushPendingUpdates(); + } + + overrideProps(classID, ['shallow'], 'updated'); + expect(committedClassProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'updated', + }); + overrideProps(classID, ['object', 'nested'], 'updated'); + expect(committedClassProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'updated', + }, + shallow: 'updated', + }); + overrideProps(classID, ['array', 1], 'updated'); + expect(committedClassProps).toStrictEqual({ + array: [1, 'updated', 3], + object: { + nested: 'updated', + }, + shallow: 'updated', + }); + + overrideProps(functionID, ['shallow'], 'updated'); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'updated', + }); + overrideProps(functionID, ['object', 'nested'], 'updated'); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'updated', + }, + shallow: 'updated', + }); + overrideProps(functionID, ['array', 1], 'updated'); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 'updated', 3], + object: { + nested: 'updated', + }, + shallow: 'updated', + }); + }); + + // Tests the combination of older frontend (DevTools UI) with newer backend (embedded within a renderer). + it('should still support overriding prop values with legacy backend methods', async () => { + await mountTestApp(); + + function overrideProps(id, path, value) { + const rendererID = utils.getRendererID(); + bridge.send('overrideProps', { + id, + path, + rendererID, + value, + }); + flushPendingUpdates(); + } + + overrideProps(classID, ['object', 'nested'], 'updated'); + expect(committedClassProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'updated', + }, + shallow: 'initial', + }); + + overrideProps(functionID, ['shallow'], 'updated'); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'updated', + }); + }); + + it('should have editable paths', async () => { + await mountTestApp(); + + function renamePath(id, oldPath, newPath) { + const rendererID = utils.getRendererID(); + bridge.send('renamePath', { + id, + oldPath, + newPath, + rendererID, + type: 'props', + }); + flushPendingUpdates(); + } + + renamePath(classID, ['shallow'], ['after']); + expect(committedClassProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + after: 'initial', + }); + renamePath(classID, ['object', 'nested'], ['object', 'after']); + expect(committedClassProps).toStrictEqual({ + array: [1, 2, 3], + object: { + after: 'initial', + }, + after: 'initial', + }); + + renamePath(functionID, ['shallow'], ['after']); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + after: 'initial', + }); + renamePath(functionID, ['object', 'nested'], ['object', 'after']); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 2, 3], + object: { + after: 'initial', + }, + after: 'initial', + }); + }); + + it('should enable adding new object properties and array values', async () => { + await mountTestApp(); + + function overrideProps(id, path, value) { + const rendererID = utils.getRendererID(); + bridge.send('overrideValueAtPath', { + id, + path, + rendererID, + type: 'props', + value, + }); + flushPendingUpdates(); + } + + overrideProps(classID, ['new'], 'value'); + expect(committedClassProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + new: 'value', + }); + + overrideProps(classID, ['object', 'new'], 'value'); + expect(committedClassProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + + overrideProps(classID, ['array', 3], 'new value'); + expect(committedClassProps).toStrictEqual({ + array: [1, 2, 3, 'new value'], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + + overrideProps(functionID, ['new'], 'value'); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + new: 'value', + }); + + overrideProps(functionID, ['object', 'new'], 'value'); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + + overrideProps(functionID, ['array', 3], 'new value'); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 2, 3, 'new value'], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + }); + + it('should have deletable keys', async () => { + await mountTestApp(); + + function deletePath(id, path) { + const rendererID = utils.getRendererID(); + bridge.send('deletePath', { + id, + path, + rendererID, + type: 'props', + }); + flushPendingUpdates(); + } + + deletePath(classID, ['shallow']); + expect(committedClassProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + }); + deletePath(classID, ['object', 'nested']); + expect(committedClassProps).toStrictEqual({ + array: [1, 2, 3], + object: {}, + }); + deletePath(classID, ['array', 1]); + expect(committedClassProps).toStrictEqual({ + array: [1, 3], + object: {}, + }); + + deletePath(functionID, ['shallow']); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + }); + deletePath(functionID, ['object', 'nested']); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 2, 3], + object: {}, + }); + deletePath(functionID, ['array', 1]); + expect(committedFunctionProps).toStrictEqual({ + array: [1, 3], + object: {}, + }); + }); + }); + + describe('state', () => { + let committedState; + let id; + + async function mountTestApp() { + class ClassComponent extends React.Component { + state = { + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }; + componentDidMount() { + committedState = this.state; + } + componentDidUpdate() { + committedState = this.state; + } + render() { + return null; + } + } + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + , + container, + ), + ); + + id = ((store.getElementIDAtIndex(0): any): number); + + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }); + } + + it('should have editable values', async () => { + await mountTestApp(); + + function overrideState(path, value) { + const rendererID = utils.getRendererID(); + bridge.send('overrideValueAtPath', { + id, + path, + rendererID, + type: 'state', + value, + }); + flushPendingUpdates(); + } + + overrideState(['shallow'], 'updated'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: {nested: 'initial'}, + shallow: 'updated', + }); + + overrideState(['object', 'nested'], 'updated'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: {nested: 'updated'}, + shallow: 'updated', + }); + + overrideState(['array', 1], 'updated'); + expect(committedState).toStrictEqual({ + array: [1, 'updated', 3], + object: {nested: 'updated'}, + shallow: 'updated', + }); + }); + + // Tests the combination of older frontend (DevTools UI) with newer backend (embedded within a renderer). + it('should still support overriding state values with legacy backend methods', async () => { + await mountTestApp(); + + function overrideState(path, value) { + const rendererID = utils.getRendererID(); + bridge.send('overrideState', { + id, + path, + rendererID, + value, + }); + flushPendingUpdates(); + } + + overrideState(['array', 1], 'updated'); + expect(committedState).toStrictEqual({ + array: [1, 'updated', 3], + object: {nested: 'initial'}, + shallow: 'initial', + }); + }); + + it('should have editable paths', async () => { + await mountTestApp(); + + function renamePath(oldPath, newPath) { + const rendererID = utils.getRendererID(); + bridge.send('renamePath', { + id, + oldPath, + newPath, + rendererID, + type: 'state', + }); + flushPendingUpdates(); + } + + renamePath(['shallow'], ['after']); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + after: 'initial', + }); + + renamePath(['object', 'nested'], ['object', 'after']); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + after: 'initial', + }, + after: 'initial', + }); + }); + + it('should enable adding new object properties and array values', async () => { + await mountTestApp(); + + function overrideState(path, value) { + const rendererID = utils.getRendererID(); + bridge.send('overrideValueAtPath', { + id, + path, + rendererID, + type: 'state', + value, + }); + flushPendingUpdates(); + } + + overrideState(['new'], 'value'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + new: 'value', + }); + + overrideState(['object', 'new'], 'value'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + + overrideState(['array', 3], 'new value'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3, 'new value'], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + }); + + it('should have deletable keys', async () => { + await mountTestApp(); + + function deletePath(path) { + const rendererID = utils.getRendererID(); + bridge.send('deletePath', { + id, + path, + rendererID, + type: 'state', + }); + flushPendingUpdates(); + } + + deletePath(['shallow']); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + }); + + deletePath(['object', 'nested']); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: {}, + }); + + deletePath(['array', 1]); + expect(committedState).toStrictEqual({ + array: [1, 3], + object: {}, + }); + }); + }); + + describe('hooks', () => { + let committedState; + let hookID; + let id; + + async function mountTestApp() { + function FunctionComponent() { + const [state] = React.useState({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }); + React.useLayoutEffect(() => { + committedState = state; + }); + return null; + } + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render(, container), + ); + + hookID = 0; // index + id = ((store.getElementIDAtIndex(0): any): number); + + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }); + } + + it('should have editable values', async () => { + await mountTestApp(); + + function overrideHookState(path, value) { + const rendererID = utils.getRendererID(); + bridge.send('overrideValueAtPath', { + hookID, + id, + path, + rendererID, + type: 'hooks', + value, + }); + flushPendingUpdates(); + } + + overrideHookState(['shallow'], 'updated'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'updated', + }); + + overrideHookState(['object', 'nested'], 'updated'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'updated', + }, + shallow: 'updated', + }); + + overrideHookState(['array', 1], 'updated'); + expect(committedState).toStrictEqual({ + array: [1, 'updated', 3], + object: { + nested: 'updated', + }, + shallow: 'updated', + }); + }); + + // Tests the combination of older frontend (DevTools UI) with newer backend (embedded within a renderer). + it('should still support overriding hook values with legacy backend methods', async () => { + await mountTestApp(); + + function overrideHookState(path, value) { + const rendererID = utils.getRendererID(); + bridge.send('overrideHookState', { + hookID, + id, + path, + rendererID, + value, + }); + flushPendingUpdates(); + } + + overrideHookState(['shallow'], 'updated'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'updated', + }); + }); + + it('should have editable paths', async () => { + await mountTestApp(); + + function renamePath(oldPath, newPath) { + const rendererID = utils.getRendererID(); + bridge.send('renamePath', { + id, + hookID, + oldPath, + newPath, + rendererID, + type: 'hooks', + }); + flushPendingUpdates(); + } + + renamePath(['shallow'], ['after']); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + after: 'initial', + }); + + renamePath(['object', 'nested'], ['object', 'after']); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + after: 'initial', + }, + after: 'initial', + }); + }); + + it('should enable adding new object properties and array values', async () => { + await mountTestApp(); + + function overrideHookState(path, value) { + const rendererID = utils.getRendererID(); + bridge.send('overrideValueAtPath', { + hookID, + id, + path, + rendererID, + type: 'hooks', + value, + }); + flushPendingUpdates(); + } + + overrideHookState(['new'], 'value'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + new: 'value', + }); + + overrideHookState(['object', 'new'], 'value'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + + overrideHookState(['array', 3], 'new value'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3, 'new value'], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + }); + + it('should have deletable keys', async () => { + await mountTestApp(); + + function deletePath(path) { + const rendererID = utils.getRendererID(); + bridge.send('deletePath', { + hookID, + id, + path, + rendererID, + type: 'hooks', + }); + flushPendingUpdates(); + } + + deletePath(['shallow']); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + }); + + deletePath(['object', 'nested']); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: {}, + }); + + deletePath(['array', 1]); + expect(committedState).toStrictEqual({ + array: [1, 3], + object: {}, + }); + }); + }); + + describe('context', () => { + let committedContext; + let id; + + async function mountTestApp() { + class LegacyContextProvider extends React.Component { + static childContextTypes = { + array: PropTypes.array, + object: PropTypes.object, + shallow: PropTypes.string, + }; + getChildContext() { + return { + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }; + } + render() { + return this.props.children; + } + } + + class ClassComponent extends React.Component { + static contextTypes = { + array: PropTypes.array, + object: PropTypes.object, + shallow: PropTypes.string, + }; + componentDidMount() { + committedContext = this.context; + } + componentDidUpdate() { + committedContext = this.context; + } + render() { + return null; + } + } + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + + + , + container, + ), + ); + + // This test only covers Class components. + // Function components using legacy context are not editable. + + id = ((store.getElementIDAtIndex(1): any): number); + + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }); + } + + it('should have editable values', async () => { + await mountTestApp(); + + function overrideContext(path, value) { + const rendererID = utils.getRendererID(); + + // To simplify hydration and display of primitive context values (e.g. number, string) + // the inspectElement() method wraps context in a {value: ...} object. + path = ['value', ...path]; + + bridge.send('overrideValueAtPath', { + id, + path, + rendererID, + type: 'context', + value, + }); + flushPendingUpdates(); + } + + overrideContext(['shallow'], 'updated'); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'updated', + }); + + overrideContext(['object', 'nested'], 'updated'); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'updated', + }, + shallow: 'updated', + }); + + overrideContext(['array', 1], 'updated'); + expect(committedContext).toStrictEqual({ + array: [1, 'updated', 3], + object: { + nested: 'updated', + }, + shallow: 'updated', + }); + }); + + // Tests the combination of older frontend (DevTools UI) with newer backend (embedded within a renderer). + it('should still support overriding context values with legacy backend methods', async () => { + await mountTestApp(); + + function overrideContext(path, value) { + const rendererID = utils.getRendererID(); + + // To simplify hydration and display of primitive context values (e.g. number, string) + // the inspectElement() method wraps context in a {value: ...} object. + path = ['value', ...path]; + + bridge.send('overrideContext', { + id, + path, + rendererID, + value, + }); + flushPendingUpdates(); + } + + overrideContext(['object', 'nested'], 'updated'); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'updated', + }, + shallow: 'initial', + }); + }); + + it('should have editable paths', async () => { + await mountTestApp(); + + function renamePath(oldPath, newPath) { + const rendererID = utils.getRendererID(); + + // To simplify hydration and display of primitive context values (e.g. number, string) + // the inspectElement() method wraps context in a {value: ...} object. + oldPath = ['value', ...oldPath]; + newPath = ['value', ...newPath]; + + bridge.send('renamePath', { + id, + oldPath, + newPath, + rendererID, + type: 'context', + }); + flushPendingUpdates(); + } + + renamePath(['shallow'], ['after']); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + after: 'initial', + }); + + renamePath(['object', 'nested'], ['object', 'after']); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + after: 'initial', + }, + after: 'initial', + }); + }); + + it('should enable adding new object properties and array values', async () => { + await mountTestApp(); + + function overrideContext(path, value) { + const rendererID = utils.getRendererID(); + + // To simplify hydration and display of primitive context values (e.g. number, string) + // the inspectElement() method wraps context in a {value: ...} object. + path = ['value', ...path]; + + bridge.send('overrideValueAtPath', { + id, + path, + rendererID, + type: 'context', + value, + }); + flushPendingUpdates(); + } + + overrideContext(['new'], 'value'); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + new: 'value', + }); + + overrideContext(['object', 'new'], 'value'); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + + overrideContext(['array', 3], 'new value'); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3, 'new value'], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + }); + + it('should have deletable keys', async () => { + await mountTestApp(); + + function deletePath(path) { + const rendererID = utils.getRendererID(); + + // To simplify hydration and display of primitive context values (e.g. number, string) + // the inspectElement() method wraps context in a {value: ...} object. + path = ['value', ...path]; + + bridge.send('deletePath', { + id, + path, + rendererID, + type: 'context', + }); + flushPendingUpdates(); + } + + deletePath(['shallow']); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + }); + + deletePath(['object', 'nested']); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: {}, + }); + + deletePath(['array', 1]); + expect(committedContext).toStrictEqual({ + array: [1, 3], + object: {}, + }); + }); + }); +}); diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js b/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js index afbfae50cd98e..29f03a9e5adcc 100644 --- a/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js +++ b/packages/react-devtools-shared/src/__tests__/inspectedElementContext-test.js @@ -1705,7 +1705,7 @@ describe('InspectedElementContext', () => { done(); }); - it('display complex values of useDebugValue', async done => { + it('should display complex values of useDebugValue', async done => { let getInspectedElementPath: GetInspectedElementPath = ((null: any): GetInspectedElementPath); let inspectedElement = null; function Suspender({target}) { diff --git a/packages/react-devtools-shared/src/__tests__/legacy/editing-test.js b/packages/react-devtools-shared/src/__tests__/legacy/editing-test.js new file mode 100644 index 0000000000000..b6df4d43e45c0 --- /dev/null +++ b/packages/react-devtools-shared/src/__tests__/legacy/editing-test.js @@ -0,0 +1,669 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 type {FrontendBridge} from 'react-devtools-shared/src/bridge'; +import type Store from 'react-devtools-shared/src/devtools/store'; + +describe('editing interface', () => { + let PropTypes; + let React; + let ReactDOM; + let bridge: FrontendBridge; + let store: Store; + + const act = (callback: Function) => { + callback(); + + jest.runAllTimers(); // Flush Bridge operations + }; + + const flushPendingUpdates = () => { + jest.runOnlyPendingTimers(); + }; + + beforeEach(() => { + bridge = global.bridge; + store = global.store; + store.collapseNodesByDefault = false; + + PropTypes = require('prop-types'); + + // Redirect all React/ReactDOM requires to the v15 UMD. + // We use the UMD because Jest doesn't enable us to mock deep imports (e.g. "react/lib/Something"). + jest.mock('react', () => jest.requireActual('react-15/dist/react.js')); + jest.mock('react-dom', () => + jest.requireActual('react-dom-15/dist/react-dom.js'), + ); + + React = require('react'); + ReactDOM = require('react-dom'); + }); + + describe('props', () => { + let committedProps; + let id; + + function mountTestApp() { + class ClassComponent extends React.Component { + componentDidMount() { + committedProps = this.props; + } + componentDidUpdate() { + committedProps = this.props; + } + render() { + return null; + } + } + + act(() => + ReactDOM.render( + , + document.createElement('div'), + ), + ); + + id = ((store.getElementIDAtIndex(0): any): number); + + expect(committedProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }); + } + + it('should have editable values', () => { + mountTestApp(); + + function overrideProps(path, value) { + const rendererID = ((store.getRendererIDForElement(id): any): number); + bridge.send('overrideValueAtPath', { + id, + path, + rendererID, + type: 'props', + value, + }); + flushPendingUpdates(); + } + + overrideProps(['shallow'], 'updated'); + expect(committedProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'updated', + }); + overrideProps(['object', 'nested'], 'updated'); + expect(committedProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'updated', + }, + shallow: 'updated', + }); + overrideProps(['array', 1], 'updated'); + expect(committedProps).toStrictEqual({ + array: [1, 'updated', 3], + object: { + nested: 'updated', + }, + shallow: 'updated', + }); + }); + + it('should have editable paths', () => { + mountTestApp(); + + function renamePath(oldPath, newPath) { + const rendererID = ((store.getRendererIDForElement(id): any): number); + bridge.send('renamePath', { + id, + oldPath, + newPath, + rendererID, + type: 'props', + }); + flushPendingUpdates(); + } + + renamePath(['shallow'], ['after']); + expect(committedProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + after: 'initial', + }); + renamePath(['object', 'nested'], ['object', 'after']); + expect(committedProps).toStrictEqual({ + array: [1, 2, 3], + object: { + after: 'initial', + }, + after: 'initial', + }); + }); + + it('should enable adding new object properties and array values', async () => { + await mountTestApp(); + + function overrideProps(path, value) { + const rendererID = ((store.getRendererIDForElement(id): any): number); + bridge.send('overrideValueAtPath', { + id, + path, + rendererID, + type: 'props', + value, + }); + flushPendingUpdates(); + } + + overrideProps(['new'], 'value'); + expect(committedProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + new: 'value', + }); + + overrideProps(['object', 'new'], 'value'); + expect(committedProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + + overrideProps(['array', 3], 'new value'); + expect(committedProps).toStrictEqual({ + array: [1, 2, 3, 'new value'], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + }); + + it('should have deletable keys', () => { + mountTestApp(); + + function deletePath(path) { + const rendererID = ((store.getRendererIDForElement(id): any): number); + bridge.send('deletePath', { + id, + path, + rendererID, + type: 'props', + }); + flushPendingUpdates(); + } + + deletePath(['shallow']); + expect(committedProps).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + }); + deletePath(['object', 'nested']); + expect(committedProps).toStrictEqual({ + array: [1, 2, 3], + object: {}, + }); + deletePath(['array', 1]); + expect(committedProps).toStrictEqual({ + array: [1, 3], + object: {}, + }); + }); + }); + + describe('state', () => { + let committedState; + let id; + + function mountTestApp() { + class ClassComponent extends React.Component { + state = { + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }; + componentDidMount() { + committedState = this.state; + } + componentDidUpdate() { + committedState = this.state; + } + render() { + return null; + } + } + + act(() => + ReactDOM.render( + , + document.createElement('div'), + ), + ); + + id = ((store.getElementIDAtIndex(0): any): number); + + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }); + } + + it('should have editable values', () => { + mountTestApp(); + + function overrideState(path, value) { + const rendererID = ((store.getRendererIDForElement(id): any): number); + bridge.send('overrideValueAtPath', { + id, + path, + rendererID, + type: 'state', + value, + }); + flushPendingUpdates(); + } + + overrideState(['shallow'], 'updated'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: {nested: 'initial'}, + shallow: 'updated', + }); + + overrideState(['object', 'nested'], 'updated'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: {nested: 'updated'}, + shallow: 'updated', + }); + + overrideState(['array', 1], 'updated'); + expect(committedState).toStrictEqual({ + array: [1, 'updated', 3], + object: {nested: 'updated'}, + shallow: 'updated', + }); + }); + + it('should have editable paths', () => { + mountTestApp(); + + function renamePath(oldPath, newPath) { + const rendererID = ((store.getRendererIDForElement(id): any): number); + bridge.send('renamePath', { + id, + oldPath, + newPath, + rendererID, + type: 'state', + }); + flushPendingUpdates(); + } + + renamePath(['shallow'], ['after']); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + after: 'initial', + }); + + renamePath(['object', 'nested'], ['object', 'after']); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + after: 'initial', + }, + after: 'initial', + }); + }); + + it('should enable adding new object properties and array values', async () => { + await mountTestApp(); + + function overrideState(path, value) { + const rendererID = ((store.getRendererIDForElement(id): any): number); + bridge.send('overrideValueAtPath', { + id, + path, + rendererID, + type: 'state', + value, + }); + flushPendingUpdates(); + } + + overrideState(['new'], 'value'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + new: 'value', + }); + + overrideState(['object', 'new'], 'value'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + + overrideState(['array', 3], 'new value'); + expect(committedState).toStrictEqual({ + array: [1, 2, 3, 'new value'], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + }); + + it('should have deletable keys', () => { + mountTestApp(); + + function deletePath(path) { + const rendererID = ((store.getRendererIDForElement(id): any): number); + bridge.send('deletePath', { + id, + path, + rendererID, + type: 'state', + }); + flushPendingUpdates(); + } + + deletePath(['shallow']); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + }); + + deletePath(['object', 'nested']); + expect(committedState).toStrictEqual({ + array: [1, 2, 3], + object: {}, + }); + + deletePath(['array', 1]); + expect(committedState).toStrictEqual({ + array: [1, 3], + object: {}, + }); + }); + }); + + describe('context', () => { + let committedContext; + let id; + + function mountTestApp() { + class LegacyContextProvider extends React.Component { + static childContextTypes = { + array: PropTypes.array, + object: PropTypes.object, + shallow: PropTypes.string, + }; + getChildContext() { + return { + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }; + } + render() { + return this.props.children; + } + } + + class ClassComponent extends React.Component { + static contextTypes = { + array: PropTypes.array, + object: PropTypes.object, + shallow: PropTypes.string, + }; + componentDidMount() { + committedContext = this.context; + } + componentDidUpdate() { + committedContext = this.context; + } + render() { + return null; + } + } + + act(() => + ReactDOM.render( + + + , + document.createElement('div'), + ), + ); + + // This test only covers Class components. + // Function components using legacy context are not editable. + + id = ((store.getElementIDAtIndex(1): any): number); + + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + }); + } + + it('should have editable values', () => { + mountTestApp(); + + function overrideContext(path, value) { + const rendererID = ((store.getRendererIDForElement(id): any): number); + + bridge.send('overrideValueAtPath', { + id, + path, + rendererID, + type: 'context', + value, + }); + flushPendingUpdates(); + } + + overrideContext(['shallow'], 'updated'); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'updated', + }); + + overrideContext(['object', 'nested'], 'updated'); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'updated', + }, + shallow: 'updated', + }); + + overrideContext(['array', 1], 'updated'); + expect(committedContext).toStrictEqual({ + array: [1, 'updated', 3], + object: { + nested: 'updated', + }, + shallow: 'updated', + }); + }); + + it('should have editable paths', () => { + mountTestApp(); + + function renamePath(oldPath, newPath) { + const rendererID = ((store.getRendererIDForElement(id): any): number); + + bridge.send('renamePath', { + id, + oldPath, + newPath, + rendererID, + type: 'context', + }); + flushPendingUpdates(); + } + + renamePath(['shallow'], ['after']); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + after: 'initial', + }); + + renamePath(['object', 'nested'], ['object', 'after']); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + after: 'initial', + }, + after: 'initial', + }); + }); + + it('should enable adding new object properties and array values', async () => { + await mountTestApp(); + + function overrideContext(path, value) { + const rendererID = ((store.getRendererIDForElement(id): any): number); + + bridge.send('overrideValueAtPath', { + id, + path, + rendererID, + type: 'context', + value, + }); + flushPendingUpdates(); + } + + overrideContext(['new'], 'value'); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + shallow: 'initial', + new: 'value', + }); + + overrideContext(['object', 'new'], 'value'); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + + overrideContext(['array', 3], 'new value'); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3, 'new value'], + object: { + nested: 'initial', + new: 'value', + }, + shallow: 'initial', + new: 'value', + }); + }); + + it('should have deletable keys', () => { + mountTestApp(); + + function deletePath(path) { + const rendererID = ((store.getRendererIDForElement(id): any): number); + + bridge.send('deletePath', { + id, + path, + rendererID, + type: 'context', + }); + flushPendingUpdates(); + } + + deletePath(['shallow']); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: { + nested: 'initial', + }, + }); + + deletePath(['object', 'nested']); + expect(committedContext).toStrictEqual({ + array: [1, 2, 3], + object: {}, + }); + + deletePath(['array', 1]); + expect(committedContext).toStrictEqual({ + array: [1, 3], + object: {}, + }); + }); + }); +}); diff --git a/packages/react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor.js b/packages/react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor.js index 35b313e160761..221ad7a9c4800 100644 --- a/packages/react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor.js +++ b/packages/react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor.js @@ -224,14 +224,16 @@ function renameStyle( customStyle[oldName] = undefined; } - agent.overrideProps({ + agent.overrideValueAtPath({ + type: 'props', id, rendererID, path: ['style', lastIndex], value: customStyle, }); } else { - agent.overrideProps({ + agent.overrideValueAtPath({ + type: 'props', id, rendererID, path: ['style'], @@ -247,14 +249,16 @@ function renameStyle( customStyle[oldName] = undefined; } - agent.overrideProps({ + agent.overrideValueAtPath({ + type: 'props', id, rendererID, path: ['style'], value: customStyle, }); } else { - agent.overrideProps({ + agent.overrideValueAtPath({ + type: 'props', id, rendererID, path: ['style'], @@ -298,14 +302,16 @@ function setStyle( typeof style[lastLength] === 'object' && !Array.isArray(style[lastLength]) ) { - agent.overrideProps({ + agent.overrideValueAtPath({ + type: 'props', id, rendererID, path: ['style', lastLength, name], value, }); } else { - agent.overrideProps({ + agent.overrideValueAtPath({ + type: 'props', id, rendererID, path: ['style'], @@ -313,7 +319,8 @@ function setStyle( }); } } else { - agent.overrideProps({ + agent.overrideValueAtPath({ + type: 'props', id, rendererID, path: ['style'], diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 14592952f434f..1e0ffe336f685 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -79,10 +79,40 @@ type OverrideHookParams = {| hookID: number, path: Array, rendererID: number, + wasForwarded?: boolean, value: any, |}; type SetInParams = {| + id: number, + path: Array, + rendererID: number, + wasForwarded?: boolean, + value: any, +|}; + +type PathType = 'props' | 'hooks' | 'state' | 'context'; + +type DeletePathParams = {| + type: PathType, + hookID?: ?number, + id: number, + path: Array, + rendererID: number, +|}; + +type RenamePathParams = {| + type: PathType, + hookID?: ?number, + id: number, + oldPath: Array, + newPath: Array, + rendererID: number, +|}; + +type OverrideValueAtPathParams = {| + type: PathType, + hookID?: ?number, id: number, path: Array, rendererID: number, @@ -140,17 +170,16 @@ export default class Agent extends EventEmitter<{| this._bridge = bridge; bridge.addListener('copyElementPath', this.copyElementPath); + bridge.addListener('deletePath', this.deletePath); bridge.addListener('getProfilingData', this.getProfilingData); bridge.addListener('getProfilingStatus', this.getProfilingStatus); bridge.addListener('getOwnersList', this.getOwnersList); bridge.addListener('inspectElement', this.inspectElement); bridge.addListener('logElementToConsole', this.logElementToConsole); - bridge.addListener('overrideContext', this.overrideContext); - bridge.addListener('overrideHookState', this.overrideHookState); - bridge.addListener('overrideProps', this.overrideProps); - bridge.addListener('overrideState', this.overrideState); bridge.addListener('overrideSuspense', this.overrideSuspense); + bridge.addListener('overrideValueAtPath', this.overrideValueAtPath); bridge.addListener('reloadAndProfile', this.reloadAndProfile); + bridge.addListener('renamePath', this.renamePath); bridge.addListener('setTraceUpdatesEnabled', this.setTraceUpdatesEnabled); bridge.addListener('startProfiling', this.startProfiling); bridge.addListener('stopProfiling', this.stopProfiling); @@ -168,6 +197,14 @@ export default class Agent extends EventEmitter<{| bridge.addListener('viewAttributeSource', this.viewAttributeSource); bridge.addListener('viewElementSource', this.viewElementSource); + // Temporarily support older standalone front-ends sending commands to newer embedded backends. + // We do this because React Native embeds the React DevTools backend, + // but cannot control which version of the frontend users use. + bridge.addListener('overrideContext', this.overrideContext); + bridge.addListener('overrideHookState', this.overrideHookState); + bridge.addListener('overrideProps', this.overrideProps); + bridge.addListener('overrideState', this.overrideState); + if (this._isProfiling) { bridge.send('profilingStatus', true); } @@ -198,6 +235,15 @@ export default class Agent extends EventEmitter<{| } }; + deletePath = ({hookID, id, path, rendererID, type}: DeletePathParams) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.deletePath(type, id, hookID, path); + } + }; + getInstanceAndStyle({ id, rendererID, @@ -288,71 +334,150 @@ export default class Agent extends EventEmitter<{| } }; - reloadAndProfile = (recordChangeDescriptions: boolean) => { - sessionStorageSetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, 'true'); - sessionStorageSetItem( - SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, - recordChangeDescriptions ? 'true' : 'false', - ); - - // This code path should only be hit if the shell has explicitly told the Store that it supports profiling. - // In that case, the shell must also listen for this specific message to know when it needs to reload the app. - // The agent can't do this in a way that is renderer agnostic. - this._bridge.send('reloadAppForProfiling'); + overrideSuspense = ({ + id, + rendererID, + forceFallback, + }: OverrideSuspenseParams) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.overrideSuspense(id, forceFallback); + } }; - overrideContext = ({id, path, rendererID, value}: SetInParams) => { + overrideValueAtPath = ({ + hookID, + id, + path, + rendererID, + type, + value, + }: OverrideValueAtPathParams) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); } else { - renderer.setInContext(id, path, value); + renderer.overrideValueAtPath(type, id, hookID, path, value); + } + }; + + // Temporarily support older standalone front-ends by forwarding the older message types + // to the new "overrideValueAtPath" command the backend is now listening to. + overrideContext = ({ + id, + path, + rendererID, + wasForwarded, + value, + }: SetInParams) => { + // Don't forward a message that's already been forwarded by the front-end Bridge. + // We only need to process the override command once! + if (!wasForwarded) { + this.overrideValueAtPath({ + id, + path, + rendererID, + type: 'context', + value, + }); } }; + // Temporarily support older standalone front-ends by forwarding the older message types + // to the new "overrideValueAtPath" command the backend is now listening to. overrideHookState = ({ id, hookID, path, rendererID, + wasForwarded, value, }: OverrideHookParams) => { - const renderer = this._rendererInterfaces[rendererID]; - if (renderer == null) { - console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); - } else { - renderer.setInHook(id, hookID, path, value); + // Don't forward a message that's already been forwarded by the front-end Bridge. + // We only need to process the override command once! + if (!wasForwarded) { + this.overrideValueAtPath({ + id, + path, + rendererID, + type: 'hooks', + value, + }); } }; - overrideProps = ({id, path, rendererID, value}: SetInParams) => { - const renderer = this._rendererInterfaces[rendererID]; - if (renderer == null) { - console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); - } else { - renderer.setInProps(id, path, value); + // Temporarily support older standalone front-ends by forwarding the older message types + // to the new "overrideValueAtPath" command the backend is now listening to. + overrideProps = ({ + id, + path, + rendererID, + wasForwarded, + value, + }: SetInParams) => { + // Don't forward a message that's already been forwarded by the front-end Bridge. + // We only need to process the override command once! + if (!wasForwarded) { + this.overrideValueAtPath({ + id, + path, + rendererID, + type: 'props', + value, + }); } }; - overrideState = ({id, path, rendererID, value}: SetInParams) => { - const renderer = this._rendererInterfaces[rendererID]; - if (renderer == null) { - console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); - } else { - renderer.setInState(id, path, value); + // Temporarily support older standalone front-ends by forwarding the older message types + // to the new "overrideValueAtPath" command the backend is now listening to. + overrideState = ({ + id, + path, + rendererID, + wasForwarded, + value, + }: SetInParams) => { + // Don't forward a message that's already been forwarded by the front-end Bridge. + // We only need to process the override command once! + if (!wasForwarded) { + this.overrideValueAtPath({ + id, + path, + rendererID, + type: 'state', + value, + }); } }; - overrideSuspense = ({ + reloadAndProfile = (recordChangeDescriptions: boolean) => { + sessionStorageSetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, 'true'); + sessionStorageSetItem( + SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY, + recordChangeDescriptions ? 'true' : 'false', + ); + + // This code path should only be hit if the shell has explicitly told the Store that it supports profiling. + // In that case, the shell must also listen for this specific message to know when it needs to reload the app. + // The agent can't do this in a way that is renderer agnostic. + this._bridge.send('reloadAppForProfiling'); + }; + + renamePath = ({ + hookID, id, + newPath, + oldPath, rendererID, - forceFallback, - }: OverrideSuspenseParams) => { + type, + }: RenamePathParams) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); } else { - renderer.overrideSuspense(id, forceFallback); + renderer.renamePath(type, id, hookID, oldPath, newPath); } }; diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 547901d4b72c6..86d987c8f2e26 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -15,8 +15,20 @@ import { ElementTypeOtherOrUnknown, } from 'react-devtools-shared/src/types'; import {getUID, utfEncodeString, printOperationsArray} from '../../utils'; -import {cleanForBridge, copyToClipboard, copyWithSet} from '../utils'; -import {getDisplayName, getInObject} from 'react-devtools-shared/src/utils'; +import { + cleanForBridge, + copyToClipboard, + copyWithDelete, + copyWithRename, + copyWithSet, +} from '../utils'; +import { + deletePathInObject, + getDisplayName, + getInObject, + renamePathInObject, + setInObject, +} from 'react-devtools-shared/src/utils'; import { __DEBUG__, TREE_OPERATION_ADD, @@ -770,11 +782,15 @@ export function attach( return { id, - // Hooks did not exist in legacy versions + // Does the current renderer support editable hooks and function props? canEditHooks: false, + canEditFunctionProps: false, - // Does the current renderer support editable function props? - canEditFunctionProps: true, + // Does the current renderer support advanced editing interface? + canEditHooksAndDeletePaths: false, + canEditHooksAndRenamePaths: false, + canEditFunctionPropsDeletePaths: false, + canEditFunctionPropsRenamePaths: false, // Suspense did not exist in legacy versions canToggleSuspense: false, @@ -873,53 +889,110 @@ export function attach( global.$type = element.type; } - function setInProps(id: number, path: Array, value: any) { + function deletePath( + type: 'context' | 'hooks' | 'props' | 'state', + id: number, + hookID: ?number, + path: Array, + ): void { const internalInstance = idToInternalInstanceMap.get(id); if (internalInstance != null) { - const element = internalInstance._currentElement; - internalInstance._currentElement = { - ...element, - props: copyWithSet(element.props, path, value), - }; - forceUpdate(internalInstance._instance); + const publicInstance = internalInstance._instance; + if (publicInstance != null) { + switch (type) { + case 'context': + deletePathInObject(publicInstance.context, path); + forceUpdate(publicInstance); + break; + case 'hooks': + throw new Error('Hooks not supported by this renderer'); + case 'props': + const element = internalInstance._currentElement; + internalInstance._currentElement = { + ...element, + props: copyWithDelete(element.props, path), + }; + forceUpdate(publicInstance); + break; + case 'state': + deletePathInObject(publicInstance.state, path); + forceUpdate(publicInstance); + break; + } + } } } - function setInState(id: number, path: Array, value: any) { + function renamePath( + type: 'context' | 'hooks' | 'props' | 'state', + id: number, + hookID: ?number, + oldPath: Array, + newPath: Array, + ): void { const internalInstance = idToInternalInstanceMap.get(id); if (internalInstance != null) { const publicInstance = internalInstance._instance; if (publicInstance != null) { - setIn(publicInstance.state, path, value); - forceUpdate(publicInstance); + switch (type) { + case 'context': + renamePathInObject(publicInstance.context, oldPath, newPath); + forceUpdate(publicInstance); + break; + case 'hooks': + throw new Error('Hooks not supported by this renderer'); + case 'props': + const element = internalInstance._currentElement; + internalInstance._currentElement = { + ...element, + props: copyWithRename(element.props, oldPath, newPath), + }; + forceUpdate(publicInstance); + break; + case 'state': + renamePathInObject(publicInstance.state, oldPath, newPath); + forceUpdate(publicInstance); + break; + } } } } - function setInContext(id: number, path: Array, value: any) { + function overrideValueAtPath( + type: 'context' | 'hooks' | 'props' | 'state', + id: number, + hookID: ?number, + path: Array, + value: any, + ): void { const internalInstance = idToInternalInstanceMap.get(id); if (internalInstance != null) { const publicInstance = internalInstance._instance; if (publicInstance != null) { - setIn(publicInstance.context, path, value); - forceUpdate(publicInstance); + switch (type) { + case 'context': + setInObject(publicInstance.context, path, value); + forceUpdate(publicInstance); + break; + case 'hooks': + throw new Error('Hooks not supported by this renderer'); + case 'props': + const element = internalInstance._currentElement; + internalInstance._currentElement = { + ...element, + props: copyWithSet(element.props, path, value), + }; + forceUpdate(publicInstance); + break; + case 'state': + setInObject(publicInstance.state, path, value); + forceUpdate(publicInstance); + break; + } } } } - function setIn(obj: Object, path: Array, value: any) { - const last = path.pop(); - const parent = path.reduce( - // $FlowFixMe - (reduced, attr) => (reduced ? reduced[attr] : null), - obj, - ); - if (parent) { - // $FlowFixMe - parent[last] = value; - } - } - // v16+ only features const getProfilingData = () => { throw new Error('getProfilingData not supported by this renderer'); @@ -933,9 +1006,6 @@ export function attach( const overrideSuspense = () => { throw new Error('overrideSuspense not supported by this renderer'); }; - const setInHook = () => { - throw new Error('setInHook not supported by this renderer'); - }; const startProfiling = () => { // Do not throw, since this would break a multi-root scenario where v15 and v16 were both present. }; @@ -973,6 +1043,7 @@ export function attach( return { cleanup, copyElementPath, + deletePath, flushInitialOperations, getBestMatchForTrackedPath, getDisplayNameForFiberID, @@ -990,13 +1061,11 @@ export function attach( inspectElement, logElementToConsole, overrideSuspense, + overrideValueAtPath, + renamePath, prepareViewAttributeSource, prepareViewElementSource, renderer, - setInContext, - setInHook, - setInProps, - setInState, setTraceUpdatesEnabled, setTrackedPath, startProfiling, diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 86dd4e8674ac1..2b382e24a59e8 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -26,15 +26,23 @@ import { ElementTypeSuspenseList, } from 'react-devtools-shared/src/types'; import { + deletePathInObject, getDisplayName, getDefaultComponentFilters, getInObject, getUID, + renamePathInObject, setInObject, utfEncodeString, } from 'react-devtools-shared/src/utils'; import {sessionStorageGetItem} from 'react-devtools-shared/src/storage'; -import {cleanForBridge, copyToClipboard, copyWithSet} from './utils'; +import { + cleanForBridge, + copyToClipboard, + copyWithDelete, + copyWithRename, + copyWithSet, +} from './utils'; import { __DEBUG__, SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, @@ -452,7 +460,11 @@ export function attach( const { overrideHookState, + overrideHookStateDeletePath, + overrideHookStateRenamePath, overrideProps, + overridePropsDeletePath, + overridePropsRenamePath, setSuspenseHandler, scheduleUpdate, } = renderer; @@ -2360,12 +2372,20 @@ export function attach( return { id, - // Does the current renderer support editable hooks? + // Does the current renderer support editable hooks and function props? canEditHooks: typeof overrideHookState === 'function', - - // Does the current renderer support editable function props? canEditFunctionProps: typeof overrideProps === 'function', + // Does the current renderer support advanced editing interface? + canEditHooksAndDeletePaths: + typeof overrideHookStateDeletePath === 'function', + canEditHooksAndRenamePaths: + typeof overrideHookStateRenamePath === 'function', + canEditFunctionPropsDeletePaths: + typeof overridePropsDeletePath === 'function', + canEditFunctionPropsRenamePaths: + typeof overridePropsRenamePath === 'function', + canToggleSuspense: supportsTogglingSuspense && // If it's showing the real content, we can always flip fallback. @@ -2687,60 +2707,181 @@ export function attach( } } - function setInHook( + function deletePath( + type: 'context' | 'hooks' | 'props' | 'state', id: number, - index: number, + hookID: ?number, path: Array, - value: any, - ) { + ): void { const fiber = findCurrentFiberUsingSlowPathById(id); if (fiber !== null) { - if (typeof overrideHookState === 'function') { - overrideHookState(fiber, index, path, value); + const instance = fiber.stateNode; + + switch (type) { + case 'context': + // To simplify hydration and display of primitive context values (e.g. number, string) + // the inspectElement() method wraps context in a {value: ...} object. + // We need to remove the first part of the path (the "value") before continuing. + path = path.slice(1); + + switch (fiber.tag) { + case ClassComponent: + if (path.length === 0) { + // Simple context value (noop) + } else { + deletePathInObject(instance.context, path); + } + instance.forceUpdate(); + break; + case FunctionComponent: + // Function components using legacy context are not editable + // because there's no instance on which to create a cloned, mutated context. + break; + } + break; + case 'hooks': + if (typeof overrideHookStateDeletePath === 'function') { + overrideHookStateDeletePath(fiber, ((hookID: any): number), path); + } + break; + case 'props': + if (instance === null) { + if (typeof overridePropsDeletePath === 'function') { + overridePropsDeletePath(fiber, path); + } + } else { + fiber.pendingProps = copyWithDelete(instance.props, path); + instance.forceUpdate(); + } + break; + case 'state': + deletePathInObject(instance.state, path); + instance.forceUpdate(); + break; } } } - function setInProps(id: number, path: Array, value: any) { + function renamePath( + type: 'context' | 'hooks' | 'props' | 'state', + id: number, + hookID: ?number, + oldPath: Array, + newPath: Array, + ): void { const fiber = findCurrentFiberUsingSlowPathById(id); if (fiber !== null) { const instance = fiber.stateNode; - if (instance === null) { - if (typeof overrideProps === 'function') { - overrideProps(fiber, path, value); - } - } else { - fiber.pendingProps = copyWithSet(instance.props, path, value); - instance.forceUpdate(); + + switch (type) { + case 'context': + // To simplify hydration and display of primitive context values (e.g. number, string) + // the inspectElement() method wraps context in a {value: ...} object. + // We need to remove the first part of the path (the "value") before continuing. + oldPath = oldPath.slice(1); + newPath = newPath.slice(1); + + switch (fiber.tag) { + case ClassComponent: + if (oldPath.length === 0) { + // Simple context value (noop) + } else { + renamePathInObject(instance.context, oldPath, newPath); + } + instance.forceUpdate(); + break; + case FunctionComponent: + // Function components using legacy context are not editable + // because there's no instance on which to create a cloned, mutated context. + break; + } + break; + case 'hooks': + if (typeof overrideHookStateRenamePath === 'function') { + overrideHookStateRenamePath( + fiber, + ((hookID: any): number), + oldPath, + newPath, + ); + } + break; + case 'props': + if (instance === null) { + if (typeof overridePropsRenamePath === 'function') { + overridePropsRenamePath(fiber, oldPath, newPath); + } + } else { + fiber.pendingProps = copyWithRename( + instance.props, + oldPath, + newPath, + ); + instance.forceUpdate(); + } + break; + case 'state': + renamePathInObject(instance.state, oldPath, newPath); + instance.forceUpdate(); + break; } } } - function setInState(id: number, path: Array, value: any) { + function overrideValueAtPath( + type: 'context' | 'hooks' | 'props' | 'state', + id: number, + hookID: ?number, + path: Array, + value: any, + ): void { const fiber = findCurrentFiberUsingSlowPathById(id); if (fiber !== null) { const instance = fiber.stateNode; - setInObject(instance.state, path, value); - instance.forceUpdate(); - } - } - function setInContext(id: number, path: Array, value: any) { - // To simplify hydration and display of primitive context values (e.g. number, string) - // the inspectElement() method wraps context in a {value: ...} object. - // We need to remove the first part of the path (the "value") before continuing. - path = path.slice(1); - - const fiber = findCurrentFiberUsingSlowPathById(id); - if (fiber !== null) { - const instance = fiber.stateNode; - if (path.length === 0) { - // Simple context value - instance.context = value; - } else { - setInObject(instance.context, path, value); + switch (type) { + case 'context': + // To simplify hydration and display of primitive context values (e.g. number, string) + // the inspectElement() method wraps context in a {value: ...} object. + // We need to remove the first part of the path (the "value") before continuing. + path = path.slice(1); + + switch (fiber.tag) { + case ClassComponent: + if (path.length === 0) { + // Simple context value + instance.context = value; + } else { + setInObject(instance.context, path, value); + } + instance.forceUpdate(); + break; + case FunctionComponent: + // Function components using legacy context are not editable + // because there's no instance on which to create a cloned, mutated context. + break; + } + break; + case 'hooks': + if (typeof overrideHookState === 'function') { + overrideHookState(fiber, ((hookID: any): number), path, value); + } + break; + case 'props': + if (instance === null) { + if (typeof overrideProps === 'function') { + overrideProps(fiber, path, value); + } + } else { + fiber.pendingProps = copyWithSet(instance.props, path, value); + instance.forceUpdate(); + } + break; + case 'state': + setInObject(instance.state, path, value); + instance.forceUpdate(); + break; } - instance.forceUpdate(); } } @@ -3192,6 +3333,7 @@ export function attach( return { cleanup, copyElementPath, + deletePath, findNativeNodesForFiberID, flushInitialOperations, getBestMatchForTrackedPath, @@ -3208,11 +3350,9 @@ export function attach( prepareViewAttributeSource, prepareViewElementSource, overrideSuspense, + overrideValueAtPath, + renamePath, renderer, - setInContext, - setInHook, - setInProps, - setInState, setTraceUpdatesEnabled, setTrackedPath, startProfiling, diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index bfa405e1a83da..d4a01d7081e1b 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -97,12 +97,36 @@ export type ReactRenderer = { path: Array, value: any, ) => void, + // 17+ + overrideHookStateDeletePath?: ?( + fiber: Object, + id: number, + path: Array, + ) => void, + // 17+ + overrideHookStateRenamePath?: ?( + fiber: Object, + id: number, + oldPath: Array, + newPath: Array, + ) => void, // 16.7+ overrideProps?: ?( fiber: Object, path: Array, value: any, ) => void, + // 17+ + overridePropsDeletePath?: ?( + fiber: Object, + path: Array, + ) => void, + // 17+ + overridePropsRenamePath?: ?( + fiber: Object, + oldPath: Array, + newPath: Array, + ) => void, // 16.9+ scheduleUpdate?: ?(fiber: Object) => void, setSuspenseHandler?: ?(shouldSuspend: (fiber: Object) => boolean) => void, @@ -184,12 +208,16 @@ export type InspectedElement = {| displayName: string | null, - // Does the current renderer support editable hooks? + // Does the current renderer support editable hooks and function props? canEditHooks: boolean, - - // Does the current renderer support editable function props? canEditFunctionProps: boolean, + // Does the current renderer support advanced editing interface? + canEditHooksAndDeletePaths: boolean, + canEditHooksAndRenamePaths: boolean, + canEditFunctionPropsDeletePaths: boolean, + canEditFunctionPropsRenamePaths: boolean, + // Is this Suspense, and can its value be overridden now? canToggleSuspense: boolean, @@ -261,9 +289,17 @@ export type InstanceAndStyle = {| style: Object | null, |}; +type Type = 'props' | 'hooks' | 'state' | 'context'; + export type RendererInterface = { cleanup: () => void, copyElementPath: (id: number, path: Array) => void, + deletePath: ( + type: Type, + id: number, + hookID: ?number, + path: Array, + ) => void, findNativeNodesForFiberID: FindNativeNodesForFiberID, flushInitialOperations: () => void, getBestMatchForTrackedPath: () => PathMatch | null, @@ -281,21 +317,26 @@ export type RendererInterface = { ) => InspectedElementPayload, logElementToConsole: (id: number) => void, overrideSuspense: (id: number, forceFallback: boolean) => void, + overrideValueAtPath: ( + type: Type, + id: number, + hook: ?number, + path: Array, + value: any, + ) => void, prepareViewAttributeSource: ( id: number, path: Array, ) => void, prepareViewElementSource: (id: number) => void, - renderer: ReactRenderer | null, - setInContext: (id: number, path: Array, value: any) => void, - setInHook: ( + renamePath: ( + type: Type, id: number, - index: number, - path: Array, - value: any, + hookID: ?number, + oldPath: Array, + newPath: Array, ) => void, - setInProps: (id: number, path: Array, value: any) => void, - setInState: (id: number, path: Array, value: any) => void, + renderer: ReactRenderer | null, setTraceUpdatesEnabled: (enabled: boolean) => void, setTrackedPath: (path: Array | null) => void, startProfiling: (recordChangeDescriptions: boolean) => void, diff --git a/packages/react-devtools-shared/src/backend/utils.js b/packages/react-devtools-shared/src/backend/utils.js index debe0db38ab59..d0ff4192d6edb 100644 --- a/packages/react-devtools-shared/src/backend/utils.js +++ b/packages/react-devtools-shared/src/backend/utils.js @@ -55,6 +55,52 @@ export function copyToClipboard(value: any): void { } } +export function copyWithDelete( + obj: Object | Array, + path: Array, + index: number = 0, +): Object | Array { + const key = path[index]; + const updated = Array.isArray(obj) ? obj.slice() : {...obj}; + if (index + 1 === path.length) { + if (Array.isArray(updated)) { + updated.splice(((key: any): number), 1); + } else { + delete updated[key]; + } + } else { + // $FlowFixMe number or string is fine here + updated[key] = copyWithDelete(obj[key], path, index + 1); + } + return updated; +} + +// This function expects paths to be the same except for the final value. +// e.g. ['path', 'to', 'foo'] and ['path', 'to', 'bar'] +export function copyWithRename( + obj: Object | Array, + oldPath: Array, + newPath: Array, + index: number = 0, +): Object | Array { + const oldKey = oldPath[index]; + const updated = Array.isArray(obj) ? obj.slice() : {...obj}; + if (index + 1 === oldPath.length) { + const newKey = newPath[index]; + // $FlowFixMe number or string is fine here + updated[newKey] = updated[oldKey]; + if (Array.isArray(updated)) { + updated.splice(((oldKey: any): number), 1); + } else { + delete updated[oldKey]; + } + } else { + // $FlowFixMe number or string is fine here + updated[oldKey] = copyWithRename(obj[oldKey], oldPath, newPath, index + 1); + } + return updated; +} + export function copyWithSet( obj: Object | Array, path: Array, diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index f30d3034db94c..2bde2aed8b071 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -38,6 +38,7 @@ type HighlightElementInDOM = {| type OverrideValue = {| ...ElementAndRendererID, path: Array, + wasForwarded?: boolean, value: any, |}; @@ -46,6 +47,31 @@ type OverrideHookState = {| hookID: number, |}; +type PathType = 'props' | 'hooks' | 'state' | 'context'; + +type DeletePath = {| + ...ElementAndRendererID, + type: PathType, + hookID?: ?number, + path: Array, +|}; + +type RenamePath = {| + ...ElementAndRendererID, + type: PathType, + hookID?: ?number, + oldPath: Array, + newPath: Array, +|}; + +type OverrideValueAtPath = {| + ...ElementAndRendererID, + type: PathType, + hookID?: ?number, + path: Array, + value: any, +|}; + type OverrideSuspense = {| ...ElementAndRendererID, forceFallback: boolean, @@ -117,19 +143,18 @@ type BackendEvents = {| type FrontendEvents = {| clearNativeElementHighlight: [], copyElementPath: [CopyElementPathParams], + deletePath: [DeletePath], getOwnersList: [ElementAndRendererID], getProfilingData: [{|rendererID: RendererID|}], getProfilingStatus: [], highlightNativeElement: [HighlightElementInDOM], inspectElement: [InspectElementParams], logElementToConsole: [ElementAndRendererID], - overrideContext: [OverrideValue], - overrideHookState: [OverrideHookState], - overrideProps: [OverrideValue], - overrideState: [OverrideValue], overrideSuspense: [OverrideSuspense], + overrideValueAtPath: [OverrideValueAtPath], profilingData: [ProfilingDataBackend], reloadAndProfile: [boolean], + renamePath: [RenamePath], selectFiber: [number], setTraceUpdatesEnabled: [boolean], shutdown: [], @@ -147,6 +172,21 @@ type FrontendEvents = {| NativeStyleEditor_measure: [ElementAndRendererID], NativeStyleEditor_renameAttribute: [NativeStyleEditor_RenameAttributeParams], NativeStyleEditor_setValue: [NativeStyleEditor_SetValueParams], + + // Temporarily support newer standalone front-ends sending commands to older embedded backends. + // We do this because React Native embeds the React DevTools backend, + // but cannot control which version of the frontend users use. + // + // Note that nothing in the newer backend actually listens to these events, + // but the new frontend still dispatches them (in case older backends are listening to them instead). + // + // Note that this approach does no support the combination of a newer backend with an older frontend. + // It would be more work to suppot both approaches (and not run handlers twice) + // so I chose to support the more likely/common scenario (and the one more difficult for an end user to "fix"). + overrideContext: [OverrideValue], + overrideHookState: [OverrideHookState], + overrideProps: [OverrideValue], + overrideState: [OverrideValue], |}; class Bridge< @@ -171,6 +211,11 @@ class Bridge< wall.listen((message: Message) => { (this: any).emit(message.event, message.payload); }) || null; + + // Temporarily support older standalone front-ends sending commands to newer embedded backends. + // We do this because React Native embeds the React DevTools backend, + // but cannot control which version of the frontend users use. + this.addListener('overrideValueAtPath', this.overrideValueAtPath); } // Listening directly to the wall isn't advised. @@ -267,6 +312,55 @@ class Bridge< this._timeoutID = setTimeout(this._flush, BATCH_DURATION); } }; + + // Temporarily support older standalone backends by forwarding "overrideValueAtPath" commands + // to the older message types they may be listening to. + overrideValueAtPath = ({ + id, + path, + rendererID, + type, + value, + }: OverrideValueAtPath) => { + switch (type) { + case 'context': + this.send('overrideContext', { + id, + path, + rendererID, + wasForwarded: true, + value, + }); + break; + case 'hooks': + this.send('overrideHookState', { + id, + path, + rendererID, + wasForwarded: true, + value, + }); + break; + case 'props': + this.send('overrideProps', { + id, + path, + rendererID, + wasForwarded: true, + value, + }); + break; + case 'state': + this.send('overrideState', { + id, + path, + rendererID, + wasForwarded: true, + value, + }); + break; + } + }; } export type BackendBridge = Bridge; diff --git a/packages/react-devtools-shared/src/devtools/utils.js b/packages/react-devtools-shared/src/devtools/utils.js index d4c9d60700d16..46b15d18a1159 100644 --- a/packages/react-devtools-shared/src/devtools/utils.js +++ b/packages/react-devtools-shared/src/devtools/utils.js @@ -7,6 +7,8 @@ * @flow */ +import JSON5 from 'json5'; + import type {Element} from './views/Components/types'; import type Store from './store'; @@ -109,7 +111,7 @@ export function smartParse(value: any) { case 'undefined': return undefined; default: - return JSON.parse(sanitizeForParse(value)); + return JSON5.parse(sanitizeForParse(value)); } } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/CannotSuspendWarningMessage.js b/packages/react-devtools-shared/src/devtools/views/Components/CannotSuspendWarningMessage.js new file mode 100644 index 0000000000000..3ceb481d49a83 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/CannotSuspendWarningMessage.js @@ -0,0 +1,44 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 {StoreContext} from '../context'; +import { + ComponentFilterElementType, + ElementTypeSuspense, +} from 'react-devtools-shared/src/types'; + +export default function CannotSuspendWarningMessage() { + const store = useContext(StoreContext); + const areSuspenseElementsHidden = !!store.componentFilters.find( + filter => + filter.type === ComponentFilterElementType && + filter.value === ElementTypeSuspense && + filter.isEnabled, + ); + + // Has the user filtered out Suspense nodes from the tree? + // If so, the selected element might actually be in a Suspense tree after all. + if (areSuspenseElementsHidden) { + return ( +
+ Suspended state cannot be toggled while Suspense components are hidden. + Disable the filter and try again. +
+ ); + } else { + return ( +
+ The selected element is not within a Suspense container. Suspending it + would cause an error. +
+ ); + } +} 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 1261e550e003e..f709913919b0d 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Components.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/Components.css @@ -19,7 +19,7 @@ overflow: auto; } -.SelectedElementWrapper { +.InspectedElementWrapper { flex: 1 1 35%; overflow-x: hidden; overflow-y: auto; @@ -47,7 +47,7 @@ flex: 0 0 var(--vertical-resize-percentage); } - .SelectedElementWrapper { + .InspectedElementWrapper { flex: 1 1 50%; } 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 23d4888bf7a61..91e31d4bf0294 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Components.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Components.js @@ -25,7 +25,7 @@ import { localStorageGetItem, localStorageSetItem, } from 'react-devtools-shared/src/storage'; -import SelectedElement from './SelectedElement'; +import InspectedElement from './InspectedElement'; import {ModalDialog} from '../ModalDialog'; import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal'; import {NativeStyleContextController} from './NativeStyleEditor/context'; @@ -165,10 +165,10 @@ function Components(_: {||}) {
-
+
}> - +
diff --git a/packages/react-devtools-shared/src/devtools/views/Components/EditableName.css b/packages/react-devtools-shared/src/devtools/views/Components/EditableName.css index 285f858a8a772..60160f16b8f57 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/EditableName.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/EditableName.css @@ -1,6 +1,5 @@ .Input { flex: 0 1 auto; - padding: 1px; box-shadow: 0px 1px 3px transparent; color: var(--color-text); } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/EditableName.js b/packages/react-devtools-shared/src/devtools/views/Components/EditableName.js index 318596b644a9c..25cf05ff350ee 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/EditableName.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/EditableName.js @@ -12,27 +12,44 @@ import {useCallback, useState} from 'react'; import AutoSizeInput from './NativeStyleEditor/AutoSizeInput'; import styles from './EditableName.css'; -type OverrideNameFn = (name: string, value: any) => void; +type Type = 'props' | 'state' | 'context' | 'hooks'; +type OverrideNameFn = ( + oldName: Array, + newName: Array, +) => void; type EditableNameProps = {| + allowEmpty?: boolean, + allowWhiteSpace?: boolean, autoFocus?: boolean, + className?: string, initialValue?: string, - overrideNameFn: OverrideNameFn, + overrideName: OverrideNameFn, + path: Array, + type: Type, |}; export default function EditableName({ + allowEmpty = false, + allowWhiteSpace = false, autoFocus = false, + className = '', initialValue = '', - overrideNameFn, + overrideName, + path, + type, }: EditableNameProps) { const [editableName, setEditableName] = useState(initialValue); const [isValid, setIsValid] = useState(false); const handleChange = useCallback( ({target}) => { - const value = target.value.trim(); + let value = target.value; + if (!allowWhiteSpace) { + value = value.trim(); + } - if (value) { + if (allowEmpty || value !== '') { setIsValid(true); } else { setIsValid(false); @@ -40,7 +57,7 @@ export default function EditableName({ setEditableName(value); }, - [overrideNameFn], + [overrideName], ); const handleKeyDown = useCallback( @@ -52,7 +69,11 @@ export default function EditableName({ case 'Enter': case 'Tab': if (isValid) { - overrideNameFn(editableName); + const basePath = path.slice(0, path.length - 1); + overrideName( + [...basePath, initialValue], + [...basePath, editableName], + ); } break; case 'Escape': @@ -62,16 +83,16 @@ export default function EditableName({ break; } }, - [editableName, setEditableName, isValid, initialValue, overrideNameFn], + [editableName, setEditableName, isValid, initialValue, overrideName], ); return ( diff --git a/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js b/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js index a49e62054bb5e..ac3cc7ac86c7f 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/EditableValue.js @@ -16,14 +16,14 @@ type OverrideValueFn = (path: Array, value: any) => void; type EditableValueProps = {| className?: string, - overrideValueFn: OverrideValueFn, + overrideValue: OverrideValueFn, path: Array, value: any, |}; export default function EditableValue({ className = '', - overrideValueFn, + overrideValue, path, value, }: EditableValueProps) { @@ -55,7 +55,7 @@ export default function EditableValue({ // So we read from target.checked rather than parsedValue (which has not yet updated). // We also don't check isValid (because that hasn't changed yet either); // we don't need to check it anyway, since target.checked is always a boolean. - overrideValueFn(path, target.checked); + overrideValue(path, target.checked); }; const handleKeyDown = event => { @@ -76,7 +76,7 @@ export default function EditableValue({ const applyChanges = () => { if (isValid && hasPendingChanges) { - overrideValueFn(path, parsedValue); + overrideValue(path, parsedValue); } }; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.css new file mode 100644 index 0000000000000..9e68729283343 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.css @@ -0,0 +1,68 @@ +.InspectedElement { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + border-left: 1px solid var(--color-border); + border-top: 1px solid var(--color-border); +} + +.TitleRow { + flex: 0 0 42px; + display: flex; + align-items: center; + font-size: var(--font-size-monospace-large); + border-bottom: 1px solid var(--color-border); + padding: 0.5rem; +} + +.Key { + flex: 0 1 auto; + padding-left: 0.25rem; + padding-right: 0.125rem; + line-height: 1rem; + border-top-left-radius: 0.125rem; + border-bottom-left-radius: 0.125rem; + display: inline-block; + background-color: var(--color-component-badge-background); + color: var(--color-text); + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-small); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.KeyArrow { + height: 1rem; + width: 1rem; + margin-right: -0.25rem; + border: 0.5rem solid transparent; + border-left: 0.5rem solid var(--color-component-badge-background); +} + +.SelectedComponentName { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + line-height: normal; +} + +.Component { + flex: 1 1 auto; + color: var(--color-component-name); + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.Loading { + padding: 0.25rem; + color: var(--color-dimmer); + font-style: italic; +} + diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js new file mode 100644 index 0000000000000..f0ecc32be3770 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -0,0 +1,240 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 {useCallback, useContext} from 'react'; +import {TreeDispatcherContext, TreeStateContext} from './TreeContext'; +import {BridgeContext, StoreContext} from '../context'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; +import {ModalDialogContext} from '../ModalDialog'; +import {InspectedElementContext} from './InspectedElementContext'; +import ViewElementSourceContext from './ViewElementSourceContext'; +import Toggle from '../Toggle'; +import {ElementTypeSuspense} from 'react-devtools-shared/src/types'; +import CannotSuspendWarningMessage from './CannotSuspendWarningMessage'; +import InspectedElementView from './InspectedElementView'; + +import styles from './InspectedElement.css'; + +import type {InspectedElementContextType} from './InspectedElementContext'; +import type {InspectedElement} from './types'; + +export type Props = {||}; + +export default function InspectedElementWrapper(_: Props) { + const {inspectedElementID} = useContext(TreeStateContext); + const dispatch = useContext(TreeDispatcherContext); + const {canViewElementSourceFunction, viewElementSourceFunction} = useContext( + ViewElementSourceContext, + ); + const bridge = useContext(BridgeContext); + const store = useContext(StoreContext); + const {dispatch: modalDialogDispatch} = useContext(ModalDialogContext); + + const { + copyInspectedElementPath, + getInspectedElementPath, + getInspectedElement, + storeAsGlobal, + } = useContext(InspectedElementContext); + + const element = + inspectedElementID !== null + ? store.getElementByID(inspectedElementID) + : null; + + const inspectedElement = + inspectedElementID != null ? getInspectedElement(inspectedElementID) : null; + + const highlightElement = useCallback(() => { + if (element !== null && inspectedElementID !== null) { + const rendererID = store.getRendererIDForElement(inspectedElementID); + if (rendererID !== null) { + bridge.send('highlightNativeElement', { + displayName: element.displayName, + hideAfterTimeout: true, + id: inspectedElementID, + openNativeElementsPanel: true, + rendererID, + scrollIntoView: true, + }); + } + } + }, [bridge, element, inspectedElementID, store]); + + const logElement = useCallback(() => { + if (inspectedElementID !== null) { + const rendererID = store.getRendererIDForElement(inspectedElementID); + if (rendererID !== null) { + bridge.send('logElementToConsole', { + id: inspectedElementID, + rendererID, + }); + } + } + }, [bridge, inspectedElementID, store]); + + const viewSource = useCallback(() => { + if (viewElementSourceFunction != null && inspectedElement !== null) { + viewElementSourceFunction( + inspectedElement.id, + ((inspectedElement: any): InspectedElement), + ); + } + }, [inspectedElement, viewElementSourceFunction]); + + // 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 canViewSource = + inspectedElement !== null && + inspectedElement.canViewSource && + viewElementSourceFunction !== null && + (canViewElementSourceFunction === null || + canViewElementSourceFunction(inspectedElement)); + + const isSuspended = + element !== null && + element.type === ElementTypeSuspense && + inspectedElement != null && + inspectedElement.state != null; + + const canToggleSuspense = + inspectedElement != null && inspectedElement.canToggleSuspense; + + // TODO (suspense toggle) Would be nice to eventually use a two setState pattern here as well. + const toggleSuspended = useCallback(() => { + let nearestSuspenseElement = null; + let currentElement = element; + while (currentElement !== null) { + if (currentElement.type === ElementTypeSuspense) { + nearestSuspenseElement = currentElement; + break; + } else if (currentElement.parentID > 0) { + currentElement = store.getElementByID(currentElement.parentID); + } else { + currentElement = null; + } + } + + // If we didn't find a Suspense ancestor, we can't suspend. + // Instead we can show a warning to the user. + if (nearestSuspenseElement === null) { + modalDialogDispatch({ + type: 'SHOW', + content: , + }); + } else { + const nearestSuspenseElementID = nearestSuspenseElement.id; + + // If we're suspending from an arbitrary (non-Suspense) component, select the nearest Suspense element in the Tree. + // This way when the fallback UI is shown and the current element is hidden, something meaningful is selected. + if (nearestSuspenseElement !== element) { + dispatch({ + type: 'SELECT_ELEMENT_BY_ID', + payload: nearestSuspenseElementID, + }); + } + + const rendererID = store.getRendererIDForElement( + nearestSuspenseElementID, + ); + + // Toggle suspended + if (rendererID !== null) { + bridge.send('overrideSuspense', { + id: nearestSuspenseElementID, + rendererID, + forceFallback: !isSuspended, + }); + } + } + }, [bridge, dispatch, element, isSuspended, modalDialogDispatch, store]); + + if (element === null) { + return ( +
+
+
+ ); + } + + return ( +
+
+ {element.key && ( + <> +
+ {element.key} +
+
+ + )} + +
+
+ {element.displayName} +
+
+ + {canToggleSuspense && ( + + + + )} + {store.supportsNativeInspection && ( + + )} + + +
+ + {inspectedElement === null && ( +
Loading...
+ )} + + {inspectedElement !== null && ( + + )} +
+ ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js index 2649f39787c49..f80f099f52479 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js @@ -198,7 +198,11 @@ function InspectedElementContextController({children}: Props) { case 'full-data': const { canEditFunctionProps, + canEditFunctionPropsDeletePaths, + canEditFunctionPropsRenamePaths, canEditHooks, + canEditHooksAndDeletePaths, + canEditHooksAndRenamePaths, canToggleSuspense, canViewSource, hasLegacyContext, @@ -217,7 +221,11 @@ function InspectedElementContextController({children}: Props) { const inspectedElement: InspectedElementFrontend = { canEditFunctionProps, + canEditFunctionPropsDeletePaths, + canEditFunctionPropsRenamePaths, canEditHooks, + canEditHooksAndDeletePaths, + canEditHooksAndRenamePaths, canToggleSuspense, canViewSource, hasLegacyContext, diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js new file mode 100644 index 0000000000000..22347cef07f63 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContextTree.js @@ -0,0 +1,99 @@ +/** + * Copyright (c) Facebook, Inc. and its 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 {copy} from 'clipboard-js'; +import * as React from 'react'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; +import KeyValue from './KeyValue'; +import {alphaSortEntries, serializeDataForCopy} from '../utils'; +import Store from '../../store'; +import styles from './InspectedElementSharedStyles.css'; +import { + ElementTypeClass, + ElementTypeFunction, +} from 'react-devtools-shared/src/types'; + +import type {GetInspectedElementPath} from './InspectedElementContext'; +import type {InspectedElement} from './types'; +import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; + +type Props = {| + bridge: FrontendBridge, + getInspectedElementPath: GetInspectedElementPath, + inspectedElement: InspectedElement, + store: Store, +|}; + +export default function InspectedElementContextTree({ + bridge, + getInspectedElementPath, + inspectedElement, + store, +}: Props) { + const {hasLegacyContext, context, type} = inspectedElement; + + const isReadOnly = type !== ElementTypeClass && type !== ElementTypeFunction; + + const entries = context != null ? Object.entries(context) : null; + if (entries !== null) { + entries.sort(alphaSortEntries); + } + + const isEmpty = entries === null || entries.length === 0; + + const handleCopy = () => copy(serializeDataForCopy(((context: any): Object))); + + // We add an object with a "value" key as a wrapper around Context data + // so that we can use the shared component to display it. + // This wrapper object can't be renamed. + const canRenamePathsAtDepth = depth => depth > 1; + + if (isEmpty) { + return null; + } else { + return ( +
+
+
+ {hasLegacyContext ? 'legacy context' : 'context'} +
+ {!isEmpty && ( + + )} +
+ {isEmpty &&
None
} + {!isEmpty && + (entries: any).map(([name, value]) => ( +
+ ); + } +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/HooksTree.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.css similarity index 100% rename from packages/react-devtools-shared/src/devtools/views/Components/HooksTree.css rename to packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.css diff --git a/packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js similarity index 62% rename from packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js rename to packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js index 9374f4a637e06..3bb939b6d74e3 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/HooksTree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementHooksTree.js @@ -13,35 +13,35 @@ import {useCallback, useContext, useRef, useState} from 'react'; import {BridgeContext, StoreContext} from '../context'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; -import EditableValue from './EditableValue'; import ExpandCollapseToggle from './ExpandCollapseToggle'; -import {InspectedElementContext} from './InspectedElementContext'; import KeyValue from './KeyValue'; import {getMetaValueLabel, serializeHooksForCopy} from '../utils'; -import styles from './HooksTree.css'; +import Store from '../../store'; +import styles from './InspectedElementHooksTree.css'; import useContextMenu from '../../ContextMenu/useContextMenu'; import {meta} from '../../../hydration'; -import type {InspectPath} from './SelectedElement'; +import type {InspectedElement} from './types'; +import type {GetInspectedElementPath} from './InspectedElementContext'; import type {HooksNode, HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; +import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; type HooksTreeViewProps = {| - canEditHooks: boolean, - hooks: HooksTree | null, - id: number, + bridge: FrontendBridge, + getInspectedElementPath: GetInspectedElementPath, + inspectedElement: InspectedElement, + store: Store, |}; -export function HooksTreeView({canEditHooks, hooks, id}: HooksTreeViewProps) { - const {getInspectedElementPath} = useContext(InspectedElementContext); - const inspectPath = useCallback( - (path: Array) => { - getInspectedElementPath(id, ['hooks', ...path]); - }, - [getInspectedElementPath, id], - ); - const handleCopy = useCallback(() => copy(serializeHooksForCopy(hooks)), [ - hooks, - ]); +export function InspectedElementHooksTree({ + bridge, + getInspectedElementPath, + inspectedElement, + store, +}: HooksTreeViewProps) { + const {hooks, id} = inspectedElement; + + const handleCopy = () => copy(serializeHooksForCopy(hooks)); if (hooks === null) { return null; @@ -55,10 +55,10 @@ export function HooksTreeView({canEditHooks, hooks, id}: HooksTreeViewProps) {
@@ -67,44 +67,61 @@ export function HooksTreeView({canEditHooks, hooks, id}: HooksTreeViewProps) { } type InnerHooksTreeViewProps = {| - canEditHooks: boolean, + getInspectedElementPath: GetInspectedElementPath, hooks: HooksTree, id: number, - inspectPath: InspectPath, + inspectedElement: InspectedElement, path: Array, |}; export function InnerHooksTreeView({ - canEditHooks, + getInspectedElementPath, hooks, id, - inspectPath, + inspectedElement, path, }: InnerHooksTreeViewProps) { // $FlowFixMe "Missing type annotation for U" whatever that means return hooks.map((hook, index) => ( )); } type HookViewProps = {| - canEditHooks: boolean, + getInspectedElementPath: GetInspectedElementPath, hook: HooksNode, id: number, - inspectPath: InspectPath, + inspectedElement: InspectedElement, path: Array, |}; -function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { +function HookView({ + getInspectedElementPath, + hook, + id, + inspectedElement, + path, +}: HookViewProps) { + const { + canEditHooks, + canEditHooksAndDeletePaths, + canEditHooksAndRenamePaths, + } = inspectedElement; const {name, id: hookID, isStateEditable, subHooks, value} = hook; + const isReadOnly = hookID == null || !isStateEditable; + + const canDeletePaths = !isReadOnly && canEditHooksAndDeletePaths; + const canEditValues = !isReadOnly && canEditHooks; + const canRenamePaths = !isReadOnly && canEditHooksAndRenamePaths; + const bridge = useContext(BridgeContext); const store = useContext(StoreContext); @@ -127,7 +144,7 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { ? hook[(meta.type: any)] : typeof value, }, - id: 'SelectedElement', + id: 'InspectedElement', ref: contextMenuTriggerRef, }); @@ -145,6 +162,10 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { ); } + // Certain hooks are not editable at all (as identified by react-debug-tools). + // Primative hook names (e.g. the "State" name for useState) are also never editable. + const canRenamePathsAtDepth = depth => isStateEditable && depth > 1; + const isCustomHook = subHooks.length > 0; const type = typeof value; @@ -174,20 +195,28 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) { if (isCustomHook) { const subHooksView = Array.isArray(subHooks) ? ( ) : ( ); @@ -208,12 +237,20 @@ function HookView({canEditHooks, hook, id, inspectPath, path}: HookViewProps) {