@samitouri / QOS-React / commits / fc924d136a

Combined search and selection contexts to avoid complicated dependencies

Brian Vaughn committed Feb 7, 2019 at 09:29 UTC fc924d136ac23ab4ac3d681675ee8d93a4047b25
8 files changed +444 -402
src/devtools/views/Element.js
+12 -15
@@ -4,8 +4,7 @@ import React, { Fragment, useCallback, useContext, useMemo } from 'react';
4 import { ElementTypeClassOrFunction } from 'src/devtools/types';
5 import { TreeContext } from './context';
6 import { createRegExp } from './utils';
7 -import { SearchContext } from './SearchContext';
8 -import { SelectedElementContext } from './SelectedElementContext';
7 +import { SearchAndSelectionContext } from './SearchAndSelectionContext';
8 import Icon from './Icon';
9
10 import styles from './Element.css';
@@ -31,15 +30,15 @@ export default function Element({ index, style }: Props) {
30
31 const { children, depth, displayName, id, key, type } = element;
32
34 - const selectedElement = useContext(SelectedElementContext);
33 + const { selectedElementID, selectElementWithID } = useContext(
34 + SearchAndSelectionContext
35 + );
36 const handleClick = useCallback(
36 - ({ metaKey }) => {
37 - selectedElement.id = metaKey ? null : id;
38 - },
37 + ({ metaKey }) => selectElementWithID(metaKey ? null : id),
38 [id]
39 );
40
42 - const isSelected = selectedElement.id === id;
41 + const isSelected = selectedElementID === id;
42 const showDollarR = isSelected && type === ElementTypeClassOrFunction;
43
44 return (
@@ -77,16 +76,14 @@ type DisplayNameProps = {|
76 |};
77
78 function DisplayName({ displayName, id }: DisplayNameProps) {
80 - const {
81 - currentIndex: currentSearchIndex,
82 - ids: searchIDs,
83 - text: searchText,
84 - } = useContext(SearchContext);
79 + const { searchIndex, searchResults, searchText } = useContext(
80 + SearchAndSelectionContext
81 + );
82 const isSearchResult = useMemo(() => {
86 - return searchIDs.includes(id);
87 - }, [id, searchIDs]);
83 + return searchResults.includes(id);
84 + }, [id, searchResults]);
85 const isCurrentResult =
89 - currentSearchIndex !== null && id === searchIDs[currentSearchIndex];
86 + searchIndex !== null && id === searchResults[searchIndex];
87
88 if (!isSearchResult || displayName === null) {
89 return displayName;
src/devtools/views/Elements.js
+10 -13
@@ -5,8 +5,7 @@ import Store from '../store';
5 import Tree from './Tree';
6 import { BridgeContext, StoreContext, TreeContext } from './context';
7 import SelectedElement from './SelectedElement';
8 -import { SearchController } from './SearchContext';
9 -import { SelectedElementController } from './SelectedElementContext';
8 +import { SearchAndSelectionController } from './SearchAndSelectionContext';
9 import styles from './Elements.css';
10
11 import './root.css';
@@ -51,18 +50,16 @@ export default function Elements({ bridge, browserName, themeName }: Props) {
50 <BridgeContext.Provider value={bridge}>
51 <StoreContext.Provider value={store}>
52 <TreeContext.Provider value={treeContext}>
54 - <SelectedElementController>
55 - <SearchController>
56 - <div className={styles.Elements}>
57 - <div className={styles.TreeWrapper}>
58 - <Tree />
59 - </div>
60 - <div className={styles.SelectedElementWrapper}>
61 - <SelectedElement />
62 - </div>
53 + <SearchAndSelectionController>
54 + <div className={styles.Elements}>
55 + <div className={styles.TreeWrapper}>
56 + <Tree />
57 </div>
64 - </SearchController>
65 - </SelectedElementController>
58 + <div className={styles.SelectedElementWrapper}>
59 + <SelectedElement />
60 + </div>
61 + </div>
62 + </SearchAndSelectionController>
63 </TreeContext.Provider>
64 </StoreContext.Provider>
65 </BridgeContext.Provider>
src/devtools/views/SearchAndSelectionContext.js new
+373
@@ -0,0 +1,373 @@
1 +// @flow
2 +
3 +// This context contains both the current search state and the selected element.
4 +// These values are combined into a single context because changes in one often impact the other.
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 + searchIndex: number | null,
24 + searchResults: Array<number>,
25 + searchText: string,
26 + selectedElementID: number | null,
27 + selectedElementIndex: number | null,
28 +
29 + // Update current context values:
30 + decrementSearchIndex(): void,
31 + incrementSearchIndex(): void,
32 + selectElementWithID(id: number | null): void,
33 + selectElementAtIndex(index: number | null): void,
34 + updateSearchText(searchText: string): void,
35 +|};
36 +
37 +const SearchAndSelectionContext = createContext<Context>(
38 + ((null: any): Context)
39 +);
40 +// $FlowFixMe displayName is a valid attribute of React$ConsearchText
41 +SearchAndSelectionContext.displayName = 'SearchAndSelectionContext';
42 +
43 +type State = {|
44 + searchIndex: number | null,
45 + searchResults: Array<number>,
46 + searchText: string,
47 + selectedElementID: number | null,
48 + selectedElementIndex: number | null,
49 +|};
50 +
51 +type Action = {|
52 + type:
53 + | 'DECREMENT_SEARCH_INDEX'
54 + | 'INCREMENT_SEARCH_INDEX'
55 + | 'REFINE_SEARCH_RESULTS'
56 + | 'SELECT_ELEMENT_WITH_ID'
57 + | 'SELECT_ELEMENT_AT_INDEX'
58 + | 'UPDATE_SEARCH_TEXT',
59 + payload?: any,
60 +|};
61 +
62 +type Props = {|
63 + children: React$Node,
64 +|};
65 +
66 +// TODO Remove this wrapper element once global ConsearchText.write API exists.
67 +function SearchAndSelectionController({ children }: Props) {
68 + const store = useContext(StoreContext);
69 +
70 + // This reducer is created inline because it needs access to the Store.
71 + // The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
72 + // so it's okay for the reducer to have an empty dependencies array.
73 + const reducer = useMemo(
74 + () =>
75 + function reducer(state: State, action: Action): State {
76 + const { searchIndex, searchResults, searchText } = state;
77 + const { payload, type } = action;
78 +
79 + let newSearchIndex,
80 + newSearchResults,
81 + newSearchText,
82 + newSelectedElementID;
83 +
84 + switch (type) {
85 + case 'DECREMENT_SEARCH_INDEX':
86 + if (searchResults.length === 0) return state;
87 +
88 + newSearchIndex =
89 + ((searchIndex: any): number) > 0
90 + ? ((searchIndex: any): number) - 1
91 + : searchResults.length - 1;
92 + newSelectedElementID = searchResults[newSearchIndex];
93 +
94 + return {
95 + ...state,
96 + searchIndex: newSearchIndex,
97 + selectedElementID: newSelectedElementID,
98 + selectedElementIndex: store.getIndexOfElementID(
99 + newSelectedElementID
100 + ),
101 + };
102 +
103 + case 'INCREMENT_SEARCH_INDEX':
104 + if (searchResults.length === 0) return state;
105 +
106 + newSearchIndex =
107 + ((searchIndex: any): number) + 1 < searchResults.length
108 + ? ((searchIndex: any): number) + 1
109 + : 0;
110 + newSelectedElementID = searchResults[newSearchIndex];
111 +
112 + return {
113 + ...state,
114 + searchIndex: newSearchIndex,
115 + selectedElementID: newSelectedElementID,
116 + selectedElementIndex: store.getIndexOfElementID(
117 + newSelectedElementID
118 + ),
119 + };
120 +
121 + case 'REFINE_SEARCH_RESULTS':
122 + const [
123 + addedElementIDs,
124 + removedElementIDs,
125 + ] = ((payload: any): Array<Uint32Array>);
126 +
127 + newSearchResults = searchResults;
128 + newSearchIndex = searchIndex;
129 +
130 + removedElementIDs.forEach(id => {
131 + // Prune this item from the search results.
132 + const index = newSearchResults.indexOf(id);
133 + if (index >= 0) {
134 + newSearchResults = newSearchResults
135 + .slice(0, index)
136 + .concat(newSearchResults.slice(index + 1));
137 +
138 + // If the results are now empty, also deselect things.
139 + if (newSearchResults.length === 0) {
140 + newSearchIndex = null;
141 + } else if (
142 + ((newSearchIndex: any): number) >= newSearchResults.length
143 + ) {
144 + newSearchIndex = newSearchResults.length - 1;
145 + }
146 + }
147 + });
148 +
149 + addedElementIDs.forEach(id => {
150 + const { displayName } = ((store.getElementByID(
151 + id
152 + ): any): Element);
153 +
154 + // Add this item to the search results if it matches.
155 + const regExp = createRegExp(searchText);
156 + if (displayName !== null && regExp.test(displayName)) {
157 + const newElementIndex = ((store.getIndexOfElementID(
158 + id
159 + ): any): number);
160 +
161 + let foundMatch = false;
162 + for (let index = 0; index < newSearchResults.length; index++) {
163 + const id = newSearchResults[index];
164 + if (
165 + newElementIndex <
166 + ((store.getIndexOfElementID(id): any): number)
167 + ) {
168 + foundMatch = true;
169 + newSearchResults = newSearchResults
170 + .slice(0, index)
171 + .concat(id)
172 + .concat(newSearchResults.slice(index));
173 + break;
174 + }
175 + }
176 + if (!foundMatch) {
177 + newSearchResults = newSearchResults.concat(id);
178 + }
179 +
180 + newSearchIndex = newSearchIndex === null ? 0 : newSearchIndex;
181 + }
182 + });
183 +
184 + newSelectedElementID =
185 + newSearchIndex !== null ? newSearchResults[newSearchIndex] : null;
186 +
187 + return {
188 + ...state,
189 + searchIndex: newSearchIndex,
190 + searchResults: newSearchResults,
191 + selectedElementID: newSelectedElementID,
192 + selectedElementIndex:
193 + newSelectedElementID !== null
194 + ? store.getIndexOfElementID(newSelectedElementID)
195 + : null,
196 + };
197 +
198 + case 'SELECT_ELEMENT_WITH_ID':
199 + newSelectedElementID = ((payload: any): number | null);
200 +
201 + return {
202 + ...state,
203 + selectedElementID: newSelectedElementID,
204 + selectedElementIndex:
205 + newSelectedElementID !== null
206 + ? store.getIndexOfElementID(newSelectedElementID)
207 + : null,
208 + };
209 +
210 + case 'SELECT_ELEMENT_AT_INDEX':
211 + newSearchIndex = ((payload: any): number | null);
212 +
213 + const element =
214 + newSearchIndex !== null
215 + ? store.getElementAtIndex(newSearchIndex)
216 + : null;
217 +
218 + return {
219 + ...state,
220 + selectedElementID: element !== null ? element.id : null,
221 + selectedElementIndex: newSearchIndex,
222 + };
223 +
224 + case 'UPDATE_SEARCH_TEXT':
225 + newSearchIndex = searchIndex;
226 + newSearchResults = [];
227 + newSearchText = ((payload: any): string);
228 +
229 + // Find all matching elements.
230 + if (newSearchText !== '') {
231 + const regExp = createRegExp(newSearchText);
232 + store.roots.forEach(rootID => {
233 + recursivelySearchTree(store, rootID, regExp, newSearchResults);
234 + });
235 +
236 + // If this is a refinement of a previous search, preserve the current index (unless it's no longer valid).
237 + // If it's a new search, reset the index.
238 + if (newSearchResults.length === 0) {
239 + newSearchIndex = null;
240 + } else if (
241 + searchIndex !== null &&
242 + newSearchText.startsWith(searchText)
243 + ) {
244 + newSearchIndex = Math.min(
245 + searchIndex,
246 + newSearchResults.length - 1
247 + );
248 + } else {
249 + newSearchIndex = 0;
250 + }
251 + } else {
252 + newSearchIndex = null;
253 + }
254 +
255 + newSelectedElementID =
256 + newSearchIndex !== null ? newSearchResults[newSearchIndex] : null;
257 +
258 + return {
259 + ...state,
260 + searchIndex: newSearchIndex,
261 + searchResults: newSearchResults,
262 + searchText: newSearchText,
263 + selectedElementID: newSelectedElementID,
264 + selectedElementIndex:
265 + newSelectedElementID !== null
266 + ? store.getIndexOfElementID(newSelectedElementID)
267 + : null,
268 + };
269 +
270 + default:
271 + throw new Error(`Unrecognized action "${type}"`);
272 + }
273 + },
274 + []
275 + );
276 +
277 + const [state, dispatch] = useReducer(reducer, {
278 + searchIndex: null,
279 + searchResults: [],
280 + searchText: '',
281 + selectedElementID: null,
282 + selectedElementIndex: null,
283 + });
284 +
285 + const decrementSearchIndex = useCallback(
286 + () => dispatch({ type: 'DECREMENT_SEARCH_INDEX' }),
287 + [dispatch]
288 + );
289 + const incrementSearchIndex = useCallback(
290 + () => dispatch({ type: 'INCREMENT_SEARCH_INDEX' }),
291 + [dispatch]
292 + );
293 + const selectElementWithID = useCallback(
294 + (id: number | null) =>
295 + dispatch({ type: 'SELECT_ELEMENT_WITH_ID', payload: id }),
296 + [dispatch]
297 + );
298 + const selectElementAtIndex = useCallback(
299 + (index: number | null) =>
300 + dispatch({ type: 'SELECT_ELEMENT_AT_INDEX', payload: index }),
301 + [dispatch]
302 + );
303 + const updateSearchText = useCallback(
304 + (searchText: string) =>
305 + dispatch({ type: 'UPDATE_SEARCH_TEXT', payload: searchText }),
306 + [dispatch]
307 + );
308 +
309 + const value = useMemo(
310 + () => ({
311 + searchIndex: state.searchIndex,
312 + searchResults: state.searchResults,
313 + searchText: state.searchText,
314 + selectedElementID: state.selectedElementID,
315 + selectedElementIndex: state.selectedElementIndex,
316 +
317 + decrementSearchIndex,
318 + incrementSearchIndex,
319 + selectElementWithID,
320 + selectElementAtIndex,
321 + updateSearchText,
322 + }),
323 + [state]
324 + );
325 +
326 + // Listen for changes to the tree and incrementally adjust the search results.
327 + useLayoutEffect(() => {
328 + const handleStoreMutated = ([
329 + addedElementIDs,
330 + removedElementIDs,
331 + ]: Array<Uint32Array>) => {
332 + if (!state.searchText) {
333 + return;
334 + }
335 +
336 + dispatch({
337 + type: 'REFINE_SEARCH_RESULTS',
338 + payload: [addedElementIDs, removedElementIDs],
339 + });
340 + };
341 +
342 + store.addListener('mutated', handleStoreMutated);
343 +
344 + return () => store.removeListener('mutated', handleStoreMutated);
345 + }, [state, store]);
346 +
347 + return (
348 + <SearchAndSelectionContext.Provider value={value}>
349 + {children}
350 + </SearchAndSelectionContext.Provider>
351 + );
352 +}
353 +
354 +function recursivelySearchTree(
355 + store: Store,
356 + elementID: number,
357 + regExp: RegExp,
358 + searchResults: Array<number>
359 +): void {
360 + const { children, displayName } = ((store.getElementByID(
361 + elementID
362 + ): any): Element);
363 + if (displayName !== null) {
364 + if (regExp.test(displayName)) {
365 + searchResults.push(elementID);
366 + }
367 + }
368 + children.forEach(childID =>
369 + recursivelySearchTree(store, childID, regExp, searchResults)
370 + );
371 +}
372 +
373 +export { SearchAndSelectionContext, SearchAndSelectionController };
src/devtools/views/SearchContext.js deleted
-241
@@ -1,241 +0,0 @@
1 -// @flow
2 -
3 -import React, {
4 - createContext,
5 - useContext,
6 - useLayoutEffect,
7 - useMemo,
8 - useReducer,
9 -} from 'react';
10 -import { createRegExp } from './utils';
11 -import { StoreContext } from './context';
12 -import { SelectedElementContext } from './SelectedElementContext';
13 -import Store from '../store';
14 -
15 -import type { Element } from 'src/devtools/types';
16 -
17 -export type SearchContextValue = {|
18 - get currentIndex(): number | null,
19 - get ids(): Array<number>,
20 - get text(): string,
21 - updateCurrentIndex(newCurrentIndex: number): void,
22 - updateText(newText: string): void,
23 -|};
24 -
25 -const SearchContext = createContext<SearchContextValue>(
26 - ((null: any): SearchContextValue)
27 -);
28 -// $FlowFixMe displayName is a valid attribute of React$Context
29 -SearchContext.displayName = 'SearchContext';
30 -
31 -type State = {|
32 - currentIndex: number | null,
33 - ids: Array<number>,
34 - text: string,
35 -|};
36 -
37 -type Action = {|
38 - type: 'UPDATE',
39 - payload: $Shape<State>,
40 -|};
41 -
42 -function reducer(state: State, action: Action) {
43 - switch (action.type) {
44 - case 'UPDATE':
45 - return {
46 - ...state,
47 - ...action.payload,
48 - };
49 - default:
50 - throw new Error();
51 - }
52 -}
53 -
54 -type Props = {|
55 - children: React$Node,
56 -|};
57 -
58 -// TODO Remove this wrapper element once global Context.write API exists.
59 -function SearchController({ children }: Props) {
60 - const store = useContext(StoreContext);
61 - const selectedElement = useContext(SelectedElementContext);
62 -
63 - const [state, dispatch] = useReducer(reducer, {
64 - currentIndex: null,
65 - ids: [],
66 - text: '',
67 - });
68 -
69 - const value = useMemo(
70 - () => ({
71 - get currentIndex(): number | null {
72 - return state.currentIndex;
73 - },
74 -
75 - get ids(): Array<number> {
76 - return state.ids;
77 - },
78 -
79 - get text(): string {
80 - return state.text;
81 - },
82 -
83 - updateCurrentIndex(newCurrentIndex: number) {
84 - dispatch({
85 - type: 'UPDATE',
86 - payload: {
87 - currentIndex: newCurrentIndex,
88 - },
89 - });
90 -
91 - // Make sure a new search result is also selected.
92 - selectedElement.id =
93 - newCurrentIndex !== null ? state.ids[newCurrentIndex] : null;
94 - },
95 -
96 - updateText(newText: string) {
97 - const { currentIndex: oldCurrentIndex, text: oldText } = state;
98 -
99 - let newCurrentIndex = oldCurrentIndex;
100 - let newIDs = [];
101 -
102 - // Find all matching elements.
103 - if (newText !== '') {
104 - let regExp = createRegExp(newText);
105 - store.roots.forEach(rootID => {
106 - searchTree(store, rootID, regExp, newIDs);
107 - });
108 -
109 - // If this is a refinement of a previous search, preserve the current index (unless it's no longer valid).
110 - // If it's a new search, reset the index.
111 - if (newIDs.length === 0) {
112 - newCurrentIndex = null;
113 - } else if (oldCurrentIndex !== null && newText.startsWith(oldText)) {
114 - newCurrentIndex = Math.min(oldCurrentIndex, newIDs.length - 1);
115 - } else {
116 - newCurrentIndex = 0;
117 - }
118 - } else {
119 - newCurrentIndex = null;
120 - }
121 -
122 - dispatch({
123 - type: 'UPDATE',
124 - payload: {
125 - currentIndex: newCurrentIndex,
126 - ids: newIDs,
127 - text: newText,
128 - },
129 - });
130 -
131 - // Make sure a new search result is also selected.
132 - selectedElement.id =
133 - newCurrentIndex !== null ? newIDs[newCurrentIndex] : null;
134 - },
135 - }),
136 - [state]
137 - );
138 -
139 - // Listen for changes to the tree and incrementally adjust the search results.
140 - useLayoutEffect(() => {
141 - const handleStoreMutated = ([
142 - addedElementIDs,
143 - removedElementIDs,
144 - ]: Array<Uint32Array>) => {
145 - const { currentIndex, ids, text } = state;
146 -
147 - if (!text) {
148 - return;
149 - }
150 -
151 - let newIDs = ids;
152 - let newCurrentIndex = currentIndex;
153 -
154 - removedElementIDs.forEach(id => {
155 - // Prune this item from the search results.
156 - const index = newIDs.indexOf(id);
157 - if (index >= 0) {
158 - newIDs = newIDs.slice(0, index).concat(newIDs.slice(index + 1));
159 -
160 - // If the results are now empty, also deselect things.
161 - if (newIDs.length === 0) {
162 - newCurrentIndex = null;
163 - } else if (((newCurrentIndex: any): number) >= newIDs.length) {
164 - newCurrentIndex = newIDs.length - 1;
165 - }
166 - }
167 - });
168 -
169 - addedElementIDs.forEach(id => {
170 - const { displayName } = ((store.getElementByID(id): any): Element);
171 -
172 - // Add this item to the search results if it matches.
173 - const regExp = createRegExp(text);
174 - if (displayName !== null && regExp.test(displayName)) {
175 - const newElementIndex = ((store.getIndexOfElementID(
176 - id
177 - ): any): number);
178 -
179 - let foundMatch = false;
180 - for (let index = 0; index < newIDs.length; index++) {
181 - const id = newIDs[index];
182 - if (
183 - newElementIndex < ((store.getIndexOfElementID(id): any): number)
184 - ) {
185 - foundMatch = true;
186 - newIDs = newIDs
187 - .slice(0, index)
188 - .concat(id)
189 - .concat(newIDs.slice(index));
190 - break;
191 - }
192 - }
193 - if (!foundMatch) {
194 - newIDs = newIDs.concat(id);
195 - }
196 -
197 - newCurrentIndex = newCurrentIndex === null ? 0 : newCurrentIndex;
198 - }
199 - });
200 -
201 - dispatch({
202 - type: 'UPDATE',
203 - payload: {
204 - currentIndex: newCurrentIndex,
205 - ids: newIDs,
206 - },
207 - });
208 -
209 - // Make sure a new search result is also selected.
210 - selectedElement.id =
211 - newCurrentIndex !== null ? newIDs[newCurrentIndex] : null;
212 - };
213 -
214 - store.addListener('mutated', handleStoreMutated);
215 -
216 - return () => store.removeListener('mutated', handleStoreMutated);
217 - }, [state, store]);
218 -
219 - return (
220 - <SearchContext.Provider value={value}>{children}</SearchContext.Provider>
221 - );
222 -}
223 -
224 -function searchTree(
225 - store: Store,
226 - elementID: number,
227 - regExp: RegExp,
228 - ids: Array<number>
229 -): void {
230 - const { children, displayName } = ((store.getElementByID(
231 - elementID
232 - ): any): Element);
233 - if (displayName !== null) {
234 - if (regExp.test(displayName)) {
235 - ids.push(elementID);
236 - }
237 - }
238 - children.forEach(childID => searchTree(store, childID, regExp, ids));
239 -}
240 -
241 -export { SearchContext, SearchController };
src/devtools/views/SearchInput.js
+24 -44
@@ -1,7 +1,7 @@
1 // @flow
2
3 import React, { useCallback, useContext, useEffect, useRef } from 'react';
4 -import { SearchContext } from './SearchContext';
4 +import { SearchAndSelectionContext } from './SearchAndSelectionContext';
5 import ButtonIcon from './ButtonIcon';
6
7 import styles from './SearchInput.css';
@@ -9,50 +9,33 @@ import styles from './SearchInput.css';
9 type Props = {||};
10
11 export default function SearchInput(props: Props) {
12 - // We use the wrapper searchContext object rather than its individual parts–
13 - // (specifically for the useCallback input arrays below)–
14 - // because a change in any one value (e.g. ids array) impacts other values (e.g. currentIndex).
15 - // It's easier to avoid stale scope issues if we depend on the entire search state.
16 - const searchContext = useContext(SearchContext);
12 + const {
13 + decrementSearchIndex,
14 + incrementSearchIndex,
15 + searchIndex,
16 + searchResults,
17 + searchText,
18 + updateSearchText,
19 + } = useContext(SearchAndSelectionContext);
20
21 const inputRef = useRef();
22
20 - const handleTextChange = useCallback(({ currentTarget }) => {
21 - searchContext.updateText(currentTarget.value);
22 - });
23 -
24 - const selectNext = useCallback(() => {
25 - const { currentIndex } = searchContext;
26 - if (currentIndex !== null) {
27 - searchContext.updateCurrentIndex(currentIndex + 1);
28 - }
29 - }, [searchContext]);
30 -
31 - const selectPrevious = useCallback(() => {
32 - const { currentIndex } = searchContext;
33 - if (currentIndex !== null) {
34 - searchContext.updateCurrentIndex(currentIndex - 1);
35 - }
36 - }, [searchContext]);
23 + const handleTextChange = useCallback(
24 + ({ currentTarget }) => updateSearchText(currentTarget.value),
25 + [updateSearchText]
26 + );
27
28 const resetSearch = useCallback(() => {
39 - searchContext.updateText('');
40 - }, [searchContext]);
29 + updateSearchText('');
30 + }, [updateSearchText]);
31
32 const handleInputKeyPress = useCallback(
33 ({ key }) => {
34 if (key === 'Enter') {
45 - const { currentIndex, ids } = searchContext;
46 - if (currentIndex !== null) {
47 - if (currentIndex + 1 < ids.length) {
48 - searchContext.updateCurrentIndex(currentIndex + 1);
49 - } else {
50 - searchContext.updateCurrentIndex(0);
51 - }
52 - }
35 + incrementSearchIndex();
36 }
37 },
55 - [searchContext]
38 + [incrementSearchIndex]
39 );
40
41 // Auto-focus search input
@@ -72,8 +55,6 @@ export default function SearchInput(props: Props) {
55 return () => window.removeEventListener('keydown', handleWindowKeyDown);
56 }, [inputRef]);
57
75 - const { currentIndex, ids, text } = searchContext;
76 -
58 return (
59 <div className={styles.SearchInput}>
60 <input
@@ -82,33 +63,32 @@ export default function SearchInput(props: Props) {
63 onChange={handleTextChange}
64 placeholder="Search (text or /regex/)"
65 ref={inputRef}
85 - value={text}
66 + value={searchText}
67 />
87 - {!!text && (
68 + {!!searchText && (
69 <span className={styles.IndexLabel}>
89 - {Math.min(currentIndex + 1, ids.length)} | {ids.length}
70 + {Math.min(searchIndex + 1, searchResults.length)} |{' '}
71 + {searchResults.length}
72 </span>
73 )}
74 <div className={styles.LeftVRule} />
75 <button
76 className={styles.IconButton}
95 - disabled={currentIndex === null || currentIndex === 0}
96 - onClick={selectPrevious}
77 + onClick={decrementSearchIndex}
78 title="Scroll to previous search result"
79 >
80 <ButtonIcon type="up" />
81 </button>
82 <button
83 className={styles.IconButton}
103 - disabled={currentIndex === null || currentIndex === ids.length - 1}
104 - onClick={selectNext}
84 + onClick={incrementSearchIndex}
85 title="Scroll to next search result"
86 >
87 <ButtonIcon type="down" />
88 </button>
89 <button
90 className={styles.IconButton}
111 - disabled={!text}
91 + disabled={!searchText}
92 onClick={resetSearch}
93 title="Reset search"
94 >
src/devtools/views/SelectedElement.js
+10 -18
@@ -1,7 +1,7 @@
1 // @flow
2
3 import React, { useContext, useLayoutEffect, useRef, useState } from 'react';
4 -import { SelectedElementContext } from './SelectedElementContext';
4 +import { SearchAndSelectionContext } from './SearchAndSelectionContext';
5 import { BridgeContext, StoreContext } from './context';
6 import ButtonIcon from './ButtonIcon';
7 import HooksTree from './HooksTree';
@@ -11,17 +11,16 @@ import styles from './SelectedElement.css';
11
12 import type { InspectedElement } from '../types';
13 import type { DehydratedData } from 'src/devtools/types';
14 -import type { SelectedElementContextValue } from './SelectedElementContext';
14
15 export type Props = {||};
16
17 export default function SelectedElement(_: Props) {
19 - const selectedElement = useContext(SelectedElementContext);
20 - const { id } = selectedElement;
18 + const { selectedElementID } = useContext(SearchAndSelectionContext);
19 const store = useContext(StoreContext);
22 - const element = id !== null ? store.getElementByID(id) : null;
20 + const element =
21 + selectedElementID !== null ? store.getElementByID(selectedElementID) : null;
22
24 - const inspectedElement = useInspectedElement(id);
23 + const inspectedElement = useInspectedElement(selectedElementID);
24
25 // TODO Make "view DOM" and "view source" buttons work
26
@@ -65,10 +64,7 @@ export default function SelectedElement(_: Props) {
64 )}
65
66 {inspectedElement !== null && (
68 - <InspectedElementView
69 - inspectedElement={inspectedElement}
70 - selectedElement={selectedElement}
71 - />
67 + <InspectedElementView inspectedElement={inspectedElement} />
68 )}
69 </div>
70 );
@@ -76,13 +72,11 @@ export default function SelectedElement(_: Props) {
72
73 type InspectedElementViewProps = {|
74 inspectedElement: InspectedElement,
79 - selectedElement: SelectedElementContextValue,
75 |};
76
82 -function InspectedElementView({
83 - inspectedElement,
84 - selectedElement,
85 -}: InspectedElementViewProps) {
77 +function InspectedElementView({ inspectedElement }: InspectedElementViewProps) {
78 + const { selectElementWithID } = useContext(SearchAndSelectionContext);
79 +
80 let { context, hooks, owners, props, state } = inspectedElement;
81
82 return (
@@ -99,9 +93,7 @@ function InspectedElementView({
93 <div
94 key={owner.id}
95 className={styles.Owner}
102 - onClick={() => {
103 - selectedElement.id = owner.id;
104 - }}
96 + onClick={() => selectElementWithID(owner.id)}
97 title={owner.displayName}
98 >
99 {owner.displayName}
src/devtools/views/SelectedElementContext.js deleted
-56
@@ -1,56 +0,0 @@
1 -// @flow
2 -
3 -import React, { createContext, useContext, useMemo, useState } from 'react';
4 -import { StoreContext } from './context';
5 -
6 -export type SelectedElementContextValue = {|
7 - id: number | null,
8 - index: number | null,
9 -|};
10 -
11 -const SelectedElementContext = createContext<SelectedElementContextValue>(
12 - ((null: any): SelectedElementContextValue)
13 -);
14 -// $FlowFixMe displayName is a valid attribute of React$Context
15 -SelectedElementContext.displayName = 'SelectedElementContext';
16 -
17 -type Props = {|
18 - children: React$Node,
19 -|};
20 -
21 -// TODO Remove this wrapper element once global Context.write API exists.
22 -function SelectedElementController({ children }: Props) {
23 - const store = useContext(StoreContext);
24 -
25 - const [id, setID] = useState<number | null>(null);
26 - const [index, setIndex] = useState<number | null>(null);
27 -
28 - const value = useMemo(
29 - () => ({
30 - get id() {
31 - return id;
32 - },
33 - set id(id: number | null) {
34 - setID(id);
35 - setIndex(id !== null ? store.getIndexOfElementID(id) : null);
36 - },
37 - get index() {
38 - return index;
39 - },
40 - set index(index: number | null) {
41 - const element = index !== null ? store.getElementAtIndex(index) : null;
42 - setID(element !== null ? element.id : null);
43 - setIndex(index);
44 - },
45 - }),
46 - [id]
47 - );
48 -
49 - return (
50 - <SelectedElementContext.Provider value={value}>
51 - {children}
52 - </SelectedElementContext.Provider>
53 - );
54 -}
55 -
56 -export { SelectedElementContext, SelectedElementController };
src/devtools/views/Tree.js
+15 -15
@@ -3,7 +3,7 @@
3 import React, { useContext, useEffect, useLayoutEffect, useRef } from 'react';
4 import AutoSizer from 'react-virtualized-auto-sizer';
5 import { FixedSizeList } from 'react-window';
6 -import { SelectedElementContext } from './SelectedElementContext';
6 +import { SearchAndSelectionContext } from './SearchAndSelectionContext';
7 import ButtonIcon from './ButtonIcon';
8 import Element from './Element';
9 import SearchInput from './SearchInput';
@@ -14,37 +14,37 @@ import styles from './Tree.css';
14 type Props = {||};
15
16 export default function Tree(props: Props) {
17 - const selectedElementContext = useContext(SelectedElementContext);
17 + const { selectedElementIndex, selectElementAtIndex } = useContext(
18 + SearchAndSelectionContext
19 + );
20 const treeContext = useContext(TreeContext);
21 const listRef = useRef<FixedSizeList<any>>();
22
23 // Make sure a newly selected element is visible in the list.
24 // This is helpful for things like the owners list.
25 useLayoutEffect(() => {
24 - const { index } = selectedElementContext;
25 - if (index !== null && listRef.current != null) {
26 - listRef.current.scrollToItem(index);
26 + if (selectedElementIndex !== null && listRef.current != null) {
27 + listRef.current.scrollToItem(selectedElementIndex);
28 }
28 - }, [listRef, selectedElementContext]);
29 + }, [listRef, selectedElementIndex]);
30
31 // Navigate the tree with up/down arrow keys.
32 useEffect(() => {
33 const handleKeyDown = event => {
33 - let index;
34 -
34 // eslint-disable-next-line default-case
35 switch (event.key) {
36 case 'ArrowDown':
38 - index = selectedElementContext.index;
39 - if (index !== null && index + 1 < treeContext.size) {
40 - selectedElementContext.index = ((index: any): number) + 1;
37 + if (
38 + selectedElementIndex !== null &&
39 + selectedElementIndex + 1 < treeContext.size
40 + ) {
41 + selectElementAtIndex(((selectedElementIndex: any): number) + 1);
42 }
43 event.preventDefault();
44 break;
45 case 'ArrowUp':
45 - index = selectedElementContext.index;
46 - if (index !== null && index > 0) {
47 - selectedElementContext.index = ((index: any): number) - 1;
46 + if (selectedElementIndex !== null && selectedElementIndex > 0) {
47 + selectElementAtIndex(((selectedElementIndex: any): number) - 1);
48 }
49 event.preventDefault();
50 break;
@@ -56,7 +56,7 @@ export default function Tree(props: Props) {
56 return () => {
57 window.removeEventListener('keydown', handleKeyDown);
58 };
59 - }, [selectedElementContext, treeContext]);
59 + }, [selectedElementIndex, selectElementAtIndex, treeContext]);
60
61 return (
62 <div className={styles.Tree}>