Refactored tree, search, selection, and owners contexts
Brian Vaughn committed
Feb 8, 2019 at 14:44 UTC
038018650169ecbe2962898746ea39ce31f73847
15 files changed
+773
-768
src/backend/renderer.js
+1
-1
@@ -546,7 +546,7 @@ export function attach(
546
const { _debugOwner } = fiber;
547
548
const ownerID =
549
- _debugOwner !== null ? getFiberID(getPrimaryFiber(_debugOwner)) : 0;
549
+ _debugOwner != null ? getFiberID(getPrimaryFiber(_debugOwner)) : 0;
550
551
let encodedDisplayName = ((null: any): Uint8Array);
552
let encodedKey = ((null: any): Uint8Array);
src/devtools/store.js
+11
-3
@@ -107,6 +107,11 @@ export default class Store extends EventEmitter {
107
return ((currentElement: any): Element);
108
}
109
110
+ getElementIDAtIndex(index: number): number | null {
111
+ const element: Element | null = this.getElementAtIndex(index);
112
+ return element === null ? null : element.id;
113
+ }
114
+
115
getElementByID(id: number): Element | null {
116
const element = this._idToElement.get(id);
117
@@ -286,9 +291,10 @@ export default class Store extends EventEmitter {
291
292
this._idToElement.set(id, element);
293
289
- // Track newly addewd items so search results can be updated
294
+ const oldAddedElementIDs = addedElementIDs;
295
addedElementIDs = new Uint32Array(addedElementIDs.length + 1);
291
- addedElementIDs[addedElementIDs.length - 1] = id;
296
+ addedElementIDs.set(oldAddedElementIDs);
297
+ addedElementIDs[oldAddedElementIDs.length] = id;
298
299
weightDelta = 1;
300
}
@@ -319,8 +325,10 @@ export default class Store extends EventEmitter {
325
}
326
327
// Track removed items so search results can be updated
328
+ const oldRemovededElementIDs = removedElementIDs;
329
removedElementIDs = new Uint32Array(removedElementIDs.length + 1);
323
- removedElementIDs[removedElementIDs.length - 1] = id;
330
+ removedElementIDs.set(oldRemovededElementIDs);
331
+ removedElementIDs[oldRemovededElementIDs.length] = id;
332
break;
333
case TREE_OPERATION_RESET_CHILDREN:
334
id = ((operations[i + 1]: any): number);
src/devtools/types.js
-10
@@ -1,7 +1,5 @@
1
// @flow
2
3
-import Store from './Store';
4
-
3
export const ElementTypeClassOrFunction = 1;
4
export const ElementTypeContext = 2;
5
export const ElementTypeForwardRef = 3;
@@ -67,14 +65,6 @@ export type InspectedElement = {|
65
66
// TODO: Add profiling type
67
70
-// The frontend Store is mutable.
71
-// This wrapper object is recreated whenever the store's tree is mutated.
72
-// It is used to trigger a re-render of the virtualized Elements tree.
73
-export type TreeMetadataType = {|
74
- size: number,
75
- store: Store,
76
-|};
77
-
68
export type DehydratedData = {|
69
cleaned: Array<Array<string>>,
70
data: Object,
src/devtools/views/ButtonIcon.js
+11
@@ -8,6 +8,7 @@ type Props = {|
8
| 'back'
9
| 'close'
10
| 'down'
11
+ | 'more'
12
| 'search'
13
| 'up'
14
| 'view-dom'
@@ -26,6 +27,9 @@ export default function ButtonIcon({ type }: Props) {
27
case 'down':
28
pathData = PATH_DOWN;
29
break;
30
+ case 'more':
31
+ pathData = PATH_MORE;
32
+ break;
33
case 'search':
34
pathData = PATH_SEARCH;
35
break;
@@ -68,6 +72,13 @@ const PATH_CLOSE =
72
73
const PATH_DOWN = 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z';
74
75
+const PATH_MORE = `
76
+ M22 3H7c-.69 0-1.23.35-1.59.88L0 12l5.41 8.11c.36.53.97.89 1.66.89H22c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9
77
+ 13.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5 0c-.83
78
+ 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5
79
+ 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z
80
+`;
81
+
82
const PATH_SEARCH = `
83
M20.94 11c-.46-4.17-3.77-7.48-7.94-7.94V1h-2v2.06C6.83 3.52 3.52 6.83 3.06 11H1v2h2.06c.46
84
4.17 3.77 7.48 7.94 7.94V23h2v-2.06c4.17-.46 7.48-3.77 7.94-7.94H23v-2h-2.06zM12 19c-3.87
src/devtools/views/Element.js
+20
-26
@@ -2,9 +2,8 @@
2
3
import React, { Fragment, useCallback, useContext, useMemo } from 'react';
4
import { ElementTypeClassOrFunction } from 'src/devtools/types';
5
-import { TreeContext } from './context';
5
import { createRegExp } from './utils';
7
-import { SearchAndSelectionContext } from './SearchAndSelectionContext';
6
+import { TreeContext } from './TreeContext';
7
import Icon from './Icon';
8
9
import type { Element } from '../types';
@@ -17,33 +16,30 @@ type Props = {
16
};
17
18
export default function ElementView({ index, style }: Props) {
20
- const { store } = useContext(TreeContext);
21
-
19
const {
23
- ownerList,
24
- pushOwnerList,
20
+ baseDepth,
21
+ getElementAtIndex,
22
+ selectOwner,
23
selectedElementID,
26
- selectElementWithID,
27
- } = useContext(SearchAndSelectionContext);
28
-
29
- const element =
30
- ownerList !== null
31
- ? ((store.getElementByID(ownerList[index]): any): Element)
32
- : ((store.getElementAtIndex(index): any): Element);
33
- const { children, depth, displayName, id, key, type } = element;
34
-
35
- let calculatedDepth = depth;
36
- if (ownerList !== null) {
37
- const owner = ((store.getElementByID(ownerList[0]): any): Element);
38
- calculatedDepth = depth - owner.depth;
39
- }
24
+ selectElementByID,
25
+ } = useContext(TreeContext);
26
41
- const handleDoubleClick = useCallback(() => pushOwnerList(id), [id]);
27
+ const element = getElementAtIndex(index);
28
+ const {
29
+ children,
30
+ depth,
31
+ displayName,
32
+ id,
33
+ key,
34
+ type,
35
+ } = ((element: any): Element);
36
+
37
+ const handleDoubleClick = useCallback(() => selectOwner(id), [id]);
38
39
// TODO Add click and key handlers for toggling element open/close state.
40
41
const handleClick = useCallback(
46
- ({ metaKey }) => selectElementWithID(metaKey ? null : id),
42
+ ({ metaKey }) => selectElementByID(metaKey ? null : id),
43
[id]
44
);
45
@@ -57,7 +53,7 @@ export default function ElementView({ index, style }: Props) {
53
onDoubleClick={handleDoubleClick}
54
style={{
55
...style, // "style" comes from react-window
60
- paddingLeft: `${1 + calculatedDepth}rem`,
56
+ paddingLeft: `${1 + depth - baseDepth}rem`,
57
}}
58
>
59
{children.length > 0 && (
@@ -86,9 +82,7 @@ type DisplayNameProps = {|
82
|};
83
84
function DisplayName({ displayName, id }: DisplayNameProps) {
89
- const { searchIndex, searchResults, searchText } = useContext(
90
- SearchAndSelectionContext
91
- );
85
+ const { searchIndex, searchResults, searchText } = useContext(TreeContext);
86
const isSearchResult = useMemo(() => {
87
return searchResults.includes(id);
88
}, [id, searchResults]);
src/devtools/views/Elements.css
+1
@@ -9,6 +9,7 @@
9
10
.TreeWrapper {
11
flex: 0 0 65%;
12
+ overflow: auto;
13
}
14
15
.SelectedElementWrapper {
src/devtools/views/Elements.js
+12
-38
@@ -1,11 +1,11 @@
1
// @flow
2
3
-import React, { useEffect, useMemo, useState } from 'react';
3
+import React, { useMemo } from 'react';
4
import Store from '../store';
5
import Tree from './Tree';
6
-import { BridgeContext, StoreContext, TreeContext } from './context';
6
+import { BridgeContext, StoreContext } from './context';
7
import SelectedElement from './SelectedElement';
8
-import { SearchAndSelectionController } from './SearchAndSelectionContext';
8
+import { TreeContextController } from './TreeContext';
9
import styles from './Elements.css';
10
11
import './root.css';
@@ -21,46 +21,20 @@ export type Props = {|
21
export default function Elements({ bridge, browserName, themeName }: Props) {
22
const store = useMemo<Store>(() => new Store(bridge), []);
23
24
- const [treeContext, setTreeContext] = useState({
25
- size: store.numElements,
26
- store,
27
- });
28
-
29
- useEffect(() => {
30
- const handler = () => {
31
- setTreeContext({
32
- size: store.numElements,
33
- store,
34
- });
35
- };
36
-
37
- // Check for changes that happened between async render and passive effect.
38
- // (We had not yet subscribed to the store so we would have missed these.)
39
- if (treeContext.size !== store.numElements) {
40
- handler();
41
- }
42
-
43
- store.addListener('mutated', handler);
44
-
45
- return () => store.removeListener('mutated', handler);
46
- }, [store]);
47
-
24
// TODO Flex wrappers below should be user resizable.
25
return (
26
<BridgeContext.Provider value={bridge}>
27
<StoreContext.Provider value={store}>
52
- <TreeContext.Provider value={treeContext}>
53
- <SearchAndSelectionController>
54
- <div className={styles.Elements}>
55
- <div className={styles.TreeWrapper}>
56
- <Tree />
57
- </div>
58
- <div className={styles.SelectedElementWrapper}>
59
- <SelectedElement />
60
- </div>
28
+ <TreeContextController>
29
+ <div className={styles.Elements}>
30
+ <div className={styles.TreeWrapper}>
31
+ <Tree />
32
+ </div>
33
+ <div className={styles.SelectedElementWrapper}>
34
+ <SelectedElement />
35
</div>
62
- </SearchAndSelectionController>
63
- </TreeContext.Provider>
36
+ </div>
37
+ </TreeContextController>
38
</StoreContext.Provider>
39
</BridgeContext.Provider>
40
);
src/devtools/views/OwnersStack.css
+2
@@ -2,6 +2,7 @@
2
flex: 1;
3
display: flex;
4
align-items: center;
5
+ overflow-x: auto;
6
}
7
8
.IconButton {
@@ -29,6 +30,7 @@
30
color: var(--color-component-name);
31
font-family: var(--font-family-monospace);
32
font-size: var(--font-size-normal);
33
+ white-space: nowrap;
34
}
35
36
.Component {
src/devtools/views/OwnersStack.js
+17
-17
@@ -2,26 +2,26 @@
2
3
import React, { useCallback, useContext } from 'react';
4
import ButtonIcon from './ButtonIcon';
5
-import { SearchAndSelectionContext } from './SearchAndSelectionContext';
6
-import { TreeContext } from './context';
5
+import { TreeContext } from './TreeContext';
6
+import { StoreContext } from './context';
7
8
import type { Element } from '../types';
9
10
import styles from './OwnersStack.css';
11
12
export default function OwnerStack() {
13
- const { clearOwnerList, ownerIDStack } = useContext(
14
- SearchAndSelectionContext
15
- );
13
+ const { ownerStack, resetOwnerStack } = useContext(TreeContext);
14
15
// $FlowFixMe "Missing type annotation for U" whatever that means
18
- const elements = ownerIDStack.map(id => <ElementView key={id} id={id} />);
16
+ const elements = ownerStack.map((id, index) => (
17
+ <ElementView key={id} id={id} index={index} />
18
+ ));
19
20
return (
21
<div className={styles.OwnerStack}>
22
<button
23
className={styles.IconButton}
24
- onClick={clearOwnerList}
24
+ onClick={resetOwnerStack}
25
title="Back to tree view"
26
>
27
<ButtonIcon type="back" />
@@ -34,21 +34,21 @@ export default function OwnerStack() {
34
35
type Props = {
36
id: number,
37
+ index: number,
38
};
39
39
-function ElementView({ id }: Props) {
40
- const { ownerIDStack, popToOwnerList } = useContext(
41
- SearchAndSelectionContext
42
- );
43
- const { store } = useContext(TreeContext);
40
+function ElementView({ id, index }: Props) {
41
+ const { ownerStackIndex, selectOwner } = useContext(TreeContext);
42
+ const store = useContext(StoreContext);
43
const { displayName } = ((store.getElementByID(id): any): Element);
44
46
- const isCurrentlyFocusedOwner = ownerIDStack[ownerIDStack.length - 1] === id;
45
+ const isCurrentlyFocusedOwner = ownerStackIndex === index;
46
48
- const handleClick = useCallback(() => popToOwnerList(id), [
49
- id,
50
- popToOwnerList,
51
- ]);
47
+ const handleClick = useCallback(() => {
48
+ if (!isCurrentlyFocusedOwner) {
49
+ selectOwner(id);
50
+ }
51
+ }, [id, isCurrentlyFocusedOwner, selectOwner]);
52
53
return (
54
<span
src/devtools/views/SearchAndSelectionContext.js
deleted
-634
@@ -1,634 +0,0 @@
1
-// @flow
2
-
3
-// This context contains both the search, owners stack, and selected element states.
4
-// These values are combined into a single context because changes in one often impact the others.
5
-// Combining them enables us to avoid cascading renders.
6
-
7
-import React, {
8
- createContext,
9
- useCallback,
10
- useContext,
11
- useLayoutEffect,
12
- useMemo,
13
- useReducer,
14
-} from 'react';
15
-import { createRegExp } from './utils';
16
-import { StoreContext } from './context';
17
-import Store from '../store';
18
-
19
-import type { Element } from 'src/devtools/types';
20
-
21
-type Context = {|
22
- // Read current context values
23
- ownerIDStack: Array<number>,
24
- ownerList: Array<number> | null,
25
- searchIndex: number | null,
26
- searchResults: Array<number>,
27
- searchText: string,
28
- selectedElementID: number | null,
29
- selectedElementIndex: number | null,
30
-
31
- // Update current context values:
32
- clearOwnerList: () => void,
33
- decrementSearchIndex(): void,
34
- incrementSearchIndex(): void,
35
- popToOwnerList(elementID: number): void,
36
- pushOwnerList(elementID: number): void,
37
- selectElementWithID(id: number | null): void,
38
- selectNextElementInTree(): void,
39
- selectPreviousElementInTree(): void,
40
- updateSearchText(searchText: string): void,
41
-|};
42
-
43
-const SearchAndSelectionContext = createContext<Context>(
44
- ((null: any): Context)
45
-);
46
-// $FlowFixMe displayName is a valid attribute of React$ConsearchText
47
-SearchAndSelectionContext.displayName = 'SearchAndSelectionContext';
48
-
49
-type State = {|
50
- ownerIDStack: Array<number>,
51
- ownerList: Array<number> | null,
52
- searchIndex: number | null,
53
- searchResults: Array<number>,
54
- searchText: string,
55
- selectedElementID: number | null,
56
- selectedElementIndex: number | null,
57
-|};
58
-
59
-type Action = {|
60
- type:
61
- | 'CLEAR_OWNER_LIST'
62
- | 'DECREMENT_SEARCH_INDEX'
63
- | 'INCREMENT_SEARCH_INDEX'
64
- | 'POP_TO_OWNER_LIST'
65
- | 'PUSH_OWNER_LIST'
66
- | 'REFINE_AFTER_MUTATION'
67
- | 'SELECT_ELEMENT_WITH_ID'
68
- | 'SELECT_NEXT_ELEMENT_IN_TREE'
69
- | 'SELECT_PREVIOUS_ELEMENT_IN_TREE'
70
- | 'UPDATE_SEARCH_TEXT',
71
- payload?: any,
72
-|};
73
-
74
-type Props = {|
75
- children: React$Node,
76
-|};
77
-
78
-// TODO Remove this wrapper element once global ConsearchText.write API exists.
79
-function SearchAndSelectionController({ children }: Props) {
80
- const store = useContext(StoreContext);
81
-
82
- // This reducer is created inline because it needs access to the Store.
83
- // The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
84
- // so it's okay for the reducer to have an empty dependencies array.
85
- const reducer = useMemo(
86
- () =>
87
- function reducer(state: State, action: Action): State {
88
- const {
89
- ownerIDStack,
90
- ownerList,
91
- searchIndex,
92
- searchResults,
93
- searchText,
94
- selectedElementID,
95
- selectedElementIndex,
96
- } = state;
97
- const { payload, type } = action;
98
-
99
- let element,
100
- elementID,
101
- newOwnerList,
102
- newOwnerIDStack,
103
- newSearchIndex,
104
- newSearchResults,
105
- newSearchText,
106
- newSelectedElementID,
107
- newSelectedElementIndex;
108
-
109
- const updateSelectedElementIndexHelper = (
110
- newSelectedElementIndex: number
111
- ) => {
112
- if (ownerList !== null) {
113
- newSelectedElementID =
114
- newSelectedElementIndex !== null
115
- ? ownerList[newSelectedElementIndex]
116
- : null;
117
-
118
- return {
119
- ...state,
120
- selectedElementID: newSelectedElementID,
121
- selectedElementIndex: newSelectedElementIndex,
122
- };
123
- } else {
124
- element =
125
- newSelectedElementIndex !== null
126
- ? store.getElementAtIndex(newSelectedElementIndex)
127
- : null;
128
-
129
- return {
130
- ...state,
131
- selectedElementID: element !== null ? element.id : null,
132
- selectedElementIndex: newSelectedElementIndex,
133
- };
134
- }
135
- };
136
-
137
- switch (type) {
138
- case 'CLEAR_OWNER_LIST':
139
- return {
140
- ...state,
141
- ownerList: null,
142
- ownerIDStack: [],
143
- };
144
-
145
- case 'DECREMENT_SEARCH_INDEX':
146
- if (ownerList !== null) {
147
- throw Error(
148
- `Can't update search index while owner list is active`
149
- );
150
- }
151
-
152
- if (searchResults.length === 0) return state;
153
-
154
- newSearchIndex =
155
- ((searchIndex: any): number) > 0
156
- ? ((searchIndex: any): number) - 1
157
- : searchResults.length - 1;
158
- newSelectedElementID = searchResults[newSearchIndex];
159
-
160
- return {
161
- ...state,
162
- searchIndex: newSearchIndex,
163
- selectedElementID: newSelectedElementID,
164
- selectedElementIndex: store.getIndexOfElementID(
165
- newSelectedElementID
166
- ),
167
- };
168
-
169
- case 'INCREMENT_SEARCH_INDEX':
170
- if (ownerList !== null) {
171
- throw Error(
172
- `Can't update search index while owner list is active`
173
- );
174
- }
175
-
176
- if (searchResults.length === 0) return state;
177
-
178
- newSearchIndex =
179
- ((searchIndex: any): number) + 1 < searchResults.length
180
- ? ((searchIndex: any): number) + 1
181
- : 0;
182
- newSelectedElementID = searchResults[newSearchIndex];
183
-
184
- return {
185
- ...state,
186
- searchIndex: newSearchIndex,
187
- selectedElementID: newSelectedElementID,
188
- selectedElementIndex: store.getIndexOfElementID(
189
- newSelectedElementID
190
- ),
191
- };
192
-
193
- case 'POP_TO_OWNER_LIST':
194
- elementID = ((payload: any): number);
195
- return {
196
- ...state,
197
- ownerList: calculateCurrentOwnerList(
198
- store,
199
- elementID,
200
- elementID,
201
- []
202
- ),
203
- ownerIDStack: ownerIDStack.slice(
204
- 0,
205
- ownerIDStack.indexOf(elementID) + 1
206
- ),
207
- selectedElementIndex: null,
208
- selectedElementID: null,
209
- };
210
-
211
- case 'PUSH_OWNER_LIST':
212
- elementID = ((payload: any): number);
213
- if (ownerIDStack.includes(elementID)) return state;
214
-
215
- // Add this new owner, and fill in the owners above it as well.
216
- const ownerIDsToConcat = [];
217
- let currentOwnerID = elementID;
218
- while (
219
- currentOwnerID !== 0 &&
220
- ownerIDStack[ownerIDStack.length - 1] !== currentOwnerID
221
- ) {
222
- ownerIDsToConcat.unshift(currentOwnerID);
223
- currentOwnerID = ((store.getElementByID(
224
- currentOwnerID
225
- ): any): Element).ownerID;
226
- }
227
-
228
- return {
229
- ...state,
230
- ownerList: calculateCurrentOwnerList(
231
- store,
232
- elementID,
233
- elementID,
234
- []
235
- ),
236
- ownerIDStack: ownerIDStack.concat(ownerIDsToConcat),
237
- selectedElementIndex: null,
238
- selectedElementID: null,
239
- searchIndex: null,
240
- searchResults: [],
241
- searchText: '',
242
- };
243
-
244
- case 'REFINE_AFTER_MUTATION':
245
- const [
246
- addedElementIDs,
247
- removedElementIDs,
248
- ] = ((payload: any): Array<Uint32Array>);
249
-
250
- newOwnerList = ownerList;
251
- newOwnerIDStack = ownerIDStack;
252
- newSearchIndex = searchIndex;
253
- newSearchResults = searchResults;
254
- newSearchText = searchText;
255
- newSelectedElementID = selectedElementID;
256
- newSelectedElementIndex = selectedElementIndex;
257
-
258
- if (searchText !== '') {
259
- removedElementIDs.forEach(id => {
260
- // Prune this item from the search results.
261
- const index = newSearchResults.indexOf(id);
262
- if (index >= 0) {
263
- newSearchResults = newSearchResults
264
- .slice(0, index)
265
- .concat(newSearchResults.slice(index + 1));
266
-
267
- // If the results are now empty, also deselect things.
268
- if (newSearchResults.length === 0) {
269
- newSearchIndex = null;
270
- } else if (
271
- ((newSearchIndex: any): number) >= newSearchResults.length
272
- ) {
273
- newSearchIndex = newSearchResults.length - 1;
274
- }
275
- }
276
- });
277
-
278
- addedElementIDs.forEach(id => {
279
- const { displayName } = ((store.getElementByID(
280
- id
281
- ): any): Element);
282
-
283
- // Add this item to the search results if it matches.
284
- const regExp = createRegExp(searchText);
285
- if (displayName !== null && regExp.test(displayName)) {
286
- const newElementIndex = ((store.getIndexOfElementID(
287
- id
288
- ): any): number);
289
-
290
- let foundMatch = false;
291
- for (
292
- let index = 0;
293
- index < newSearchResults.length;
294
- index++
295
- ) {
296
- const id = newSearchResults[index];
297
- if (
298
- newElementIndex <
299
- ((store.getIndexOfElementID(id): any): number)
300
- ) {
301
- foundMatch = true;
302
- newSearchResults = newSearchResults
303
- .slice(0, index)
304
- .concat(id)
305
- .concat(newSearchResults.slice(index));
306
- break;
307
- }
308
- }
309
- if (!foundMatch) {
310
- newSearchResults = newSearchResults.concat(id);
311
- }
312
-
313
- newSearchIndex = newSearchIndex === null ? 0 : newSearchIndex;
314
- }
315
- });
316
-
317
- newSelectedElementID =
318
- newSearchIndex !== null
319
- ? newSearchResults[newSearchIndex]
320
- : null;
321
- newSelectedElementIndex =
322
- newSelectedElementID !== null
323
- ? store.getIndexOfElementID(newSelectedElementID)
324
- : null;
325
- }
326
-
327
- // If an item in the owner stack has been removed from the tree, unwind it.
328
- if (ownerIDStack.length > 0) {
329
- let indexOfRemovedItem = -1;
330
- for (let i = 0; i < ownerIDStack.length; i++) {
331
- if (removedElementIDs.includes(ownerIDStack[i])) {
332
- indexOfRemovedItem = i;
333
- break;
334
- }
335
- }
336
-
337
- if (indexOfRemovedItem >= 0) {
338
- newOwnerIDStack = ownerIDStack.slice(0, indexOfRemovedItem);
339
- }
340
-
341
- if (newOwnerIDStack.length === 0) {
342
- newOwnerList = null;
343
- } else {
344
- elementID = newOwnerIDStack[newOwnerIDStack.length - 1];
345
-
346
- newOwnerList = calculateCurrentOwnerList(
347
- store,
348
- elementID,
349
- elementID,
350
- []
351
- );
352
-
353
- newSelectedElementIndex =
354
- newSelectedElementID !== null
355
- ? newOwnerList.indexOf(newSelectedElementID)
356
- : null;
357
- }
358
- }
359
-
360
- return {
361
- ...state,
362
- ownerIDStack: newOwnerIDStack,
363
- ownerList: newOwnerList,
364
- searchIndex: newSearchIndex,
365
- searchResults: newSearchResults,
366
- selectedElementID: newSelectedElementID,
367
- selectedElementIndex: newSelectedElementIndex,
368
- };
369
-
370
- case 'SELECT_ELEMENT_WITH_ID':
371
- newSelectedElementID = ((payload: any): number | null);
372
- newSelectedElementIndex = null;
373
-
374
- if (newSelectedElementID !== null) {
375
- if (ownerList !== null) {
376
- newSelectedElementIndex = ownerList.indexOf(
377
- newSelectedElementID
378
- );
379
- } else {
380
- newSelectedElementIndex = store.getIndexOfElementID(
381
- newSelectedElementID
382
- );
383
- }
384
- }
385
-
386
- const isInOwnerList =
387
- newSelectedElementID !== null &&
388
- ownerList !== null &&
389
- ownerList.includes(newSelectedElementID);
390
-
391
- return {
392
- ...state,
393
- ownerIDStack: isInOwnerList ? ownerIDStack : [],
394
- ownerList: isInOwnerList ? ownerList : null,
395
- selectedElementID: newSelectedElementID,
396
- selectedElementIndex: newSelectedElementIndex,
397
- };
398
-
399
- case 'SELECT_NEXT_ELEMENT_IN_TREE':
400
- if (selectedElementIndex === null) {
401
- return state;
402
- } else if (ownerList !== null) {
403
- return updateSelectedElementIndexHelper(
404
- selectedElementIndex + 1 < ownerList.length
405
- ? selectedElementIndex + 1
406
- : selectedElementIndex
407
- );
408
- } else {
409
- return updateSelectedElementIndexHelper(
410
- selectedElementIndex + 1 < store.numElements
411
- ? selectedElementIndex + 1
412
- : selectedElementIndex
413
- );
414
- }
415
-
416
- case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
417
- if (selectedElementIndex === null) {
418
- return state;
419
- } else if (ownerList !== null) {
420
- return updateSelectedElementIndexHelper(
421
- selectedElementIndex > 0
422
- ? selectedElementIndex - 1
423
- : selectedElementIndex
424
- );
425
- } else {
426
- return updateSelectedElementIndexHelper(
427
- selectedElementIndex > 0
428
- ? selectedElementIndex - 1
429
- : selectedElementIndex
430
- );
431
- }
432
-
433
- case 'UPDATE_SEARCH_TEXT':
434
- newSearchIndex = searchIndex;
435
- newSearchResults = [];
436
- newSearchText = ((payload: any): string);
437
-
438
- // Find all matching elements.
439
- if (newSearchText !== '') {
440
- const regExp = createRegExp(newSearchText);
441
- store.roots.forEach(rootID => {
442
- recursivelySearchTree(store, rootID, regExp, newSearchResults);
443
- });
444
-
445
- // If this is a refinement of a previous search, preserve the current index (unless it's no longer valid).
446
- // If it's a new search, reset the index.
447
- if (newSearchResults.length === 0) {
448
- newSearchIndex = null;
449
- } else if (
450
- searchIndex !== null &&
451
- newSearchText.startsWith(searchText)
452
- ) {
453
- newSearchIndex = Math.min(
454
- searchIndex,
455
- newSearchResults.length - 1
456
- );
457
- } else {
458
- newSearchIndex = 0;
459
- }
460
- } else {
461
- newSearchIndex = null;
462
- }
463
-
464
- newSelectedElementID =
465
- newSearchIndex !== null ? newSearchResults[newSearchIndex] : null;
466
-
467
- return {
468
- ...state,
469
- searchIndex: newSearchIndex,
470
- searchResults: newSearchResults,
471
- searchText: newSearchText,
472
- selectedElementID: newSelectedElementID,
473
- selectedElementIndex:
474
- newSelectedElementID !== null
475
- ? store.getIndexOfElementID(newSelectedElementID)
476
- : null,
477
- };
478
-
479
- default:
480
- throw new Error(`Unrecognized action "${type}"`);
481
- }
482
- },
483
- []
484
- );
485
-
486
- const [state, dispatch] = useReducer(reducer, {
487
- ownerIDStack: [],
488
- ownerList: null,
489
- searchIndex: null,
490
- searchResults: [],
491
- searchText: '',
492
- selectedElementID: null,
493
- selectedElementIndex: null,
494
- });
495
-
496
- const clearOwnerList = useCallback(
497
- () => dispatch({ type: 'CLEAR_OWNER_LIST' }),
498
- [dispatch]
499
- );
500
- const decrementSearchIndex = useCallback(
501
- () => dispatch({ type: 'DECREMENT_SEARCH_INDEX' }),
502
- [dispatch]
503
- );
504
- const incrementSearchIndex = useCallback(
505
- () => dispatch({ type: 'INCREMENT_SEARCH_INDEX' }),
506
- [dispatch]
507
- );
508
- const popToOwnerList = useCallback(
509
- (elementID: number) =>
510
- dispatch({ type: 'POP_TO_OWNER_LIST', payload: elementID }),
511
- [dispatch]
512
- );
513
- const pushOwnerList = useCallback(
514
- (elementID: number) =>
515
- dispatch({ type: 'PUSH_OWNER_LIST', payload: elementID }),
516
- [dispatch]
517
- );
518
- const selectElementWithID = useCallback(
519
- (id: number | null) =>
520
- dispatch({ type: 'SELECT_ELEMENT_WITH_ID', payload: id }),
521
- [dispatch]
522
- );
523
- const selectNextElementInTree = useCallback(
524
- () => dispatch({ type: 'SELECT_NEXT_ELEMENT_IN_TREE' }),
525
- [dispatch]
526
- );
527
- const selectPreviousElementInTree = useCallback(
528
- () => dispatch({ type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE' }),
529
- [dispatch]
530
- );
531
- const updateSearchText = useCallback(
532
- (searchText: string) =>
533
- dispatch({ type: 'UPDATE_SEARCH_TEXT', payload: searchText }),
534
- [dispatch]
535
- );
536
-
537
- const value = useMemo(
538
- () => ({
539
- ownerList: state.ownerList,
540
- ownerIDStack: state.ownerIDStack,
541
- searchIndex: state.searchIndex,
542
- searchResults: state.searchResults,
543
- searchText: state.searchText,
544
- selectedElementID: state.selectedElementID,
545
- selectedElementIndex: state.selectedElementIndex,
546
-
547
- clearOwnerList,
548
- decrementSearchIndex,
549
- incrementSearchIndex,
550
- popToOwnerList,
551
- pushOwnerList,
552
- selectElementWithID,
553
- selectNextElementInTree,
554
- selectPreviousElementInTree,
555
- updateSearchText,
556
- }),
557
- [state]
558
- );
559
-
560
- // Listen for changes to the tree and incrementally adjust the search results.
561
- useLayoutEffect(() => {
562
- const handleStoreMutated = ([
563
- addedElementIDs,
564
- removedElementIDs,
565
- ]: Array<Uint32Array>) => {
566
- if (!state.searchText && state.ownerList === null) {
567
- return;
568
- }
569
-
570
- dispatch({
571
- type: 'REFINE_AFTER_MUTATION',
572
- payload: [addedElementIDs, removedElementIDs],
573
- });
574
- };
575
-
576
- store.addListener('mutated', handleStoreMutated);
577
-
578
- return () => store.removeListener('mutated', handleStoreMutated);
579
- }, [state, store]);
580
-
581
- return (
582
- <SearchAndSelectionContext.Provider value={value}>
583
- {children}
584
- </SearchAndSelectionContext.Provider>
585
- );
586
-}
587
-
588
-function calculateCurrentOwnerList(
589
- store: Store,
590
- rootOwnerID: number,
591
- elementID: number,
592
- ownerList: Array<number>
593
-): Array<number> {
594
- if (elementID === rootOwnerID) {
595
- ownerList.push(elementID);
596
- const { children } = ((store.getElementByID(elementID): any): Element);
597
- children.forEach(childID =>
598
- calculateCurrentOwnerList(store, rootOwnerID, childID, ownerList)
599
- );
600
- } else {
601
- const { children, ownerID } = ((store.getElementByID(
602
- elementID
603
- ): any): Element);
604
- if (ownerID === rootOwnerID) {
605
- ownerList.push(elementID);
606
- children.forEach(childID =>
607
- calculateCurrentOwnerList(store, rootOwnerID, childID, ownerList)
608
- );
609
- }
610
- }
611
-
612
- return ownerList;
613
-}
614
-
615
-function recursivelySearchTree(
616
- store: Store,
617
- elementID: number,
618
- regExp: RegExp,
619
- searchResults: Array<number>
620
-): void {
621
- const { children, displayName } = ((store.getElementByID(
622
- elementID
623
- ): any): Element);
624
- if (displayName !== null) {
625
- if (regExp.test(displayName)) {
626
- searchResults.push(elementID);
627
- }
628
- }
629
- children.forEach(childID =>
630
- recursivelySearchTree(store, childID, regExp, searchResults)
631
- );
632
-}
633
-
634
-export { SearchAndSelectionContext, SearchAndSelectionController };
src/devtools/views/SearchInput.js
+15
-13
@@ -1,7 +1,7 @@
1
// @flow
2
3
import React, { useCallback, useContext, useEffect, useRef } from 'react';
4
-import { SearchAndSelectionContext } from './SearchAndSelectionContext';
4
+import { TreeContext } from './TreeContext';
5
import ButtonIcon from './ButtonIcon';
6
7
import styles from './SearchInput.css';
@@ -10,32 +10,32 @@ type Props = {||};
10
11
export default function SearchInput(props: Props) {
12
const {
13
- decrementSearchIndex,
14
- incrementSearchIndex,
13
+ goToNextSearchResult,
14
+ goToPreviousSearchResult,
15
searchIndex,
16
searchResults,
17
searchText,
18
- updateSearchText,
19
- } = useContext(SearchAndSelectionContext);
18
+ setSearchText,
19
+ } = useContext(TreeContext);
20
21
const inputRef = useRef();
22
23
const handleTextChange = useCallback(
24
- ({ currentTarget }) => updateSearchText(currentTarget.value),
25
- [updateSearchText]
24
+ ({ currentTarget }) => setSearchText(currentTarget.value),
25
+ [setSearchText]
26
);
27
28
const resetSearch = useCallback(() => {
29
- updateSearchText('');
30
- }, [updateSearchText]);
29
+ setSearchText('');
30
+ }, [setSearchText]);
31
32
const handleInputKeyPress = useCallback(
33
({ key }) => {
34
if (key === 'Enter') {
35
- incrementSearchIndex();
35
+ goToNextSearchResult();
36
}
37
},
38
- [incrementSearchIndex]
38
+ [goToNextSearchResult]
39
);
40
41
// Auto-focus search input
@@ -74,14 +74,16 @@ export default function SearchInput(props: Props) {
74
<div className={styles.LeftVRule} />
75
<button
76
className={styles.IconButton}
77
- onClick={decrementSearchIndex}
77
+ disabled={!searchText}
78
+ onClick={goToPreviousSearchResult}
79
title="Scroll to previous search result"
80
>
81
<ButtonIcon type="up" />
82
</button>
83
<button
84
className={styles.IconButton}
84
- onClick={incrementSearchIndex}
85
+ disabled={!searchText}
86
+ onClick={goToNextSearchResult}
87
title="Scroll to next search result"
88
>
89
<ButtonIcon type="down" />
src/devtools/views/SelectedElement.js
+4
-4
@@ -7,7 +7,7 @@ import React, {
7
useRef,
8
useState,
9
} from 'react';
10
-import { SearchAndSelectionContext } from './SearchAndSelectionContext';
10
+import { TreeContext } from './TreeContext';
11
import { BridgeContext, StoreContext } from './context';
12
import ButtonIcon from './ButtonIcon';
13
import HooksTree from './HooksTree';
@@ -21,7 +21,7 @@ import type { DehydratedData } from 'src/devtools/types';
21
export type Props = {||};
22
23
export default function SelectedElement(_: Props) {
24
- const { selectedElementID } = useContext(SearchAndSelectionContext);
24
+ const { selectedElementID } = useContext(TreeContext);
25
const store = useContext(StoreContext);
26
const element =
27
selectedElementID !== null ? store.getElementByID(selectedElementID) : null;
@@ -107,9 +107,9 @@ function InspectedElementView({ inspectedElement }: InspectedElementViewProps) {
107
}
108
109
function OwnerView({ displayName, id }: { displayName: string, id: number }) {
110
- const { selectElementWithID } = useContext(SearchAndSelectionContext);
110
+ const { selectElementByID } = useContext(TreeContext);
111
112
- const handleClick = useCallback(() => selectElementWithID(id), [id]);
112
+ const handleClick = useCallback(() => selectElementByID(id), [id]);
113
114
return (
115
<div
src/devtools/views/Tree.js
+16
-15
@@ -9,12 +9,11 @@ import React, {
9
} from 'react';
10
import AutoSizer from 'react-virtualized-auto-sizer';
11
import { FixedSizeList } from 'react-window';
12
-import { SearchAndSelectionContext } from './SearchAndSelectionContext';
12
+import { TreeContext } from './TreeContext';
13
import ButtonIcon from './ButtonIcon';
14
import Element from './Element';
15
import OwnersStack from './OwnersStack';
16
import SearchInput from './SearchInput';
17
-import { TreeContext } from './context';
17
18
import styles from './Tree.css';
19
@@ -22,13 +21,14 @@ type Props = {||};
21
22
export default function Tree(props: Props) {
23
const {
25
- ownerIDStack,
26
- ownerList,
24
+ baseDepth,
25
+ getElementAtIndex,
26
+ numElements,
27
+ ownerStack,
28
selectedElementIndex,
29
selectNextElementInTree,
30
selectPreviousElementInTree,
30
- } = useContext(SearchAndSelectionContext);
31
- const treeContext = useContext(TreeContext);
31
+ } = useContext(TreeContext);
32
const listRef = useRef<FixedSizeList<any>>();
33
34
// Make sure a newly selected element is visible in the list.
@@ -66,22 +66,23 @@ export default function Tree(props: Props) {
66
selectedElementIndex,
67
selectNextElementInTree,
68
selectPreviousElementInTree,
69
- treeContext,
69
]);
70
72
- const itemCount = ownerList !== null ? ownerList.length : treeContext.size;
73
-
71
// Let react-window know to re-render any time the underlying tree data changes.
72
// This includes the owner context, since it controls a filtered view of the tree.
76
- const itemData = useMemo(() => ({ treeContext, ownerList }), [
77
- treeContext,
78
- ownerList,
79
- ]);
73
+ const itemData = useMemo(
74
+ () => ({
75
+ baseDepth,
76
+ numElements,
77
+ getElementAtIndex,
78
+ }),
79
+ [baseDepth, numElements, getElementAtIndex]
80
+ );
81
82
return (
83
<div className={styles.Tree}>
84
<div className={styles.SearchInput}>
84
- {ownerIDStack.length > 0 ? <OwnersStack /> : <SearchInput />}
85
+ {ownerStack.length > 0 ? <OwnersStack /> : <SearchInput />}
86
<button
87
className={styles.IconButton}
88
title="Select an element in the page to inspect it"
@@ -95,7 +96,7 @@ export default function Tree(props: Props) {
96
<FixedSizeList
97
className={styles.List}
98
height={height}
98
- itemCount={itemCount}
99
+ itemCount={numElements}
100
itemData={itemData}
101
itemSize={20}
102
ref={listRef}
src/devtools/views/TreeContext.js
new
+663
@@ -0,0 +1,663 @@
1
+// @flow
2
+
3
+// This context combines tree/selection state, search, and the owners stack.
4
+// These values are managed together because changes in one often impact the others.
5
+// Combining them enables us to avoid cascading renders.
6
+//
7
+// Changes to search state may impact tree state.
8
+// For example, updating the selected search result also updates the tree's selected value.
9
+// Search does not fundamanetally change the tree though.
10
+// It is also possible to update the selected tree value independently.
11
+//
12
+// Changes to owners state mask search and tree values.
13
+// When owners statck is not empty, search is temporarily disabnled,
14
+// and tree values (e.g. num elements, selected element) are masked.
15
+// Both tree and search values are restored when the owners stack is cleared.
16
+//
17
+// For this reason, changes to the tree context are processed in sequence: tree -> search -> owners
18
+// This enables each section to potentially override (or mask) previous values.
19
+
20
+import React, {
21
+ createContext,
22
+ useCallback,
23
+ useContext,
24
+ useLayoutEffect,
25
+ useMemo,
26
+ useReducer,
27
+} from 'react';
28
+import { createRegExp } from './utils';
29
+import { StoreContext } from './context';
30
+import Store from '../store';
31
+
32
+import type { Element } from 'src/devtools/types';
33
+
34
+type Context = {|
35
+ // Tree
36
+ baseDepth: number,
37
+ numElements: number,
38
+ selectedElementID: number | null,
39
+ selectedElementIndex: number | null,
40
+ getElementAtIndex(index: number): Element | null,
41
+ selectElementAtIndex(index: number): void,
42
+ selectElementByID(id: number | null): void,
43
+ selectNextElementInTree(): void,
44
+ selectPreviousElementInTree(): void,
45
+
46
+ // Search
47
+ searchIndex: number | null,
48
+ searchResults: Array<number>,
49
+ searchText: string,
50
+ setSearchText(text: string): void,
51
+ goToNextSearchResult(): void,
52
+ goToPreviousSearchResult(): void,
53
+
54
+ // Owners
55
+ ownerStack: Array<number>,
56
+ ownerStackIndex: number | null,
57
+ resetOwnerStack(): void,
58
+ selectOwner(id: number): void,
59
+|};
60
+
61
+const TreeContext = createContext<Context>(((null: any): Context));
62
+// $FlowFixMe displayName is a valid attribute of React$ConsearchText
63
+TreeContext.displayName = 'TreeContext';
64
+
65
+type State = {|
66
+ // Tree
67
+ baseDepth: number,
68
+ numElements: number,
69
+ selectedElementID: number | null,
70
+ selectedElementIndex: number | null,
71
+
72
+ // Search
73
+ searchIndex: number | null,
74
+ searchResults: Array<number>,
75
+ searchText: string,
76
+
77
+ // Owners
78
+ ownerStack: Array<number>,
79
+ ownerStackIndex: number | null,
80
+ _ownerFlatTree: Array<number> | null,
81
+|};
82
+
83
+type Action = {|
84
+ type:
85
+ | 'GO_TO_NEXT_SEARCH_RESULT'
86
+ | 'GO_TO_PREVIOUS_SEARCH_RESULT'
87
+ | 'HANDLE_STORE_MUTATION'
88
+ | 'RESET_OWNER_STACK'
89
+ | 'SELECT_ELEMENT_AT_INDEX'
90
+ | 'SELECT_ELEMENT_BY_ID'
91
+ | 'SELECT_NEXT_ELEMENT_IN_TREE'
92
+ | 'SELECT_PREVIOUS_ELEMENT_IN_TREE'
93
+ | 'SELECT_OWNER'
94
+ | 'SET_SEARCH_TEXT',
95
+ payload?: any,
96
+|};
97
+
98
+function reduceTreeState(store: Store, state: State, action: Action): State {
99
+ const { type, payload } = action;
100
+
101
+ let {
102
+ numElements,
103
+ ownerStack,
104
+ selectedElementIndex,
105
+ selectedElementID,
106
+ } = state;
107
+
108
+ // Base tree should ignore selected element changes when the owner's tree is active.
109
+ if (ownerStack.length === 0) {
110
+ switch (type) {
111
+ case 'HANDLE_STORE_MUTATION':
112
+ numElements = store.numElements;
113
+
114
+ // If the currently-selected Element has been removed from the tree, update selection state.
115
+ if (selectedElementID !== null) {
116
+ const removedElementIDs = ((payload: any): Array<Uint32Array>)[1];
117
+ if (removedElementIDs.includes(((selectedElementID: any): number))) {
118
+ selectedElementIndex = null;
119
+ }
120
+ }
121
+ break;
122
+ case 'SELECT_ELEMENT_AT_INDEX':
123
+ selectedElementIndex = ((payload: any): number | null);
124
+ break;
125
+ case 'SELECT_ELEMENT_BY_ID':
126
+ selectedElementIndex =
127
+ payload === null
128
+ ? null
129
+ : store.getIndexOfElementID(((payload: any): number));
130
+ break;
131
+ case 'SELECT_NEXT_ELEMENT_IN_TREE':
132
+ if (
133
+ selectedElementIndex !== null &&
134
+ selectedElementIndex + 1 < numElements
135
+ ) {
136
+ selectedElementIndex++;
137
+ }
138
+ break;
139
+ case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
140
+ if (selectedElementIndex !== null && selectedElementIndex > 0) {
141
+ selectedElementIndex--;
142
+ }
143
+ break;
144
+ default:
145
+ // React can bailout of no-op updates.
146
+ return state;
147
+ }
148
+ }
149
+
150
+ // Keep selected item ID and index in sync.
151
+ if (selectedElementIndex !== state.selectedElementIndex) {
152
+ if (selectedElementIndex === null) {
153
+ selectedElementID = null;
154
+ } else {
155
+ selectedElementID = store.getElementIDAtIndex(
156
+ ((selectedElementIndex: any): number)
157
+ );
158
+ }
159
+ }
160
+
161
+ return {
162
+ ...state,
163
+
164
+ numElements,
165
+ selectedElementIndex,
166
+ selectedElementID,
167
+ };
168
+}
169
+
170
+function reduceSearchState(store: Store, state: State, action: Action): State {
171
+ const { type, payload } = action;
172
+
173
+ let {
174
+ ownerStack,
175
+ searchIndex,
176
+ searchResults,
177
+ searchText,
178
+ selectedElementID,
179
+ selectedElementIndex,
180
+ } = state;
181
+
182
+ const prevSearchIndex = searchIndex;
183
+ const numPrevSearchResults = searchResults.length;
184
+
185
+ // Search isn't supported when the owner's tree is active.
186
+ if (ownerStack.length === 0) {
187
+ switch (type) {
188
+ case 'GO_TO_NEXT_SEARCH_RESULT':
189
+ if (numPrevSearchResults > 0) {
190
+ searchIndex =
191
+ searchIndex + 1 < numPrevSearchResults ? searchIndex + 1 : 0;
192
+ }
193
+ break;
194
+ case 'GO_TO_PREVIOUS_SEARCH_RESULT':
195
+ if (numPrevSearchResults > 0) {
196
+ searchIndex =
197
+ ((searchIndex: any): number) > 0
198
+ ? ((searchIndex: any): number) - 1
199
+ : numPrevSearchResults - 1;
200
+ }
201
+ break;
202
+ case 'HANDLE_STORE_MUTATION':
203
+ if (searchText !== '') {
204
+ const [
205
+ addedElementIDs,
206
+ removedElementIDs,
207
+ ] = ((payload: any): Array<Uint32Array>);
208
+
209
+ removedElementIDs.forEach(id => {
210
+ // Prune this item from the search results.
211
+ const index = searchResults.indexOf(id);
212
+ if (index >= 0) {
213
+ searchResults = searchResults
214
+ .slice(0, index)
215
+ .concat(searchResults.slice(index + 1));
216
+
217
+ // If the results are now empty, also deselect things.
218
+ if (searchResults.length === 0) {
219
+ searchIndex = null;
220
+ } else if (((searchIndex: any): number) >= searchResults.length) {
221
+ searchIndex = searchResults.length - 1;
222
+ }
223
+ }
224
+ });
225
+
226
+ addedElementIDs.forEach(id => {
227
+ const { displayName } = ((store.getElementByID(id): any): Element);
228
+
229
+ // Add this item to the search results if it matches.
230
+ const regExp = createRegExp(searchText);
231
+ if (displayName !== null && regExp.test(displayName)) {
232
+ const newElementIndex = ((store.getIndexOfElementID(
233
+ id
234
+ ): any): number);
235
+
236
+ let foundMatch = false;
237
+ for (let index = 0; index < searchResults.length; index++) {
238
+ const id = searchResults[index];
239
+ if (
240
+ newElementIndex <
241
+ ((store.getIndexOfElementID(id): any): number)
242
+ ) {
243
+ foundMatch = true;
244
+ searchResults = searchResults
245
+ .slice(0, index)
246
+ .concat(id)
247
+ .concat(searchResults.slice(index));
248
+ break;
249
+ }
250
+ }
251
+ if (!foundMatch) {
252
+ searchResults = searchResults.concat(id);
253
+ }
254
+
255
+ searchIndex = searchIndex === null ? 0 : searchIndex;
256
+ }
257
+ });
258
+ }
259
+ break;
260
+ case 'SET_SEARCH_TEXT':
261
+ searchIndex = null;
262
+ searchResults = [];
263
+ searchText = ((payload: any): string);
264
+
265
+ if (searchText !== '') {
266
+ const regExp = createRegExp(searchText);
267
+ store.roots.forEach(rootID => {
268
+ recursivelySearchTree(store, rootID, regExp, searchResults);
269
+ });
270
+
271
+ if (searchResults.length > 0) {
272
+ if (prevSearchIndex === null) {
273
+ searchIndex = 0;
274
+ } else {
275
+ searchIndex = Math.min(
276
+ ((prevSearchIndex: any): number),
277
+ searchResults.length - 1
278
+ );
279
+ }
280
+ }
281
+ }
282
+ break;
283
+ default:
284
+ // React can bailout of no-op updates.
285
+ return state;
286
+ }
287
+ }
288
+
289
+ // Changes in search index should override the selected element.
290
+ if (searchIndex !== prevSearchIndex) {
291
+ if (searchIndex === null) {
292
+ selectedElementIndex = null;
293
+ selectedElementID = null;
294
+ } else {
295
+ selectedElementID = ((searchResults[searchIndex]: any): number);
296
+ selectedElementIndex = store.getIndexOfElementID(
297
+ ((selectedElementID: any): number)
298
+ );
299
+ }
300
+ }
301
+
302
+ return {
303
+ ...state,
304
+
305
+ selectedElementID,
306
+ selectedElementIndex,
307
+
308
+ searchIndex,
309
+ searchResults,
310
+ searchText,
311
+ };
312
+}
313
+
314
+function reduceOwnersState(store: Store, state: State, action: Action): State {
315
+ const { payload, type } = action;
316
+
317
+ let {
318
+ baseDepth,
319
+ numElements,
320
+ selectedElementID,
321
+ selectedElementIndex,
322
+ ownerStack,
323
+ ownerStackIndex,
324
+ searchIndex,
325
+ searchResults,
326
+ searchText,
327
+ _ownerFlatTree,
328
+ } = state;
329
+
330
+ switch (type) {
331
+ case 'HANDLE_STORE_MUTATION':
332
+ if (ownerStack.length > 0) {
333
+ // eslint-disable-next-line no-unused-vars
334
+ const [_, removedElementIDs] = ((payload: any): Array<Uint32Array>);
335
+
336
+ let indexOfRemovedItem = -1;
337
+ for (let i = 0; i < ownerStack.length; i++) {
338
+ if (removedElementIDs.includes(ownerStack[i])) {
339
+ indexOfRemovedItem = i;
340
+ break;
341
+ }
342
+ }
343
+
344
+ if (indexOfRemovedItem >= 0) {
345
+ ownerStack = ownerStack.slice(0, indexOfRemovedItem);
346
+ if (ownerStack.length === 0) {
347
+ _ownerFlatTree = null;
348
+ } else {
349
+ ownerStackIndex = ownerStack.length - 1;
350
+ }
351
+ }
352
+ }
353
+ break;
354
+ case 'RESET_OWNER_STACK':
355
+ ownerStack = [];
356
+ ownerStackIndex = null;
357
+ _ownerFlatTree = null;
358
+ break;
359
+ case 'SELECT_ELEMENT_AT_INDEX':
360
+ if (_ownerFlatTree !== null) {
361
+ selectedElementIndex = ((payload: any): number | null);
362
+ }
363
+ break;
364
+ case 'SELECT_ELEMENT_BY_ID':
365
+ if (_ownerFlatTree !== null) {
366
+ selectedElementIndex =
367
+ payload === null ? null : _ownerFlatTree.indexOf(payload);
368
+ }
369
+ break;
370
+ case 'SELECT_NEXT_ELEMENT_IN_TREE':
371
+ if (_ownerFlatTree !== null && _ownerFlatTree.length > 0) {
372
+ if (selectedElementIndex === null) {
373
+ selectedElementIndex = 0;
374
+ } else if (selectedElementIndex + 1 < _ownerFlatTree.length) {
375
+ selectedElementIndex++;
376
+ }
377
+ }
378
+ break;
379
+ case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
380
+ if (_ownerFlatTree !== null && _ownerFlatTree.length > 0) {
381
+ if (selectedElementIndex !== null && selectedElementIndex > 0) {
382
+ selectedElementIndex--;
383
+ }
384
+ }
385
+ break;
386
+ case 'SELECT_OWNER':
387
+ ownerStackIndex = ownerStack.indexOf(payload);
388
+
389
+ // If this owner is already in the current stack, just select it.
390
+ // Otherwise, create a new stack.
391
+ if (ownerStackIndex < 0) {
392
+ // Add this new owner, and fill in the owners above it as well.
393
+ ownerStack = [];
394
+ let currentOwnerID = ((payload: any): number);
395
+ while (currentOwnerID !== 0) {
396
+ ownerStack.unshift(currentOwnerID);
397
+ currentOwnerID = ((store.getElementByID(
398
+ currentOwnerID
399
+ ): any): Element).ownerID;
400
+ }
401
+ ownerStackIndex = ownerStack.length - 1;
402
+ selectedElementIndex = 0;
403
+
404
+ if (searchText !== '') {
405
+ searchIndex = null;
406
+ searchResults = [];
407
+ searchText = '';
408
+ }
409
+ }
410
+ break;
411
+ default:
412
+ // React can bailout of no-op updates.
413
+ return state;
414
+ }
415
+
416
+ // Changes in the selected owner require re-calculating the owners tree.
417
+ if (
418
+ ownerStackIndex !== state.ownerStackIndex ||
419
+ ownerStack !== state.ownerStack ||
420
+ type === 'HANDLE_STORE_MUTATION'
421
+ ) {
422
+ if (ownerStackIndex === null) {
423
+ _ownerFlatTree = null;
424
+ baseDepth = 0;
425
+ numElements = store.numElements;
426
+ } else {
427
+ _ownerFlatTree = calculateCurrentOwnerList(
428
+ store,
429
+ ownerStack[ownerStackIndex],
430
+ ownerStack[ownerStackIndex],
431
+ []
432
+ );
433
+
434
+ baseDepth = ((store.getElementByID(_ownerFlatTree[0]): any): Element)
435
+ .depth;
436
+ numElements = _ownerFlatTree.length;
437
+ }
438
+ }
439
+
440
+ // Keep selected item ID and index in sync.
441
+ if (selectedElementIndex !== state.selectedElementIndex) {
442
+ if (selectedElementIndex === null) {
443
+ selectedElementID = null;
444
+ } else if (_ownerFlatTree !== null) {
445
+ selectedElementID = _ownerFlatTree[((selectedElementIndex: any): number)];
446
+ }
447
+ }
448
+
449
+ return {
450
+ ...state,
451
+
452
+ baseDepth,
453
+ numElements,
454
+ selectedElementID,
455
+ selectedElementIndex,
456
+
457
+ searchIndex,
458
+ searchResults,
459
+ searchText,
460
+
461
+ ownerStack,
462
+ ownerStackIndex,
463
+ _ownerFlatTree,
464
+ };
465
+}
466
+
467
+// TODO Remove TreeContextController wrapper element once global ConsearchText.write API exists.
468
+function TreeContextController({ children }: {| children: React$Node |}) {
469
+ const store = useContext(StoreContext);
470
+
471
+ // This reducer is created inline because it needs access to the Store.
472
+ // The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
473
+ // so it's okay for the reducer to have an empty dependencies array.
474
+ const reducer = useMemo(
475
+ () => (state: State, action: Action): State => {
476
+ const { type } = action;
477
+ switch (type) {
478
+ case 'GO_TO_NEXT_SEARCH_RESULT':
479
+ case 'GO_TO_PREVIOUS_SEARCH_RESULT':
480
+ case 'HANDLE_STORE_MUTATION':
481
+ case 'RESET_OWNER_STACK':
482
+ case 'SELECT_ELEMENT_AT_INDEX':
483
+ case 'SELECT_ELEMENT_BY_ID':
484
+ case 'SELECT_NEXT_ELEMENT_IN_TREE':
485
+ case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
486
+ case 'SELECT_OWNER':
487
+ case 'SET_SEARCH_TEXT':
488
+ state = reduceTreeState(store, state, action);
489
+ state = reduceSearchState(store, state, action);
490
+ state = reduceOwnersState(store, state, action);
491
+ return state;
492
+ default:
493
+ throw new Error(`Unrecognized action "${type}"`);
494
+ }
495
+ },
496
+ [store]
497
+ );
498
+
499
+ const [state, dispatch] = useReducer(reducer, {
500
+ // Tree
501
+ baseDepth: 0,
502
+ numElements: store.numElements,
503
+ selectedElementIndex: null,
504
+ selectedElementID: null,
505
+
506
+ // Search
507
+ searchIndex: null,
508
+ searchResults: [],
509
+ searchText: '',
510
+
511
+ // Owners
512
+ ownerStack: [],
513
+ ownerStackIndex: null,
514
+ _ownerFlatTree: null,
515
+ });
516
+
517
+ const getElementAtIndex = useCallback(
518
+ (index: number) => {
519
+ return state._ownerFlatTree === null
520
+ ? store.getElementAtIndex(index)
521
+ : store.getElementByID(state._ownerFlatTree[index]);
522
+ },
523
+ [state]
524
+ );
525
+ const selectElementAtIndex = useCallback(
526
+ (index: number) =>
527
+ dispatch({ type: 'SELECT_ELEMENT_AT_INDEX', payload: index }),
528
+ [dispatch]
529
+ );
530
+ const selectElementByID = useCallback(
531
+ (id: number | null) =>
532
+ dispatch({ type: 'SELECT_ELEMENT_BY_ID', payload: id }),
533
+ [dispatch]
534
+ );
535
+ const setSearchText = useCallback(
536
+ (text: string) => dispatch({ type: 'SET_SEARCH_TEXT', payload: text }),
537
+ [dispatch]
538
+ );
539
+ const goToNextSearchResult = useCallback(
540
+ () => dispatch({ type: 'GO_TO_NEXT_SEARCH_RESULT' }),
541
+ [dispatch]
542
+ );
543
+ const goToPreviousSearchResult = useCallback(
544
+ () => dispatch({ type: 'GO_TO_PREVIOUS_SEARCH_RESULT' }),
545
+ [dispatch]
546
+ );
547
+ const resetOwnerStack = useCallback(
548
+ () => dispatch({ type: 'RESET_OWNER_STACK' }),
549
+ [dispatch]
550
+ );
551
+ const selectNextElementInTree = useCallback(
552
+ () => dispatch({ type: 'SELECT_NEXT_ELEMENT_IN_TREE' }),
553
+ [dispatch]
554
+ );
555
+ const selectPreviousElementInTree = useCallback(
556
+ () => dispatch({ type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE' }),
557
+ [dispatch]
558
+ );
559
+ const selectOwner = useCallback(
560
+ (id: number) => dispatch({ type: 'SELECT_OWNER', payload: id }),
561
+ [dispatch]
562
+ );
563
+
564
+ const value = useMemo(
565
+ () => ({
566
+ // Tree (derived from Store or owners state)
567
+ baseDepth: state.baseDepth,
568
+ numElements: state.numElements,
569
+ selectedElementID: state.selectedElementID,
570
+ selectedElementIndex: state.selectedElementIndex,
571
+ getElementAtIndex,
572
+ selectElementByID,
573
+ selectElementAtIndex,
574
+ selectNextElementInTree,
575
+ selectPreviousElementInTree,
576
+
577
+ // Search
578
+ searchIndex: state.searchIndex,
579
+ searchResults: state.searchResults,
580
+ searchText: state.searchText,
581
+ setSearchText,
582
+ goToNextSearchResult,
583
+ goToPreviousSearchResult,
584
+
585
+ // Owners
586
+ ownerStack: state.ownerStack,
587
+ ownerStackIndex: state.ownerStackIndex,
588
+ resetOwnerStack,
589
+ selectOwner,
590
+ }),
591
+ [state]
592
+ );
593
+
594
+ // Mutations to the underlying tree may impact this context (e.g. search results, selection state).
595
+ useLayoutEffect(() => {
596
+ const handleStoreMutated = ([
597
+ addedElementIDs,
598
+ removedElementIDs,
599
+ ]: Array<Uint32Array>) => {
600
+ dispatch({
601
+ type: 'HANDLE_STORE_MUTATION',
602
+ payload: [addedElementIDs, removedElementIDs],
603
+ });
604
+ };
605
+
606
+ // TODO Even though we're using layout effect, concurrent rendering may cause us to miss a mutation.
607
+ // Should the store expose some sort of version number that we could check after mounting?
608
+
609
+ store.addListener('mutated', handleStoreMutated);
610
+
611
+ return () => store.removeListener('mutated', handleStoreMutated);
612
+ }, [state, store]);
613
+
614
+ return <TreeContext.Provider value={value}>{children}</TreeContext.Provider>;
615
+}
616
+
617
+function calculateCurrentOwnerList(
618
+ store: Store,
619
+ rootOwnerID: number,
620
+ elementID: number,
621
+ ownerList: Array<number>
622
+): Array<number> {
623
+ if (elementID === rootOwnerID) {
624
+ ownerList.push(elementID);
625
+ const { children } = ((store.getElementByID(elementID): any): Element);
626
+ children.forEach(childID =>
627
+ calculateCurrentOwnerList(store, rootOwnerID, childID, ownerList)
628
+ );
629
+ } else {
630
+ const { children, ownerID } = ((store.getElementByID(
631
+ elementID
632
+ ): any): Element);
633
+ if (ownerID === rootOwnerID) {
634
+ ownerList.push(elementID);
635
+ children.forEach(childID =>
636
+ calculateCurrentOwnerList(store, rootOwnerID, childID, ownerList)
637
+ );
638
+ }
639
+ }
640
+
641
+ return ownerList;
642
+}
643
+
644
+function recursivelySearchTree(
645
+ store: Store,
646
+ elementID: number,
647
+ regExp: RegExp,
648
+ searchResults: Array<number>
649
+): void {
650
+ const { children, displayName } = ((store.getElementByID(
651
+ elementID
652
+ ): any): Element);
653
+ if (displayName !== null) {
654
+ if (regExp.test(displayName)) {
655
+ searchResults.push(elementID);
656
+ }
657
+ }
658
+ children.forEach(childID =>
659
+ recursivelySearchTree(store, childID, regExp, searchResults)
660
+ );
661
+}
662
+
663
+export { TreeContext, TreeContextController };
src/devtools/views/context.js
-7
@@ -3,7 +3,6 @@
3
import { createContext } from 'react';
4
5
import type { Bridge } from '../../types';
6
-import type { TreeMetadataType } from '../types';
6
7
import Store from '../store';
8
@@ -14,9 +13,3 @@ BridgeContext.displayName = 'BridgeContext';
13
export const StoreContext = createContext<Store>(((null: any): Store));
14
// $FlowFixMe displayName is a valid attribute of React$Context
15
StoreContext.displayName = 'StoreContext';
17
-
18
-export const TreeContext = createContext<TreeMetadataType>(
19
- ((null: any): TreeMetadataType)
20
-);
21
-// $FlowFixMe displayName is a valid attribute of React$Context
22
-TreeContext.displayName = 'TreeMetadataContext';