diff --git a/shells/dev/app/Hydration/index.js b/shells/dev/app/Hydration/index.js new file mode 100644 index 0000000000..bf0cd66a52 --- /dev/null +++ b/shells/dev/app/Hydration/index.js @@ -0,0 +1,133 @@ +// @flow + +import React, { Fragment, useDebugValue, useState } from 'react'; + +const div = document.createElement('div'); +const exmapleFunction = () => {}; +const typedArray = new Uint8Array(3); +typedArray[0] = 1; +typedArray[1] = 2; +typedArray[2] = 3; + +const arrayOfArrays = [ + [['a', 'b', 'c'], ['d', 'e', 'f'], ['h', 'i', 'j']], + [['k', 'l', 'm'], ['n', 'o', 'p'], ['q', 'r', 's']], + [['t', 'u', 'v'], ['w', 'x', 'y'], ['z']], + [], +]; + +const objectOfObjects = { + foo: { + a: 1, + b: 2, + c: 3, + }, + bar: { + e: 4, + f: 5, + g: 6, + }, + baz: { + h: 7, + i: 8, + j: 9, + }, + qux: {}, +}; + +function useOuterFoo() { + useDebugValue({ + debugA: { + debugB: { + debugC: 'abc', + }, + }, + }); + useState({ + valueA: { + valueB: { + valueC: 'abc', + }, + }, + }); + return useInnerFoo(); +} + +function useInnerFoo() { + const [value] = useState([[['a', 'b', 'c']]]); + return value; +} + +function useOuterBar() { + useDebugValue({ + debugA: { + debugB: { + debugC: 'abc', + }, + }, + }); + return useInnerBar(); +} + +function useInnerBar() { + useDebugValue({ + debugA: { + debugB: { + debugC: 'abc', + }, + }, + }); + const [count] = useState(123); + return count; +} + +function useOuterBaz() { + return useInnerBaz(); +} + +function useInnerBaz() { + const [count] = useState(123); + return count; +} + +export default function Hydration() { + return ( + +

Hydration

+ } + array_buffer={typedArray.buffer} + typed_array={typedArray} + date={new Date()} + array={arrayOfArrays} + object={objectOfObjects} + /> + +
+ ); +} + +function DehydratableProps({ array, object }: any) { + return ( + + ); +} + +function DeepHooks(props: any) { + const foo = useOuterFoo(); + const bar = useOuterBar(); + const baz = useOuterBaz(); + return ( + + ); +} diff --git a/shells/dev/app/index.js b/shells/dev/app/index.js index 1a2336982c..b5f0923c1b 100644 --- a/shells/dev/app/index.js +++ b/shells/dev/app/index.js @@ -10,6 +10,7 @@ import { import DeeplyNestedComponents from './DeeplyNestedComponents'; import EditableProps from './EditableProps'; import ElementTypes from './ElementTypes'; +import Hydration from './Hydration'; import InspectableElements from './InspectableElements'; import InteractionTracing from './InteractionTracing'; import PriorityLevels from './PriorityLevels'; @@ -36,6 +37,7 @@ function mountTestApp() { mountHelper(ToDoList); mountHelper(InteractionTracing); mountHelper(InspectableElements); + mountHelper(Hydration); mountHelper(ElementTypes); mountHelper(EditableProps); mountHelper(PriorityLevels); diff --git a/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap b/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap index 2587fed2b4..1c9be05a4f 100644 --- a/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap +++ b/src/__tests__/__snapshots__/inspectedElementContext-test.js.snap @@ -1,11 +1,101 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`InspectedElementContext should inspect the currently selected element: 1: mount 1`] = ` -[root] - +exports[`InspectedElementContext should include updates for nested values that were previously hydrated: 1: Initially inspect element 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": null, + "props": { + "nestedObject": { + "a": {}, + "c": {} + } + }, + "state": null +} `; -exports[`InspectedElementContext should inspect the currently selected element: 2: Inspected element 2 1`] = ` +exports[`InspectedElementContext should include updates for nested values that were previously hydrated: 2: Inspect props.nestedObject.a 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": null, + "props": { + "nestedObject": { + "a": { + "value": 1, + "b": { + "value": 1 + } + }, + "c": {} + } + }, + "state": null +} +`; + +exports[`InspectedElementContext should include updates for nested values that were previously hydrated: 3: Inspect props.nestedObject.c 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": null, + "props": { + "nestedObject": { + "a": { + "value": 1, + "b": { + "value": 1 + } + }, + "c": { + "value": 1, + "d": { + "value": 1, + "e": {} + } + } + } + }, + "state": null +} +`; + +exports[`InspectedElementContext should include updates for nested values that were previously hydrated: 4: update inspected element 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": null, + "props": { + "nestedObject": { + "a": { + "value": 2, + "b": { + "value": 2 + } + }, + "c": { + "value": 2, + "d": { + "value": 2, + "e": {} + } + } + } + }, + "state": null +} +`; + +exports[`InspectedElementContext should inspect the currently selected element: 1: Inspected element 2 1`] = ` { "id": 2, "owners": null, @@ -21,20 +111,218 @@ exports[`InspectedElementContext should inspect the currently selected element: } ], "props": { - "foo": 1, - "bar": "abc" + "a": 1, + "b": "abc" }, "state": null } `; -exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 1: mount 1`] = ` -[root] - ▾ - +exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 1: Initially inspect element 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": [ + { + "id": 0, + "isStateEditable": true, + "name": "State", + "value": { + "foo": {} + }, + "subHooks": [] + } + ], + "props": { + "nestedObject": { + "a": {} + } + }, + "state": null +} `; -exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 2: initial render 1`] = ` +exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 2: Inspect props.nestedObject.a 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": [ + { + "id": 0, + "isStateEditable": true, + "name": "State", + "value": { + "foo": {} + }, + "subHooks": [] + } + ], + "props": { + "nestedObject": { + "a": { + "b": { + "c": {} + } + } + } + }, + "state": null +} +`; + +exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 3: Inspect props.nestedObject.a.b.c 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": [ + { + "id": 0, + "isStateEditable": true, + "name": "State", + "value": { + "foo": {} + }, + "subHooks": [] + } + ], + "props": { + "nestedObject": { + "a": { + "b": { + "c": [ + { + "d": {} + } + ] + } + } + } + }, + "state": null +} +`; + +exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 4: Inspect props.nestedObject.a.b.c.0.d 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": [ + { + "id": 0, + "isStateEditable": true, + "name": "State", + "value": { + "foo": {} + }, + "subHooks": [] + } + ], + "props": { + "nestedObject": { + "a": { + "b": { + "c": [ + { + "d": { + "e": {} + } + } + ] + } + } + } + }, + "state": null +} +`; + +exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 5: Inspect hooks.0.value 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": [ + { + "id": 0, + "isStateEditable": true, + "name": "State", + "value": { + "foo": { + "bar": {} + } + }, + "subHooks": [] + } + ], + "props": { + "nestedObject": { + "a": { + "b": { + "c": [ + { + "d": { + "e": {} + } + } + ] + } + } + } + }, + "state": null +} +`; + +exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 6: Inspect hooks.0.value.foo.bar 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": [ + { + "id": 0, + "isStateEditable": true, + "name": "State", + "value": { + "foo": { + "bar": { + "baz": "hi" + } + } + }, + "subHooks": [] + } + ], + "props": { + "nestedObject": { + "a": { + "b": { + "c": [ + { + "d": { + "e": {} + } + } + ] + } + } + } + }, + "state": null +} +`; + +exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 1: initial render 1`] = ` { "id": 3, "owners": null, @@ -50,14 +338,14 @@ exports[`InspectedElementContext should not re-render a function with hooks if i } ], "props": { - "foo": 1, - "bar": "abc" + "a": 1, + "b": "abc" }, "state": null } `; -exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 3: updated state 1`] = ` +exports[`InspectedElementContext should not re-render a function with hooks if it did not update since it was last inspected: 2: updated state 1`] = ` { "id": 3, "owners": null, @@ -73,19 +361,14 @@ exports[`InspectedElementContext should not re-render a function with hooks if i } ], "props": { - "foo": 2, - "bar": "def" + "a": 2, + "b": "def" }, "state": null } `; -exports[`InspectedElementContext should poll for updates for the currently selected element: 1: mount 1`] = ` -[root] - -`; - -exports[`InspectedElementContext should poll for updates for the currently selected element: 2: initial render 1`] = ` +exports[`InspectedElementContext should not tear if hydration is requested after an update: 1: Initially inspect element 1`] = ` { "id": 2, "owners": null, @@ -93,8 +376,47 @@ exports[`InspectedElementContext should poll for updates for the currently selec "events": null, "hooks": null, "props": { - "foo": 1, - "bar": "abc" + "nestedObject": { + "value": 1, + "a": {} + } + }, + "state": null +} +`; + +exports[`InspectedElementContext should not tear if hydration is requested after an update: 2: Inspect props.nestedObject.a 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": null, + "props": { + "nestedObject": { + "value": 2, + "a": { + "value": 2, + "b": { + "value": 2 + } + } + } + }, + "state": null +} +`; + +exports[`InspectedElementContext should poll for updates for the currently selected element: 1: initial render 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": null, + "props": { + "a": 1, + "b": "abc" }, "state": null } @@ -108,19 +430,34 @@ exports[`InspectedElementContext should poll for updates for the currently selec "events": null, "hooks": null, "props": { - "foo": 2, - "bar": "def" + "a": 2, + "b": "def" }, "state": null } `; -exports[`InspectedElementContext should support custom objects with enumerable properties and getters: 1: mount 1`] = ` -[root] - +exports[`InspectedElementContext should support complex data types: 1: Inspected element 2 1`] = ` +{ + "id": 2, + "owners": null, + "context": null, + "events": null, + "hooks": null, + "props": { + "html_element": {}, + "fn": {}, + "symbol": {}, + "react_element": {}, + "array_buffer": {}, + "typed_array": {}, + "date": {} + }, + "state": null +} `; -exports[`InspectedElementContext should support custom objects with enumerable properties and getters: 2: Inspected element 2 1`] = ` +exports[`InspectedElementContext should support custom objects with enumerable properties and getters: 1: Inspected element 2 1`] = ` { "id": 2, "owners": null, diff --git a/src/__tests__/inspectedElementContext-test.js b/src/__tests__/inspectedElementContext-test.js index 1a66f1dcd1..105a23135f 100644 --- a/src/__tests__/inspectedElementContext-test.js +++ b/src/__tests__/inspectedElementContext-test.js @@ -1,7 +1,7 @@ // @flow import typeof ReactTestRenderer from 'react-test-renderer'; -import type { Element } from 'src/devtools/views/Components/types'; +import type { GetInspectedElementPath } from 'src/devtools/views/Components/InspectedElementContext'; import type Bridge from 'src/bridge'; import type Store from 'src/devtools/store'; @@ -11,24 +11,29 @@ describe('InspectedElementContext', () => { let TestRenderer: ReactTestRenderer; let bridge: Bridge; let store: Store; + let meta; let utils; let BridgeContext; let InspectedElementContext; let InspectedElementContextController; let StoreContext; + let TestUtils; let TreeContextController; beforeEach(() => { utils = require('./utils'); utils.beforeEachProfiling(); + meta = require('src/hydration').meta; + bridge = global.bridge; store = global.store; store.collapseNodesByDefault = false; React = require('react'); ReactDOM = require('react-dom'); + TestUtils = require('react-dom/test-utils'); TestRenderer = utils.requireTestRenderer(); BridgeContext = require('src/devtools/views/context').BridgeContext; @@ -68,20 +73,17 @@ describe('InspectedElementContext', () => { const container = document.createElement('div'); await utils.actAsync(() => - ReactDOM.render(, container) + ReactDOM.render(, container) ); - expect(store).toMatchSnapshot('1: mount'); - const example = ((store.getElementAtIndex(0): any): Element); + const id = ((store.getElementIDAtIndex(0): any): number); let didFinish = false; function Suspender({ target }) { - const { read } = React.useContext(InspectedElementContext); - const inspectedElement = read(target.id); - expect(inspectedElement).toMatchSnapshot( - `2: Inspected element ${target.id}` - ); + const { getInspectedElement } = React.useContext(InspectedElementContext); + const inspectedElement = getInspectedElement(id); + expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); didFinish = true; return null; } @@ -90,11 +92,11 @@ describe('InspectedElementContext', () => { () => TestRenderer.create( - + ), @@ -110,18 +112,17 @@ describe('InspectedElementContext', () => { const container = document.createElement('div'); await utils.actAsync( - () => ReactDOM.render(, container), + () => ReactDOM.render(, container), false ); - expect(store).toMatchSnapshot('1: mount'); - const example = ((store.getElementAtIndex(0): any): Element); + const id = ((store.getElementIDAtIndex(0): any): number); let inspectedElement = null; function Suspender({ target }) { - const { read } = React.useContext(InspectedElementContext); - inspectedElement = read(target.id); + const { getInspectedElement } = React.useContext(InspectedElementContext); + inspectedElement = getInspectedElement(id); return null; } @@ -129,20 +130,17 @@ describe('InspectedElementContext', () => { await utils.actAsync(() => { renderer = TestRenderer.create( - + - + ); }, false); - expect(inspectedElement).toMatchSnapshot('2: initial render'); + expect(inspectedElement).toMatchSnapshot('1: initial render'); await utils.actAsync( - () => ReactDOM.render(, container), + () => ReactDOM.render(, container), false ); @@ -151,11 +149,11 @@ describe('InspectedElementContext', () => { () => renderer.update( - + ), @@ -180,20 +178,19 @@ describe('InspectedElementContext', () => { await utils.actAsync(() => ReactDOM.render( - + , container ) ); - expect(store).toMatchSnapshot('1: mount'); const id = ((store.getElementIDAtIndex(1): any): number); let inspectedElement = null; function Suspender({ target }) { - const { read } = React.useContext(InspectedElementContext); - inspectedElement = read(target); + const { getInspectedElement } = React.useContext(InspectedElementContext); + inspectedElement = getInspectedElement(target); return null; } @@ -215,7 +212,7 @@ describe('InspectedElementContext', () => { false ); expect(targetRenderCount).toBe(1); - expect(inspectedElement).toMatchSnapshot('2: initial render'); + expect(inspectedElement).toMatchSnapshot('1: initial render'); const initialInspectedElement = inspectedElement; @@ -244,7 +241,7 @@ describe('InspectedElementContext', () => { () => ReactDOM.render( - + , container ), @@ -253,7 +250,93 @@ describe('InspectedElementContext', () => { // Target should have been rendered once (by ReactDOM) and once by DevTools for inspection. expect(targetRenderCount).toBe(2); - expect(inspectedElement).toMatchSnapshot('3: updated state'); + expect(inspectedElement).toMatchSnapshot('2: updated state'); + + done(); + }); + + it('should support complex data types', async done => { + const Example = () => null; + + const div = document.createElement('div'); + const exmapleFunction = () => {}; + const typedArray = new Uint8Array(3); + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + } + array_buffer={typedArray.buffer} + typed_array={typedArray} + date={new Date()} + />, + container + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let inspectedElement = null; + + function Suspender({ target }) { + const { getInspectedElement } = React.useContext(InspectedElementContext); + inspectedElement = getInspectedElement(id); + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + false + ); + + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); + + const { + html_element, + fn, + symbol, + react_element, + array_buffer, + typed_array, + date, + } = (inspectedElement: any).props; + expect(html_element[meta.inspectable]).toBe(false); + expect(html_element[meta.name]).toBe('DIV'); + expect(html_element[meta.type]).toBe('html_element'); + expect(fn[meta.inspectable]).toBe(false); + expect(fn[meta.name]).toBe('exmapleFunction'); + expect(fn[meta.type]).toBe('function'); + expect(symbol[meta.inspectable]).toBe(false); + expect(symbol[meta.name]).toBe('Symbol(symbol)'); + expect(symbol[meta.type]).toBe('symbol'); + expect(react_element[meta.inspectable]).toBe(false); + expect(react_element[meta.name]).toBe('span'); + expect(react_element[meta.type]).toBe('react_element'); + expect(array_buffer[meta.size]).toBe(3); + expect(array_buffer[meta.inspectable]).toBe(false); + expect(array_buffer[meta.name]).toBe('ArrayBuffer'); + expect(array_buffer[meta.type]).toBe('array_buffer'); + expect(typed_array[meta.size]).toBe(3); + expect(typed_array[meta.inspectable]).toBe(false); + expect(typed_array[meta.name]).toBe('Uint8Array'); + expect(typed_array[meta.type]).toBe('typed_array'); + expect(date[meta.inspectable]).toBe(false); + expect(date[meta.type]).toBe('date'); done(); }); @@ -276,24 +359,21 @@ describe('InspectedElementContext', () => { descriptor.enumerable = true; Object.defineProperty(CustomData.prototype, 'number', descriptor); - const Example = ({ data }) => null; + const Example = () => null; const container = document.createElement('div'); await utils.actAsync(() => ReactDOM.render(, container) ); - expect(store).toMatchSnapshot('1: mount'); - const example = ((store.getElementAtIndex(0): any): Element); + const id = ((store.getElementIDAtIndex(0): any): number); let didFinish = false; function Suspender({ target }) { - const { read } = React.useContext(InspectedElementContext); - const inspectedElement = read(target.id); - expect(inspectedElement).toMatchSnapshot( - `2: Inspected element ${target.id}` - ); + const { getInspectedElement } = React.useContext(InspectedElementContext); + const inspectedElement = getInspectedElement(id); + expect(inspectedElement).toMatchSnapshot(`1: Inspected element ${id}`); didFinish = true; return null; } @@ -302,11 +382,11 @@ describe('InspectedElementContext', () => { () => TestRenderer.create( - + ), @@ -316,4 +396,315 @@ describe('InspectedElementContext', () => { done(); }); + + it('should not dehydrate nested values until explicitly requested', async done => { + const Example = () => { + const [state] = React.useState({ + foo: { + bar: { + baz: 'hi', + }, + }, + }); + + return state.foo.bar.baz; + }; + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + , + container + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let getInspectedElementPath: GetInspectedElementPath = ((null: any): GetInspectedElementPath); + let inspectedElement = null; + + function Suspender({ target }) { + const context = React.useContext(InspectedElementContext); + getInspectedElementPath = context.getInspectedElementPath; + inspectedElement = context.getInspectedElement(target); + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + false + ); + expect(getInspectedElementPath).not.toBeNull(); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('1: Initially inspect element'); + + inspectedElement = null; + TestUtils.act(() => { + getInspectedElementPath(id, ['props', 'nestedObject', 'a']); + jest.runOnlyPendingTimers(); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a'); + + inspectedElement = null; + TestUtils.act(() => { + getInspectedElementPath(id, ['props', 'nestedObject', 'a', 'b', 'c']); + jest.runOnlyPendingTimers(); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot( + '3: Inspect props.nestedObject.a.b.c' + ); + + inspectedElement = null; + TestUtils.act(() => { + getInspectedElementPath(id, [ + 'props', + 'nestedObject', + 'a', + 'b', + 'c', + 0, + 'd', + ]); + jest.runOnlyPendingTimers(); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot( + '4: Inspect props.nestedObject.a.b.c.0.d' + ); + + inspectedElement = null; + TestUtils.act(() => { + getInspectedElementPath(id, ['hooks', 0, 'value']); + jest.runOnlyPendingTimers(); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('5: Inspect hooks.0.value'); + + inspectedElement = null; + TestUtils.act(() => { + getInspectedElementPath(id, ['hooks', 0, 'value', 'foo', 'bar']); + jest.runOnlyPendingTimers(); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot( + '6: Inspect hooks.0.value.foo.bar' + ); + + done(); + }); + + it('should include updates for nested values that were previously hydrated', async done => { + const Example = () => null; + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + , + container + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let getInspectedElementPath: GetInspectedElementPath = ((null: any): GetInspectedElementPath); + let inspectedElement = null; + + function Suspender({ target }) { + const context = React.useContext(InspectedElementContext); + getInspectedElementPath = context.getInspectedElementPath; + inspectedElement = context.getInspectedElement(id); + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + false + ); + expect(getInspectedElementPath).not.toBeNull(); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('1: Initially inspect element'); + + inspectedElement = null; + TestUtils.act(() => { + getInspectedElementPath(id, ['props', 'nestedObject', 'a']); + jest.runOnlyPendingTimers(); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a'); + + inspectedElement = null; + TestUtils.act(() => { + getInspectedElementPath(id, ['props', 'nestedObject', 'c']); + jest.runOnlyPendingTimers(); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('3: Inspect props.nestedObject.c'); + + TestUtils.act(() => { + ReactDOM.render( + , + container + ); + }); + + TestUtils.act(() => { + inspectedElement = null; + jest.advanceTimersByTime(1000); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('4: update inspected element'); + }); + + done(); + }); + + it('should not tear if hydration is requested after an update', async done => { + const Example = () => null; + + const container = document.createElement('div'); + await utils.actAsync(() => + ReactDOM.render( + , + container + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let getInspectedElementPath: GetInspectedElementPath = ((null: any): GetInspectedElementPath); + let inspectedElement = null; + + function Suspender({ target }) { + const context = React.useContext(InspectedElementContext); + getInspectedElementPath = context.getInspectedElementPath; + inspectedElement = context.getInspectedElement(id); + return null; + } + + await utils.actAsync( + () => + TestRenderer.create( + + + + + + ), + false + ); + expect(getInspectedElementPath).not.toBeNull(); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('1: Initially inspect element'); + + TestUtils.act(() => { + ReactDOM.render( + , + container + ); + }); + + inspectedElement = null; + TestUtils.act(() => { + getInspectedElementPath(id, ['props', 'nestedObject', 'a']); + jest.runOnlyPendingTimers(); + }); + expect(inspectedElement).not.toBeNull(); + expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a'); + + done(); + }); }); diff --git a/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap b/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap new file mode 100644 index 0000000000..db3aec6f29 --- /dev/null +++ b/src/__tests__/legacy/__snapshots__/inspectElement-test.js.snap @@ -0,0 +1,167 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InspectedElementContext should inspect the currently selected element: 1: Initial inspection 1`] = ` +Object { + "id": 2, + "type": "full-data", + "value": { + "id": 2, + "owners": null, + "context": {}, + "events": null, + "hooks": null, + "props": { + "a": 1, + "b": "abc" + }, + "state": null +}, +} +`; + +exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 1: Initially inspect element 1`] = ` +Object { + "id": 2, + "type": "full-data", + "value": { + "id": 2, + "owners": null, + "context": {}, + "events": null, + "hooks": null, + "props": { + "nestedObject": { + "a": {} + } + }, + "state": null +}, +} +`; + +exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 2: Inspect props.nestedObject.a 1`] = ` +Object { + "id": 2, + "type": "full-data", + "value": { + "id": 2, + "owners": null, + "context": {}, + "events": null, + "hooks": null, + "props": { + "nestedObject": { + "a": { + "b": { + "c": {} + } + } + } + }, + "state": null +}, +} +`; + +exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 3: Inspect props.nestedObject.a.b.c 1`] = ` +Object { + "id": 2, + "type": "full-data", + "value": { + "id": 2, + "owners": null, + "context": {}, + "events": null, + "hooks": null, + "props": { + "nestedObject": { + "a": { + "b": { + "c": [ + { + "d": {} + } + ] + } + } + } + }, + "state": null +}, +} +`; + +exports[`InspectedElementContext should not dehydrate nested values until explicitly requested: 4: Inspect props.nestedObject.a.b.c.0.d 1`] = ` +Object { + "id": 2, + "type": "full-data", + "value": { + "id": 2, + "owners": null, + "context": {}, + "events": null, + "hooks": null, + "props": { + "nestedObject": { + "a": { + "b": { + "c": [ + { + "d": { + "e": {} + } + } + ] + } + } + } + }, + "state": null +}, +} +`; + +exports[`InspectedElementContext should support complex data types: 1: Initial inspection 1`] = ` +Object { + "id": 2, + "type": "full-data", + "value": { + "id": 2, + "owners": null, + "context": {}, + "events": null, + "hooks": null, + "props": { + "html_element": {}, + "fn": {}, + "symbol": {}, + "react_element": {}, + "array_buffer": {}, + "typed_array": {}, + "date": {} + }, + "state": null +}, +} +`; + +exports[`InspectedElementContext should support custom objects with enumerable properties and getters: 1: Initial inspection 1`] = ` +Object { + "id": 2, + "type": "full-data", + "value": { + "id": 2, + "owners": null, + "context": {}, + "events": null, + "hooks": null, + "props": { + "data": { + "_number": 42, + "number": 42 + } + }, + "state": null +}, +} +`; diff --git a/src/__tests__/legacy/inspectElement-test.js b/src/__tests__/legacy/inspectElement-test.js new file mode 100644 index 0000000000..23e384e529 --- /dev/null +++ b/src/__tests__/legacy/inspectElement-test.js @@ -0,0 +1,240 @@ +// @flow + +import type { InspectedElementPayload } from 'src/backend/types'; +import type { DehydratedData } from 'src/devtools/views/Components/types'; +import type Bridge from 'src/bridge'; +import type Store from 'src/devtools/store'; + +describe('InspectedElementContext', () => { + let React; + let ReactDOM; + let hydrate; + let meta; + let bridge: Bridge; + let store: Store; + + const act = (callback: Function) => { + callback(); + + jest.runAllTimers(); // Flush Bridge operations + }; + + function dehydrateHelper( + dehydratedData: DehydratedData | null + ): Object | null { + if (dehydratedData !== null) { + return hydrate(dehydratedData.data, dehydratedData.cleaned); + } else { + return null; + } + } + + async function read( + id: number, + path?: Array + ): Promise { + return new Promise((resolve, reject) => { + const rendererID = ((store.getRendererIDForElement(id): any): number); + + const onInspectedElement = (payload: InspectedElementPayload) => { + bridge.removeListener('inspectedElement', onInspectedElement); + + if (payload.type === 'full-data' && payload.value !== null) { + payload.value.context = dehydrateHelper(payload.value.context); + payload.value.props = dehydrateHelper(payload.value.props); + payload.value.state = dehydrateHelper(payload.value.state); + } + + resolve(payload); + }; + + bridge.addListener('inspectedElement', onInspectedElement); + bridge.send('inspectElement', { id, path, rendererID }); + + jest.runOnlyPendingTimers(); + }); + } + + beforeEach(() => { + bridge = global.bridge; + store = global.store; + + hydrate = require('src/hydration').hydrate; + meta = require('src/hydration').meta; + + // 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'); + }); + + it('should inspect the currently selected element', async done => { + const Example = () => null; + + act(() => + ReactDOM.render(, document.createElement('div')) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + const inspectedElement = await read(id); + + expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); + + done(); + }); + + it('should support complex data types', async done => { + const Example = () => null; + + const div = document.createElement('div'); + const exmapleFunction = () => {}; + const typedArray = new Uint8Array(3); + + act(() => + ReactDOM.render( + } + array_buffer={typedArray.buffer} + typed_array={typedArray} + date={new Date()} + />, + document.createElement('div') + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + const inspectedElement = await read(id); + + expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); + + const { + html_element, + fn, + symbol, + react_element, + array_buffer, + typed_array, + date, + } = inspectedElement.value.props; + expect(html_element[meta.inspectable]).toBe(false); + expect(html_element[meta.name]).toBe('DIV'); + expect(html_element[meta.type]).toBe('html_element'); + expect(fn[meta.inspectable]).toBe(false); + expect(fn[meta.name]).toBe('exmapleFunction'); + expect(fn[meta.type]).toBe('function'); + expect(symbol[meta.inspectable]).toBe(false); + expect(symbol[meta.name]).toBe('Symbol(symbol)'); + expect(symbol[meta.type]).toBe('symbol'); + expect(react_element[meta.inspectable]).toBe(false); + expect(react_element[meta.name]).toBe('span'); + expect(react_element[meta.type]).toBe('react_element'); + expect(array_buffer[meta.size]).toBe(3); + expect(array_buffer[meta.inspectable]).toBe(false); + expect(array_buffer[meta.name]).toBe('ArrayBuffer'); + expect(array_buffer[meta.type]).toBe('array_buffer'); + expect(typed_array[meta.size]).toBe(3); + expect(typed_array[meta.inspectable]).toBe(false); + expect(typed_array[meta.name]).toBe('Uint8Array'); + expect(typed_array[meta.type]).toBe('typed_array'); + expect(date[meta.inspectable]).toBe(false); + expect(date[meta.type]).toBe('date'); + + done(); + }); + + it('should support custom objects with enumerable properties and getters', async done => { + class CustomData { + _number = 42; + get number() { + return this._number; + } + set number(value) { + this._number = value; + } + } + + const descriptor = ((Object.getOwnPropertyDescriptor( + CustomData.prototype, + 'number' + ): any): PropertyDescriptor); + descriptor.enumerable = true; + Object.defineProperty(CustomData.prototype, 'number', descriptor); + + const Example = ({ data }) => null; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + const inspectedElement = await read(id); + + expect(inspectedElement).toMatchSnapshot('1: Initial inspection'); + + done(); + }); + + it('should not dehydrate nested values until explicitly requested', async done => { + const Example = () => null; + + act(() => + ReactDOM.render( + , + document.createElement('div') + ) + ); + + const id = ((store.getElementIDAtIndex(0): any): number); + + let inspectedElement = await read(id); + expect(inspectedElement).toMatchSnapshot('1: Initially inspect element'); + + inspectedElement = await read(id, ['props', 'nestedObject', 'a']); + expect(inspectedElement).toMatchSnapshot('2: Inspect props.nestedObject.a'); + + inspectedElement = await read(id, ['props', 'nestedObject', 'a', 'b', 'c']); + expect(inspectedElement).toMatchSnapshot( + '3: Inspect props.nestedObject.a.b.c' + ); + + inspectedElement = await read(id, [ + 'props', + 'nestedObject', + 'a', + 'b', + 'c', + 0, + 'd', + ]); + expect(inspectedElement).toMatchSnapshot( + '4: Inspect props.nestedObject.a.b.c.0.d' + ); + + done(); + }); +}); diff --git a/src/backend/agent.js b/src/backend/agent.js index d8dcc15862..75ca51b201 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -42,6 +42,12 @@ type ElementAndRendererID = {| rendererID: number, |}; +type InspectElementParams = {| + id: number, + path?: Array, + rendererID: number, +|}; + type OverrideHookParams = {| id: number, hookID: number, @@ -242,12 +248,12 @@ export default class Agent extends EventEmitter<{| } }; - inspectElement = ({ id, rendererID }: ElementAndRendererID) => { + inspectElement = ({ id, path, rendererID }: InspectElementParams) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); } else { - this._bridge.send('inspectedElement', renderer.inspectElement(id)); + this._bridge.send('inspectedElement', renderer.inspectElement(id, path)); } }; diff --git a/src/backend/legacy/renderer.js b/src/backend/legacy/renderer.js index 272dbcc35e..2e22bc7337 100644 --- a/src/backend/legacy/renderer.js +++ b/src/backend/legacy/renderer.js @@ -21,6 +21,7 @@ import { decorateMany, forceUpdate, restoreMany } from './utils'; import type { DevToolsHook, GetFiberIDForNative, + InspectedElementPayload, NativeType, PathFrame, PathMatch, @@ -548,16 +549,78 @@ export function attach( return stringID; } - function inspectElement(id: number): InspectedElement | null { - let result = inspectElementRaw(id); - if (result === null) { - return null; + let currentlyInspectedElementID: number | null = null; + let currentlyInspectedPaths: Object = {}; + + // Track the intersection of currently inspected paths, + // so that we can send their data along if the element is re-rendered. + function mergeInspectedPaths(path: Array) { + let current = currentlyInspectedPaths; + path.forEach(key => { + if (!current[key]) { + current[key] = {}; + } + current = current[key]; + }); + } + + function createIsPathWhitelisted(key: string) { + // This function helps prevent previously-inspected paths from being dehydrated in updates. + // This is important to avoid a bad user experience where expanded toggles collapse on update. + return function isPathWhitelisted(path: Array): boolean { + let current = currentlyInspectedPaths[key]; + if (!current) { + return false; + } + for (let i = 0; i < path.length; i++) { + current = current[path[i]]; + if (!current) { + return false; + } + } + return true; + }; + } + + function inspectElement( + id: number, + path?: Array + ): InspectedElementPayload { + if (currentlyInspectedElementID !== id) { + currentlyInspectedElementID = id; + currentlyInspectedPaths = {}; } - // TODO Review sanitization approach for the below inspectable values. - result.context = cleanForBridge(result.context); - result.props = cleanForBridge(result.props); - result.state = cleanForBridge(result.state); - return result; + + const inspectedElement = inspectElementRaw(id); + if (inspectedElement === null) { + return { + id, + type: 'not-found', + }; + } + + if (path != null) { + mergeInspectedPaths(path); + } + + inspectedElement.context = cleanForBridge( + inspectedElement.context, + createIsPathWhitelisted('context') + ); + inspectedElement.props = cleanForBridge( + inspectedElement.props, + createIsPathWhitelisted('props') + ); + inspectedElement.state = cleanForBridge( + inspectedElement.state, + createIsPathWhitelisted('state') + ); + + return { + id, + type: 'full-data', + value: inspectedElement, + }; } function inspectElementRaw(id: number): InspectedElement | null { diff --git a/src/backend/renderer.js b/src/backend/renderer.js index 79ed649c89..9041160850 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -22,11 +22,13 @@ import { import { getDisplayName, getDefaultComponentFilters, + getInObject, getUID, + setInObject, utfEncodeString, } from 'src/utils'; import { sessionStorageGetItem } from 'src/storage'; -import { cleanForBridge, copyWithSet, setInObject } from './utils'; +import { cleanForBridge, copyWithSet } from './utils'; import { __DEBUG__, SESSION_STORAGE_RELOAD_AND_PROFILE_KEY, @@ -44,6 +46,7 @@ import type { DevToolsHook, Fiber, InspectedElement, + InspectedElementPayload, Owner, PathFrame, PathMatch, @@ -1297,8 +1300,8 @@ export function attach( } if ( - mostRecentlyInspectedElementID !== null && - mostRecentlyInspectedElementID === + mostRecentlyInspectedElement !== null && + mostRecentlyInspectedElement.id === getFiberID(getPrimaryFiber(nextFiber)) && didFiberRender(prevFiber, nextFiber) ) { @@ -2122,37 +2125,158 @@ export function attach( }; } - let mostRecentlyInspectedElementID: number | null = null; + let mostRecentlyInspectedElement: InspectedElement | null = null; let hasElementUpdatedSinceLastInspected: boolean = false; + let currentlyInspectedPaths: Object = {}; - function inspectElement(id: number): InspectedElement | number | null { - // If this element has not been updated since it was last inspected, we don't need to re-run it. - // Instead we can just return the ID to indicate that it has not changed. - if ( - mostRecentlyInspectedElementID === id && + function isMostRecentlyInspectedElementCurrent(id: number): boolean { + return ( + mostRecentlyInspectedElement !== null && + mostRecentlyInspectedElement.id === id && !hasElementUpdatedSinceLastInspected - ) { - return id; + ); + } + + // Track the intersection of currently inspected paths, + // so that we can send their data along if the element is re-rendered. + function mergeInspectedPaths(path: Array) { + let current = currentlyInspectedPaths; + path.forEach(key => { + if (!current[key]) { + current[key] = {}; + } + current = current[key]; + }); + } + + function createIsPathWhitelisted(isHooksPath: boolean, key: string | null) { + // This function helps prevent previously-inspected paths from being dehydrated in updates. + // This is important to avoid a bad user experience where expanded toggles collapse on update. + return function isPathWhitelisted(path: Array): boolean { + // Dehydrating the 'subHooks' property makes the HooksTree UI a lot more complicated, + // so it's easiest for now if we just don't break on this boundary. + // We can always dehydrate a level deeper (in the value object). + if (isHooksPath) { + if (path.length === 1) { + // Never dehydrate the hooks object at the top level. + return true; + } + if ( + path[path.length - 1] === 'subHooks' || + path[path.length - 2] === 'subHooks' + ) { + // Never dehydrate the subHooks array + return true; + } + } + + let current = + key === null ? currentlyInspectedPaths : currentlyInspectedPaths[key]; + if (!current) { + return false; + } + for (let i = 0; i < path.length; i++) { + current = current[path[i]]; + if (!current) { + return false; + } + } + return true; + }; + } + + function inspectElement( + id: number, + path?: Array + ): InspectedElementPayload { + const isCurrent = isMostRecentlyInspectedElementCurrent(id); + + if (isCurrent) { + if (path != null) { + mergeInspectedPaths(path); + + // If this element has not been updated since it was last inspected, + // we can just return the subset of data in the newly-inspected path. + return { + id, + type: 'hydrated-path', + path, + value: cleanForBridge( + getInObject( + ((mostRecentlyInspectedElement: any): InspectedElement), + path + ), + createIsPathWhitelisted(path[0] === 'hooks', null), + path + ), + }; + } else { + // If this element has not been updated since it was last inspected, we don't need to re-run it. + // Instead we can just return the ID to indicate that it has not changed. + return { + id, + type: 'no-change', + }; + } + } else { + hasElementUpdatedSinceLastInspected = false; + + if ( + mostRecentlyInspectedElement === null || + mostRecentlyInspectedElement.id !== id + ) { + currentlyInspectedPaths = {}; + } + + mostRecentlyInspectedElement = inspectElementRaw(id); + if (mostRecentlyInspectedElement === null) { + return { + id, + type: 'not-found', + }; + } + + if (path != null) { + mergeInspectedPaths(path); + } + + // Clone before cleaning so that we preserve the full data. + // This will enable us to send patches without re-inspecting if hydrated paths are requested. + // (Reducing how often we shallow-render is a better DX for function components that use hooks.) + const cleanedInspectedElement = { ...mostRecentlyInspectedElement }; + cleanedInspectedElement.context = cleanForBridge( + cleanedInspectedElement.context, + createIsPathWhitelisted(false, 'context') + ); + cleanedInspectedElement.events = cleanForBridge( + cleanedInspectedElement.events, + createIsPathWhitelisted(false, 'events') + ); + cleanedInspectedElement.hooks = cleanForBridge( + cleanedInspectedElement.hooks, + createIsPathWhitelisted(true, 'hooks') + ); + cleanedInspectedElement.props = cleanForBridge( + cleanedInspectedElement.props, + createIsPathWhitelisted(false, 'props') + ); + cleanedInspectedElement.state = cleanForBridge( + cleanedInspectedElement.state, + createIsPathWhitelisted(false, 'state') + ); + + return { + id, + type: 'full-data', + value: cleanedInspectedElement, + }; } - - mostRecentlyInspectedElementID = id; - hasElementUpdatedSinceLastInspected = false; - - const inspectedElement = inspectElementRaw(id); - if (inspectedElement === null) { - return null; - } - inspectedElement.context = cleanForBridge(inspectedElement.context); - inspectedElement.events = cleanForBridge(inspectedElement.events); - inspectedElement.hooks = cleanForBridge(inspectedElement.hooks); - inspectedElement.props = cleanForBridge(inspectedElement.props); - inspectedElement.state = cleanForBridge(inspectedElement.state); - - return inspectedElement; } function logElementToConsole(id) { - const result = inspectElementRaw(id); + const result = isMostRecentlyInspectedElementCurrent(id) + ? mostRecentlyInspectedElement + : inspectElementRaw(id); if (result === null) { console.warn(`Could not find Fiber with id "${id}"`); return; @@ -2690,10 +2814,10 @@ export function attach( return { cleanup, + findNativeNodesForFiberID, flushInitialOperations, getBestMatchForTrackedPath, getFiberIDForNative, - findNativeNodesForFiberID, getOwnersList, getPathForElement, getProfilingData, diff --git a/src/backend/types.js b/src/backend/types.js index b039d08e08..b9e3a8521e 100644 --- a/src/backend/types.js +++ b/src/backend/types.js @@ -215,6 +215,40 @@ export type InspectedElement = {| type: ElementType, |}; +export const InspectElementFullDataType = 'full-data'; +export const InspectElementNoChangeType = 'no-change'; +export const InspectElementNotFoundType = 'not-found'; +export const InspectElementHydratedPathType = 'hydrated-path'; + +type InspectElementFullData = {| + id: number, + type: 'full-data', + value: InspectedElement, +|}; + +type InspectElementHydratedPath = {| + id: number, + type: 'hydrated-path', + path: Array, + value: any, +|}; + +type InspectElementNoChange = {| + id: number, + type: 'no-change', +|}; + +type InspectElementNotFound = {| + id: number, + type: 'not-found', +|}; + +export type InspectedElementPayload = + | InspectElementFullData + | InspectElementHydratedPath + | InspectElementNoChange + | InspectElementNotFound; + export type RendererInterface = { cleanup: () => void, findNativeNodesForFiberID: FindNativeNodesForFiberID, @@ -226,7 +260,10 @@ export type RendererInterface = { getPathForElement: (id: number) => Array | null, handleCommitFiberRoot: (fiber: Object, commitPriority?: number) => void, handleCommitFiberUnmount: (fiber: Object) => void, - inspectElement: (id: number) => InspectedElement | number | null, + inspectElement: ( + id: number, + path?: Array + ) => InspectedElementPayload, logElementToConsole: (id: number) => void, overrideSuspense: (id: number, forceFallback: boolean) => void, prepareViewElementSource: (id: number) => void, diff --git a/src/backend/utils.js b/src/backend/utils.js index 7b6499ac23..020d841d46 100644 --- a/src/backend/utils.js +++ b/src/backend/utils.js @@ -4,12 +4,16 @@ import { dehydrate } from '../hydration'; import type { DehydratedData } from 'src/devtools/views/Components/types'; -export function cleanForBridge(data: Object | null): DehydratedData | null { +export function cleanForBridge( + data: Object | null, + isPathWhitelisted: (path: Array) => boolean, + path?: Array = [] +): DehydratedData | null { if (data !== null) { const cleaned = []; return { - data: dehydrate(data, cleaned), + data: dehydrate(data, cleaned, path, isPathWhitelisted), cleaned, }; } else { @@ -23,6 +27,7 @@ export function copyWithSet( value: any, index: number = 0 ): Object | Array { + console.log('[utils] copyWithSet()', obj, path, index, value); if (index >= path.length) { return value; } @@ -32,21 +37,3 @@ export function copyWithSet( updated[key] = copyWithSet(obj[key], path, value, index + 1); return updated; } - -export function setInObject( - object: Object, - path: Array, - value: any -) { - const last = path.pop(); - if (object != null) { - const parent: Object = path.reduce( - // $FlowFixMe - (reduced, attribute) => reduced[attribute], - object - ); - if (parent) { - parent[last] = value; - } - } -} diff --git a/src/bridge.js b/src/bridge.js index c6c3f1b223..1d08474e39 100644 --- a/src/bridge.js +++ b/src/bridge.js @@ -4,7 +4,7 @@ import EventEmitter from 'events'; import type { ComponentFilter, Wall } from './types'; import type { - InspectedElement, + InspectedElementPayload, OwnersList, ProfilingDataBackend, RendererID, @@ -43,6 +43,11 @@ type OverrideSuspense = {| forceFallback: boolean, |}; +type InspectElementParams = {| + ...ElementAndRendererID, + path?: Array, +|}; + export default class Bridge extends EventEmitter<{| captureScreenshot: [{| commitIndex: number, rootID: number |}], clearHighlightedElementInDOM: [], @@ -51,8 +56,8 @@ export default class Bridge extends EventEmitter<{| getProfilingStatus: [], highlightElementInDOM: [HighlightElementInDOM], init: [], - inspectElement: [ElementAndRendererID], - inspectedElement: [InspectedElement | number | null], + inspectElement: [InspectElementParams], + inspectedElement: [InspectedElementPayload], isBackendStorageAPISupported: [boolean], logElementToConsole: [ElementAndRendererID], operations: [Uint32Array], diff --git a/src/devtools/views/Components/ExpandCollapseToggle.js b/src/devtools/views/Components/ExpandCollapseToggle.js index b5425c8129..61fdca7f33 100644 --- a/src/devtools/views/Components/ExpandCollapseToggle.js +++ b/src/devtools/views/Components/ExpandCollapseToggle.js @@ -1,6 +1,6 @@ // @flow -import React, { useCallback } from 'react'; +import React from 'react'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; @@ -15,14 +15,10 @@ export default function ExpandCollapseToggle({ isOpen, setIsOpen, }: ExpandCollapseToggleProps) { - const handleClick = useCallback(() => { - setIsOpen(prevIsOpen => !prevIsOpen); - }, [setIsOpen]); - return ( - + ); } @@ -46,12 +61,16 @@ type InnerHooksTreeViewProps = {| canEditHooks: boolean, hooks: HooksTree, id: number, + inspectPath: InspectPath, + path: Array, |}; export function InnerHooksTreeView({ canEditHooks, hooks, id, + inspectPath, + path, }: InnerHooksTreeViewProps) { // $FlowFixMe "Missing type annotation for U" whatever that means return hooks.map((hook, index) => ( @@ -60,6 +79,8 @@ export function InnerHooksTreeView({ canEditHooks={canEditHooks} hook={hooks[index]} id={id} + inspectPath={inspectPath} + path={path.concat([index])} /> )); } @@ -68,10 +89,17 @@ type HookViewProps = {| canEditHooks: boolean, hook: HooksNode, id: number, - path?: Array, + inspectPath: InspectPath, + path: Array, |}; -function HookView({ canEditHooks, hook, id, path = [] }: HookViewProps) { +function HookView({ + canEditHooks, + hook, + id, + inspectPath, + path, +}: HookViewProps) { const { name, id: hookID, isStateEditable, subHooks, value } = hook; const bridge = useContext(BridgeContext); @@ -86,7 +114,9 @@ function HookView({ canEditHooks, hook, id, path = [] }: HookViewProps) { if (hook.hasOwnProperty(meta.inspected)) { // This Hook is too deep and hasn't been hydrated. - // TODO: show UI to load its data. + if (__DEV__) { + console.warn('Unexpected dehydrated hook; this is a DevTools error.'); + } return (
@@ -96,8 +126,6 @@ function HookView({ canEditHooks, hook, id, path = [] }: HookViewProps) { ); } - // TODO Add click and key handlers for toggling element open/close state. - const isCustomHook = subHooks.length > 0; const type = typeof value; @@ -125,6 +153,24 @@ function HookView({ canEditHooks, hook, id, path = [] }: HookViewProps) { } if (isCustomHook) { + const subHooksView = Array.isArray(subHooks) ? ( + + ) : ( + + ); + if (isComplexDisplayValue) { return (
@@ -135,12 +181,14 @@ function HookView({ canEditHooks, hook, id, path = [] }: HookViewProps) {
); @@ -156,11 +204,7 @@ function HookView({ canEditHooks, hook, id, path = [] }: HookViewProps) { {displayValue}
); @@ -169,12 +213,16 @@ function HookView({ canEditHooks, hook, id, path = [] }: HookViewProps) { let overrideValueFn = null; // TODO Maybe read editable value from debug hook? if (canEditHooks && isStateEditable) { - overrideValueFn = (path: Array, value: any) => { + overrideValueFn = (absolutePath: Array, value: any) => { const rendererID = store.getRendererIDForElement(id); bridge.send('overrideHookState', { id, hookID, - path, + // Hooks override function expects a relative path for the specified hook (id), + // starting with its id within the (flat) hooks list structure. + // This relative path does not include the fake tree structure DevTools uses for display, + // so it's important that we remove that part of the path before sending the update. + path: absolutePath.slice(path.length + 1), rendererID, value, }); @@ -186,8 +234,10 @@ function HookView({ canEditHooks, hook, id, path = [] }: HookViewProps) {
diff --git a/src/devtools/views/Components/InspectedElementContext.js b/src/devtools/views/Components/InspectedElementContext.js index 119cb47d9e..97efcac574 100644 --- a/src/devtools/views/Components/InspectedElementContext.js +++ b/src/devtools/views/Components/InspectedElementContext.js @@ -8,13 +8,17 @@ import React, { useMemo, useState, } from 'react'; +import { unstable_batchedUpdates as batchedUpdates } from 'react-dom'; import { createResource } from '../../cache'; import { BridgeContext, StoreContext } from '../context'; -import { hydrate } from 'src/hydration'; +import { hydrate, fillInPath } from 'src/hydration'; import { TreeStateContext } from './TreeContext'; import { separateDisplayNameAndHOCs } from 'src/utils'; -import type { InspectedElement as InspectedElementBackend } from 'src/backend/types'; +import type { + InspectedElement as InspectedElementBackend, + InspectedElementPayload, +} from 'src/backend/types'; import type { DehydratedData, Element, @@ -22,8 +26,17 @@ import type { } from 'src/devtools/views/Components/types'; import type { Resource, Thenable } from '../../cache'; +export type GetInspectedElementPath = ( + id: number, + path: Array +) => void; +export type GetInspectedElement = ( + id: number +) => InspectedElementFrontend | null; + type Context = {| - read(id: number): InspectedElementFrontend | null, + getInspectedElementPath: GetInspectedElementPath, + getInspectedElement: GetInspectedElement, |}; const InspectedElementContext = createContext(((null: any): Context)); @@ -68,7 +81,16 @@ function InspectedElementContextController({ children }: Props) { const bridge = useContext(BridgeContext); const store = useContext(StoreContext); - const read = useCallback( + // Ask the backend to fill in a "dehydrated" path; this will result in a "inspectedElement". + const getInspectedElementPath = useCallback( + (id: number, path: Array) => { + const rendererID = store.getRendererIDForElement(id); + bridge.send('inspectElement', { id, path, rendererID }); + }, + [bridge, store] + ); + + const getInspectedElement = useCallback( (id: number) => { const element = store.getElementByID(id); if (element !== null) { @@ -85,72 +107,117 @@ function InspectedElementContextController({ children }: Props) { // would itself be blocked by the same render that suspends (waiting for the data). const { selectedElementID } = useContext(TreeStateContext); - const [count, setCount] = useState(0); + const [ + currentlyInspectedElement, + setCurrentlyInspectedElement, + ] = useState(null); // This effect handler invalidates the suspense cache and schedules rendering updates with React. useEffect(() => { - const onInspectedElement = ( - data: InspectedElementBackend | number | null - ) => { - // A null value means that the element no longer exists in the backend. - // If it's the same element that's currently selected, that selection will be removed once the Store updates. - // If it's not- then we can just ignore it anyway. - // Either way there is nothing we need to do in this case. - // A numeric value indicates that the element hasn't changed since we last requested its data, - // in which case we don't need to invalidate the cache and re-render anything in the DevTools. - if (data !== null && typeof data === 'object') { - const id = data.id; + const onInspectedElement = (data: InspectedElementPayload) => { + const { id } = data; - const inspectedElement: InspectedElementFrontend = { - canEditFunctionProps: data.canEditFunctionProps, - canEditHooks: data.canEditHooks, - canToggleSuspense: data.canToggleSuspense, - canViewSource: data.canViewSource, - id: data.id, - source: data.source, - type: data.type, - owners: - data.owners === null - ? null - : data.owners.map(owner => { - const [ - displayName, - hocDisplayNames, - ] = separateDisplayNameAndHOCs(owner.displayName, owner.type); - return { - ...owner, - displayName, - hocDisplayNames, - }; - }), - context: hydrateHelper(data.context), - events: hydrateHelper(data.events), - hooks: hydrateHelper(data.hooks), - props: hydrateHelper(data.props), - state: hydrateHelper(data.state), - }; + let element; - const element = store.getElementByID(id); - if (element !== null) { - const request = inProgressRequests.get(element); - if (request != null) { - inProgressRequests.delete(element); - request.resolveFn(inspectedElement); - } else { - resource.write(element, inspectedElement); + switch (data.type) { + case 'no-change': + case 'not-found': + // No-op + break; + case 'hydrated-path': + // Merge new data into previous object and invalidate cache + element = store.getElementByID(id); + if (element !== null) { + if (currentlyInspectedElement != null) { + const value = hydrateHelper(data.value, data.path); + const inspectedElement = { ...currentlyInspectedElement }; - // Schedule update with React if the curently-selected element has been invalidated. - if (id === selectedElementID) { - setCount(count => count + 1); + fillInPath(inspectedElement, data.path, value); + + resource.write(element, inspectedElement); + + // Schedule update with React if the curently-selected element has been invalidated. + if (id === selectedElementID) { + setCurrentlyInspectedElement(inspectedElement); + } } } - } + break; + case 'full-data': + const { + canEditFunctionProps, + canEditHooks, + canToggleSuspense, + canViewSource, + source, + type, + owners, + context, + events, + hooks, + props, + state, + } = ((data.value: any): InspectedElementBackend); + + const inspectedElement: InspectedElementFrontend = { + canEditFunctionProps, + canEditHooks, + canToggleSuspense, + canViewSource, + id, + source, + type, + owners: + owners === null + ? null + : owners.map(owner => { + const [ + displayName, + hocDisplayNames, + ] = separateDisplayNameAndHOCs( + owner.displayName, + owner.type + ); + return { + ...owner, + displayName, + hocDisplayNames, + }; + }), + context: hydrateHelper(context), + events: hydrateHelper(events), + hooks: hydrateHelper(hooks), + props: hydrateHelper(props), + state: hydrateHelper(state), + }; + + element = store.getElementByID(id); + if (element !== null) { + const request = inProgressRequests.get(element); + if (request != null) { + inProgressRequests.delete(element); + batchedUpdates(() => { + request.resolveFn(inspectedElement); + setCurrentlyInspectedElement(inspectedElement); + }); + } else { + resource.write(element, inspectedElement); + + // Schedule update with React if the curently-selected element has been invalidated. + if (id === selectedElementID) { + setCurrentlyInspectedElement(inspectedElement); + } + } + } + break; + default: + break; } }; bridge.addListener('inspectedElement', onInspectedElement); return () => bridge.removeListener('inspectedElement', onInspectedElement); - }, [bridge, selectedElementID, store]); + }, [bridge, currentlyInspectedElement, selectedElementID, store]); // This effect handler polls for updates on the currently selected element. useEffect(() => { @@ -175,15 +242,20 @@ function InspectedElementContextController({ children }: Props) { // Update the $r variable. bridge.send('selectElement', { id: selectedElementID, rendererID }); - const onInspectedElement = ( - data: InspectedElementBackend | number | null - ) => { - if (data !== null) { - // If this is the element we requested, wait a little bit and then ask for an update. - if (data === selectedElementID) { - timeoutID = setTimeout(sendRequest, 1000); - } else if (typeof data === 'object' && data.id === selectedElementID) { - timeoutID = setTimeout(sendRequest, 1000); + const onInspectedElement = (data: InspectedElementPayload) => { + // If this is the element we requested, wait a little bit and then ask for another update. + if (data.id === selectedElementID) { + switch (data.type) { + case 'no-change': + case 'full-data': + case 'hydrated-path': + if (timeoutID !== null) { + clearTimeout(timeoutID); + } + timeoutID = setTimeout(sendRequest, 1000); + break; + default: + break; } } }; @@ -200,10 +272,10 @@ function InspectedElementContextController({ children }: Props) { }, [bridge, selectedElementID, store]); const value = useMemo( - () => ({ read }), - // Count is used to invalidate the cache and schedule an update with React. + () => ({ getInspectedElement, getInspectedElementPath }), + // InspectedElement is used to invalidate the cache and schedule an update with React. // eslint-disable-next-line react-hooks/exhaustive-deps - [count, read] + [currentlyInspectedElement, getInspectedElement, getInspectedElementPath] ); return ( @@ -213,9 +285,23 @@ function InspectedElementContextController({ children }: Props) { ); } -function hydrateHelper(dehydratedData: DehydratedData | null): Object | null { +function hydrateHelper( + dehydratedData: DehydratedData | null, + path?: Array +): Object | null { if (dehydratedData !== null) { - return hydrate(dehydratedData.data, dehydratedData.cleaned); + let { cleaned, data } = dehydratedData; + + if (path) { + const { length } = path; + if (length > 0) { + // Hydration helper requires full paths, but inspection dehydrates with relative paths. + // In that event it's important that we adjust the "cleaned" paths to match. + cleaned = cleaned.map(cleanedPath => cleanedPath.slice(length)); + } + } + + return hydrate(data, cleaned); } else { return null; } diff --git a/src/devtools/views/Components/InspectedElementTree.js b/src/devtools/views/Components/InspectedElementTree.js index aeab263fce..5475b236c8 100644 --- a/src/devtools/views/Components/InspectedElementTree.js +++ b/src/devtools/views/Components/InspectedElementTree.js @@ -8,10 +8,13 @@ import KeyValue from './KeyValue'; import { serializeDataForCopy } from '../utils'; import styles from './InspectedElementTree.css'; +import type { InspectPath } from './SelectedElement'; + type OverrideValueFn = (path: Array, value: any) => void; type Props = {| data: Object | null, + inspectPath?: InspectPath, label: string, overrideValueFn?: ?OverrideValueFn, showWhenEmpty?: boolean, @@ -19,6 +22,7 @@ type Props = {| export default function InspectedElementTree({ data, + inspectPath, label, overrideValueFn, showWhenEmpty = false, @@ -32,7 +36,6 @@ export default function InspectedElementTree({ if (isEmpty && !showWhenEmpty) { return null; } else { - // TODO Add click and key handlers for toggling element open/close state. return (
@@ -49,6 +52,7 @@ export default function InspectedElementTree({ , value: any) => void; type KeyValueProps = {| depth: number, hidden?: boolean, + inspectPath?: InspectPath, name: string, overrideValueFn?: ?OverrideValueFn, - path?: Array, + path: Array, value: any, |}; export default function KeyValue({ depth, + inspectPath, hidden, name, overrideValueFn, - path = [], + path, value, }: KeyValueProps) { const [isOpen, setIsOpen] = useState(false); + const prevIsOpenRef = useRef(isOpen); - const toggleIsOpen = useCallback( - () => setIsOpen(prevIsOpen => !prevIsOpen), - [] - ); + const isInspectable = + value !== null && + typeof value === 'object' && + value[meta.inspectable] && + value[meta.size] !== 0; + + useEffect(() => { + if ( + isInspectable && + isOpen && + !prevIsOpenRef.current && + typeof inspectPath === 'function' + ) { + inspectPath(path); + } + prevIsOpenRef.current = isOpen; + }, [inspectPath, isInspectable, isOpen, path]); + + const toggleIsOpen = () => setIsOpen(prevIsOpen => !prevIsOpen); const dataType = typeof value; const isSimpleType = @@ -78,11 +98,19 @@ export default function KeyValue({
); } else if (value.hasOwnProperty(meta.type)) { - // TODO Is this type even necessary? Can we just drop it? children = (