mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
Merge pull request #116 from bvaughn/issues/105
Add collapse/toggle UI to Tree
This commit is contained in:
+1
-1
@@ -104,7 +104,7 @@
|
||||
"react-dom": "^16.8.4",
|
||||
"react-is": "^16.8.4",
|
||||
"react-virtualized-auto-sizer": "^1.0.2",
|
||||
"react-window": "^1.5.1",
|
||||
"react-window": "^1.7.2",
|
||||
"scheduler": "^0.13",
|
||||
"semver": "^5.5.1",
|
||||
"style-loader": "^0.23.1",
|
||||
|
||||
+51
-11
@@ -71,10 +71,6 @@ export default class Store extends EventEmitter {
|
||||
// When profiling is in progress, operations are stored so that we can later reconstruct past commit trees.
|
||||
_isProfiling: boolean = false;
|
||||
|
||||
// Total number of visible elements (within all roots).
|
||||
// Used for windowing purposes.
|
||||
_numElements: number = 0;
|
||||
|
||||
// Suspense cache for reading profilign data.
|
||||
_profilingCache: ProfilingCache;
|
||||
|
||||
@@ -112,6 +108,10 @@ export default class Store extends EventEmitter {
|
||||
_supportsProfiling: boolean = false;
|
||||
_supportsReloadAndProfile: boolean = false;
|
||||
|
||||
// Total number of visible elements (within all roots).
|
||||
// Used for windowing purposes.
|
||||
_weightAcrossRoots: number = 0;
|
||||
|
||||
constructor(bridge: Bridge, config?: Config) {
|
||||
super();
|
||||
|
||||
@@ -198,7 +198,7 @@ export default class Store extends EventEmitter {
|
||||
}
|
||||
|
||||
get numElements(): number {
|
||||
return this._numElements;
|
||||
return this._weightAcrossRoots;
|
||||
}
|
||||
|
||||
get profilingCache(): ProfilingCache {
|
||||
@@ -287,16 +287,18 @@ export default class Store extends EventEmitter {
|
||||
let currentElement = ((this._idToElement.get(firstChildID): any): Element);
|
||||
let currentWeight = rootWeight;
|
||||
while (index !== currentWeight) {
|
||||
for (let i = 0; i < currentElement.children.length; i++) {
|
||||
const numChildren = currentElement.children.length;
|
||||
for (let i = 0; i < numChildren; i++) {
|
||||
const childID = currentElement.children[i];
|
||||
const child = ((this._idToElement.get(childID): any): Element);
|
||||
const { weight } = child;
|
||||
if (index <= currentWeight + weight) {
|
||||
const childWeight = child.isCollapsed ? 1 : child.weight;
|
||||
|
||||
if (index <= currentWeight + childWeight) {
|
||||
currentWeight++;
|
||||
currentElement = child;
|
||||
break;
|
||||
} else {
|
||||
currentWeight += weight;
|
||||
currentWeight += childWeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,7 +353,7 @@ export default class Store extends EventEmitter {
|
||||
break;
|
||||
}
|
||||
const child = ((this._idToElement.get(childID): any): Element);
|
||||
index += child.weight;
|
||||
index += child.isCollapsed ? 1 : child.weight;
|
||||
}
|
||||
|
||||
previousID = current.id;
|
||||
@@ -420,6 +422,29 @@ export default class Store extends EventEmitter {
|
||||
this.emit('isProfiling');
|
||||
}
|
||||
|
||||
toggleIsCollapsed(id: number, isCollapsed: boolean): void {
|
||||
const element = this.getElementByID(id);
|
||||
if (element !== null) {
|
||||
const oldWeight = element.isCollapsed ? 1 : element.weight;
|
||||
element.isCollapsed = isCollapsed;
|
||||
const newWeight = element.isCollapsed ? 1 : element.weight;
|
||||
const weightDelta = newWeight - oldWeight;
|
||||
|
||||
this._weightAcrossRoots += weightDelta;
|
||||
|
||||
let parentElement = this._idToElement.get(element.parentID);
|
||||
while (parentElement != null) {
|
||||
parentElement.weight += weightDelta;
|
||||
parentElement = this._idToElement.get(parentElement.parentID);
|
||||
}
|
||||
|
||||
// The Tree context's search reducer expects an explicit list of ids for nodes that were added or removed.
|
||||
// In this case, we can pass it empty arrays since nodes in a collapsed tree are still there (just hidden).
|
||||
// Updating the selected search index later may require auto-expanding a collapsed subtree though.
|
||||
this.emit('mutated', [[], []]);
|
||||
}
|
||||
}
|
||||
|
||||
_captureScreenshot = throttle(
|
||||
memoize((commitIndex: number) => {
|
||||
this._bridge.send('captureScreenshot', { commitIndex });
|
||||
@@ -518,6 +543,7 @@ export default class Store extends EventEmitter {
|
||||
depth: -1,
|
||||
displayName: null,
|
||||
id,
|
||||
isCollapsed: false,
|
||||
key: null,
|
||||
ownerID: 0,
|
||||
parentID: 0,
|
||||
@@ -566,6 +592,7 @@ export default class Store extends EventEmitter {
|
||||
depth: parentElement.depth + 1,
|
||||
displayName,
|
||||
id,
|
||||
isCollapsed: false,
|
||||
key,
|
||||
ownerID,
|
||||
parentID: parentElement.id,
|
||||
@@ -715,14 +742,27 @@ export default class Store extends EventEmitter {
|
||||
throw Error(`Unsupported Bridge operation ${operation}`);
|
||||
}
|
||||
|
||||
this._numElements += weightDelta;
|
||||
let isInsideCollapsedSubTree = false;
|
||||
|
||||
while (parentElement != null) {
|
||||
parentElement.weight += weightDelta;
|
||||
|
||||
// Additions and deletions within a collapsed subtree should not bubble beyond the collapsed parent.
|
||||
// Their weight will bubble up when the parent is expanded.
|
||||
if (parentElement.isCollapsed) {
|
||||
isInsideCollapsedSubTree = true;
|
||||
break;
|
||||
}
|
||||
|
||||
parentElement = ((this._idToElement.get(
|
||||
parentElement.parentID
|
||||
): any): Element);
|
||||
}
|
||||
|
||||
// Additions and deletions within a collapsed subtree should not affect the overall number of elements.
|
||||
if (!isInsideCollapsedSubTree) {
|
||||
this._weightAcrossRoots += weightDelta;
|
||||
}
|
||||
}
|
||||
|
||||
this._revision++;
|
||||
|
||||
@@ -7,8 +7,10 @@ export type IconType =
|
||||
| 'back'
|
||||
| 'cancel'
|
||||
| 'close'
|
||||
| 'collapsed'
|
||||
| 'copy'
|
||||
| 'down'
|
||||
| 'expanded'
|
||||
| 'export'
|
||||
| 'filter'
|
||||
| 'import'
|
||||
@@ -40,12 +42,18 @@ export default function ButtonIcon({ type }: Props) {
|
||||
case 'close':
|
||||
pathData = PATH_CLOSE;
|
||||
break;
|
||||
case 'collapsed':
|
||||
pathData = PATH_COLLAPSED;
|
||||
break;
|
||||
case 'copy':
|
||||
pathData = PATH_COPY;
|
||||
break;
|
||||
case 'down':
|
||||
pathData = PATH_DOWN;
|
||||
break;
|
||||
case 'expanded':
|
||||
pathData = PATH_EXPANDED;
|
||||
break;
|
||||
case 'export':
|
||||
pathData = PATH_EXPORT;
|
||||
break;
|
||||
@@ -121,6 +129,8 @@ const PATH_CANCEL = `
|
||||
const PATH_CLOSE =
|
||||
'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z';
|
||||
|
||||
const PATH_COLLAPSED = 'M10 17l5-5-5-5v10z';
|
||||
|
||||
const PATH_COPY = `
|
||||
M3 13h2v-2H3v2zm0 4h2v-2H3v2zm2 4v-2H3a2 2 0 0 0 2 2zM3 9h2V7H3v2zm12 12h2v-2h-2v2zm4-18H9a2 2 0 0 0-2
|
||||
2v10a2 2 0 0 0 2 2h10c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 12H9V5h10v10zm-8 6h2v-2h-2v2zm-4 0h2v-2H7v2z
|
||||
@@ -128,6 +138,8 @@ const PATH_COPY = `
|
||||
|
||||
const PATH_DOWN = 'M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z';
|
||||
|
||||
const PATH_EXPANDED = 'M7 10l5 5 5-5z';
|
||||
|
||||
const PATH_EXPORT = 'M15.82,2.14v7H21l-9,9L3,9.18H8.18v-7ZM3,20.13H21v1.73H3Z';
|
||||
|
||||
const PATH_FILTER = 'M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z';
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
|
||||
--color-expand-collapse-toggle: var(--color-dim);
|
||||
}
|
||||
.Element:hover {
|
||||
background-color: var(--color-hover-background);
|
||||
@@ -21,6 +23,7 @@
|
||||
--color-jsx-arrow-brackets: var(--color-jsx-arrow-brackets-inverted);
|
||||
--color-attribute-name: var(--color-hover-background);
|
||||
--color-attribute-value: var(--color-component-name-inverted);
|
||||
--color-expand-collapse-toggle: var(--color-component-name-inverted);
|
||||
}
|
||||
|
||||
.DollarR {
|
||||
@@ -55,3 +58,10 @@
|
||||
.CurrentHighlight {
|
||||
background-color: var(--color-search-match-current);
|
||||
}
|
||||
|
||||
.ExpandCollapseToggle {
|
||||
display: inline-flex;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: var(--color-expand-collapse-toggle);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import React, {
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { ElementTypeClass, ElementTypeFunction } from 'src/devtools/types';
|
||||
import Store from 'src/devtools/store';
|
||||
import ButtonIcon from '../ButtonIcon';
|
||||
import { createRegExp } from '../utils';
|
||||
import { TreeContext } from './TreeContext';
|
||||
import { BridgeContext, StoreContext } from '../context';
|
||||
@@ -28,6 +30,7 @@ export default function ElementView({ data, index, style }: Props) {
|
||||
const {
|
||||
baseDepth,
|
||||
getElementAtIndex,
|
||||
ownerStack,
|
||||
selectOwner,
|
||||
selectedElementID,
|
||||
selectElementByID,
|
||||
@@ -75,8 +78,6 @@ export default function ElementView({ data, index, style }: Props) {
|
||||
}
|
||||
}, [id, isSelected, lastScrolledIDRef]);
|
||||
|
||||
// TODO Add click and key handlers for toggling element open/close state.
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
({ metaKey }) => {
|
||||
if (id !== null) {
|
||||
@@ -114,8 +115,6 @@ export default function ElementView({ data, index, style }: Props) {
|
||||
const showDollarR =
|
||||
isSelected && (type === ElementTypeClass || type === ElementTypeFunction);
|
||||
|
||||
// TODO styles.SelectedElement is 100% width but it doesn't take horizontal overflow into account.
|
||||
|
||||
return (
|
||||
<div
|
||||
className={isSelected ? styles.SelectedElement : styles.Element}
|
||||
@@ -138,6 +137,9 @@ export default function ElementView({ data, index, style }: Props) {
|
||||
marginBottom: `-${style.height}px`,
|
||||
}}
|
||||
>
|
||||
{ownerStack.length === 0 ? (
|
||||
<ExpandCollapseToggle element={element} store={store} />
|
||||
) : null}
|
||||
<span className={styles.Component} ref={ref}>
|
||||
<DisplayName displayName={displayName} id={((id: any): number)} />
|
||||
{key && (
|
||||
@@ -152,6 +154,45 @@ export default function ElementView({ data, index, style }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent double clicks on toggle from drilling into the owner list.
|
||||
const swallowDoubleClick = event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
type ExpandCollapseToggleProps = {|
|
||||
element: Element,
|
||||
store: Store,
|
||||
|};
|
||||
|
||||
function ExpandCollapseToggle({ element, store }: ExpandCollapseToggleProps) {
|
||||
const { children, id, isCollapsed } = element;
|
||||
|
||||
const toggleCollapsed = useCallback(
|
||||
event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
store.toggleIsCollapsed(id, !isCollapsed);
|
||||
},
|
||||
[id, isCollapsed, store]
|
||||
);
|
||||
|
||||
if (children.length === 0) {
|
||||
return <div className={styles.ExpandCollapseToggle} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.ExpandCollapseToggle}
|
||||
onClick={toggleCollapsed}
|
||||
onDoubleClick={swallowDoubleClick}
|
||||
>
|
||||
<ButtonIcon type={isCollapsed ? 'collapsed' : 'expanded'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DisplayNameProps = {|
|
||||
displayName: string | null,
|
||||
id: number,
|
||||
|
||||
@@ -12,7 +12,7 @@ import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
import { TreeContext } from './TreeContext';
|
||||
import { SettingsContext } from '../Settings/SettingsContext';
|
||||
import { BridgeContext } from '../context';
|
||||
import { BridgeContext, StoreContext } from '../context';
|
||||
import ElementView from './Element';
|
||||
import InspectHostNodesToggle from './InspectHostNodesToggle';
|
||||
import OwnersStack from './OwnersStack';
|
||||
@@ -37,12 +37,14 @@ export default function Tree(props: Props) {
|
||||
getElementAtIndex,
|
||||
numElements,
|
||||
ownerStack,
|
||||
selectedElementID,
|
||||
selectedElementIndex,
|
||||
selectNextElementInTree,
|
||||
selectParentElementInTree,
|
||||
selectPreviousElementInTree,
|
||||
} = useContext(TreeContext);
|
||||
const bridge = useContext(BridgeContext);
|
||||
const store = useContext(StoreContext);
|
||||
// $FlowFixMe https://github.com/facebook/flow/issues/7341
|
||||
const listRef = useRef<FixedSizeList<ItemData> | null>(null);
|
||||
const treeRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -77,6 +79,8 @@ export default function Tree(props: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
let element;
|
||||
|
||||
// eslint-disable-next-line default-case
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
@@ -84,10 +88,34 @@ export default function Tree(props: Props) {
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
selectParentElementInTree();
|
||||
element =
|
||||
selectedElementID !== null
|
||||
? store.getElementByID(selectedElementID)
|
||||
: null;
|
||||
if (
|
||||
element !== null &&
|
||||
element.children.length > 0 &&
|
||||
!element.isCollapsed
|
||||
) {
|
||||
store.toggleIsCollapsed(element.id, true);
|
||||
} else {
|
||||
selectParentElementInTree();
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
selectNextElementInTree();
|
||||
element =
|
||||
selectedElementID !== null
|
||||
? store.getElementByID(selectedElementID)
|
||||
: null;
|
||||
if (
|
||||
element !== null &&
|
||||
element.children.length > 0 &&
|
||||
element.isCollapsed
|
||||
) {
|
||||
store.toggleIsCollapsed(element.id, false);
|
||||
} else {
|
||||
selectNextElementInTree();
|
||||
}
|
||||
event.preventDefault();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
@@ -107,9 +135,11 @@ export default function Tree(props: Props) {
|
||||
ownerDocument.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [
|
||||
selectedElementID,
|
||||
selectNextElementInTree,
|
||||
selectParentElementInTree,
|
||||
selectPreviousElementInTree,
|
||||
store,
|
||||
]);
|
||||
|
||||
// Let react-window know to re-render any time the underlying tree data changes.
|
||||
|
||||
@@ -22,8 +22,10 @@ import React, {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { createRegExp } from '../utils';
|
||||
import { BridgeContext, StoreContext } from '../context';
|
||||
@@ -109,6 +111,8 @@ function reduceTreeState(store: Store, state: State, action: Action): State {
|
||||
selectedElementID,
|
||||
} = state;
|
||||
|
||||
let lookupIDForIndex = true;
|
||||
|
||||
// Base tree should ignore selected element changes when the owner's tree is active.
|
||||
if (ownerStack.length === 0) {
|
||||
switch (type) {
|
||||
@@ -127,6 +131,11 @@ function reduceTreeState(store: Store, state: State, action: Action): State {
|
||||
selectedElementIndex = ((payload: any): number | null);
|
||||
break;
|
||||
case 'SELECT_ELEMENT_BY_ID':
|
||||
// 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;
|
||||
|
||||
selectedElementID = payload;
|
||||
selectedElementIndex =
|
||||
payload === null
|
||||
? null
|
||||
@@ -167,7 +176,7 @@ function reduceTreeState(store: Store, state: State, action: Action): State {
|
||||
}
|
||||
|
||||
// Keep selected item ID and index in sync.
|
||||
if (selectedElementIndex !== state.selectedElementIndex) {
|
||||
if (lookupIDForIndex && selectedElementIndex !== state.selectedElementIndex) {
|
||||
if (selectedElementIndex === null) {
|
||||
selectedElementID = null;
|
||||
} else {
|
||||
@@ -686,6 +695,25 @@ function TreeContextController({ children, viewElementSource }: Props) {
|
||||
return () => bridge.removeListener('selectFiber', handleSelectFiber);
|
||||
}, [bridge, dispatch]);
|
||||
|
||||
// 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 prevSelectedElementID = useRef<number | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (state.selectedElementID !== prevSelectedElementID.current) {
|
||||
prevSelectedElementID.current = state.selectedElementID;
|
||||
|
||||
if (state.selectedElementID !== null) {
|
||||
let element = store.getElementByID(state.selectedElementID);
|
||||
while (element !== null && element.parentID > 0) {
|
||||
element = ((store.getElementByID(element.parentID): any): Element);
|
||||
if (element.isCollapsed) {
|
||||
store.toggleIsCollapsed(element.id, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [state.selectedElementID, store]);
|
||||
|
||||
// Mutations to the underlying tree may impact this context (e.g. search results, selection state).
|
||||
useEffect(() => {
|
||||
const handleStoreMutated = ([
|
||||
|
||||
@@ -14,6 +14,9 @@ export type Element = {|
|
||||
displayName: string | null,
|
||||
key: number | string | null,
|
||||
|
||||
// Should the elements children be visible in the tree?
|
||||
isCollapsed: boolean,
|
||||
|
||||
// Owner (if available)
|
||||
ownerID: number,
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ type Props = {|
|
||||
|
||||
function ProfilerContextController({ children }: Props) {
|
||||
const store = useContext(StoreContext);
|
||||
const { selectElementAtIndex, selectedElementID } = useContext(TreeContext);
|
||||
const { selectElementByID, selectedElementID } = useContext(TreeContext);
|
||||
|
||||
const subscription = useMemo(
|
||||
() => ({
|
||||
@@ -152,13 +152,14 @@ function ProfilerContextController({ children }: Props) {
|
||||
selectFiberID(id);
|
||||
selectFiberName(name);
|
||||
if (id !== null) {
|
||||
const index = store.getIndexOfElementID(id);
|
||||
if (index !== null) {
|
||||
selectElementAtIndex(index);
|
||||
// If this element is still in the store, then select it in the Components tab as well.
|
||||
const element = store.getElementByID(id);
|
||||
if (element !== null) {
|
||||
selectElementByID(id);
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectElementAtIndex, selectFiberID, selectFiberName, store]
|
||||
[selectElementByID, selectFiberID, selectFiberName, store]
|
||||
);
|
||||
|
||||
if (isProfiling) {
|
||||
|
||||
@@ -7323,6 +7323,11 @@ mem@^4.0.0:
|
||||
mimic-fn "^1.0.0"
|
||||
p-is-promise "^2.0.0"
|
||||
|
||||
"memoize-one@>=3.1.1 <6":
|
||||
version "5.0.4"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.4.tgz#005928aced5c43d890a4dfab18ca908b0ec92cbc"
|
||||
integrity sha512-P0z5IeAH6qHHGkJIXWw0xC2HNEgkx/9uWWBQw64FJj3/ol14VYdfVGWWr0fXfjhhv3TKVIqUq65os6O4GUNksA==
|
||||
|
||||
memoize-one@^3.1.1:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-3.1.1.tgz#ef609811e3bc28970eac2884eece64d167830d17"
|
||||
@@ -8799,13 +8804,13 @@ react-virtualized-auto-sizer@^1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz#a61dd4f756458bbf63bd895a92379f9b70f803bd"
|
||||
integrity sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg==
|
||||
|
||||
react-window@^1.5.1:
|
||||
version "1.5.2"
|
||||
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.5.2.tgz#39dbfd7aa47c1d80b37928f3269f7112a5900294"
|
||||
integrity sha512-xGGKS9bR2y/XbkyQBk0qRO3y1mdENXVfksjAIn1fcbZ9qwiML52HryKfCaK50c1bIX3f0xqPgu8Q6FIxHKKbag==
|
||||
react-window@^1.7.2:
|
||||
version "1.7.2"
|
||||
resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.7.2.tgz#2e1528d5b9991e863302bfe74cb52d0ff7082e78"
|
||||
integrity sha512-GK1gxSeGPLBDSQhPmYhCYrtf5MkGK8rwVjeyPgxZLvLRw0wvyzKZPMc/jfemiGNGfuJyW3kx1z6QR9uK7r2XdA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.0.0"
|
||||
memoize-one "^3.1.1"
|
||||
memoize-one ">=3.1.1 <6"
|
||||
|
||||
react@^16.8.4:
|
||||
version "16.8.4"
|
||||
|
||||
Reference in New Issue
Block a user