Merge pull request #264 from bvaughn/filter-owners-list

Fetch owners list from renderer (using suspense)
This commit is contained in:
Brian Vaughn
2019-05-10 08:10:24 -07:00
committed by GitHub
19 changed files with 2358 additions and 224 deletions
@@ -0,0 +1,77 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`OwnersListContext should fetch the owners list for the selected element that includes filtered components: mount 1`] = `
[root]
▾ <Grandparent>
<Child>
<Child>
`;
exports[`OwnersListContext should fetch the owners list for the selected element that includes filtered components: owners for "Child" 1`] = `
Array [
Object {
"displayName": "Grandparent",
"id": 7,
},
Object {
"displayName": "Parent",
"id": 9,
},
Object {
"displayName": "Child",
"id": 8,
},
]
`;
exports[`OwnersListContext should fetch the owners list for the selected element: mount 1`] = `
[root]
▾ <Grandparent>
▾ <Parent>
<Child>
<Child>
`;
exports[`OwnersListContext should fetch the owners list for the selected element: owners for "Child" 1`] = `
Array [
Object {
"displayName": "Grandparent",
"id": 2,
},
Object {
"displayName": "Parent",
"id": 3,
},
Object {
"displayName": "Child",
"id": 4,
},
]
`;
exports[`OwnersListContext should fetch the owners list for the selected element: owners for "Parent" 1`] = `
Array [
Object {
"displayName": "Grandparent",
"id": 2,
},
Object {
"displayName": "Parent",
"id": 3,
},
]
`;
exports[`OwnersListContext should include the current element even if there are no other owners: mount 1`] = `
[root]
<Grandparent>
`;
exports[`OwnersListContext should include the current element even if there are no other owners: owners for "Grandparent" 1`] = `
Array [
Object {
"displayName": "Grandparent",
"id": 5,
},
]
`;
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
// @flow
import typeof ReactTestRenderer from 'react-test-renderer';
import type { Element } from 'src/devtools/views/Components/types';
import type Bridge from 'src/bridge';
import type Store from 'src/devtools/store';
describe('OwnersListContext', () => {
let React;
let ReactDOM;
let TestRenderer: ReactTestRenderer;
let bridge: Bridge;
let store: Store;
let utils;
let BridgeContext;
let OwnersListContext;
let OwnersListContextController;
let StoreContext;
let TreeContextController;
beforeEach(() => {
utils = require('./utils');
utils.beforeEachProfiling();
bridge = global.bridge;
store = global.store;
store.collapseNodesByDefault = false;
React = require('react');
ReactDOM = require('react-dom');
TestRenderer = utils.requireTestRenderer();
BridgeContext = require('src/devtools/views/context').BridgeContext;
OwnersListContext = require('src/devtools/views/Components/OwnersListContext')
.OwnersListContext;
OwnersListContextController = require('src/devtools/views/Components/OwnersListContext')
.OwnersListContextController;
StoreContext = require('src/devtools/views/context').StoreContext;
TreeContextController = require('src/devtools/views/Components/TreeContext')
.TreeContextController;
});
const Contexts = ({ children, defaultOwnerID = null }) => (
<BridgeContext.Provider value={bridge}>
<StoreContext.Provider value={store}>
<TreeContextController defaultOwnerID={defaultOwnerID}>
<OwnersListContextController>{children}</OwnersListContextController>
</TreeContextController>
</StoreContext.Provider>
</BridgeContext.Provider>
);
it('should fetch the owners list for the selected element', async done => {
const Grandparent = () => <Parent />;
const Parent = () => {
return (
<React.Fragment>
<Child />
<Child />
</React.Fragment>
);
};
const Child = () => null;
utils.act(() =>
ReactDOM.render(<Grandparent />, document.createElement('div'))
);
expect(store).toMatchSnapshot('mount');
const parent = ((store.getElementAtIndex(1): any): Element);
const firstChild = ((store.getElementAtIndex(2): any): Element);
let didFinish = false;
function Suspender({ owner }) {
const read = React.useContext(OwnersListContext);
const owners = read(owner.id);
expect(owners).toMatchSnapshot(
`owners for "${(owner && owner.displayName) || ''}"`
);
didFinish = true;
return null;
}
await utils.actSuspense(
() =>
TestRenderer.create(
<Contexts defaultOwnerID={parent.id}>
<React.Suspense fallback={null}>
<Suspender owner={parent} />
</React.Suspense>
</Contexts>
),
3
);
expect(didFinish).toBe(true);
didFinish = false;
await utils.actSuspense(
() =>
TestRenderer.create(
<Contexts defaultOwnerID={firstChild.id}>
<React.Suspense fallback={null}>
<Suspender owner={firstChild} />
</React.Suspense>
</Contexts>
),
3
);
expect(didFinish).toBe(true);
done();
});
it('should fetch the owners list for the selected element that includes filtered components', async done => {
store.componentFilters = [utils.createDisplayNameFilter('^Parent$')];
const Grandparent = () => <Parent />;
const Parent = () => {
return (
<React.Fragment>
<Child />
<Child />
</React.Fragment>
);
};
const Child = () => null;
utils.act(() =>
ReactDOM.render(<Grandparent />, document.createElement('div'))
);
expect(store).toMatchSnapshot('mount');
const firstChild = ((store.getElementAtIndex(1): any): Element);
let didFinish = false;
function Suspender({ owner }) {
const read = React.useContext(OwnersListContext);
const owners = read(owner.id);
expect(owners).toMatchSnapshot(
`owners for "${(owner && owner.displayName) || ''}"`
);
didFinish = true;
return null;
}
await utils.actSuspense(
() =>
TestRenderer.create(
<Contexts defaultOwnerID={firstChild.id}>
<React.Suspense fallback={null}>
<Suspender owner={firstChild} />
</React.Suspense>
</Contexts>
),
3
);
expect(didFinish).toBe(true);
done();
});
it('should include the current element even if there are no other owners', async done => {
store.componentFilters = [utils.createDisplayNameFilter('^Parent$')];
const Grandparent = () => <Parent />;
const Parent = () => null;
utils.act(() =>
ReactDOM.render(<Grandparent />, document.createElement('div'))
);
expect(store).toMatchSnapshot('mount');
const grandparent = ((store.getElementAtIndex(0): any): Element);
let didFinish = false;
function Suspender({ owner }) {
const read = React.useContext(OwnersListContext);
const owners = read(owner.id);
expect(owners).toMatchSnapshot(
`owners for "${(owner && owner.displayName) || ''}"`
);
didFinish = true;
return null;
}
await utils.actSuspense(
() =>
TestRenderer.create(
<Contexts defaultOwnerID={grandparent.id}>
<React.Suspense fallback={null}>
<Suspender owner={grandparent} />
</React.Suspense>
</Contexts>
),
3
);
expect(didFinish).toBe(true);
done();
});
});
+18 -48
View File
@@ -6,42 +6,7 @@ describe('Store component filters', () => {
let TestUtils;
let Types;
let store;
const createElementTypeFilter = (elementType, isEnabled = true) => ({
type: Types.ComponentFilterElementType,
isEnabled,
value: elementType,
});
const createDisplayNameFilter = (source, isEnabled = true) => {
let isValid = true;
try {
new RegExp(source);
} catch (error) {
isValid = false;
}
return {
type: Types.ComponentFilterDisplayName,
isEnabled,
isValid,
value: source,
};
};
const createLocationFilter = (source, isEnabled = true) => {
let isValid = true;
try {
new RegExp(source);
} catch (error) {
isValid = false;
}
return {
type: Types.ComponentFilterLocation,
isEnabled,
isValid,
value: source,
};
};
let utils;
const act = (callback: Function) => {
TestUtils.act(() => {
@@ -59,6 +24,7 @@ describe('Store component filters', () => {
ReactDOM = require('react-dom');
TestUtils = require('react-dom/test-utils');
Types = require('src/types');
utils = require('./utils');
});
it('should throw if filters are updated while profiling', () => {
@@ -89,7 +55,7 @@ describe('Store component filters', () => {
act(
() =>
(store.componentFilters = [
createElementTypeFilter(Types.ElementTypeHostComponent),
utils.createElementTypeFilter(Types.ElementTypeHostComponent),
])
);
@@ -98,7 +64,7 @@ describe('Store component filters', () => {
act(
() =>
(store.componentFilters = [
createElementTypeFilter(Types.ElementTypeClass),
utils.createElementTypeFilter(Types.ElementTypeClass),
])
);
@@ -107,8 +73,8 @@ describe('Store component filters', () => {
act(
() =>
(store.componentFilters = [
createElementTypeFilter(Types.ElementTypeClass),
createElementTypeFilter(Types.ElementTypeFunction),
utils.createElementTypeFilter(Types.ElementTypeClass),
utils.createElementTypeFilter(Types.ElementTypeFunction),
])
);
@@ -117,8 +83,8 @@ describe('Store component filters', () => {
act(
() =>
(store.componentFilters = [
createElementTypeFilter(Types.ElementTypeClass, false),
createElementTypeFilter(Types.ElementTypeFunction, false),
utils.createElementTypeFilter(Types.ElementTypeClass, false),
utils.createElementTypeFilter(Types.ElementTypeFunction, false),
])
);
@@ -134,7 +100,7 @@ describe('Store component filters', () => {
act(
() =>
(store.componentFilters = [
createElementTypeFilter(Types.ElementTypeRoot),
utils.createElementTypeFilter(Types.ElementTypeRoot),
])
);
@@ -159,13 +125,17 @@ describe('Store component filters', () => {
);
expect(store).toMatchSnapshot('1: mount');
act(() => (store.componentFilters = [createDisplayNameFilter('Foo')]));
act(
() => (store.componentFilters = [utils.createDisplayNameFilter('Foo')])
);
expect(store).toMatchSnapshot('2: filter "Foo"');
act(() => (store.componentFilters = [createDisplayNameFilter('Ba')]));
act(() => (store.componentFilters = [utils.createDisplayNameFilter('Ba')]));
expect(store).toMatchSnapshot('3: filter "Ba"');
act(() => (store.componentFilters = [createDisplayNameFilter('B.z')]));
act(
() => (store.componentFilters = [utils.createDisplayNameFilter('B.z')])
);
expect(store).toMatchSnapshot('4: filter "B.z"');
});
@@ -178,7 +148,7 @@ describe('Store component filters', () => {
act(
() =>
(store.componentFilters = [
createLocationFilter(__filename.replace(__dirname, '')),
utils.createLocationFilter(__filename.replace(__dirname, '')),
])
);
@@ -189,7 +159,7 @@ describe('Store component filters', () => {
act(
() =>
(store.componentFilters = [
createLocationFilter('this:is:a:made:up:path'),
utils.createLocationFilter('this:is:a:made:up:path'),
])
);
+550
View File
@@ -0,0 +1,550 @@
// @flow
import typeof ReactTestRenderer from 'react-test-renderer';
import type Bridge from 'src/bridge';
import type Store from 'src/devtools/store';
import type {
DispatcherContext,
StateContext,
} from 'src/devtools/views/Components/TreeContext';
describe('TreeListContext', () => {
let React;
let ReactDOM;
let TestRenderer: ReactTestRenderer;
let bridge: Bridge;
let store: Store;
let utils;
let BridgeContext;
let StoreContext;
let TreeContext;
let dispatch: DispatcherContext;
let state: StateContext;
beforeEach(() => {
utils = require('./utils');
utils.beforeEachProfiling();
bridge = global.bridge;
store = global.store;
store.collapseNodesByDefault = false;
React = require('react');
ReactDOM = require('react-dom');
TestRenderer = utils.requireTestRenderer();
BridgeContext = require('src/devtools/views/context').BridgeContext;
StoreContext = require('src/devtools/views/context').StoreContext;
TreeContext = require('src/devtools/views/Components/TreeContext');
});
afterEach(() => {
// Reset between tests
dispatch = ((null: any): DispatcherContext);
state = ((null: any): StateContext);
});
const Capture = () => {
dispatch = React.useContext(TreeContext.TreeDispatcherContext);
state = React.useContext(TreeContext.TreeStateContext);
return null;
};
const Contexts = () => {
return (
<BridgeContext.Provider value={bridge}>
<StoreContext.Provider value={store}>
<TreeContext.TreeContextController>
<Capture />
</TreeContext.TreeContextController>
</StoreContext.Provider>
</BridgeContext.Provider>
);
};
describe('tree state', () => {
it('should select the next and previous elements in the tree', () => {
const Grandparent = () => <Parent />;
const Parent = () => (
<React.Fragment>
<Child />
<Child />
</React.Fragment>
);
const Child = () => null;
utils.act(() =>
ReactDOM.render(<Grandparent />, document.createElement('div'))
);
expect(store).toMatchSnapshot('0: mount');
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
expect(state).toMatchSnapshot('1: initial state');
utils.act(() => dispatch({ type: 'SELECT_NEXT_ELEMENT_IN_TREE' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('2: select first element');
while (
state.selectedElementIndex !== null &&
state.selectedElementIndex < store.numElements - 1
) {
const index = ((state.selectedElementIndex: any): number);
utils.act(() => dispatch({ type: 'SELECT_NEXT_ELEMENT_IN_TREE' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot(`3: select element after (${index})`);
}
while (
state.selectedElementIndex !== null &&
state.selectedElementIndex > 0
) {
const index = ((state.selectedElementIndex: any): number);
utils.act(() => dispatch({ type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot(`4: select element before (${index})`);
}
utils.act(() => dispatch({ type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('5: select previous wraps around to last');
utils.act(() => dispatch({ type: 'SELECT_NEXT_ELEMENT_IN_TREE' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('6: select next wraps around to first');
});
it('should select child elements', () => {
const Grandparent = () => (
<React.Fragment>
<Parent />
<Parent />
</React.Fragment>
);
const Parent = () => (
<React.Fragment>
<Child />
<Child />
</React.Fragment>
);
const Child = () => null;
utils.act(() =>
ReactDOM.render(<Grandparent />, document.createElement('div'))
);
expect(store).toMatchSnapshot('0: mount');
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
expect(state).toMatchSnapshot('1: initial state');
utils.act(() =>
dispatch({ type: 'SELECT_ELEMENT_AT_INDEX', payload: 0 })
);
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('2: select first element');
utils.act(() => dispatch({ type: 'SELECT_CHILD_ELEMENT_IN_TREE' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('3: select Parent');
utils.act(() => dispatch({ type: 'SELECT_CHILD_ELEMENT_IN_TREE' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('4: select Child');
const previousState = state;
// There are no more children to select, so this should be a no-op
utils.act(() => dispatch({ type: 'SELECT_CHILD_ELEMENT_IN_TREE' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toEqual(previousState);
});
it('should select parent elements and then collapse', () => {
const Grandparent = () => (
<React.Fragment>
<Parent />
<Parent />
</React.Fragment>
);
const Parent = () => (
<React.Fragment>
<Child />
<Child />
</React.Fragment>
);
const Child = () => null;
utils.act(() =>
ReactDOM.render(<Grandparent />, document.createElement('div'))
);
expect(store).toMatchSnapshot('0: mount');
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
expect(state).toMatchSnapshot('1: initial state');
const lastChildID = store.getElementIDAtIndex(store.numElements - 1);
utils.act(() =>
dispatch({ type: 'SELECT_ELEMENT_BY_ID', payload: lastChildID })
);
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('2: select last child');
utils.act(() => dispatch({ type: 'SELECT_PARENT_ELEMENT_IN_TREE' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('3: select Parent');
utils.act(() => dispatch({ type: 'SELECT_PARENT_ELEMENT_IN_TREE' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('4: select Grandparent');
const previousState = state;
// There are no more ancestors to select, so this should be a no-op
utils.act(() => dispatch({ type: 'SELECT_PARENT_ELEMENT_IN_TREE' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toEqual(previousState);
});
it('should clear selection if the selected element is unmounted', async done => {
const Grandparent = props => props.children || null;
const Parent = props => props.children || null;
const Child = () => null;
const container = document.createElement('div');
utils.act(() =>
ReactDOM.render(
<Grandparent>
<Parent>
<Child />
<Child />
</Parent>
</Grandparent>,
container
)
);
expect(store).toMatchSnapshot('0: mount');
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
expect(state).toMatchSnapshot('1: initial state');
utils.act(() =>
dispatch({ type: 'SELECT_ELEMENT_AT_INDEX', payload: 3 })
);
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('2: select second child');
await utils.actSuspense(() =>
ReactDOM.render(
<Grandparent>
<Parent />
</Grandparent>,
container
)
);
expect(state).toMatchSnapshot(
'3: remove children (parent should now be selected)'
);
await utils.actSuspense(() => ReactDOM.unmountComponentAtNode(container));
expect(state).toMatchSnapshot(
'4: unmount root (nothing should be selected)'
);
done();
});
});
describe('search state', () => {
it('should find elements matching search text', () => {
const Foo = () => null;
const Bar = () => null;
const Baz = () => null;
utils.act(() =>
ReactDOM.render(
<React.Fragment>
<Foo />
<Bar />
<Baz />
</React.Fragment>,
document.createElement('div')
)
);
expect(store).toMatchSnapshot('0: mount');
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
expect(state).toMatchSnapshot('1: initial state');
utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'ba' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('2: search for "ba"');
utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'f' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('3: search for "f"');
utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'q' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('4: search for "q"');
});
it('should select the next and previous items within the search results', () => {
const Foo = () => null;
const Bar = () => null;
const Baz = () => null;
utils.act(() =>
ReactDOM.render(
<React.Fragment>
<Foo />
<Baz />
<Bar />
<Baz />
</React.Fragment>,
document.createElement('div')
)
);
expect(store).toMatchSnapshot('0: mount');
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
expect(state).toMatchSnapshot('1: initial state');
utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'ba' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('2: search for "ba"');
utils.act(() => dispatch({ type: 'GO_TO_NEXT_SEARCH_RESULT' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('3: go to second result');
utils.act(() => dispatch({ type: 'GO_TO_NEXT_SEARCH_RESULT' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('4: go to third result');
utils.act(() => dispatch({ type: 'GO_TO_PREVIOUS_SEARCH_RESULT' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('5: go to second result');
utils.act(() => dispatch({ type: 'GO_TO_PREVIOUS_SEARCH_RESULT' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('6: go to first result');
utils.act(() => dispatch({ type: 'GO_TO_PREVIOUS_SEARCH_RESULT' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('7: wrap to last result');
utils.act(() => dispatch({ type: 'GO_TO_NEXT_SEARCH_RESULT' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('8: wrap to first result');
});
it('should add newly mounted elements to the search results set if they match the current text', async done => {
const Foo = () => null;
const Bar = () => null;
const Baz = () => null;
const container = document.createElement('div');
utils.act(() =>
ReactDOM.render(
<React.Fragment>
<Foo />
<Bar />
</React.Fragment>,
container
)
);
expect(store).toMatchSnapshot('0: mount');
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
expect(state).toMatchSnapshot('1: initial state');
utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'ba' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('2: search for "ba"');
await utils.actSuspense(() =>
ReactDOM.render(
<React.Fragment>
<Foo />
<Bar />
<Baz />
</React.Fragment>,
container
)
);
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('3: mount Baz');
done();
});
it('should remove unmounted elements from the search results set', async done => {
const Foo = () => null;
const Bar = () => null;
const Baz = () => null;
const container = document.createElement('div');
utils.act(() =>
ReactDOM.render(
<React.Fragment>
<Foo />
<Bar />
<Baz />
</React.Fragment>,
container
)
);
expect(store).toMatchSnapshot('0: mount');
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
expect(state).toMatchSnapshot('1: initial state');
utils.act(() => dispatch({ type: 'SET_SEARCH_TEXT', payload: 'ba' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('2: search for "ba"');
utils.act(() => dispatch({ type: 'GO_TO_NEXT_SEARCH_RESULT' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('3: go to second result');
await utils.actSuspense(() =>
ReactDOM.render(
<React.Fragment>
<Foo />
<Bar />
</React.Fragment>,
container
)
);
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('4: unmount Baz');
done();
});
});
describe('owners state', () => {
it('should support entering and existing the owners tree view', () => {
const Grandparent = () => <Parent />;
const Parent = () => (
<React.Fragment>
<Child />
<Child />
</React.Fragment>
);
const Child = () => null;
utils.act(() =>
ReactDOM.render(<Grandparent />, document.createElement('div'))
);
expect(store).toMatchSnapshot('0: mount');
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
expect(state).toMatchSnapshot('1: initial state');
let parentID = ((store.getElementIDAtIndex(1): any): number);
utils.act(() => dispatch({ type: 'SELECT_OWNER', payload: parentID }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('2: parent owners tree');
utils.act(() => dispatch({ type: 'RESET_OWNER_STACK' }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('3: final state');
});
it('should remove an element from the owners list if it is unmounted', async done => {
const Grandparent = ({ count }) => <Parent count={count} />;
const Parent = ({ count }) =>
new Array(count).fill(true).map((_, index) => <Child key={index} />);
const Child = () => null;
const container = document.createElement('div');
utils.act(() => ReactDOM.render(<Grandparent count={2} />, container));
expect(store).toMatchSnapshot('0: mount');
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
expect(state).toMatchSnapshot('1: initial state');
let parentID = ((store.getElementIDAtIndex(1): any): number);
utils.act(() => dispatch({ type: 'SELECT_OWNER', payload: parentID }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('2: parent owners tree');
await utils.actSuspense(() =>
ReactDOM.render(<Grandparent count={1} />, container)
);
expect(state).toMatchSnapshot('3: remove second child');
await utils.actSuspense(() =>
ReactDOM.render(<Grandparent count={0} />, container)
);
expect(state).toMatchSnapshot('4: remove first child');
done();
});
it('should exit the owners list if the current owner is unmounted', async done => {
const Parent = props => props.children || null;
const Child = () => null;
const container = document.createElement('div');
utils.act(() =>
ReactDOM.render(
<Parent>
<Child />
</Parent>,
container
)
);
expect(store).toMatchSnapshot('0: mount');
let renderer;
utils.act(() => (renderer = TestRenderer.create(<Contexts />)));
expect(state).toMatchSnapshot('1: initial state');
let childID = ((store.getElementIDAtIndex(1): any): number);
utils.act(() => dispatch({ type: 'SELECT_OWNER', payload: childID }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('2: child owners tree');
await utils.actSuspense(() => ReactDOM.render(<Parent />, container));
expect(state).toMatchSnapshot('3: remove child');
let parentID = ((store.getElementIDAtIndex(0): any): number);
utils.act(() => dispatch({ type: 'SELECT_OWNER', payload: parentID }));
utils.act(() => renderer.update(<Contexts />));
expect(state).toMatchSnapshot('4: parent owners tree');
await utils.actSuspense(() => ReactDOM.unmountComponentAtNode(container));
expect(state).toMatchSnapshot('5: unmount root');
done();
});
});
});
+52
View File
@@ -2,6 +2,8 @@
import typeof ReactTestRenderer from 'react-test-renderer';
import type { ElementType } from 'src/types';
export function act(callback: Function): void {
const TestUtils = require('react-dom/test-utils');
TestUtils.act(() => {
@@ -54,6 +56,56 @@ export function beforeEachProfiling(): void {
);
}
export function createElementTypeFilter(
elementType: ElementType,
isEnabled: boolean = true
) {
const Types = require('src/types');
return {
type: Types.ComponentFilterElementType,
isEnabled,
value: elementType,
};
}
export function createDisplayNameFilter(
source: string,
isEnabled: boolean = true
) {
const Types = require('src/types');
let isValid = true;
try {
new RegExp(source);
} catch (error) {
isValid = false;
}
return {
type: Types.ComponentFilterDisplayName,
isEnabled,
isValid,
value: source,
};
}
export function createLocationFilter(
source: string,
isEnabled: boolean = true
) {
const Types = require('src/types');
let isValid = true;
try {
new RegExp(source);
} catch (error) {
isValid = false;
}
return {
type: Types.ComponentFilterLocation,
isEnabled,
isValid,
value: source,
};
}
export function getRendererID(): number {
if (global.agent == null) {
throw Error('Agent unavailable.');
+17 -5
View File
@@ -16,6 +16,7 @@ import type {
RendererID,
RendererInterface,
} from './types';
import type { OwnersList } from 'src/devtools/views/Components/types';
import type { Bridge, ComponentFilter } from '../types';
const debug = (methodName, ...args) => {
@@ -29,7 +30,7 @@ const debug = (methodName, ...args) => {
}
};
type InspectSelectParams = {|
type ElementAndRendererID = {|
id: number,
rendererID: number,
|};
@@ -99,6 +100,7 @@ export default class Agent extends EventEmitter {
bridge.addListener('getProfilingStatus', this.getProfilingStatus);
bridge.addListener('getProfilingSummary', this.getProfilingSummary);
bridge.addListener('highlightElementInDOM', this.highlightElementInDOM);
bridge.addListener('getOwnersList', this.getOwnersList);
bridge.addListener('inspectElement', this.inspectElement);
bridge.addListener('logElementToConsole', this.logElementToConsole);
bridge.addListener('overrideContext', this.overrideContext);
@@ -303,7 +305,17 @@ export default class Agent extends EventEmitter {
}
};
inspectElement = ({ id, rendererID }: InspectSelectParams) => {
getOwnersList = ({ id, rendererID }: ElementAndRendererID) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
} else {
const owners = renderer.getOwnersList(id);
this._bridge.send('ownersList', ({ id, owners }: OwnersList));
}
};
inspectElement = ({ id, rendererID }: ElementAndRendererID) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
@@ -312,7 +324,7 @@ export default class Agent extends EventEmitter {
}
};
logElementToConsole = ({ id, rendererID }: InspectSelectParams) => {
logElementToConsole = ({ id, rendererID }: ElementAndRendererID) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
@@ -340,7 +352,7 @@ export default class Agent extends EventEmitter {
this._bridge.send('screenshotCaptured', { commitIndex, dataURL });
};
selectElement = ({ id, rendererID }: InspectSelectParams) => {
selectElement = ({ id, rendererID }: ElementAndRendererID) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
@@ -506,7 +518,7 @@ export default class Agent extends EventEmitter {
}
};
viewElementSource = ({ id, rendererID }: InspectSelectParams) => {
viewElementSource = ({ id, rendererID }: ElementAndRendererID) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
+34 -1
View File
@@ -50,7 +50,10 @@ import type {
ReactRenderer,
RendererInterface,
} from './types';
import type { InspectedElement } from 'src/devtools/views/Components/types';
import type {
InspectedElement,
Owner,
} from 'src/devtools/views/Components/types';
import type { ComponentFilter, ElementType } from 'src/types';
function getInternalReactConstants(version) {
@@ -1685,6 +1688,35 @@ export function attach(
}
}
function getOwnersList(id: number): Array<Owner> | null {
let fiber = findCurrentFiberUsingSlowPathById(id);
if (fiber == null) {
return null;
}
const { _debugOwner } = fiber;
const owners = [
{
displayName: getDisplayNameForFiber(fiber) || 'Unknown',
id,
},
];
if (_debugOwner) {
let owner = _debugOwner;
while (owner !== null) {
owners.unshift({
displayName: getDisplayNameForFiber(owner) || 'Unknown',
id: getFiberID(getPrimaryFiber(owner)),
});
owner = owner._debugOwner || null;
}
}
return owners;
}
function inspectElementRaw(id: number): InspectedElement | null {
let fiber = findCurrentFiberUsingSlowPathById(id);
if (fiber == null) {
@@ -2385,6 +2417,7 @@ export function attach(
getFiberCommits,
getInteractions,
findNativeByFiberID,
getOwnersList,
getPathForElement,
getProfilingDataForDownload,
getProfilingSummary,
+5 -1
View File
@@ -1,7 +1,10 @@
// @flow
import type { ComponentFilter, ElementType } from 'src/types';
import type { InspectedElement } from 'src/devtools/views/Components/types';
import type {
InspectedElement,
Owner,
} from 'src/devtools/views/Components/types';
type BundleType =
| 0 // PROD
@@ -175,6 +178,7 @@ export type RendererInterface = {
) => number | null,
getFiberCommits: (rootID: number, fiberID: number) => FiberCommitsBackend,
getInteractions: (rootID: number) => InteractionsBackend,
getOwnersList: (id: number) => Array<Owner> | null,
getProfilingDataForDownload: (rootID: number) => Object,
getProfilingSummary: (rootID: number) => ProfilingSummaryBackend,
getPathForElement: (id: number) => Array<PathFrame> | null,
+16 -13
View File
@@ -4,6 +4,7 @@ import React, { Suspense } from 'react';
import Tree from './Tree';
import SelectedElement from './SelectedElement';
import { InspectedElementContextController } from './InspectedElementContext';
import { OwnersListContextController } from './OwnersListContext';
import portaledContent from '../portaledContent';
import { ModalDialog } from '../ModalDialog';
@@ -12,19 +13,21 @@ import styles from './Components.css';
function Components(_: {||}) {
// TODO Flex wrappers below should be user resizable.
return (
<div className={styles.Components}>
<div className={styles.TreeWrapper}>
<Tree />
</div>
<div className={styles.SelectedElementWrapper}>
<InspectedElementContextController>
<Suspense fallback={<Loading />}>
<SelectedElement />
</Suspense>
</InspectedElementContextController>
</div>
<ModalDialog />
</div>
<OwnersListContextController>
<InspectedElementContextController>
<div className={styles.Components}>
<div className={styles.TreeWrapper}>
<Tree />
</div>
<div className={styles.SelectedElementWrapper}>
<Suspense fallback={<Loading />}>
<SelectedElement />
</Suspense>
</div>
<ModalDialog />
</div>
</InspectedElementContextController>
</OwnersListContextController>
);
}
+2 -2
View File
@@ -29,7 +29,7 @@ type Props = {
export default function ElementView({ data, index, style }: Props) {
const store = useContext(StoreContext);
const { ownerFlatTree, ownerStack, selectedElementID } = useContext(
const { ownerFlatTree, ownerID, selectedElementID } = useContext(
TreeStateContext
);
const dispatch = useContext(TreeDispatcherContext);
@@ -168,7 +168,7 @@ export default function ElementView({ data, index, style }: Props) {
}}
>
<span className={styles.ScrollAnchor} ref={scrollAnchorStartRef} />
{ownerStack.length === 0 ? (
{ownerID === null ? (
<ExpandCollapseToggle element={element} store={store} />
) : null}
<span className={styles.Component}>
@@ -0,0 +1,109 @@
// @flow
import React, {
createContext,
useCallback,
useContext,
useEffect,
} from 'react';
import { createResource } from '../../cache';
import { BridgeContext, StoreContext } from '../context';
import { TreeStateContext } from './TreeContext';
import type {
Element,
Owner,
OwnersList,
} from 'src/devtools/views/Components/types';
import type { Resource, Thenable } from '../../cache';
type Context = (id: number) => Array<Owner> | null;
const OwnersListContext = createContext<Context>(((null: any): Context));
OwnersListContext.displayName = 'OwnersListContext';
type ResolveFn = (ownersList: Array<Owner> | null) => void;
type InProgressRequest = {|
promise: Thenable<Array<Owner>>,
resolveFn: ResolveFn,
|};
const inProgressRequests: WeakMap<Element, InProgressRequest> = new WeakMap();
const resource: Resource<Element, Element, Array<Owner>> = createResource(
(element: Element) => {
let request = inProgressRequests.get(element);
if (request != null) {
return request.promise;
}
let resolveFn = ((null: any): ResolveFn);
const promise = new Promise(resolve => {
resolveFn = resolve;
});
inProgressRequests.set(element, { promise, resolveFn });
return promise;
},
(element: Element) => element,
{ useWeakMap: true }
);
type Props = {|
children: React$Node,
|};
function OwnersListContextController({ children }: Props) {
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
const { ownerID } = useContext(TreeStateContext);
const read = useCallback(
(id: number) => {
const element = store.getElementByID(id);
if (element !== null) {
return resource.read(element);
} else {
return null;
}
},
[store]
);
useEffect(() => {
const onOwnersList = (ownersList: OwnersList) => {
const id = ownersList.id;
const element = store.getElementByID(id);
if (element !== null) {
const request = inProgressRequests.get(element);
if (request != null) {
inProgressRequests.delete(element);
request.resolveFn(ownersList.owners);
}
}
};
bridge.addListener('ownersList', onOwnersList);
return () => bridge.removeListener('ownersList', onOwnersList);
}, [bridge, store]);
// This effect requests an updated owners list any time the selected owner changes
useEffect(() => {
if (ownerID !== null) {
const rendererID = store.getRendererIDForElement(ownerID);
bridge.send('getOwnersList', { id: ownerID, rendererID });
}
return () => {};
}, [bridge, ownerID, store]);
return (
<OwnersListContext.Provider value={read}>
{children}
</OwnersListContext.Provider>
);
}
export { OwnersListContext, OwnersListContextController };
@@ -91,3 +91,8 @@
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
}
.NotInStore,
.NotInStore:hover {
color: var(--color-dimmest);
}
+158 -56
View File
@@ -4,6 +4,7 @@ import React, {
useCallback,
useContext,
useLayoutEffect,
useReducer,
useRef,
useState,
} from 'react';
@@ -12,22 +13,113 @@ import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import Toggle from '../Toggle';
import { OwnersListContext } from './OwnersListContext';
import { TreeDispatcherContext, TreeStateContext } from './TreeContext';
import { StoreContext } from '../context';
import { useIsOverflowing } from '../hooks';
import { StoreContext } from '../context';
import type { Element } from './types';
import type { Owner } from './types';
import styles from './OwnersStack.css';
type SelectOwner = (owner: Owner | null) => void;
type ACTION_UPDATE_OWNER_ID = {|
type: 'UPDATE_OWNER_ID',
ownerID: number | null,
owners: Array<Owner>,
|};
type ACTION_UPDATE_SELECTED_INDEX = {|
type: 'UPDATE_SELECTED_INDEX',
selectedIndex: number,
|};
type Action = ACTION_UPDATE_OWNER_ID | ACTION_UPDATE_SELECTED_INDEX;
type State = {|
ownerID: number | null,
owners: Array<Owner>,
selectedIndex: number,
|};
function dialogReducer(state, action) {
switch (action.type) {
case 'UPDATE_OWNER_ID':
const selectedIndex = action.owners.findIndex(
owner => owner.id === action.ownerID
);
return {
ownerID: action.ownerID,
owners: action.owners,
selectedIndex,
};
case 'UPDATE_SELECTED_INDEX':
return {
...state,
selectedIndex: action.selectedIndex,
};
default:
throw new Error(`Invalid action "${action.type}"`);
}
}
export default function OwnerStack() {
const { ownerStack, ownerStackIndex } = useContext(TreeStateContext);
const dispatch = useContext(TreeDispatcherContext);
const read = useContext(OwnersListContext);
const { ownerID } = useContext(TreeStateContext);
const treeDispatch = useContext(TreeDispatcherContext);
const [state, dispatch] = useReducer<State, Action>(dialogReducer, {
ownerID: null,
owners: [],
selectedIndex: 0,
});
// When an owner is selected, we either need to update the selected index, or we need to fetch a new list of owners.
// We use a reducer here so that we can avoid fetching a new list unless the owner ID has actually changed.
if (ownerID === null) {
dispatch({
type: 'UPDATE_OWNER_ID',
ownerID: null,
owners: [],
});
} else if (ownerID !== state.ownerID) {
const isInStore =
state.owners.findIndex(owner => owner.id === ownerID) >= 0;
dispatch({
type: 'UPDATE_OWNER_ID',
ownerID,
owners: isInStore ? state.owners : read(ownerID) || [],
});
}
const { owners, selectedIndex } = state;
const selectOwner = useCallback<SelectOwner>(
(owner: Owner | null) => {
if (owner !== null) {
const index = owners.indexOf(owner);
dispatch({
type: 'UPDATE_SELECTED_INDEX',
selectedIndex: index >= 0 ? index : 0,
});
treeDispatch({ type: 'SELECT_OWNER', payload: owner.id });
} else {
dispatch({
type: 'UPDATE_SELECTED_INDEX',
selectedIndex: 0,
});
treeDispatch({ type: 'RESET_OWNER_STACK' });
}
},
[owners, treeDispatch]
);
const [elementsTotalWidth, setElementsTotalWidth] = useState(0);
const elementsBarRef = useRef<HTMLDivElement | null>(null);
const isOverflowing = useIsOverflowing(elementsBarRef, elementsTotalWidth);
const selectedOwner = owners[selectedIndex];
useLayoutEffect(() => {
// If we're already overflowing, then we don't need to re-measure items.
// That's because once the owners stack is open, it can only get larger (by driling in).
@@ -37,7 +129,7 @@ export default function OwnerStack() {
}
let elementsTotalWidth = 0;
for (let i = 0; i < ownerStack.length; i++) {
for (let i = 0; i < owners.length; i++) {
const element = elementsBarRef.current.children[i];
const computedStyle = getComputedStyle(element);
@@ -48,7 +140,7 @@ export default function OwnerStack() {
}
setElementsTotalWidth(elementsTotalWidth);
}, [elementsBarRef, isOverflowing, ownerStack.length]);
}, [elementsBarRef, isOverflowing, owners.length]);
return (
<div className={styles.OwnerStack}>
@@ -56,28 +148,38 @@ export default function OwnerStack() {
{isOverflowing && (
<Fragment>
<ElementsDropdown
ownerStack={ownerStack}
ownerStackIndex={ownerStackIndex}
owners={owners}
selectedIndex={selectedIndex}
selectOwner={selectOwner}
/>
<BackToOwnerButton
ownerStack={ownerStack}
ownerStackIndex={ownerStackIndex}
/>
<ElementView
id={ownerStack[((ownerStackIndex: any): number)]}
index={ownerStackIndex}
owners={owners}
selectedIndex={selectedIndex}
selectOwner={selectOwner}
/>
{selectedOwner != null && (
<ElementView
owner={selectedOwner}
isSelected
selectOwner={selectOwner}
/>
)}
</Fragment>
)}
{!isOverflowing &&
ownerStack.map((id, index) => (
<ElementView key={id} id={id} index={index} />
owners.map((owner, index) => (
<ElementView
key={index}
owner={owner}
isSelected={index === selectedIndex}
selectOwner={selectOwner}
/>
))}
</div>
<div className={styles.VRule} />
<Button
className={styles.IconButton}
onClick={() => dispatch({ type: 'RESET_OWNER_STACK' })}
onClick={() => selectOwner(null)}
title="Back to tree view"
>
<ButtonIcon type="close" />
@@ -87,26 +189,28 @@ export default function OwnerStack() {
}
type ElementsDropdownProps = {
ownerStack: Array<number>,
ownerStackIndex: number | null,
owners: Array<Owner>,
selectedIndex: number,
selectOwner: SelectOwner,
};
function ElementsDropdown({
ownerStack,
ownerStackIndex,
owners,
selectedIndex,
selectOwner,
}: ElementsDropdownProps) {
const store = useContext(StoreContext);
const dispatch = useContext(TreeDispatcherContext);
const menuItems = [];
for (let index = ownerStack.length - 1; index >= 0; index--) {
const id = ownerStack[index];
for (let index = owners.length - 1; index >= 0; index--) {
const owner = owners[index];
const isInStore = store.containsElement(owner.id);
menuItems.push(
<MenuItem
key={id}
className={styles.Component}
onSelect={() => dispatch({ type: 'SELECT_OWNER', payload: id })}
key={owner.id}
className={`${styles.Component} ${isInStore ? '' : styles.NotInStore}`}
onSelect={() => (isInStore ? selectOwner(owner) : null)}
>
{((store.getElementByID(id): any): Element).displayName}
{owner.displayName}
</MenuItem>
);
}
@@ -126,28 +230,26 @@ function ElementsDropdown({
}
type ElementViewProps = {
id: number,
index: number | null,
isSelected: boolean,
owner: Owner,
selectOwner: SelectOwner,
};
function ElementView({ id, index }: ElementViewProps) {
function ElementView({ isSelected, owner, selectOwner }: ElementViewProps) {
const store = useContext(StoreContext);
const { ownerStackIndex } = useContext(TreeStateContext);
const dispatch = useContext(TreeDispatcherContext);
const { displayName } = ((store.getElementByID(id): any): Element);
const isChecked = ownerStackIndex === index;
const { displayName } = owner;
const isInStore = store.containsElement(owner.id);
const handleChange = useCallback(() => {
if (!isChecked) {
dispatch({ type: 'SELECT_OWNER', payload: id });
if (isInStore) {
selectOwner(owner);
}
}, [dispatch, id, isChecked]);
}, [isInStore, selectOwner, owner]);
return (
<Toggle
className={styles.Component}
isChecked={isChecked}
className={`${styles.Component} ${isInStore ? '' : styles.NotInStore}`}
isChecked={isSelected}
onChange={handleChange}
>
{displayName}
@@ -156,32 +258,32 @@ function ElementView({ id, index }: ElementViewProps) {
}
type BackToOwnerButtonProps = {|
ownerStack: Array<number>,
ownerStackIndex: number | null,
owners: Array<Owner>,
selectedIndex: number,
selectOwner: SelectOwner,
|};
function BackToOwnerButton({
ownerStack,
ownerStackIndex,
owners,
selectedIndex,
selectOwner,
}: BackToOwnerButtonProps) {
const store = useContext(StoreContext);
const dispatch = useContext(TreeDispatcherContext);
if (ownerStackIndex === null || ownerStackIndex === 0) {
if (selectedIndex <= 0) {
return null;
}
const ownerID = ownerStack[ownerStackIndex - 1];
const owner = store.getElementByID(ownerID);
const owner = owners[selectedIndex - 1];
if (owner == null) {
debugger;
}
const isInStore = store.containsElement(owner.id);
return (
<Button
onClick={() =>
dispatch({
type: 'SELECT_OWNER',
payload: ownerID,
})
}
title={`Up to ${(owner !== null && owner.displayName) || 'owner'}`}
className={isInStore ? undefined : styles.NotInStore}
onClick={() => (isInStore ? selectOwner(owner) : null)}
title={`Up to ${owner.displayName || 'owner'}`}
>
<ButtonIcon type="previous" />
</Button>
@@ -227,7 +227,7 @@ function InspectedElementView({
state,
} = inspectedElement;
const { ownerStack } = useContext(TreeStateContext);
const { ownerID } = useContext(TreeStateContext);
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
@@ -298,13 +298,13 @@ function InspectedElementView({
overrideValueFn={overrideContextFn}
/>
{ownerStack.length === 0 && owners !== null && owners.length > 0 && (
{ownerID === null && owners !== null && owners.length > 0 && (
<div className={styles.Owners}>
<div className={styles.OwnersHeader}>rendered by</div>
{owners.map(owner => (
<OwnerView
key={owner.id}
displayName={owner.displayName}
displayName={owner.displayName || 'Unknown'}
id={owner.id}
isInStore={store.containsElement(owner.id)}
/>
+11
View File
@@ -37,3 +37,14 @@
margin: 0 0.5rem;
background-color: var(--color-border);
}
.Loading {
height: 100%;
padding-left: 0.5rem;
display: flex;
align-items: center;
flex: 1;
justify-content: flex-start;
font-size: var(--font-size-sans-large);
color: var(--color-dim);
}
+13 -7
View File
@@ -1,6 +1,7 @@
// @flow
import React, {
Suspense,
useState,
useCallback,
useContext,
@@ -38,7 +39,7 @@ export default function Tree(props: Props) {
const dispatch = useContext(TreeDispatcherContext);
const {
numElements,
ownerStack,
ownerID,
searchIndex,
searchResults,
selectedElementID,
@@ -277,7 +278,9 @@ export default function Tree(props: Props) {
<div className={styles.SearchInput}>
<InspectHostNodesToggle />
<div className={styles.VRule} />
{ownerStack.length > 0 ? <OwnersStack /> : <SearchInput />}
<Suspense fallback={<Loading />}>
{ownerID !== null ? <OwnersStack /> : <SearchInput />}
</Suspense>
<div className={styles.VRule} />
<ToggleComponentFiltersModalButton />
</div>
@@ -317,7 +320,7 @@ export default function Tree(props: Props) {
}
function InnerElementType({ style, ...rest }) {
const { ownerStack } = useContext(TreeStateContext);
const { ownerID } = useContext(TreeStateContext);
// The list may need to scroll horizontally due to deeply nested elements.
// We don't know the maximum scroll width up front, because we're windowing.
@@ -346,10 +349,9 @@ function InnerElementType({ style, ...rest }) {
// We shouldn't retain this width across different conceptual trees though,
// so when the user opens the "owners tree" view, we should discard the previous width.
const hasOwnerStack = ownerStack.length > 0;
const [prevHasOwnerStack, setPrevHasOwnerStack] = useState(hasOwnerStack);
if (hasOwnerStack !== prevHasOwnerStack) {
setPrevHasOwnerStack(hasOwnerStack);
const [prevOwnerID, setPrevOwnerID] = useState(ownerID);
if (ownerID !== prevOwnerID) {
setPrevOwnerID(ownerID);
setMinWidth(null);
}
@@ -371,3 +373,7 @@ function InnerElementType({ style, ...rest }) {
/>
);
}
function Loading() {
return <div className={styles.Loading}>Loading...</div>;
}
+51 -87
View File
@@ -38,7 +38,7 @@ import Store from '../../store';
import type { Element } from './types';
type StateContext = {|
export type StateContext = {|
// Tree
numElements: number,
selectedElementID: number | null,
@@ -50,9 +50,8 @@ type StateContext = {|
searchText: string,
// Owners
ownerID: number | null,
ownerFlatTree: Array<Element> | null,
ownerStack: Array<number>,
ownerStackIndex: number | null,
// Inspection element panel
inspectedElementID: number | null,
@@ -118,7 +117,7 @@ type Action =
| ACTION_SET_SEARCH_TEXT
| ACTION_UPDATE_INSPECTED_ELEMENT_ID;
type DispatcherContext = (action: Action) => void;
export type DispatcherContext = (action: Action) => void;
const TreeStateContext = createContext<StateContext>(
((null: any): StateContext)
@@ -142,8 +141,7 @@ type State = {|
searchText: string,
// Owners
ownerStack: Array<number>,
ownerStackIndex: number | null,
ownerID: number | null,
ownerFlatTree: Array<Element> | null,
// Inspection element panel
@@ -151,17 +149,12 @@ type State = {|
|};
function reduceTreeState(store: Store, state: State, action: Action): State {
let {
numElements,
ownerStack,
selectedElementIndex,
selectedElementID,
} = state;
let { numElements, ownerID, selectedElementIndex, selectedElementID } = state;
let lookupIDForIndex = true;
// Base tree should ignore selected element changes when the owner's tree is active.
if (ownerStack.length === 0) {
if (ownerID === null) {
switch (action.type) {
case 'HANDLE_STORE_MUTATION':
numElements = store.numElements;
@@ -276,7 +269,7 @@ function reduceTreeState(store: Store, state: State, action: Action): State {
function reduceSearchState(store: Store, state: State, action: Action): State {
let {
ownerStack,
ownerID,
searchIndex,
searchResults,
searchText,
@@ -295,7 +288,7 @@ function reduceSearchState(store: Store, state: State, action: Action): State {
let didRequestSearch = false;
// Search isn't supported when the owner's tree is active.
if (ownerStack.length === 0) {
if (ownerID === null) {
switch (action.type) {
case 'GO_TO_NEXT_SEARCH_RESULT':
if (numPrevSearchResults > 0) {
@@ -442,9 +435,8 @@ function reduceOwnersState(store: Store, state: State, action: Action): State {
numElements,
selectedElementID,
selectedElementIndex,
ownerID,
ownerFlatTree,
ownerStack,
ownerStackIndex,
searchIndex,
searchResults,
searchText,
@@ -454,30 +446,20 @@ function reduceOwnersState(store: Store, state: State, action: Action): State {
switch (action.type) {
case 'HANDLE_STORE_MUTATION':
if (ownerStack.length > 0) {
let indexOfRemovedItem = -1;
for (let i = 0; i < ownerStack.length; i++) {
if (store.getElementByID(ownerStack[i]) === null) {
indexOfRemovedItem = i;
break;
if (ownerID !== null) {
if (!store.containsElement(ownerID)) {
ownerID = null;
ownerFlatTree = null;
selectedElementID = null;
} else {
ownerFlatTree = store.getOwnersListForElement(ownerID);
if (selectedElementID !== null) {
// Mutation might have caused the index of this ID to shift.
selectedElementIndex = ownerFlatTree.findIndex(
element => element.id === selectedElementID
);
}
}
if (indexOfRemovedItem >= 0) {
ownerStack = ownerStack.slice(0, indexOfRemovedItem);
if (ownerStack.length === 0) {
ownerFlatTree = null;
ownerStackIndex = null;
} else {
ownerStackIndex = ownerStack.length - 1;
}
}
if (selectedElementID !== null && ownerFlatTree !== null) {
// Mutation might have caused the index of this ID to shift.
selectedElementIndex = ownerFlatTree.findIndex(
element => element.id === selectedElementID
);
}
} else {
if (selectedElementID !== null) {
// Mutation might have caused the index of this ID to shift.
@@ -491,13 +473,12 @@ function reduceOwnersState(store: Store, state: State, action: Action): State {
}
break;
case 'RESET_OWNER_STACK':
ownerStack = [];
ownerStackIndex = null;
ownerID = null;
ownerFlatTree = null;
selectedElementIndex =
selectedElementID !== null
? store.getIndexOfElementID(selectedElementID)
: null;
ownerFlatTree = null;
break;
case 'SELECT_ELEMENT_AT_INDEX':
if (ownerFlatTree !== null) {
@@ -533,33 +514,12 @@ function reduceOwnersState(store: Store, state: State, action: Action): State {
// If the Store doesn't have any owners metadata, don't drill into an empty stack.
// This is a confusing user experience.
if (store.hasOwnerMetadata) {
const id = (action: ACTION_SELECT_OWNER).payload;
ownerStackIndex = ownerStack.indexOf(id);
ownerID = (action: ACTION_SELECT_OWNER).payload;
ownerFlatTree = store.getOwnersListForElement(ownerID);
// Always force reset selection to be the top of the new owner tree.
selectedElementIndex = 0;
prevSelectedElementIndex = null;
// If this owner is already in the current stack, just select it.
// Otherwise, create a new stack.
if (ownerStackIndex < 0) {
// Add this new owner, and fill in the owners above it as well.
ownerStack = [];
let currentOwnerID = id;
while (currentOwnerID !== 0) {
ownerStack.unshift(currentOwnerID);
currentOwnerID = ((store.getElementByID(
currentOwnerID
): any): Element).ownerID;
}
ownerStackIndex = ownerStack.length - 1;
if (searchText !== '') {
searchIndex = null;
searchResults = [];
searchText = '';
}
}
}
break;
default:
@@ -569,17 +529,12 @@ function reduceOwnersState(store: Store, state: State, action: Action): State {
// Changes in the selected owner require re-calculating the owners tree.
if (
ownerStackIndex !== state.ownerStackIndex ||
ownerStack !== state.ownerStack ||
ownerFlatTree !== state.ownerFlatTree ||
action.type === 'HANDLE_STORE_MUTATION'
) {
if (ownerStackIndex === null) {
ownerFlatTree = null;
if (ownerFlatTree === null) {
numElements = store.numElements;
} else {
ownerFlatTree = store.getOwnersListForElement(
ownerStack[ownerStackIndex]
);
numElements = ownerFlatTree.length;
}
}
@@ -588,9 +543,10 @@ function reduceOwnersState(store: Store, state: State, action: Action): State {
if (selectedElementIndex !== prevSelectedElementIndex) {
if (selectedElementIndex === null) {
selectedElementID = null;
} else if (ownerFlatTree !== null) {
selectedElementID =
ownerFlatTree[((selectedElementIndex: any): number)].id;
} else {
if (ownerFlatTree !== null) {
selectedElementID = ownerFlatTree[selectedElementIndex].id;
}
}
}
@@ -605,8 +561,7 @@ function reduceOwnersState(store: Store, state: State, action: Action): State {
searchResults,
searchText,
ownerStack,
ownerStackIndex,
ownerID,
ownerFlatTree,
};
}
@@ -619,20 +574,30 @@ function reduceSuspenseState(
const { type } = action;
switch (type) {
case 'UPDATE_INSPECTED_ELEMENT_ID':
return {
...state,
inspectedElementID: state.selectedElementID,
};
if (state.inspectedElementID !== state.selectedElementID) {
return {
...state,
inspectedElementID: state.selectedElementID,
};
}
break;
default:
// React can bailout of no-op updates.
return state;
break;
}
// React can bailout of no-op updates.
return state;
}
type Props = {| children: React$Node |};
type Props = {|
children: React$Node,
// Used for automated testing
defaultOwnerID?: ?number,
|};
// TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists.
function TreeContextController({ children }: Props) {
function TreeContextController({ children, defaultOwnerID }: Props) {
const bridge = useContext(BridgeContext);
const store = useContext(StoreContext);
@@ -696,8 +661,7 @@ function TreeContextController({ children }: Props) {
searchText: '',
// Owners
ownerStack: [],
ownerStackIndex: null,
ownerID: defaultOwnerID == null ? null : defaultOwnerID,
ownerFlatTree: null,
// Inspection element panel
+6 -1
View File
@@ -31,10 +31,15 @@ export type Element = {|
|};
export type Owner = {|
displayName: string,
displayName: string | null,
id: number,
|};
export type OwnersList = {|
id: number,
owners: Array<Owner> | null,
|};
export type InspectedElement = {|
id: number,