Files
react/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1084 lines
34 KiB
JavaScript
Raw Normal View History

2019-08-27 10:54:01 -07:00
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
2019-08-27 10:54:01 -07:00
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
// This context combines tree/selection state, search, and the owners stack.
// These values are managed together because changes in one often impact the others.
// Combining them enables us to avoid cascading renders.
//
// Changes to search state may impact tree state.
// For example, updating the selected search result also updates the tree's selected value.
2020-06-15 19:59:44 -04:00
// Search does not fundamentally change the tree though.
// It is also possible to update the selected tree value independently.
//
// Changes to owners state mask search and tree values.
2020-06-15 19:59:44 -04:00
// When owners stack is not empty, search is temporarily disabled,
// and tree values (e.g. num elements, selected element) are masked.
// Both tree and search values are restored when the owners stack is cleared.
//
// For this reason, changes to the tree context are processed in sequence: tree -> search -> owners
// This enables each section to potentially override (or mask) previous values.
import type {ReactContext} from 'shared/ReactTypes';
import * as React from 'react';
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
DevTools: refactor NativeStyleEditor, don't use custom cache implementation (#32298) We have this really old (5+ years) feature for inspecting native styles of React Native Host components. We also have a custom Cache implementation in React DevTools, which was forked from React at some point. We know that this should be removed, but it spans through critical parts of the application, like fetching and caching inspected element. Before this PR, this was also used for caching native style and layouts of RN Host components. This approach is out of date, and was based on the presence of Suspense boundary around inspected element View, which we have removed to speed up element inspection - https://github.com/facebook/react/pull/30555. Looks like I've introduced a regression in https://github.com/facebook/react/pull/31956: - Custom Cache implementation will throw thenables and suspend. - Because of this, some descendant Suspense boundaries will not resolve for a long time, and React will throw an error https://react.dev/errors/482. I've switched from a usage of this custom Cache implementation to a naive fetching in effect and keeping the layout and style in a local state of a Context, which will be propagated downwards. The race should be impossible, this is guaranteed by the mechanism for queueing messages through microtasks queue. The only downside is the UI. If you quickly switch between 2 elements, and one of them has native style, while the other doesn't, UI will feel jumpy. We can address this later with a Suspense boundary, if needed.
2025-02-05 12:52:48 +00:00
startTransition,
} from 'react';
2019-08-13 17:58:03 -07:00
import {createRegExp} from '../utils';
import {StoreContext} from '../context';
import Store from '../../store';
import type {Element} from 'react-devtools-shared/src/frontend/types';
export type StateContext = {
// Tree
numElements: number,
ownerSubtreeLeafElementID: number | null,
// Search
searchIndex: number | null,
searchResults: Array<number>,
searchText: string,
// Owners
ownerID: number | null,
ownerFlatTree: Array<Element> | null,
// Activity slice
activityID: Element['id'] | null,
activities: $ReadOnlyArray<{id: Element['id'], depth: number}>,
// Inspection element panel
inspectedElementID: number | null,
inspectedElementIndex: number | null,
};
type ACTION_GO_TO_NEXT_SEARCH_RESULT = {
type: 'GO_TO_NEXT_SEARCH_RESULT',
};
type ACTION_GO_TO_PREVIOUS_SEARCH_RESULT = {
type: 'GO_TO_PREVIOUS_SEARCH_RESULT',
};
type ACTION_HANDLE_STORE_MUTATION = {
type: 'HANDLE_STORE_MUTATION',
payload: [Array<number>, Map<number, number>, null | Element['id']],
};
type ACTION_RESET_OWNER_STACK = {
type: 'RESET_OWNER_STACK',
};
type ACTION_SELECT_CHILD_ELEMENT_IN_TREE = {
type: 'SELECT_CHILD_ELEMENT_IN_TREE',
};
type ACTION_SELECT_ELEMENT_AT_INDEX = {
type: 'SELECT_ELEMENT_AT_INDEX',
payload: number | null,
};
type ACTION_SELECT_ELEMENT_BY_ID = {
type: 'SELECT_ELEMENT_BY_ID',
payload: number | null,
};
type ACTION_SELECT_NEXT_ELEMENT_IN_TREE = {
type: 'SELECT_NEXT_ELEMENT_IN_TREE',
};
type ACTION_SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE = {
type: 'SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE',
};
type ACTION_SELECT_NEXT_SIBLING_IN_TREE = {
type: 'SELECT_NEXT_SIBLING_IN_TREE',
};
type ACTION_SELECT_OWNER = {
type: 'SELECT_OWNER',
payload: number,
};
type ACTION_SELECT_PARENT_ELEMENT_IN_TREE = {
type: 'SELECT_PARENT_ELEMENT_IN_TREE',
};
type ACTION_SELECT_PREVIOUS_ELEMENT_IN_TREE = {
type: 'SELECT_PREVIOUS_ELEMENT_IN_TREE',
};
type ACTION_SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE = {
type: 'SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE',
};
type ACTION_SELECT_PREVIOUS_SIBLING_IN_TREE = {
type: 'SELECT_PREVIOUS_SIBLING_IN_TREE',
};
type ACTION_SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE = {
type: 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE',
};
type ACTION_SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE = {
type: 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE',
};
type ACTION_SET_SEARCH_TEXT = {
type: 'SET_SEARCH_TEXT',
payload: string,
};
type Action =
| ACTION_GO_TO_NEXT_SEARCH_RESULT
| ACTION_GO_TO_PREVIOUS_SEARCH_RESULT
| ACTION_HANDLE_STORE_MUTATION
| ACTION_RESET_OWNER_STACK
| ACTION_SELECT_CHILD_ELEMENT_IN_TREE
| ACTION_SELECT_ELEMENT_AT_INDEX
| ACTION_SELECT_ELEMENT_BY_ID
| ACTION_SELECT_NEXT_ELEMENT_IN_TREE
| ACTION_SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE
| ACTION_SELECT_NEXT_SIBLING_IN_TREE
| ACTION_SELECT_OWNER
| ACTION_SELECT_PARENT_ELEMENT_IN_TREE
| ACTION_SELECT_PREVIOUS_ELEMENT_IN_TREE
| ACTION_SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE
| ACTION_SELECT_PREVIOUS_SIBLING_IN_TREE
| ACTION_SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE
| ACTION_SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE
| ACTION_SET_SEARCH_TEXT;
2019-05-09 15:52:50 -07:00
export type DispatcherContext = (action: Action) => void;
const TreeStateContext: ReactContext<StateContext> =
createContext<StateContext>(((null: any): StateContext));
TreeStateContext.displayName = 'TreeStateContext';
// TODO: `dispatch` is an Action and should be named accordingly.
const TreeDispatcherContext: ReactContext<DispatcherContext> =
createContext<DispatcherContext>(((null: any): DispatcherContext));
TreeDispatcherContext.displayName = 'TreeDispatcherContext';
type State = {
// Tree
numElements: number,
ownerSubtreeLeafElementID: number | null,
// Search
searchIndex: number | null,
searchResults: Array<number>,
searchText: string,
// Owners
ownerID: number | null,
ownerFlatTree: Array<Element> | null,
// Activity slice
activityID: Element['id'] | null,
activities: $ReadOnlyArray<{id: Element['id'], depth: number}>,
// Inspection element panel
inspectedElementID: number | null,
inspectedElementIndex: number | null,
};
function reduceTreeState(store: Store, state: State, action: Action): State {
let {
numElements,
ownerSubtreeLeafElementID,
inspectedElementID,
inspectedElementIndex,
} = state;
const ownerID = state.ownerID;
let lookupIDForIndex = true;
// Base tree should ignore selected element changes when the owner's tree is active.
if (ownerID === null) {
switch (action.type) {
case 'HANDLE_STORE_MUTATION':
numElements = store.numElements;
// If the currently-selected Element has been removed from the tree, update selection state.
const removedIDs = action.payload[1];
// Find the closest parent that wasn't removed during this batch.
// We deduce the parent-child mapping from removedIDs (id -> parentID)
// because by now it's too late to read them from the store.
while (
inspectedElementID !== null &&
removedIDs.has(inspectedElementID)
) {
// $FlowExpectedError[incompatible-type]
inspectedElementID = removedIDs.get(inspectedElementID);
}
if (inspectedElementID === 0) {
// The whole root was removed.
inspectedElementIndex = null;
}
break;
case 'SELECT_CHILD_ELEMENT_IN_TREE':
ownerSubtreeLeafElementID = null;
if (inspectedElementIndex !== null) {
const inspectedElement = store.getElementAtIndex(
inspectedElementIndex,
);
if (
inspectedElement !== null &&
inspectedElement.children.length > 0 &&
!inspectedElement.isCollapsed
) {
const firstChildID = inspectedElement.children[0];
const firstChildIndex = store.getIndexOfElementID(firstChildID);
if (firstChildIndex !== null) {
inspectedElementIndex = firstChildIndex;
}
}
}
break;
case 'SELECT_ELEMENT_AT_INDEX':
ownerSubtreeLeafElementID = null;
inspectedElementIndex = (action: ACTION_SELECT_ELEMENT_AT_INDEX)
.payload;
break;
case 'SELECT_ELEMENT_BY_ID':
ownerSubtreeLeafElementID = null;
// Skip lookup in this case; it would be redundant.
// It might also cause problems if the specified element was inside of a (not yet expanded) subtree.
lookupIDForIndex = false;
inspectedElementID = (action: ACTION_SELECT_ELEMENT_BY_ID).payload;
inspectedElementIndex =
inspectedElementID === null
? null
: store.getIndexOfElementID(inspectedElementID);
break;
case 'SELECT_NEXT_ELEMENT_IN_TREE':
ownerSubtreeLeafElementID = null;
if (
inspectedElementIndex === null ||
inspectedElementIndex + 1 >= numElements
) {
inspectedElementIndex = 0;
} else {
inspectedElementIndex++;
}
break;
case 'SELECT_NEXT_SIBLING_IN_TREE':
ownerSubtreeLeafElementID = null;
if (inspectedElementIndex !== null) {
const selectedElement = store.getElementAtIndex(
((inspectedElementIndex: any): number),
);
if (selectedElement !== null && selectedElement.parentID !== 0) {
const parent = store.getElementByID(selectedElement.parentID);
if (parent !== null) {
const {children} = parent;
const selectedChildIndex = children.indexOf(selectedElement.id);
const nextChildID =
selectedChildIndex < children.length - 1
? children[selectedChildIndex + 1]
: children[0];
inspectedElementIndex = store.getIndexOfElementID(nextChildID);
}
}
}
break;
case 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE':
if (inspectedElementIndex !== null) {
if (
ownerSubtreeLeafElementID !== null &&
ownerSubtreeLeafElementID !== inspectedElementID
) {
const leafElement = store.getElementByID(ownerSubtreeLeafElementID);
if (leafElement !== null) {
let currentElement: null | Element = leafElement;
while (currentElement !== null) {
if (currentElement.ownerID === inspectedElementID) {
inspectedElementIndex = store.getIndexOfElementID(
currentElement.id,
);
break;
} else if (currentElement.ownerID !== 0) {
currentElement = store.getElementByID(currentElement.ownerID);
}
}
}
}
}
break;
case 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE':
if (inspectedElementIndex !== null) {
if (ownerSubtreeLeafElementID === null) {
// If this is the first time we're stepping through the owners tree,
// pin the current component as the owners list leaf.
// This will enable us to step back down to this component.
ownerSubtreeLeafElementID = inspectedElementID;
}
const selectedElement = store.getElementAtIndex(
((inspectedElementIndex: any): number),
);
if (selectedElement !== null && selectedElement.ownerID !== 0) {
const ownerIndex = store.getIndexOfElementID(
selectedElement.ownerID,
);
if (ownerIndex !== null) {
inspectedElementIndex = ownerIndex;
}
}
}
break;
2019-02-27 13:49:46 -08:00
case 'SELECT_PARENT_ELEMENT_IN_TREE':
ownerSubtreeLeafElementID = null;
if (inspectedElementIndex !== null) {
2019-02-27 13:49:46 -08:00
const selectedElement = store.getElementAtIndex(
((inspectedElementIndex: any): number),
2019-02-27 13:49:46 -08:00
);
if (selectedElement !== null && selectedElement.parentID !== 0) {
const parentIndex = store.getIndexOfElementID(
2019-08-13 17:58:03 -07:00
selectedElement.parentID,
);
if (parentIndex !== null) {
inspectedElementIndex = parentIndex;
}
2019-02-27 13:49:46 -08:00
}
}
break;
case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
ownerSubtreeLeafElementID = null;
if (inspectedElementIndex === null || inspectedElementIndex === 0) {
inspectedElementIndex = numElements - 1;
} else {
inspectedElementIndex--;
}
break;
case 'SELECT_PREVIOUS_SIBLING_IN_TREE':
ownerSubtreeLeafElementID = null;
if (inspectedElementIndex !== null) {
const selectedElement = store.getElementAtIndex(
((inspectedElementIndex: any): number),
);
if (selectedElement !== null && selectedElement.parentID !== 0) {
const parent = store.getElementByID(selectedElement.parentID);
if (parent !== null) {
const {children} = parent;
const selectedChildIndex = children.indexOf(selectedElement.id);
const nextChildID =
selectedChildIndex > 0
? children[selectedChildIndex - 1]
: children[children.length - 1];
inspectedElementIndex = store.getIndexOfElementID(nextChildID);
}
}
}
break;
case 'SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE': {
const elementIndicesWithErrorsOrWarnings =
store.getElementsWithErrorsAndWarnings();
if (elementIndicesWithErrorsOrWarnings.length === 0) {
return state;
}
let flatIndex = 0;
if (inspectedElementIndex !== null) {
// Resume from the current position in the list.
// Otherwise step to the previous item, relative to the current selection.
for (
let i = elementIndicesWithErrorsOrWarnings.length - 1;
i >= 0;
i--
) {
const {index} = elementIndicesWithErrorsOrWarnings[i];
if (index >= inspectedElementIndex) {
flatIndex = i;
} else {
break;
}
}
}
let prevEntry;
if (flatIndex === 0) {
prevEntry =
elementIndicesWithErrorsOrWarnings[
elementIndicesWithErrorsOrWarnings.length - 1
];
inspectedElementID = prevEntry.id;
inspectedElementIndex = prevEntry.index;
} else {
prevEntry = elementIndicesWithErrorsOrWarnings[flatIndex - 1];
inspectedElementID = prevEntry.id;
inspectedElementIndex = prevEntry.index;
}
lookupIDForIndex = false;
break;
}
case 'SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE': {
const elementIndicesWithErrorsOrWarnings =
store.getElementsWithErrorsAndWarnings();
if (elementIndicesWithErrorsOrWarnings.length === 0) {
return state;
}
let flatIndex = -1;
if (inspectedElementIndex !== null) {
// Resume from the current position in the list.
// Otherwise step to the next item, relative to the current selection.
for (let i = 0; i < elementIndicesWithErrorsOrWarnings.length; i++) {
const {index} = elementIndicesWithErrorsOrWarnings[i];
if (index <= inspectedElementIndex) {
flatIndex = i;
} else {
break;
}
}
}
let nextEntry;
if (flatIndex >= elementIndicesWithErrorsOrWarnings.length - 1) {
nextEntry = elementIndicesWithErrorsOrWarnings[0];
inspectedElementID = nextEntry.id;
inspectedElementIndex = nextEntry.index;
} else {
nextEntry = elementIndicesWithErrorsOrWarnings[flatIndex + 1];
inspectedElementID = nextEntry.id;
inspectedElementIndex = nextEntry.index;
}
lookupIDForIndex = false;
break;
}
default:
// React can bailout of no-op updates.
return state;
}
}
// Keep selected item ID and index in sync.
if (
lookupIDForIndex &&
inspectedElementIndex !== state.inspectedElementIndex
) {
if (inspectedElementIndex === null) {
inspectedElementID = null;
} else {
inspectedElementID = store.getElementIDAtIndex(
((inspectedElementIndex: any): number),
);
}
}
return {
...state,
numElements,
ownerSubtreeLeafElementID,
inspectedElementIndex,
inspectedElementID,
};
}
function reduceSearchState(store: Store, state: State, action: Action): State {
let {
searchIndex,
searchResults,
searchText,
inspectedElementID,
inspectedElementIndex,
} = state;
const ownerID = state.ownerID;
const prevSearchIndex = searchIndex;
const prevSearchText = searchText;
const numPrevSearchResults = searchResults.length;
2019-04-09 00:06:13 +01:00
// We track explicitly whether search was requested because
// we might want to search even if search index didn't change.
// For example, if you press "next result" on a search with a single
// result but a different current selection, we'll set this to true.
let didRequestSearch = false;
// Search isn't supported when the owner's tree is active.
if (ownerID === null) {
switch (action.type) {
case 'GO_TO_NEXT_SEARCH_RESULT':
if (numPrevSearchResults > 0) {
2019-04-09 00:06:13 +01:00
didRequestSearch = true;
searchIndex =
// $FlowFixMe[unsafe-addition] addition with possible null/undefined value
searchIndex + 1 < numPrevSearchResults ? searchIndex + 1 : 0;
}
break;
case 'GO_TO_PREVIOUS_SEARCH_RESULT':
if (numPrevSearchResults > 0) {
2019-04-09 00:06:13 +01:00
didRequestSearch = true;
searchIndex =
((searchIndex: any): number) > 0
? ((searchIndex: any): number) - 1
: numPrevSearchResults - 1;
}
break;
case 'HANDLE_STORE_MUTATION':
if (searchText !== '') {
const [addedElementIDs, removedElementIDs] =
(action: ACTION_HANDLE_STORE_MUTATION).payload;
removedElementIDs.forEach((parentID, id) => {
// Prune this item from the search results.
const index = searchResults.indexOf(id);
if (index >= 0) {
searchResults = searchResults
.slice(0, index)
.concat(searchResults.slice(index + 1));
// If the results are now empty, also deselect things.
if (searchResults.length === 0) {
searchIndex = null;
} else if (((searchIndex: any): number) >= searchResults.length) {
searchIndex = searchResults.length - 1;
}
}
});
addedElementIDs.forEach(id => {
const element = ((store.getElementByID(id): any): Element);
// It's possible that multiple tree operations will fire before this action has run.
// So it's important to check for elements that may have been added and then removed.
if (element !== null) {
2019-08-13 17:58:03 -07:00
const {displayName} = element;
// Add this item to the search results if it matches.
const regExp = createRegExp(searchText);
if (displayName !== null && regExp.test(displayName)) {
const newElementIndex = ((store.getIndexOfElementID(
2019-08-13 17:58:03 -07:00
id,
): any): number);
let foundMatch = false;
for (let index = 0; index < searchResults.length; index++) {
2019-08-13 21:59:07 -07:00
const resultID = searchResults[index];
if (
newElementIndex <
2019-08-13 21:59:07 -07:00
((store.getIndexOfElementID(resultID): any): number)
) {
foundMatch = true;
searchResults = searchResults
.slice(0, index)
2019-08-13 21:59:07 -07:00
.concat(resultID)
.concat(searchResults.slice(index));
break;
}
}
if (!foundMatch) {
searchResults = searchResults.concat(id);
}
searchIndex = searchIndex === null ? 0 : searchIndex;
}
}
});
}
break;
case 'SET_SEARCH_TEXT':
searchIndex = null;
searchResults = [];
searchText = (action: ACTION_SET_SEARCH_TEXT).payload;
if (searchText !== '') {
const regExp = createRegExp(searchText);
store.roots.forEach(rootID => {
recursivelySearchTree(store, rootID, regExp, searchResults);
});
if (searchResults.length > 0) {
if (prevSearchIndex === null) {
if (inspectedElementIndex !== null) {
searchIndex = getNearestResultIndex(
store,
searchResults,
inspectedElementIndex,
);
} else {
searchIndex = 0;
}
} else {
searchIndex = Math.min(
((prevSearchIndex: any): number),
2019-08-13 17:58:03 -07:00
searchResults.length - 1,
);
}
}
}
break;
default:
// React can bailout of no-op updates.
return state;
}
}
2019-04-10 15:08:13 +01:00
if (searchText !== prevSearchText) {
const newSearchIndex = searchResults.indexOf(inspectedElementID);
2019-04-10 15:25:03 +01:00
if (newSearchIndex === -1) {
2019-04-10 15:08:13 +01:00
// Only move the selection if the new query
// doesn't match the current selection anymore.
didRequestSearch = true;
} else {
// Selected item still matches the new search query.
// Adjust the index to reflect its position in new results.
2019-04-10 15:25:03 +01:00
searchIndex = newSearchIndex;
2019-04-10 15:08:13 +01:00
}
2019-04-09 00:06:13 +01:00
}
if (didRequestSearch && searchIndex !== null) {
inspectedElementID = ((searchResults[searchIndex]: any): number);
inspectedElementIndex = store.getIndexOfElementID(
((inspectedElementID: any): number),
);
}
return {
...state,
inspectedElementID,
inspectedElementIndex,
searchIndex,
searchResults,
searchText,
};
}
function reduceOwnersState(store: Store, state: State, action: Action): State {
let {
numElements,
ownerID,
ownerFlatTree,
inspectedElementID,
inspectedElementIndex,
} = state;
const {searchIndex, searchResults, searchText} = state;
let prevInspectedElementIndex = inspectedElementIndex;
switch (action.type) {
case 'HANDLE_STORE_MUTATION':
if (ownerID !== null) {
2019-05-10 08:07:27 -07:00
if (!store.containsElement(ownerID)) {
ownerID = null;
ownerFlatTree = null;
prevInspectedElementIndex = null;
2019-05-10 08:07:27 -07:00
} else {
ownerFlatTree = store.getOwnersListForElement(ownerID);
if (inspectedElementID !== null) {
2019-05-10 08:07:27 -07:00
// Mutation might have caused the index of this ID to shift.
prevInspectedElementIndex = ownerFlatTree.findIndex(
element => element.id === inspectedElementID,
2019-05-10 08:07:27 -07:00
);
}
2019-04-10 17:28:36 +01:00
}
} else {
if (inspectedElementID !== null) {
2019-04-10 17:28:36 +01:00
// Mutation might have caused the index of this ID to shift.
inspectedElementIndex = store.getIndexOfElementID(inspectedElementID);
2019-04-10 17:28:36 +01:00
}
}
if (inspectedElementIndex === -1) {
2019-04-10 17:28:36 +01:00
// If we couldn't find this ID after mutation, unselect it.
inspectedElementIndex = null;
inspectedElementID = null;
}
break;
case 'RESET_OWNER_STACK':
ownerID = null;
ownerFlatTree = null;
inspectedElementIndex =
inspectedElementID !== null
? store.getIndexOfElementID(inspectedElementID)
: null;
break;
case 'SELECT_ELEMENT_AT_INDEX':
if (ownerFlatTree !== null) {
inspectedElementIndex = (action: ACTION_SELECT_ELEMENT_AT_INDEX)
.payload;
}
break;
case 'SELECT_ELEMENT_BY_ID':
if (ownerFlatTree !== null) {
const payload = (action: ACTION_SELECT_ELEMENT_BY_ID).payload;
if (payload === null) {
inspectedElementIndex = null;
} else {
inspectedElementIndex = ownerFlatTree.findIndex(
2019-08-13 17:58:03 -07:00
element => element.id === payload,
);
// If the selected element is outside of the current owners list,
// exit the list and select the element in the main tree.
// This supports features like toggling Suspense.
if (inspectedElementIndex !== null && inspectedElementIndex < 0) {
ownerID = null;
ownerFlatTree = null;
inspectedElementIndex = store.getIndexOfElementID(payload);
}
}
}
break;
case 'SELECT_NEXT_ELEMENT_IN_TREE':
if (ownerFlatTree !== null && ownerFlatTree.length > 0) {
if (inspectedElementIndex === null) {
inspectedElementIndex = 0;
} else if (inspectedElementIndex + 1 < ownerFlatTree.length) {
inspectedElementIndex++;
}
}
break;
case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
if (ownerFlatTree !== null && ownerFlatTree.length > 0) {
if (inspectedElementIndex !== null && inspectedElementIndex > 0) {
inspectedElementIndex--;
}
}
break;
case 'SELECT_OWNER':
// If the Store doesn't have any owners metadata, don't drill into an empty stack.
// This is a confusing user experience.
if (store.hasOwnerMetadata) {
ownerID = (action: ACTION_SELECT_OWNER).payload;
ownerFlatTree = store.getOwnersListForElement(ownerID);
// Always force reset selection to be the top of the new owner tree.
inspectedElementIndex = 0;
prevInspectedElementIndex = null;
}
break;
default:
// React can bailout of no-op updates.
return state;
}
// Changes in the selected owner require re-calculating the owners tree.
if (
ownerFlatTree !== state.ownerFlatTree ||
action.type === 'HANDLE_STORE_MUTATION'
) {
if (ownerFlatTree === null) {
numElements = store.numElements;
} else {
numElements = ownerFlatTree.length;
}
}
// Keep selected item ID and index in sync.
if (inspectedElementIndex !== prevInspectedElementIndex) {
if (inspectedElementIndex === null) {
inspectedElementID = null;
} else {
if (ownerFlatTree !== null) {
inspectedElementID = ownerFlatTree[inspectedElementIndex].id;
}
}
}
return {
...state,
numElements,
searchIndex,
searchResults,
searchText,
ownerID,
ownerFlatTree,
inspectedElementID,
inspectedElementIndex,
};
}
function reduceActivityState(
store: Store,
state: State,
action: Action,
): State {
switch (action.type) {
case 'HANDLE_STORE_MUTATION':
let {activityID} = state;
const [, , activitySliceIDChange] = action.payload;
const activities = store.getActivities();
if (activitySliceIDChange === 0 && activityID !== null) {
activityID = null;
} else if (
activitySliceIDChange !== null &&
activitySliceIDChange !== activityID
) {
activityID = activitySliceIDChange;
}
if (activityID !== state.activityID || activities !== state.activities) {
return {
...state,
activityID,
activities,
};
}
}
return state;
}
type Props = {
2019-05-09 14:21:22 -07:00
children: React$Node,
// Used for automated testing
defaultOwnerID?: ?number,
defaultInspectedElementID?: ?number,
defaultInspectedElementIndex?: ?number,
};
function getInitialState({
defaultOwnerID,
defaultInspectedElementID,
defaultInspectedElementIndex,
store,
}: {
defaultOwnerID?: ?number,
defaultInspectedElementID?: ?number,
defaultInspectedElementIndex?: ?number,
store: Store,
}): State {
return {
// Tree
numElements: store.numElements,
ownerSubtreeLeafElementID: null,
// Search
searchIndex: null,
searchResults: [],
searchText: '',
// Owners
ownerID: defaultOwnerID == null ? null : defaultOwnerID,
ownerFlatTree: null,
// Activity slice
activityID: null,
activities: store.getActivities(),
// Inspection element panel
inspectedElementID:
defaultInspectedElementID != null
? defaultInspectedElementID
: store.lastSelectedHostInstanceElementId,
inspectedElementIndex:
defaultInspectedElementIndex != null
? defaultInspectedElementIndex
: store.lastSelectedHostInstanceElementId
? store.getIndexOfElementID(store.lastSelectedHostInstanceElementId)
: null,
};
}
// TODO Remove TreeContextController wrapper element once global Context.write API exists.
function TreeContextController({
children,
defaultOwnerID,
defaultInspectedElementID,
defaultInspectedElementIndex,
}: Props): React.Node {
const store = useContext(StoreContext);
2019-02-16 08:29:26 -08:00
const initialRevision = useMemo(() => store.revision, [store]);
// This reducer is created inline because it needs access to the Store.
// The store is mutable, but the Store itself is global and lives for the lifetime of the DevTools,
// so it's okay for the reducer to have an empty dependencies array.
const reducer = useMemo(
() =>
(state: State, action: Action): State => {
const {type} = action;
switch (type) {
case 'GO_TO_NEXT_SEARCH_RESULT':
case 'GO_TO_PREVIOUS_SEARCH_RESULT':
case 'HANDLE_STORE_MUTATION':
case 'RESET_OWNER_STACK':
case 'SELECT_ELEMENT_AT_INDEX':
case 'SELECT_ELEMENT_BY_ID':
case 'SELECT_CHILD_ELEMENT_IN_TREE':
case 'SELECT_NEXT_ELEMENT_IN_TREE':
case 'SELECT_NEXT_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE':
case 'SELECT_NEXT_SIBLING_IN_TREE':
case 'SELECT_OWNER_LIST_NEXT_ELEMENT_IN_TREE':
case 'SELECT_OWNER_LIST_PREVIOUS_ELEMENT_IN_TREE':
case 'SELECT_PARENT_ELEMENT_IN_TREE':
case 'SELECT_PREVIOUS_ELEMENT_IN_TREE':
case 'SELECT_PREVIOUS_ELEMENT_WITH_ERROR_OR_WARNING_IN_TREE':
case 'SELECT_PREVIOUS_SIBLING_IN_TREE':
case 'SELECT_OWNER':
case 'SET_SEARCH_TEXT':
state = reduceTreeState(store, state, action);
state = reduceSearchState(store, state, action);
state = reduceOwnersState(store, state, action);
state = reduceActivityState(store, state, action);
// TODO(hoxyq): review
// If the selected ID is in a collapsed subtree, reset the selected index to null.
// We'll know the correct index after the layout effect will toggle the tree,
// and the store tree is mutated to account for that.
if (
state.inspectedElementID !== null &&
store.isInsideCollapsedSubTree(state.inspectedElementID)
) {
return {
...state,
inspectedElementIndex: null,
};
}
return state;
default:
throw new Error(`Unrecognized action "${type}"`);
}
},
2019-08-13 17:58:03 -07:00
[store],
);
const [state, dispatch] = useReducer(
reducer,
{
defaultOwnerID,
defaultInspectedElementID,
defaultInspectedElementIndex,
store,
},
getInitialState,
);
const transitionDispatch = useMemo(
() => (action: Action) =>
startTransition(() => {
dispatch(action);
}),
DevTools: refactor NativeStyleEditor, don't use custom cache implementation (#32298) We have this really old (5+ years) feature for inspecting native styles of React Native Host components. We also have a custom Cache implementation in React DevTools, which was forked from React at some point. We know that this should be removed, but it spans through critical parts of the application, like fetching and caching inspected element. Before this PR, this was also used for caching native style and layouts of RN Host components. This approach is out of date, and was based on the presence of Suspense boundary around inspected element View, which we have removed to speed up element inspection - https://github.com/facebook/react/pull/30555. Looks like I've introduced a regression in https://github.com/facebook/react/pull/31956: - Custom Cache implementation will throw thenables and suspend. - Because of this, some descendant Suspense boundaries will not resolve for a long time, and React will throw an error https://react.dev/errors/482. I've switched from a usage of this custom Cache implementation to a naive fetching in effect and keeping the layout and style in a local state of a Context, which will be propagated downwards. The race should be impossible, this is guaranteed by the mechanism for queueing messages through microtasks queue. The only downside is the UI. If you quickly switch between 2 elements, and one of them has native style, while the other doesn't, UI will feel jumpy. We can address this later with a Suspense boundary, if needed.
2025-02-05 12:52:48 +00:00
[dispatch],
);
2019-02-16 08:29:26 -08:00
// Listen for host element selections.
useEffect(() => {
const handler = (id: Element['id']) =>
transitionDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
store.addListener('hostInstanceSelected', handler);
return () => store.removeListener('hostInstanceSelected', handler);
}, [store, transitionDispatch]);
2019-02-16 08:29:26 -08:00
// If a newly-selected search result or inspection selection is inside of a collapsed subtree, auto expand it.
// This needs to be a layout effect to avoid temporarily flashing an incorrect selection.
const prevInspectedElementID = useRef<number | null>(null);
useLayoutEffect(() => {
if (state.inspectedElementID !== prevInspectedElementID.current) {
prevInspectedElementID.current = state.inspectedElementID;
if (state.inspectedElementID !== null) {
const element = store.getElementByID(state.inspectedElementID);
if (element !== null && element.parentID > 0) {
store.toggleIsCollapsed(element.parentID, false);
}
}
}
}, [state.inspectedElementID, store]);
// Mutations to the underlying tree may impact this context (e.g. search results, selection state).
useEffect(() => {
const handleStoreMutated = ([
addedElementIDs,
removedElementIDs,
activitySliceIDChange,
]: [Array<number>, Map<number, number>, null | Element['id']]) => {
dispatch({
type: 'HANDLE_STORE_MUTATION',
payload: [addedElementIDs, removedElementIDs, activitySliceIDChange],
});
};
// Since this is a passive effect, the tree may have been mutated before our initial subscription.
if (store.revision !== initialRevision) {
// At the moment, we can treat this as a mutation.
// We don't know which Elements were newly added/removed, but that should be okay in this case.
// It would only impact the search state, which is unlikely to exist yet at this point.
dispatch({
type: 'HANDLE_STORE_MUTATION',
payload: [[], new Map(), null],
});
}
store.addListener('mutated', handleStoreMutated);
return () => store.removeListener('mutated', handleStoreMutated);
}, [dispatch, initialRevision, store]);
return (
<TreeStateContext.Provider value={state}>
<TreeDispatcherContext.Provider value={transitionDispatch}>
{children}
</TreeDispatcherContext.Provider>
</TreeStateContext.Provider>
);
}
function recursivelySearchTree(
store: Store,
elementID: number,
regExp: RegExp,
2019-08-13 17:58:03 -07:00
searchResults: Array<number>,
): void {
const element = store.getElementByID(elementID);
if (element == null) {
return;
}
const {
children,
displayName,
hocDisplayNames,
compiledWithForget,
key,
nameProp,
} = element;
if (displayName != null && regExp.test(displayName) === true) {
searchResults.push(elementID);
} else if (
hocDisplayNames != null &&
hocDisplayNames.length > 0 &&
hocDisplayNames.some(name => regExp.test(name)) === true
) {
searchResults.push(elementID);
} else if (compiledWithForget && regExp.test('Forget')) {
searchResults.push(elementID);
} else if (typeof key === 'string' && regExp.test(key)) {
searchResults.push(elementID);
} else if (typeof nameProp === 'string' && regExp.test(nameProp)) {
searchResults.push(elementID);
}
children.forEach(childID =>
2019-08-13 17:58:03 -07:00
recursivelySearchTree(store, childID, regExp, searchResults),
);
}
function getNearestResultIndex(
store: Store,
searchResults: Array<number>,
inspectedElementIndex: number,
): number {
const index = searchResults.findIndex(id => {
2019-08-13 21:59:07 -07:00
const innerIndex = store.getIndexOfElementID(id);
return innerIndex !== null && innerIndex >= inspectedElementIndex;
});
return index === -1 ? 0 : index;
}
2019-08-13 17:58:03 -07:00
export {TreeDispatcherContext, TreeStateContext, TreeContextController};