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) {