@samitouri / QOS-React / commits / 0380186501

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';