main
js 1,131 lines 36.4 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 // This context combines tree/selection state, search, and the owners stack.
11 // These values are managed together because changes in one often impact the others.
12 // Combining them enables us to avoid cascading renders.
13 //
14 // Changes to search state may impact tree state.
15 // For example, updating the selected search result also updates the tree's selected value.
16 // Search does not fundamentally change the tree though.
17 // It is also possible to update the selected tree value independently.
18 //
19 // Changes to owners state mask search and tree values.
20 // When owners stack is not empty, search is temporarily disabled,
21 // and tree values (e.g. num elements, selected element) are masked.
22 // Both tree and search values are restored when the owners stack is cleared.
23 //
24 // For this reason, changes to the tree context are processed in sequence: tree -> search -> owners
25 // This enables each section to potentially override (or mask) previous values.
26
27 import type {ReactContext} from 'shared/ReactTypes';
28
29 import * as React from 'react';
30 import {
31 createContext,
32 useContext,
33 useEffect,
34 useLayoutEffect,
35 useMemo,
36 useReducer,
37 useRef,
38 startTransition,
39 } from 'react';
40 import {createRegExp} from '../utils';
41 import {StoreContext} from '../context';
42 import Store from '../../store';
43
44 import type {Element} from 'react-devtools-shared/src/frontend/types';
45
46 export type StateContext = {
47 // Tree
48 numElements: number,
49 ownerSubtreeLeafElementID: number | null,
50
51 // Search
52 searchIndex: number | null,
53 searchResults: Array<number>,
54 searchText: string,
55
56 // Owners
57 ownerID: number | null,
58 ownerFlatTree: Array<Element> | null,
59
60 // Activity slice
61 activityID: Element['id'] | null,
62 activities: $ReadOnlyArray<{id: Element['id'], depth: number}>,
63
64 // Inspection element panel
65 inspectedElementID: number | null,
66 inspectedElementIndex: number | null,
67 };
68
69 type ACTION_GO_TO_NEXT_SEARCH_RESULT = {
70 type: 'GO_TO_NEXT_SEARCH_RESULT',
71 };
72 type ACTION_GO_TO_PREVIOUS_SEARCH_RESULT = {
73 type: 'GO_TO_PREVIOUS_SEARCH_RESULT',
74 };
75 type ACTION_GO_TO_SEARCH_RESULT = {
76 type: 'GO_TO_SEARCH_RESULT',
77 payload: number,
78 };
79 type ACTION_HANDLE_STORE_MUTATION = {
80 type: 'HANDLE_STORE_MUTATION',
81 payload: [Array<number>, Map<number, number>, null | Element['id']],
82 };
83 type ACTION_RESET_OWNER_STACK = {
84 type: 'RESET_OWNER_STACK',
85 };
86 type ACTION_SELECT_CHILD_ELEMENT_IN_TREE = {
87 type: 'SELECT_CHILD_ELEMENT_IN_TREE',
88 };
89 type ACTION_SELECT_ELEMENT_AT_INDEX = {
90 type: 'SELECT_ELEMENT_AT_INDEX',
91 payload: number | null,
92 };
93 type ACTION_SELECT_ELEMENT_BY_ID = {
94 type: 'SELECT_ELEMENT_BY_ID',
95 payload: number | null,
96 };
97 type ACTION_SELECT_NEXT_ELEMENT_IN_TREE = {
98 type: 'SELECT_NEXT_ELEMENT_IN_TREE',
99 };
100 type ACTION_SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE = {
101 type: 'SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE',
102 };
103 type ACTION_SELECT_NEXT_SIBLING_IN_TREE = {
104 type: 'SELECT_NEXT_SIBLING_IN_TREE',
105 };
106 type ACTION_SELECT_OWNER = {
107 type: 'SELECT_OWNER',
108 payload: number,
109 };
110 type ACTION_SELECT_PARENT_ELEMENT_IN_TREE = {
111 type: 'SELECT_PARENT_ELEMENT_IN_TREE',
112 };
113 type ACTION_SELECT_PREVIOUS_ELEMENT_IN_TREE = {
114 type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE',
115 };
116 type ACTION_SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE = {
117 type: 'SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE',
118 };
119 type ACTION_SELECT_PREVIOUS_SIBLING_IN_TREE = {
120 type: 'SELECT_PREVIOUS_SIBLING_IN_TREE',
121 };
122 type ACTION_SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE = {
123 type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE',
124 };
125 type ACTION_SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE = {
126 type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE',
127 };
128 type ACTION_SET_SEARCH_TEXT = {
129 type: 'SET_SEARCH_TEXT',
130 payload: string,
131 };
132
133 type Action =
134 | ACTION_GO_TO_NEXT_SEARCH_RESULT
135 | ACTION_GO_TO_PREVIOUS_SEARCH_RESULT
136 | ACTION_GO_TO_SEARCH_RESULT
137 | ACTION_HANDLE_STORE_MUTATION
138 | ACTION_RESET_OWNER_STACK
139 | ACTION_SELECT_CHILD_ELEMENT_IN_TREE
140 | ACTION_SELECT_ELEMENT_AT_INDEX
141 | ACTION_SELECT_ELEMENT_BY_ID
142 | ACTION_SELECT_NEXT_ELEMENT_IN_TREE
143 | ACTION_SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE
144 | ACTION_SELECT_NEXT_SIBLING_IN_TREE
145 | ACTION_SELECT_OWNER
146 | ACTION_SELECT_PARENT_ELEMENT_IN_TREE
147 | ACTION_SELECT_PREVIOUS_ELEMENT_IN_TREE
148 | ACTION_SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE
149 | ACTION_SELECT_PREVIOUS_SIBLING_IN_TREE
150 | ACTION_SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE
151 | ACTION_SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE
152 | ACTION_SET_SEARCH_TEXT;
153
154 export type DispatcherContext = (action: Action) => void;
155
156 const TreeStateContext: ReactContext<StateContext> =
157 createContext<StateContext>(null as any as StateContext);
158 TreeStateContext.displayName = 'TreeStateContext';
159
160 // TODO: `dispatch` is an Action and should be named accordingly.
161 const TreeDispatcherContext: ReactContext<DispatcherContext> =
162 createContext<DispatcherContext>(null as any as DispatcherContext);
163 TreeDispatcherContext.displayName = 'TreeDispatcherContext';
164
165 type State = {
166 // Tree
167 numElements: number,
168 ownerSubtreeLeafElementID: number | null,
169
170 // Search
171 searchIndex: number | null,
172 searchResults: Array<number>,
173 searchText: string,
174
175 // Owners
176 ownerID: number | null,
177 ownerFlatTree: Array<Element> | null,
178
179 // Activity slice
180 activityID: Element['id'] | null,
181 activities: $ReadOnlyArray<{id: Element['id'], depth: number}>,
182
183 // Inspection element panel
184 inspectedElementID: number | null,
185 inspectedElementIndex: number | null,
186 };
187
188 function reduceTreeState(store: Store, state: State, action: Action): State {
189 let {
190 numElements,
191 ownerSubtreeLeafElementID,
192 inspectedElementID,
193 inspectedElementIndex,
194 } = state;
195 const ownerID = state.ownerID;
196
197 let lookupIDForIndex = true;
198
199 // Base tree should ignore selected element changes when the owner's tree is active.
200 if (ownerID === null) {
201 switch (action.type) {
202 case 'HANDLE_STORE_MUTATION':
203 numElements = store.numElements;
204
205 // If the currently-selected Element has been removed from the tree, update selection state.
206 const removedIDs = action.payload[1];
207 // Find the closest parent that wasn't removed during this batch.
208 // We deduce the parent-child mapping from removedIDs (id -> parentID)
209 // because by now it's too late to read them from the store.
210 while (
211 inspectedElementID !== null &&
212 removedIDs.has(inspectedElementID)
213 ) {
214 // $FlowExpectedError[incompatible-type]
215 inspectedElementID = removedIDs.get(inspectedElementID);
216 }
217 if (inspectedElementID === 0) {
218 // The whole root was removed.
219 inspectedElementIndex = null;
220 }
221 break;
222 case 'SELECT_CHILD_ELEMENT_IN_TREE':
223 ownerSubtreeLeafElementID = null;
224
225 if (inspectedElementIndex !== null) {
226 const inspectedElement = store.getElementAtIndex(
227 inspectedElementIndex,
228 );
229 if (
230 inspectedElement !== null &&
231 inspectedElement.children.length > 0 &&
232 !inspectedElement.isCollapsed
233 ) {
234 const firstChildID = inspectedElement.children[0];
235 const firstChildIndex = store.getIndexOfElementID(firstChildID);
236 if (firstChildIndex !== null) {
237 inspectedElementIndex = firstChildIndex;
238 }
239 }
240 }
241 break;
242 case 'SELECT_ELEMENT_AT_INDEX':
243 ownerSubtreeLeafElementID = null;
244
245 inspectedElementIndex = (action as ACTION_SELECT_ELEMENT_AT_INDEX)
246 .payload;
247 break;
248 case 'SELECT_ELEMENT_BY_ID':
249 ownerSubtreeLeafElementID = null;
250
251 // Skip lookup in this case; it would be redundant.
252 // It might also cause problems if the specified element was inside of a (not yet expanded) subtree.
253 lookupIDForIndex = false;
254
255 inspectedElementID = (action as ACTION_SELECT_ELEMENT_BY_ID).payload;
256 inspectedElementIndex =
257 inspectedElementID === null
258 ? null
259 : store.getIndexOfElementID(inspectedElementID);
260 break;
261 case 'SELECT_NEXT_ELEMENT_IN_TREE':
262 ownerSubtreeLeafElementID = null;
263
264 if (
265 inspectedElementIndex === null ||
266 inspectedElementIndex + 1 >= numElements
267 ) {
268 inspectedElementIndex = 0;
269 } else {
270 inspectedElementIndex++;
271 }
272 break;
273 case 'SELECT_NEXT_SIBLING_IN_TREE':
274 ownerSubtreeLeafElementID = null;
275
276 if (inspectedElementIndex !== null) {
277 const selectedElement = store.getElementAtIndex(
278 inspectedElementIndex as any as number,
279 );
280 if (selectedElement !== null && selectedElement.parentID !== 0) {
281 const parent = store.getElementByID(selectedElement.parentID);
282 if (parent !== null) {
283 const {children} = parent;
284 const selectedChildIndex = children.indexOf(selectedElement.id);
285 const nextChildID =
286 selectedChildIndex < children.length - 1
287 ? children[selectedChildIndex + 1]
288 : children[0];
289 inspectedElementIndex = store.getIndexOfElementID(nextChildID);
290 }
291 }
292 }
293 break;
294 case 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE':
295 if (inspectedElementIndex !== null) {
296 if (
297 ownerSubtreeLeafElementID !== null &&
298 ownerSubtreeLeafElementID !== inspectedElementID
299 ) {
300 const leafElement = store.getElementByID(ownerSubtreeLeafElementID);
301 if (leafElement !== null) {
302 let currentElement: null | Element = leafElement;
303 while (currentElement !== null) {
304 if (currentElement.ownerID === inspectedElementID) {
305 inspectedElementIndex = store.getIndexOfElementID(
306 currentElement.id,
307 );
308 break;
309 } else if (currentElement.ownerID !== 0) {
310 currentElement = store.getElementByID(currentElement.ownerID);
311 }
312 }
313 }
314 }
315 }
316 break;
317 case 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE':
318 if (inspectedElementIndex !== null) {
319 if (ownerSubtreeLeafElementID === null) {
320 // If this is the first time we're stepping through the owners tree,
321 // pin the current component as the owners list leaf.
322 // This will enable us to step back down to this component.
323 ownerSubtreeLeafElementID = inspectedElementID;
324 }
325
326 const selectedElement = store.getElementAtIndex(
327 inspectedElementIndex as any as number,
328 );
329 if (selectedElement !== null && selectedElement.ownerID !== 0) {
330 const ownerIndex = store.getIndexOfElementID(
331 selectedElement.ownerID,
332 );
333 if (ownerIndex !== null) {
334 inspectedElementIndex = ownerIndex;
335 }
336 }
337 }
338 break;
339 case 'SELECT_PARENT_ELEMENT_IN_TREE':
340 ownerSubtreeLeafElementID = null;
341
342 if (inspectedElementIndex !== null) {
343 const selectedElement = store.getElementAtIndex(
344 inspectedElementIndex as any as number,
345 );
346 if (selectedElement !== null && selectedElement.parentID !== 0) {
347 const parentIndex = store.getIndexOfElementID(
348 selectedElement.parentID,
349 );
350 if (parentIndex !== null) {
351 inspectedElementIndex = parentIndex;
352 }
353 }
354 }
355 break;
356 case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
357 ownerSubtreeLeafElementID = null;
358
359 if (inspectedElementIndex === null || inspectedElementIndex === 0) {
360 inspectedElementIndex = numElements - 1;
361 } else {
362 inspectedElementIndex--;
363 }
364 break;
365 case 'SELECT_PREVIOUS_SIBLING_IN_TREE':
366 ownerSubtreeLeafElementID = null;
367
368 if (inspectedElementIndex !== null) {
369 const selectedElement = store.getElementAtIndex(
370 inspectedElementIndex as any as number,
371 );
372 if (selectedElement !== null && selectedElement.parentID !== 0) {
373 const parent = store.getElementByID(selectedElement.parentID);
374 if (parent !== null) {
375 const {children} = parent;
376 const selectedChildIndex = children.indexOf(selectedElement.id);
377 const nextChildID =
378 selectedChildIndex > 0
379 ? children[selectedChildIndex - 1]
380 : children[children.length - 1];
381 inspectedElementIndex = store.getIndexOfElementID(nextChildID);
382 }
383 }
384 }
385 break;
386 case 'SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE': {
387 const elementIndicesWithErrorsOrWarnings =
388 store.getElementsWithErrorsAndWarnings();
389 if (elementIndicesWithErrorsOrWarnings.length === 0) {
390 return state;
391 }
392
393 let flatIndex = 0;
394 if (inspectedElementIndex !== null) {
395 // Resume from the current position in the list.
396 // Otherwise step to the previous item, relative to the current selection.
397 for (
398 let i = elementIndicesWithErrorsOrWarnings.length - 1;
399 i >= 0;
400 i--
401 ) {
402 const {index} = elementIndicesWithErrorsOrWarnings[i];
403 if (index >= inspectedElementIndex) {
404 flatIndex = i;
405 } else {
406 break;
407 }
408 }
409 }
410
411 let prevEntry;
412 if (flatIndex === 0) {
413 prevEntry =
414 elementIndicesWithErrorsOrWarnings[
415 elementIndicesWithErrorsOrWarnings.length - 1
416 ];
417 inspectedElementID = prevEntry.id;
418 inspectedElementIndex = prevEntry.index;
419 } else {
420 prevEntry = elementIndicesWithErrorsOrWarnings[flatIndex - 1];
421 inspectedElementID = prevEntry.id;
422 inspectedElementIndex = prevEntry.index;
423 }
424
425 lookupIDForIndex = false;
426 break;
427 }
428 case 'SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE': {
429 const elementIndicesWithErrorsOrWarnings =
430 store.getElementsWithErrorsAndWarnings();
431 if (elementIndicesWithErrorsOrWarnings.length === 0) {
432 return state;
433 }
434
435 let flatIndex = -1;
436 if (inspectedElementIndex !== null) {
437 // Resume from the current position in the list.
438 // Otherwise step to the next item, relative to the current selection.
439 for (let i = 0; i < elementIndicesWithErrorsOrWarnings.length; i++) {
440 const {index} = elementIndicesWithErrorsOrWarnings[i];
441 if (index <= inspectedElementIndex) {
442 flatIndex = i;
443 } else {
444 break;
445 }
446 }
447 }
448
449 let nextEntry;
450 if (flatIndex >= elementIndicesWithErrorsOrWarnings.length - 1) {
451 nextEntry = elementIndicesWithErrorsOrWarnings[0];
452 inspectedElementID = nextEntry.id;
453 inspectedElementIndex = nextEntry.index;
454 } else {
455 nextEntry = elementIndicesWithErrorsOrWarnings[flatIndex + 1];
456 inspectedElementID = nextEntry.id;
457 inspectedElementIndex = nextEntry.index;
458 }
459
460 lookupIDForIndex = false;
461 break;
462 }
463 default:
464 // React can bailout of no-op updates.
465 return state;
466 }
467 }
468
469 // Keep selected item ID and index in sync.
470 if (
471 lookupIDForIndex &&
472 inspectedElementIndex !== state.inspectedElementIndex
473 ) {
474 if (inspectedElementIndex === null) {
475 inspectedElementID = null;
476 } else {
477 inspectedElementID = store.getElementIDAtIndex(
478 inspectedElementIndex as any as number,
479 );
480 }
481 }
482
483 return {
484 ...state,
485
486 numElements,
487 ownerSubtreeLeafElementID,
488 inspectedElementIndex,
489 inspectedElementID,
490 };
491 }
492
493 function reduceSearchState(store: Store, state: State, action: Action): State {
494 let {
495 searchIndex,
496 searchResults,
497 searchText,
498 inspectedElementID,
499 inspectedElementIndex,
500 } = state;
501 const ownerID = state.ownerID;
502
503 const prevSearchIndex = searchIndex;
504 const prevSearchText = searchText;
505 const numPrevSearchResults = searchResults.length;
506
507 // We track explicitly whether search was requested because
508 // we might want to search even if search index didn't change.
509 // For example, if you press "next result" on a search with a single
510 // result but a different current selection, we'll set this to true.
511 let didRequestSearch = false;
512
513 // Search isn't supported when the owner's tree is active.
514 if (ownerID === null) {
515 switch (action.type) {
516 case 'GO_TO_NEXT_SEARCH_RESULT':
517 if (numPrevSearchResults > 0) {
518 didRequestSearch = true;
519 searchIndex =
520 // $FlowFixMe[unsafe-addition] addition with possible null/undefined value
521 searchIndex + 1 < numPrevSearchResults ? searchIndex + 1 : 0;
522 }
523 break;
524 case 'GO_TO_PREVIOUS_SEARCH_RESULT':
525 if (numPrevSearchResults > 0) {
526 didRequestSearch = true;
527 searchIndex =
528 (searchIndex as any as number) > 0
529 ? (searchIndex as any as number) - 1
530 : numPrevSearchResults - 1;
531 }
532 break;
533 case 'GO_TO_SEARCH_RESULT':
534 if (numPrevSearchResults > 0) {
535 didRequestSearch = true;
536 // Jump directly to a specific result (0-based), clamped to range.
537 // This lets users skip past large virtualized lists instead of
538 // stepping through results one at a time.
539 const targetIndex = (action as ACTION_GO_TO_SEARCH_RESULT).payload;
540 searchIndex = Math.max(
541 0,
542 Math.min(targetIndex, numPrevSearchResults - 1),
543 );
544 }
545 break;
546 case 'HANDLE_STORE_MUTATION':
547 if (searchText !== '') {
548 const [addedElementIDs, removedElementIDs] = (
549 action as ACTION_HANDLE_STORE_MUTATION
550 ).payload;
551
552 removedElementIDs.forEach((parentID, id) => {
553 // Prune this item from the search results.
554 const index = searchResults.indexOf(id);
555 if (index >= 0) {
556 searchResults = searchResults
557 .slice(0, index)
558 .concat(searchResults.slice(index + 1));
559
560 // If the results are now empty, also deselect things.
561 if (searchResults.length === 0) {
562 searchIndex = null;
563 } else if (
564 (searchIndex as any as number) >= searchResults.length
565 ) {
566 searchIndex = searchResults.length - 1;
567 }
568 }
569 });
570
571 addedElementIDs.forEach(id => {
572 const element = store.getElementByID(id) as any as Element;
573
574 // It's possible that multiple tree operations will fire before this action has run.
575 // So it's important to check for elements that may have been added and then removed.
576 // $FlowFixMe[invalid-compare]
577 if (element !== null) {
578 const {displayName} = element;
579
580 // Add this item to the search results if it matches.
581 const regExp = createRegExp(searchText);
582 if (displayName !== null && regExp.test(displayName)) {
583 const newElementIndex = store.getIndexOfElementID(
584 id,
585 ) as any as number;
586
587 let foundMatch = false;
588 for (let index = 0; index < searchResults.length; index++) {
589 const resultID = searchResults[index];
590 if (
591 newElementIndex <
592 (store.getIndexOfElementID(resultID) as any as number)
593 ) {
594 foundMatch = true;
595 searchResults = searchResults
596 .slice(0, index)
597 .concat(resultID)
598 .concat(searchResults.slice(index));
599 break;
600 }
601 }
602 if (!foundMatch) {
603 searchResults = searchResults.concat(id);
604 }
605
606 searchIndex = searchIndex === null ? 0 : searchIndex;
607 }
608 }
609 });
610 }
611 break;
612 case 'SET_SEARCH_TEXT':
613 searchIndex = null;
614 searchResults = [];
615 searchText = (action as ACTION_SET_SEARCH_TEXT).payload;
616
617 if (searchText !== '') {
618 const regExp = createRegExp(searchText);
619 store.roots.forEach(rootID => {
620 recursivelySearchTree(store, rootID, regExp, searchResults);
621 });
622 if (searchResults.length > 0) {
623 if (prevSearchIndex === null) {
624 if (inspectedElementIndex !== null) {
625 searchIndex = getNearestResultIndex(
626 store,
627 searchResults,
628 inspectedElementIndex,
629 );
630 } else {
631 searchIndex = 0;
632 }
633 } else {
634 searchIndex = Math.min(
635 prevSearchIndex as any as number,
636 searchResults.length - 1,
637 );
638 }
639 }
640 }
641 break;
642 default:
643 // React can bailout of no-op updates.
644 return state;
645 }
646 }
647
648 if (searchText !== prevSearchText) {
649 // $FlowFixMe[incompatible-type]
650 const newSearchIndex = searchResults.indexOf(inspectedElementID);
651 if (prevSearchText === '') {
652 // Starting a fresh search (e.g. after clearing the box). Honor the index
653 // computed above, which uses "find next" semantics so that retyping the
654 // same query advances past the still-selected result instead of snapping
655 // back to it.
656 if (searchIndex !== null) {
657 didRequestSearch = true;
658 }
659 } else if (newSearchIndex === -1) {
660 // Refining an existing query and the current selection no longer matches,
661 // so move the selection to the nearest result.
662 didRequestSearch = true;
663 } else {
664 // Refining an existing query and the current selection still matches.
665 // Keep it selected and adjust the index to its position in new results.
666 searchIndex = newSearchIndex;
667 }
668 }
669 if (didRequestSearch && searchIndex !== null) {
670 inspectedElementID = searchResults[searchIndex] as any as number;
671 inspectedElementIndex = store.getIndexOfElementID(
672 inspectedElementID as any as number,
673 );
674 }
675
676 return {
677 ...state,
678
679 inspectedElementID,
680 inspectedElementIndex,
681
682 searchIndex,
683 searchResults,
684 searchText,
685 };
686 }
687
688 function reduceOwnersState(store: Store, state: State, action: Action): State {
689 let {
690 numElements,
691 ownerID,
692 ownerFlatTree,
693 inspectedElementID,
694 inspectedElementIndex,
695 } = state;
696 const {searchIndex, searchResults, searchText} = state;
697
698 let prevInspectedElementIndex = inspectedElementIndex;
699
700 switch (action.type) {
701 case 'HANDLE_STORE_MUTATION':
702 if (ownerID !== null) {
703 if (!store.containsElement(ownerID)) {
704 ownerID = null;
705 ownerFlatTree = null;
706 prevInspectedElementIndex = null;
707 } else {
708 ownerFlatTree = store.getOwnersListForElement(ownerID);
709 if (inspectedElementID !== null) {
710 // Mutation might have caused the index of this ID to shift.
711 prevInspectedElementIndex = ownerFlatTree.findIndex(
712 element => element.id === inspectedElementID,
713 );
714 }
715 }
716 } else {
717 if (inspectedElementID !== null) {
718 // Mutation might have caused the index of this ID to shift.
719 inspectedElementIndex = store.getIndexOfElementID(inspectedElementID);
720 }
721 }
722 if (inspectedElementIndex === -1) {
723 // If we couldn't find this ID after mutation, unselect it.
724 inspectedElementIndex = null;
725 inspectedElementID = null;
726 }
727 break;
728 case 'RESET_OWNER_STACK':
729 ownerID = null;
730 ownerFlatTree = null;
731 inspectedElementIndex =
732 inspectedElementID !== null
733 ? store.getIndexOfElementID(inspectedElementID)
734 : null;
735 break;
736 case 'SELECT_ELEMENT_AT_INDEX':
737 if (ownerFlatTree !== null) {
738 inspectedElementIndex = (action as ACTION_SELECT_ELEMENT_AT_INDEX)
739 .payload;
740 }
741 break;
742 case 'SELECT_ELEMENT_BY_ID':
743 if (ownerFlatTree !== null) {
744 const payload = (action as ACTION_SELECT_ELEMENT_BY_ID).payload;
745 if (payload === null) {
746 inspectedElementIndex = null;
747 } else {
748 inspectedElementIndex = ownerFlatTree.findIndex(
749 element => element.id === payload,
750 );
751
752 // If the selected element is outside of the current owners list,
753 // exit the list and select the element in the main tree.
754 // This supports features like toggling Suspense.
755 // $FlowFixMe[invalid-compare]
756 if (inspectedElementIndex !== null && inspectedElementIndex < 0) {
757 ownerID = null;
758 ownerFlatTree = null;
759 inspectedElementIndex = store.getIndexOfElementID(payload);
760 }
761 }
762 }
763 break;
764 case 'SELECT_NEXT_ELEMENT_IN_TREE':
765 if (ownerFlatTree !== null && ownerFlatTree.length > 0) {
766 if (inspectedElementIndex === null) {
767 inspectedElementIndex = 0;
768 } else if (inspectedElementIndex + 1 < ownerFlatTree.length) {
769 inspectedElementIndex++;
770 }
771 }
772 break;
773 case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
774 if (ownerFlatTree !== null && ownerFlatTree.length > 0) {
775 if (inspectedElementIndex !== null && inspectedElementIndex > 0) {
776 inspectedElementIndex--;
777 }
778 }
779 break;
780 case 'SELECT_OWNER':
781 // If the Store doesn't have any owners metadata, don't drill into an empty stack.
782 // This is a confusing user experience.
783 if (store.hasOwnerMetadata) {
784 ownerID = (action as ACTION_SELECT_OWNER).payload;
785 ownerFlatTree = store.getOwnersListForElement(ownerID);
786
787 // Always force reset selection to be the top of the new owner tree.
788 inspectedElementIndex = 0;
789 prevInspectedElementIndex = null;
790 }
791 break;
792 default:
793 // React can bailout of no-op updates.
794 return state;
795 }
796
797 // Changes in the selected owner require re-calculating the owners tree.
798 if (
799 ownerFlatTree !== state.ownerFlatTree ||
800 action.type === 'HANDLE_STORE_MUTATION'
801 ) {
802 if (ownerFlatTree === null) {
803 numElements = store.numElements;
804 } else {
805 numElements = ownerFlatTree.length;
806 }
807 }
808
809 // Keep selected item ID and index in sync.
810 if (inspectedElementIndex !== prevInspectedElementIndex) {
811 if (inspectedElementIndex === null) {
812 inspectedElementID = null;
813 } else {
814 if (ownerFlatTree !== null) {
815 inspectedElementID = ownerFlatTree[inspectedElementIndex].id;
816 }
817 }
818 }
819
820 return {
821 ...state,
822
823 numElements,
824
825 searchIndex,
826 searchResults,
827 searchText,
828
829 ownerID,
830 ownerFlatTree,
831
832 inspectedElementID,
833 inspectedElementIndex,
834 };
835 }
836
837 function reduceActivityState(
838 store: Store,
839 state: State,
840 action: Action,
841 ): State {
842 switch (action.type) {
843 case 'HANDLE_STORE_MUTATION':
844 let {activityID} = state;
845 const [, , activitySliceIDChange] = action.payload;
846 const activities = store.getActivities();
847 if (activitySliceIDChange === 0 && activityID !== null) {
848 activityID = null;
849 } else if (
850 activitySliceIDChange !== null &&
851 activitySliceIDChange !== activityID
852 ) {
853 activityID = activitySliceIDChange;
854 }
855 if (activityID !== state.activityID || activities !== state.activities) {
856 return {
857 ...state,
858 activityID,
859 activities,
860 };
861 }
862 }
863 return state;
864 }
865
866 type Props = {
867 children: React$Node,
868
869 // Used for automated testing
870 defaultOwnerID?: ?number,
871 defaultInspectedElementID?: ?number,
872 defaultInspectedElementIndex?: ?number,
873 };
874
875 function getInitialState({
876 defaultOwnerID,
877 defaultInspectedElementID,
878 defaultInspectedElementIndex,
879 store,
880 }: {
881 defaultOwnerID?: ?number,
882 defaultInspectedElementID?: ?number,
883 defaultInspectedElementIndex?: ?number,
884 store: Store,
885 }): State {
886 return {
887 // Tree
888 numElements: store.numElements,
889 ownerSubtreeLeafElementID: null,
890
891 // Search
892 searchIndex: null,
893 searchResults: [],
894 searchText: '',
895
896 // Owners
897 ownerID: defaultOwnerID == null ? null : defaultOwnerID,
898 ownerFlatTree: null,
899
900 // Activity slice
901 activityID: null,
902 activities: store.getActivities(),
903
904 // Inspection element panel
905 inspectedElementID:
906 defaultInspectedElementID != null
907 ? defaultInspectedElementID
908 : store.lastSelectedHostInstanceElementId,
909 inspectedElementIndex:
910 defaultInspectedElementIndex != null
911 ? defaultInspectedElementIndex
912 : store.lastSelectedHostInstanceElementId
913 ? store.getIndexOfElementID(store.lastSelectedHostInstanceElementId)
914 : null,
915 };
916 }
917
918 // TODO Remove TreeContextController wrapper element once global Context.write API exists.
919 function TreeContextController({
920 children,
921 defaultOwnerID,
922 defaultInspectedElementID,
923 defaultInspectedElementIndex,
924 }: Props): React.Node {
925 const store = useContext(StoreContext);
926
927 const initialRevision = useMemo(() => store.revision, [store]);
928
929 // This reducer is created inline because it needs access to the Store.
930 // The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
931 // so it's okay for the reducer to have an empty dependencies array.
932 const reducer = useMemo(
933 () =>
934 (state: State, action: Action): State => {
935 const {type} = action;
936 switch (type) {
937 case 'GO_TO_NEXT_SEARCH_RESULT':
938 case 'GO_TO_PREVIOUS_SEARCH_RESULT':
939 case 'GO_TO_SEARCH_RESULT':
940 case 'HANDLE_STORE_MUTATION':
941 case 'RESET_OWNER_STACK':
942 case 'SELECT_ELEMENT_AT_INDEX':
943 case 'SELECT_ELEMENT_BY_ID':
944 case 'SELECT_CHILD_ELEMENT_IN_TREE':
945 case 'SELECT_NEXT_ELEMENT_IN_TREE':
946 case 'SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE':
947 case 'SELECT_NEXT_SIBLING_IN_TREE':
948 case 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE':
949 case 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE':
950 case 'SELECT_PARENT_ELEMENT_IN_TREE':
951 case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
952 case 'SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE':
953 case 'SELECT_PREVIOUS_SIBLING_IN_TREE':
954 case 'SELECT_OWNER':
955 case 'SET_SEARCH_TEXT':
956 state = reduceTreeState(store, state, action);
957 state = reduceSearchState(store, state, action);
958 state = reduceOwnersState(store, state, action);
959 state = reduceActivityState(store, state, action);
960
961 // TODO(hoxyq): review
962 // If the selected ID is in a collapsed subtree, reset the selected index to null.
963 // We'll know the correct index after the layout effect will toggle the tree,
964 // and the store tree is mutated to account for that.
965 if (
966 state.inspectedElementID !== null &&
967 store.isInsideCollapsedSubTree(state.inspectedElementID)
968 ) {
969 return {
970 ...state,
971 inspectedElementIndex: null,
972 };
973 }
974
975 return state;
976 default:
977 throw new Error(`Unrecognized action "${type}"`);
978 }
979 },
980 [store],
981 );
982
983 const [state, dispatch] = useReducer(
984 reducer,
985 {
986 defaultOwnerID,
987 defaultInspectedElementID,
988 defaultInspectedElementIndex,
989 store,
990 },
991 getInitialState,
992 );
993 const transitionDispatch = useMemo(
994 () => (action: Action) =>
995 startTransition(() => {
996 dispatch(action);
997 }),
998 [dispatch],
999 );
1000
1001 // Listen for host element selections.
1002 useEffect(() => {
1003 const handler = (id: Element['id'] | null) => {
1004 transitionDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
1005 };
1006
1007 store.addListener('hostInstanceSelected', handler);
1008 return () => store.removeListener('hostInstanceSelected', handler);
1009 }, [store, transitionDispatch]);
1010
1011 // If a newly-selected search result or inspection selection is inside of a collapsed subtree, auto expand it.
1012 // This needs to be a layout effect to avoid temporarily flashing an incorrect selection.
1013 const prevInspectedElementID = useRef<number | null>(null);
1014 useLayoutEffect(() => {
1015 if (state.inspectedElementID !== prevInspectedElementID.current) {
1016 prevInspectedElementID.current = state.inspectedElementID;
1017
1018 if (state.inspectedElementID !== null) {
1019 const element = store.getElementByID(state.inspectedElementID);
1020 if (element !== null && element.parentID > 0) {
1021 store.toggleIsCollapsed(element.parentID, false);
1022 }
1023 }
1024 }
1025 }, [state.inspectedElementID, store]);
1026
1027 // Mutations to the underlying tree may impact this context (e.g. search results, selection state).
1028 useEffect(() => {
1029 const handleStoreMutated = ([
1030 addedElementIDs,
1031 removedElementIDs,
1032 activitySliceIDChange,
1033 ]: [Array<number>, Map<number, number>, null | Element['id']]) => {
1034 dispatch({
1035 type: 'HANDLE_STORE_MUTATION',
1036 payload: [addedElementIDs, removedElementIDs, activitySliceIDChange],
1037 });
1038 };
1039
1040 // Since this is a passive effect, the tree may have been mutated before our initial subscription.
1041 if (store.revision !== initialRevision) {
1042 // At the moment, we can treat this as a mutation.
1043 // We don't know which Elements were newly added/removed, but that should be okay in this case.
1044 // It would only impact the search state, which is unlikely to exist yet at this point.
1045 dispatch({
1046 type: 'HANDLE_STORE_MUTATION',
1047 payload: [[], new Map(), null],
1048 });
1049 }
1050
1051 store.addListener('mutated', handleStoreMutated);
1052 return () => store.removeListener('mutated', handleStoreMutated);
1053 }, [dispatch, initialRevision, store]);
1054
1055 return (
1056 <TreeStateContext.Provider value={state}>
1057 <TreeDispatcherContext.Provider value={transitionDispatch}>
1058 {children}
1059 </TreeDispatcherContext.Provider>
1060 </TreeStateContext.Provider>
1061 );
1062 }
1063 function recursivelySearchTree(
1064 store: Store,
1065 elementID: number,
1066 regExp: RegExp,
1067 searchResults: Array<number>,
1068 ): void {
1069 const element = store.getElementByID(elementID);
1070
1071 if (element == null) {
1072 return;
1073 }
1074
1075 const {
1076 children,
1077 displayName,
1078 hocDisplayNames,
1079 compiledWithForget,
1080 key,
1081 nameProp,
1082 } = element;
1083 if (displayName != null && regExp.test(displayName) === true) {
1084 searchResults.push(elementID);
1085 } else if (
1086 hocDisplayNames != null &&
1087 hocDisplayNames.length > 0 &&
1088 hocDisplayNames.some(name => regExp.test(name)) === true
1089 ) {
1090 searchResults.push(elementID);
1091 } else if (compiledWithForget && regExp.test('Forget')) {
1092 searchResults.push(elementID);
1093 } else if (typeof key === 'string' && regExp.test(key)) {
1094 searchResults.push(elementID);
1095 } else if (typeof nameProp === 'string' && regExp.test(nameProp)) {
1096 searchResults.push(elementID);
1097 }
1098
1099 children.forEach(childID =>
1100 recursivelySearchTree(store, childID, regExp, searchResults),
1101 );
1102 }
1103
1104 function getNearestResultIndex(
1105 store: Store,
1106 searchResults: Array<number>,
1107 inspectedElementIndex: number,
1108 ): number {
1109 // When the currently selected element is itself a match for the new query
1110 // (e.g. you cleared the search and retyped the same text while a result was
1111 // still selected), advance to the *next* match instead of snapping back to
1112 // the same component. This mirrors "find next" semantics in browsers/editors
1113 // and avoids the search feeling stuck on the same result.
1114 const selectedIsResult = searchResults.some(
1115 id => store.getIndexOfElementID(id) === inspectedElementIndex,
1116 );
1117
1118 const index = searchResults.findIndex(id => {
1119 const innerIndex = store.getIndexOfElementID(id);
1120 if (innerIndex === null) {
1121 return false;
1122 }
1123 return selectedIsResult
1124 ? innerIndex > inspectedElementIndex
1125 : innerIndex >= inspectedElementIndex;
1126 });
1127
1128 return index === -1 ? 0 : index;
1129 }
1130
1131 export {TreeDispatcherContext, TreeStateContext, TreeContextController};