Merge pull request #325 from bvaughn/hydration

Lazily send props/state/hooks across the bridge
This commit is contained in:
Brian Vaughn
2019-06-18 08:50:21 -07:00
committed by GitHub
23 changed files with 2076 additions and 310 deletions
+133
View File
@@ -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 (
<Fragment>
<h1>Hydration</h1>
<DehydratableProps
html_element={div}
fn={exmapleFunction}
symbol={Symbol('symbol')}
react_element={<span />}
array_buffer={typedArray.buffer}
typed_array={typedArray}
date={new Date()}
array={arrayOfArrays}
object={objectOfObjects}
/>
<DeepHooks />
</Fragment>
);
}
function DehydratableProps({ array, object }: any) {
return (
<ul>
<li>array: {JSON.stringify(array, null, 2)}</li>
<li>object: {JSON.stringify(object, null, 2)}</li>
</ul>
);
}
function DeepHooks(props: any) {
const foo = useOuterFoo();
const bar = useOuterBar();
const baz = useOuterBaz();
return (
<ul>
<li>foo: {foo}</li>
<li>bar: {bar}</li>
<li>baz: {baz}</li>
</ul>
);
}
+2
View File
@@ -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);
@@ -1,11 +1,101 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InspectedElementContext should inspect the currently selected element: 1: mount 1`] = `
[root]
<Example>
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]
▾ <Wrapper>
<Anonymous>
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]
<Example>
`;
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]
<Example>
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,
+433 -42
View File
@@ -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(<Example foo={1} bar="abc" />, container)
ReactDOM.render(<Example a={1} b="abc" />, 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(
<Contexts
defaultSelectedElementID={example.id}
defaultSelectedElementID={id}
defaultSelectedElementIndex={0}
>
<React.Suspense fallback={null}>
<Suspender target={example} />
<Suspender target={id} />
</React.Suspense>
</Contexts>
),
@@ -110,18 +112,17 @@ describe('InspectedElementContext', () => {
const container = document.createElement('div');
await utils.actAsync(
() => ReactDOM.render(<Example foo={1} bar="abc" />, container),
() => ReactDOM.render(<Example a={1} b="abc" />, 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(
<Contexts
defaultSelectedElementID={example.id}
defaultSelectedElementIndex={0}
>
<Contexts defaultSelectedElementID={id} defaultSelectedElementIndex={0}>
<React.Suspense fallback={null}>
<Suspender target={example} />
<Suspender target={id} />
</React.Suspense>
</Contexts>
);
}, false);
expect(inspectedElement).toMatchSnapshot('2: initial render');
expect(inspectedElement).toMatchSnapshot('1: initial render');
await utils.actAsync(
() => ReactDOM.render(<Example foo={2} bar="def" />, container),
() => ReactDOM.render(<Example a={2} b="def" />, container),
false
);
@@ -151,11 +149,11 @@ describe('InspectedElementContext', () => {
() =>
renderer.update(
<Contexts
defaultSelectedElementID={example.id}
defaultSelectedElementID={id}
defaultSelectedElementIndex={0}
>
<React.Suspense fallback={null}>
<Suspender target={example} />
<Suspender target={id} />
</React.Suspense>
</Contexts>
),
@@ -180,20 +178,19 @@ describe('InspectedElementContext', () => {
await utils.actAsync(() =>
ReactDOM.render(
<Wrapper>
<Target foo={1} bar="abc" />
<Target a={1} b="abc" />
</Wrapper>,
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(
<Wrapper>
<Target foo={2} bar="def" />
<Target a={2} b="def" />
</Wrapper>,
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(
<Example
html_element={div}
fn={exmapleFunction}
symbol={Symbol('symbol')}
react_element={<span />}
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(
<Contexts
defaultSelectedElementID={id}
defaultSelectedElementIndex={0}
>
<React.Suspense fallback={null}>
<Suspender target={id} />
</React.Suspense>
</Contexts>
),
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(<Example data={new CustomData()} />, 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(
<Contexts
defaultSelectedElementID={example.id}
defaultSelectedElementID={id}
defaultSelectedElementIndex={0}
>
<React.Suspense fallback={null}>
<Suspender target={example} />
<Suspender target={id} />
</React.Suspense>
</Contexts>
),
@@ -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(
<Example
nestedObject={{
a: {
b: {
c: [
{
d: {
e: {},
},
},
],
},
},
}}
/>,
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(
<Contexts
defaultSelectedElementID={id}
defaultSelectedElementIndex={0}
>
<React.Suspense fallback={null}>
<Suspender target={id} />
</React.Suspense>
</Contexts>
),
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(
<Example
nestedObject={{
a: {
value: 1,
b: {
value: 1,
},
},
c: {
value: 1,
d: {
value: 1,
e: {
value: 1,
},
},
},
}}
/>,
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(
<Contexts
defaultSelectedElementID={id}
defaultSelectedElementIndex={0}
>
<React.Suspense fallback={null}>
<Suspender target={id} />
</React.Suspense>
</Contexts>
),
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(
<Example
nestedObject={{
a: {
value: 2,
b: {
value: 2,
},
},
c: {
value: 2,
d: {
value: 2,
e: {
value: 2,
},
},
},
}}
/>,
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(
<Example
nestedObject={{
value: 1,
a: {
value: 1,
b: {
value: 1,
},
},
}}
/>,
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(
<Contexts
defaultSelectedElementID={id}
defaultSelectedElementIndex={0}
>
<React.Suspense fallback={null}>
<Suspender target={id} />
</React.Suspense>
</Contexts>
),
false
);
expect(getInspectedElementPath).not.toBeNull();
expect(inspectedElement).not.toBeNull();
expect(inspectedElement).toMatchSnapshot('1: Initially inspect element');
TestUtils.act(() => {
ReactDOM.render(
<Example
nestedObject={{
value: 2,
a: {
value: 2,
b: {
value: 2,
},
},
}}
/>,
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();
});
});
@@ -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
},
}
`;
+240
View File
@@ -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<string | number>
): Promise<Object> {
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(<Example a={1} b="abc" />, 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(
<Example
html_element={div}
fn={exmapleFunction}
symbol={Symbol('symbol')}
react_element={<span />}
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<number>);
descriptor.enumerable = true;
Object.defineProperty(CustomData.prototype, 'number', descriptor);
const Example = ({ data }) => null;
act(() =>
ReactDOM.render(
<Example data={new CustomData()} />,
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(
<Example
nestedObject={{
a: {
b: {
c: [
{
d: {
e: {},
},
},
],
},
},
}}
/>,
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();
});
});
+8 -2
View File
@@ -42,6 +42,12 @@ type ElementAndRendererID = {|
rendererID: number,
|};
type InspectElementParams = {|
id: number,
path?: Array<string | number>,
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));
}
};
+72 -9
View File
@@ -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<string | number>) {
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<string | number>): 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<string | number>
): 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 {
+152 -28
View File
@@ -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<string | number>) {
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<string | number>): 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<string | number>
): 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,
+38 -1
View File
@@ -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<string | number>,
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<PathFrame> | null,
handleCommitFiberRoot: (fiber: Object, commitPriority?: number) => void,
handleCommitFiberUnmount: (fiber: Object) => void,
inspectElement: (id: number) => InspectedElement | number | null,
inspectElement: (
id: number,
path?: Array<string | number>
) => InspectedElementPayload,
logElementToConsole: (id: number) => void,
overrideSuspense: (id: number, forceFallback: boolean) => void,
prepareViewElementSource: (id: number) => void,
+7 -20
View File
@@ -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<string | number>) => boolean,
path?: Array<string | number> = []
): 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<any> {
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<string | number>,
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;
}
}
}
+8 -3
View File
@@ -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<string | number>,
|};
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],
@@ -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 (
<Button
className={styles.ExpandCollapseToggle}
onClick={handleClick}
onClick={() => setIsOpen(prevIsOpen => !prevIsOpen)}
title={`${isOpen ? 'Collapse' : 'Expand'} prop value`}
>
<ButtonIcon type={isOpen ? 'expanded' : 'collapsed'} />
+68 -18
View File
@@ -7,11 +7,13 @@ 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 { serializeHooksForCopy } from '../utils';
import styles from './HooksTree.css';
import { meta } from '../../../hydration';
import type { InspectPath } from './SelectedElement';
import type { HooksNode, HooksTree } from 'src/backend/types';
type HooksTreeViewProps = {|
@@ -21,6 +23,13 @@ type HooksTreeViewProps = {|
|};
export function HooksTreeView({ canEditHooks, hooks, id }: HooksTreeViewProps) {
const { getInspectedElementPath } = useContext(InspectedElementContext);
const inspectPath = useCallback(
(path: Array<string | number>) => {
getInspectedElementPath(id, ['hooks', ...path]);
},
[getInspectedElementPath, id]
);
const handleCopy = useCallback(() => copy(serializeHooksForCopy(hooks)), [
hooks,
]);
@@ -36,7 +45,13 @@ export function HooksTreeView({ canEditHooks, hooks, id }: HooksTreeViewProps) {
<ButtonIcon type="copy" />
</Button>
</div>
<InnerHooksTreeView canEditHooks={canEditHooks} hooks={hooks} id={id} />
<InnerHooksTreeView
canEditHooks={canEditHooks}
hooks={hooks}
id={id}
inspectPath={inspectPath}
path={[]}
/>
</div>
);
}
@@ -46,12 +61,16 @@ type InnerHooksTreeViewProps = {|
canEditHooks: boolean,
hooks: HooksTree,
id: number,
inspectPath: InspectPath,
path: Array<string | number>,
|};
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<any>,
inspectPath: InspectPath,
path: Array<string | number>,
|};
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 (
<div className={styles.Hook}>
<div className={styles.NameValueRow}>
@@ -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) ? (
<InnerHooksTreeView
canEditHooks={canEditHooks}
hooks={subHooks}
id={id}
inspectPath={inspectPath}
path={path.concat(['subHooks'])}
/>
) : (
<KeyValue
depth={1}
inspectPath={inspectPath}
name="subHooks"
path={path.concat(['subHooks'])}
value={subHooks}
/>
);
if (isComplexDisplayValue) {
return (
<div className={styles.Hook}>
@@ -135,12 +181,14 @@ function HookView({ canEditHooks, hook, id, path = [] }: HookViewProps) {
</span>
</div>
<div className={styles.Children} hidden={!isOpen}>
<KeyValue depth={1} name="DebugValue" value={value} />
<InnerHooksTreeView
canEditHooks={canEditHooks}
hooks={subHooks}
id={id}
<KeyValue
depth={1}
inspectPath={inspectPath}
name="DebugValue"
path={path.concat(['value'])}
value={value}
/>
{subHooksView}
</div>
</div>
);
@@ -156,11 +204,7 @@ function HookView({ canEditHooks, hook, id, path = [] }: HookViewProps) {
<span className={styles.Value}>{displayValue}</span>
</div>
<div className={styles.Children} hidden={!isOpen}>
<InnerHooksTreeView
canEditHooks={canEditHooks}
hooks={subHooks}
id={id}
/>
{subHooksView}
</div>
</div>
);
@@ -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<string | number>, value: any) => {
overrideValueFn = (absolutePath: Array<string | number>, 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) {
<div className={styles.Hook}>
<KeyValue
depth={1}
inspectPath={inspectPath}
name={name}
overrideValueFn={overrideValueFn}
path={path.concat(['value'])}
value={value}
/>
</div>
@@ -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<string | number>
) => void;
export type GetInspectedElement = (
id: number
) => InspectedElementFrontend | null;
type Context = {|
read(id: number): InspectedElementFrontend | null,
getInspectedElementPath: GetInspectedElementPath,
getInspectedElement: GetInspectedElement,
|};
const InspectedElementContext = createContext<Context>(((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<GetInspectedElementPath>(
(id: number, path: Array<string | number>) => {
const rendererID = store.getRendererIDForElement(id);
bridge.send('inspectElement', { id, path, rendererID });
},
[bridge, store]
);
const getInspectedElement = useCallback<GetInspectedElement>(
(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<number>(0);
const [
currentlyInspectedElement,
setCurrentlyInspectedElement,
] = useState<InspectedElementFrontend | null>(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<string | number>
): 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;
}
@@ -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<string | number>, 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 (
<div className={styles.InspectedElementTree}>
<div className={styles.HeaderRow}>
@@ -49,6 +52,7 @@ export default function InspectedElementTree({
<KeyValue
key={name}
depth={1}
inspectPath={inspectPath}
name={name}
overrideValueFn={overrideValueFn}
path={[name]}
+3 -1
View File
@@ -21,7 +21,9 @@
.Value {
color: var(--color-attribute-value);
word-break: break-all;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.None {
+40 -10
View File
@@ -1,6 +1,6 @@
// @flow
import React, { useCallback, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import type { Element } from 'react';
import EditableValue from './EditableValue';
import ExpandCollapseToggle from './ExpandCollapseToggle';
@@ -8,31 +8,51 @@ import { getMetaValueLabel } from '../utils';
import { meta } from '../../../hydration';
import styles from './KeyValue.css';
import type { InspectPath } from './SelectedElement';
type OverrideValueFn = (path: Array<string | number>, value: any) => void;
type KeyValueProps = {|
depth: number,
hidden?: boolean,
inspectPath?: InspectPath,
name: string,
overrideValueFn?: ?OverrideValueFn,
path?: Array<any>,
path: Array<any>,
value: any,
|};
export default function KeyValue({
depth,
inspectPath,
hidden,
name,
overrideValueFn,
path = [],
path,
value,
}: KeyValueProps) {
const [isOpen, setIsOpen] = useState<boolean>(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({
</div>
);
} else if (value.hasOwnProperty(meta.type)) {
// TODO Is this type even necessary? Can we just drop it?
children = (
<div key="root" className={styles.Item} hidden={hidden} style={style}>
<div className={styles.ExpandCollapseToggleSpacer} />
<span className={styles.Name}>{name}</span>
{isInspectable ? (
<ExpandCollapseToggle isOpen={isOpen} setIsOpen={setIsOpen} />
) : (
<div className={styles.ExpandCollapseToggleSpacer} />
)}
<span
className={styles.Name}
onClick={isInspectable ? toggleIsOpen : undefined}
>
{name}
</span>
<span className={styles.Value}>{getMetaValueLabel(value)}</span>
</div>
);
@@ -94,6 +122,7 @@ export default function KeyValue({
<KeyValue
key={index}
depth={depth + 1}
inspectPath={inspectPath}
hidden={hidden || !isOpen}
name={index}
overrideValueFn={overrideValueFn}
@@ -129,6 +158,7 @@ export default function KeyValue({
<KeyValue
key={name}
depth={depth + 1}
inspectPath={inspectPath}
hidden={hidden || !isOpen}
name={name}
overrideValueFn={overrideValueFn}
@@ -25,6 +25,7 @@ import {
import styles from './SelectedElement.css';
import type { GetInspectedElementPath } from './InspectedElementContext';
import type { Element, InspectedElement } from './types';
import type { ElementType } from 'src/types';
@@ -38,7 +39,9 @@ export default function SelectedElement(_: Props) {
const store = useContext(StoreContext);
const { dispatch: modalDialogDispatch } = useContext(ModalDialogContext);
const { read } = useContext(InspectedElementContext);
const { getInspectedElementPath, getInspectedElement } = useContext(
InspectedElementContext
);
const element =
inspectedElementID !== null
@@ -46,7 +49,7 @@ export default function SelectedElement(_: Props) {
: null;
const inspectedElement =
inspectedElementID != null ? read(inspectedElementID) : null;
inspectedElementID != null ? getInspectedElement(inspectedElementID) : null;
const highlightElement = useCallback(() => {
if (element !== null && inspectedElementID !== null) {
@@ -200,7 +203,11 @@ export default function SelectedElement(_: Props) {
{inspectedElement !== null && (
<InspectedElementView
key={
inspectedElementID /* Force reset when seleted Element changes */
}
element={element}
getInspectedElementPath={getInspectedElementPath}
inspectedElement={inspectedElement}
/>
)}
@@ -208,8 +215,11 @@ export default function SelectedElement(_: Props) {
);
}
export type InspectPath = (path: Array<string | number>) => void;
type InspectedElementViewProps = {|
element: Element,
getInspectedElementPath: GetInspectedElementPath,
inspectedElement: InspectedElement,
|};
@@ -217,6 +227,7 @@ const IS_SUSPENDED = 'Suspended';
function InspectedElementView({
element,
getInspectedElementPath,
inspectedElement,
}: InspectedElementViewProps) {
const { id, type } = element;
@@ -236,6 +247,25 @@ function InspectedElementView({
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
const inspectContextPath = useCallback(
(path: Array<string | number>) => {
getInspectedElementPath(id, ['context', ...path]);
},
[getInspectedElementPath, id]
);
const inspectPropsPath = useCallback(
(path: Array<string | number>) => {
getInspectedElementPath(id, ['props', ...path]);
},
[getInspectedElementPath, id]
);
const inspectStatePath = useCallback(
(path: Array<string | number>) => {
getInspectedElementPath(id, ['state', ...path]);
},
[getInspectedElementPath, id]
);
let overrideContextFn = null;
let overridePropsFn = null;
let overrideStateFn = null;
@@ -279,6 +309,7 @@ function InspectedElementView({
<InspectedElementTree
label="props"
data={props}
inspectPath={inspectPropsPath}
overrideValueFn={overridePropsFn}
showWhenEmpty
/>
@@ -294,6 +325,7 @@ function InspectedElementView({
<InspectedElementTree
label="state"
data={state}
inspectPath={inspectStatePath}
overrideValueFn={overrideStateFn}
/>
)}
@@ -301,6 +333,7 @@ function InspectedElementView({
<InspectedElementTree
label="context"
data={context}
inspectPath={inspectContextPath}
overrideValueFn={overrideContextFn}
/>
{events !== null && events.length > 0 && <EventsTree events={events} />}
+1 -1
View File
@@ -83,6 +83,6 @@ export type InspectedElement = {|
// TODO: Add profiling type
export type DehydratedData = {|
cleaned: Array<Array<string>>,
cleaned: Array<Array<string | number>>,
data: Object,
|};
+1 -1
View File
@@ -91,7 +91,7 @@ export function getMetaValueLabel(data: Object): string | null {
case 'data_view':
case 'array':
case 'typed_array':
return `${name}[${data[meta.meta].length}]`;
return `${name}[${data[meta.size]}]`;
default:
return null;
}
+110 -64
View File
@@ -16,27 +16,32 @@ import {
StrictMode,
Suspense,
} from 'react-is';
import { getDisplayName } from './utils';
import { getDisplayName, getInObject, setInObject } from './utils';
export const meta = {
name: Symbol('name'),
type: Symbol('type'),
inspectable: Symbol('inspectable'),
inspected: Symbol('inspected'),
meta: Symbol('meta'),
proto: Symbol('proto'),
name: Symbol('name'),
readonly: Symbol('readonly'),
size: Symbol('size'),
type: Symbol('type'),
};
type Dehydrated = {|
inspectable: boolean,
name: string | null,
readonly?: boolean,
size?: number,
type: string,
|};
// This threshold determines the depth at which the bridge "dehydrates" nested data.
// Dehydration means that we don't serialize the data for e.g. postMessage or stringify,
// unless the frontend explicitly requests it (e.g. a user clicks to expand a props object).
// We tried reducing this value from 2 to 1 to improve performance:
// https://github.com/facebook/react-devtools/issues/1200
// But this caused problems with the Profiler's interaction tracing output.
// Because React mutates Fibers, profiling data that is dehydrated for old commits
// will not be available later from within the Profiler.
// This impacts props/state as well as Interactions.
// https://github.com/facebook/react-devtools/issues/1262
const LEVEL_THRESHOLD = 6;
//
// Reducing this threshold will improve the speed of initial component inspection,
// but may decrease the responsiveness of expanding objects/arrays to inspect further.
const LEVEL_THRESHOLD = 2;
/**
* Get a enhanced/artificial type string based on the object instance
@@ -84,29 +89,33 @@ function getPropType(data: Object): string | null {
*/
function createDehydrated(
type: string,
inspectable: boolean,
data: Object,
cleaned: Array<Array<string>>,
path: Array<string>
): Object {
const meta = {};
if (type === 'array' || type === 'typed_array') {
meta.length = data.length;
}
if (type === 'iterator' || type === 'typed_array') {
meta.readOnly = true;
}
cleaned: Array<Array<string | number>>,
path: Array<string | number>
): Dehydrated {
cleaned.push(path);
return {
const dehydrated: Dehydrated = {
inspectable,
type,
meta,
name:
!data.constructor || data.constructor.name === 'Object'
? ''
: data.constructor.name,
};
if (type === 'array' || type === 'typed_array') {
dehydrated.size = data.length;
} else if (type === 'object') {
dehydrated.size = Object.keys(data).length;
}
if (type === 'iterator' || type === 'typed_array') {
dehydrated.readonly = true;
}
return dehydrated;
}
/**
@@ -129,16 +138,18 @@ function createDehydrated(
*/
export function dehydrate(
data: Object,
cleaned: Array<Array<string>>,
path?: Array<string> = [],
cleaned: Array<Array<string | number>>,
path: Array<string | number>,
isPathWhitelisted: (path: Array<string | number>) => boolean,
level?: number = 0
): string | Object {
): string | Dehydrated | { [key: string]: string | Dehydrated } {
const type = getPropType(data);
switch (type) {
case 'html_element':
cleaned.push(path);
return {
inspectable: false,
name: data.tagName,
type: 'html_element',
};
@@ -146,6 +157,7 @@ export function dehydrate(
case 'function':
cleaned.push(path);
return {
inspectable: false,
name: data.name,
type: 'function',
};
@@ -158,8 +170,9 @@ export function dehydrate(
case 'symbol':
cleaned.push(path);
return {
type: 'symbol',
inspectable: false,
name: data.toString(),
type: 'symbol',
};
// React Elements aren't very inspector-friendly,
@@ -167,6 +180,7 @@ export function dehydrate(
case 'react_element':
cleaned.push(path);
return {
inspectable: false,
name: getDisplayNameForReactElement(data),
type: 'react_element',
};
@@ -176,48 +190,55 @@ export function dehydrate(
case 'data_view':
cleaned.push(path);
return {
type,
inspectable: false,
name: type === 'data_view' ? 'DataView' : 'ArrayBuffer',
meta: {
length: data.byteLength,
uninspectable: true,
},
size: data.byteLength,
type,
};
case 'array':
if (level > LEVEL_THRESHOLD) {
return createDehydrated(type, data, cleaned, path);
const arrayPathCheck = isPathWhitelisted(path);
if (level >= LEVEL_THRESHOLD && !arrayPathCheck) {
return createDehydrated(type, true, data, cleaned, path);
}
return data.map((item, i) =>
dehydrate(item, cleaned, path.concat([i]), level + 1)
dehydrate(
item,
cleaned,
path.concat([i]),
isPathWhitelisted,
arrayPathCheck ? 1 : level + 1
)
);
case 'typed_array':
case 'iterator':
return createDehydrated(type, data, cleaned, path);
return createDehydrated(type, false, data, cleaned, path);
case 'date':
cleaned.push(path);
return {
inspectable: false,
name: data.toString(),
type: 'date',
meta: {
uninspectable: true,
},
};
case 'object':
if (level > LEVEL_THRESHOLD) {
return createDehydrated(type, data, cleaned, path);
const objectPathCheck = isPathWhitelisted(path);
if (level >= LEVEL_THRESHOLD && !objectPathCheck) {
return createDehydrated(type, true, data, cleaned, path);
} else {
const res = {};
const object = {};
for (let name in data) {
res[name] = dehydrate(
object[name] = dehydrate(
data[name],
cleaned,
path.concat([name]),
level + 1
isPathWhitelisted,
objectPathCheck ? 1 : level + 1
);
}
return res;
return object;
}
default:
@@ -225,24 +246,49 @@ export function dehydrate(
}
}
export function hydrate(data: Object, cleaned: Array<Array<string>>): Object {
cleaned.forEach((path: Array<string>) => {
const last = path.pop();
const reduced: Object = path.reduce(
(object: Object, attr: string) => (object ? object[attr] : (null: any)),
data
);
if (!reduced || !reduced[last]) {
export function fillInPath(
object: Object,
path: Array<string | number>,
value: any
) {
const target = getInObject(object, path);
if (target != null) {
delete target[meta.inspectable];
delete target[meta.inspected];
delete target[meta.name];
delete target[meta.readonly];
delete target[meta.size];
delete target[meta.type];
}
setInObject(object, path, value);
}
export function hydrate(
object: Object,
cleaned: Array<Array<string | number>>
): Object {
cleaned.forEach((path: Array<string | number>) => {
const length = path.length;
const last = path[length - 1];
const parent = getInObject(object, path.slice(0, length - 1));
if (!parent || !parent[last]) {
return;
}
const replace: { [key: Symbol]: boolean | string } = {};
replace[meta.name] = reduced[last].name;
replace[meta.type] = reduced[last].type;
replace[meta.meta] = reduced[last].meta;
replace[meta.inspected] = false;
reduced[last] = replace;
const value = parent[last];
// Replace the string keys with Symbols so they're non-enumerable.
const replaced: { [key: Symbol]: boolean | string } = {};
replaced[meta.inspectable] = !!value.inspectable;
replaced[meta.inspected] = false;
replaced[meta.name] = value.name;
replaced[meta.size] = value.size;
replaced[meta.readonly] = !!value.readonly;
replaced[meta.type] = value.type;
parent[last] = replaced;
});
return data;
return object;
}
export function getDisplayNameForReactElement(
+27
View File
@@ -245,3 +245,30 @@ export function shallowDiffers(prev: Object, next: Object): boolean {
}
return false;
}
export function getInObject(object: Object, path: Array<string | number>): any {
return path.reduce((reduced: Object, attr: string | number): any => {
if (typeof reduced === 'object' && reduced !== null) {
return reduced[attr];
} else if (Array.isArray(reduced)) {
return reduced[attr];
} else {
return null;
}
}, object);
}
export function setInObject(
object: Object,
path: Array<string | number>,
value: any
) {
const length = path.length;
const last = path[length - 1];
if (object != null) {
const parent = getInObject(object, path.slice(0, length - 1));
if (parent) {
parent[last] = value;
}
}
}