Merge pull request #245 from bvaughn/custom-filtering

Add configurable component filters
This commit is contained in:
Brian Vaughn
2019-05-01 17:52:27 -07:00
committed by GitHub
35 changed files with 1506 additions and 285 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ For example, adding a function component `<Foo>` with an id 2:
[
1, // add operation
2, // fiber id
2, // ElementTypeFunction
1, // ElementTypeClass
1, // parent id
0, // owner id
3, // encoded display name size
+1
View File
@@ -29,6 +29,7 @@ module.exports = api => {
plugins: [
['@babel/plugin-transform-flow-strip-types'],
['@babel/plugin-proposal-class-properties', { loose: false }],
['@babel/plugin-transform-react-jsx-source'],
],
presets: [
['@babel/preset-env', { targets }],
+1
View File
@@ -70,6 +70,7 @@
"@babel/core": "^7.1.6",
"@babel/plugin-proposal-class-properties": "^7.1.0",
"@babel/plugin-transform-flow-strip-types": "^7.1.6",
"@babel/plugin-transform-react-jsx-source": "^7.2.0",
"@babel/preset-env": "^7.1.6",
"@babel/preset-flow": "^7.0.0",
"@babel/preset-react": "^7.0.0",
@@ -0,0 +1,98 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Store component filters should filter by display name: 1: mount 1`] = `
[root]
▾ <Foo>
<Text>
▾ <Bar>
<Text>
▾ <Baz>
<Text>
`;
exports[`Store component filters should filter by display name: 2: filter "Foo" 1`] = `
[root]
<Text>
▾ <Bar>
<Text>
▾ <Baz>
<Text>
`;
exports[`Store component filters should filter by display name: 3: filter "Ba" 1`] = `
[root]
▾ <Foo>
<Text>
<Text>
<Text>
`;
exports[`Store component filters should filter by display name: 4: filter "B.z" 1`] = `
[root]
▾ <Foo>
<Text>
▾ <Bar>
<Text>
<Text>
`;
exports[`Store component filters should filter by path: 1: mount 1`] = `
[root]
▾ <Component>
<div>
`;
exports[`Store component filters should filter by path: 2: hide all components declared within this test filed 1`] = `[root]`;
exports[`Store component filters should filter by path: 3: hide components in a made up fake path 1`] = `
[root]
▾ <Component>
<div>
`;
exports[`Store component filters should ignore invalid ElementTypeRoot filter: 1: mount 1`] = `
[root]
▾ <Root>
<div>
`;
exports[`Store component filters should ignore invalid ElementTypeRoot filter: 2: add invalid filter 1`] = `
[root]
▾ <Root>
<div>
`;
exports[`Store component filters should support filtering by element type: 1: mount 1`] = `
[root]
▾ <Root>
▾ <div>
▾ <Component>
<div>
`;
exports[`Store component filters should support filtering by element type: 2: hide host components 1`] = `
[root]
▾ <Root>
<Component>
`;
exports[`Store component filters should support filtering by element type: 3: hide class components 1`] = `
[root]
▾ <div>
▾ <Component>
<div>
`;
exports[`Store component filters should support filtering by element type: 4: hide class and function components 1`] = `
[root]
▾ <div>
<div>
`;
exports[`Store component filters should support filtering by element type: 5: disable all filters 1`] = `
[root]
▾ <Root>
▾ <div>
▾ <Component>
<div>
`;
+198
View File
@@ -0,0 +1,198 @@
// @flow
describe('Store component filters', () => {
let React;
let ReactDOM;
let TestUtils;
let Types;
let store;
const createElementTypeFilter = (elementType, isEnabled = true) => ({
type: Types.ComponentFilterElementType,
isEnabled,
value: elementType,
});
const createDisplayNameFilter = (source, isEnabled = true) => {
let isValid = true;
try {
new RegExp(source);
} catch (error) {
isValid = false;
}
return {
type: Types.ComponentFilterDisplayName,
isEnabled,
isValid,
value: source,
};
};
const createLocationFilter = (source, isEnabled = true) => {
let isValid = true;
try {
new RegExp(source);
} catch (error) {
isValid = false;
}
return {
type: Types.ComponentFilterLocation,
isEnabled,
isValid,
value: source,
};
};
const act = (callback: Function) => {
TestUtils.act(() => {
callback();
});
jest.runAllTimers(); // Flush Bridge operations
};
beforeEach(() => {
store = global.store;
store.collapseNodesByDefault = false;
store.componentFilters = [];
React = require('react');
ReactDOM = require('react-dom');
TestUtils = require('react-dom/test-utils');
Types = require('src/types');
});
it('should throw if filters are updated while profiling', () => {
act(() => store.startProfiling());
expect(() => (store.componentFilters = [])).toThrow(
'Cannot modify filter preferences while profiling'
);
});
it('should support filtering by element type', () => {
class Root extends React.Component<{| children: React$Node |}> {
render() {
return <div>{this.props.children}</div>;
}
}
const Component = () => <div>Hi</div>;
act(() =>
ReactDOM.render(
<Root>
<Component />
</Root>,
document.createElement('div')
)
);
expect(store).toMatchSnapshot('1: mount');
act(
() =>
(store.componentFilters = [
createElementTypeFilter(Types.ElementTypeHostComponent),
])
);
expect(store).toMatchSnapshot('2: hide host components');
act(
() =>
(store.componentFilters = [
createElementTypeFilter(Types.ElementTypeClass),
])
);
expect(store).toMatchSnapshot('3: hide class components');
act(
() =>
(store.componentFilters = [
createElementTypeFilter(Types.ElementTypeClass),
createElementTypeFilter(Types.ElementTypeFunction),
])
);
expect(store).toMatchSnapshot('4: hide class and function components');
act(
() =>
(store.componentFilters = [
createElementTypeFilter(Types.ElementTypeClass, false),
createElementTypeFilter(Types.ElementTypeFunction, false),
])
);
expect(store).toMatchSnapshot('5: disable all filters');
});
it('should ignore invalid ElementTypeRoot filter', () => {
const Root = () => <div>Hi</div>;
act(() => ReactDOM.render(<Root />, document.createElement('div')));
expect(store).toMatchSnapshot('1: mount');
act(
() =>
(store.componentFilters = [
createElementTypeFilter(Types.ElementTypeRoot),
])
);
expect(store).toMatchSnapshot('2: add invalid filter');
});
it('should filter by display name', () => {
const Text = ({ label }) => label;
const Foo = () => <Text label="foo" />;
const Bar = () => <Text label="bar" />;
const Baz = () => <Text label="baz" />;
act(() =>
ReactDOM.render(
<React.Fragment>
<Foo />
<Bar />
<Baz />
</React.Fragment>,
document.createElement('div')
)
);
expect(store).toMatchSnapshot('1: mount');
act(() => (store.componentFilters = [createDisplayNameFilter('Foo')]));
expect(store).toMatchSnapshot('2: filter "Foo"');
act(() => (store.componentFilters = [createDisplayNameFilter('Ba')]));
expect(store).toMatchSnapshot('3: filter "Ba"');
act(() => (store.componentFilters = [createDisplayNameFilter('B.z')]));
expect(store).toMatchSnapshot('4: filter "B.z"');
});
it('should filter by path', () => {
const Component = () => <div>Hi</div>;
act(() => ReactDOM.render(<Component />, document.createElement('div')));
expect(store).toMatchSnapshot('1: mount');
act(
() =>
(store.componentFilters = [
createLocationFilter(__filename.replace(__dirname, '')),
])
);
expect(store).toMatchSnapshot(
'2: hide all components declared within this test filed'
);
act(
() =>
(store.componentFilters = [
createLocationFilter('this:is:a:made:up:path'),
])
);
expect(store).toMatchSnapshot('3: hide components in a made up fake path');
});
});
+11 -1
View File
@@ -16,7 +16,7 @@ import type {
RendererID,
RendererInterface,
} from './types';
import type { Bridge } from '../types';
import type { Bridge, ComponentFilter } from '../types';
const debug = (methodName, ...args) => {
if (__DEBUG__) {
@@ -118,6 +118,7 @@ export default class Agent extends EventEmitter {
this.syncSelectionFromNativeElementsPanel
);
bridge.addListener('shutdown', this.shutdown);
bridge.addListener('updateComponentFilters', this.updateComponentFilters);
bridge.addListener('viewElementSource', this.viewElementSource);
if (this._isProfiling) {
@@ -489,6 +490,15 @@ export default class Agent extends EventEmitter {
this._bridge.send('profilingStatus', this._isProfiling);
};
updateComponentFilters = (componentFilters: Array<ComponentFilter>) => {
for (let rendererID in this._rendererInterfaces) {
const renderer = ((this._rendererInterfaces[
(rendererID: any)
]: any): RendererInterface);
renderer.updateComponentFilters(componentFilters);
}
};
viewElementSource = ({ id, rendererID }: InspectSelectParams) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
+221 -159
View File
@@ -2,19 +2,28 @@
import { gte } from 'semver';
import {
ComponentFilterDisplayName,
ComponentFilterElementType,
ComponentFilterLocation,
ElementTypeClass,
ElementTypeFunction,
ElementTypeContext,
ElementTypeEventComponent,
ElementTypeEventTarget,
ElementTypeFunction,
ElementTypeForwardRef,
ElementTypeHostComponent,
ElementTypeMemo,
ElementTypeOtherOrUnknown,
ElementTypeProfiler,
ElementTypeRoot,
ElementTypeSuspense,
} from 'src/devtools/types';
import { getDisplayName, utfEncodeString } from '../utils';
} from 'src/types';
import {
getDisplayName,
getSavedComponentFilters,
getUID,
utfEncodeString,
} from 'src/utils';
import { cleanForBridge, copyWithSet, setInObject } from './utils';
import {
__DEBUG__,
@@ -24,7 +33,6 @@ import {
TREE_OPERATION_REORDER_CHILDREN,
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
} from '../constants';
import { getUID } from '../utils';
import { inspectHooksOfFiber } from './ReactDebugHooks';
import type {
@@ -32,7 +40,6 @@ import type {
DevToolsHook,
Fiber,
FiberCommitsBackend,
FiberData,
InteractionBackend,
InteractionsBackend,
InteractionWithCommitsBackend,
@@ -43,6 +50,7 @@ import type {
RendererInterface,
} from './types';
import type { InspectedElement } from 'src/devtools/views/Components/types';
import type { ComponentFilter, ElementType } from 'src/types';
function getInternalReactConstants(version) {
const ReactSymbols = {
@@ -239,18 +247,15 @@ export function attach(
const debug = (name: string, fiber: Fiber, parentFiber: ?Fiber): void => {
if (__DEBUG__) {
const fiberData = getDataForFiber(fiber);
const fiberDisplayName = (fiberData && fiberData.displayName) || 'null';
const parentFiberData =
parentFiber == null ? null : getDataForFiber(parentFiber);
const parentFiberDisplayName =
(parentFiberData && parentFiberData.displayName) || 'null';
const displayName = getDisplayNameForFiber(fiber) || 'null';
const parentDisplayName =
(parentFiber !== null && getDisplayNameForFiber(parentFiber)) || 'null';
// NOTE: calling getFiberID or getPrimaryFiber is unsafe here
// because it will put them in the map. For now, we'll omit them.
// TODO: better debugging story for this.
console.log(
`[renderer] %c${name} %c${fiberDisplayName} %c${
parentFiber ? parentFiberDisplayName : ''
`[renderer] %c${name} %c${displayName} %c${
parentFiber ? parentDisplayName : ''
}`,
'color: red; font-weight: bold;',
'color: blue;',
@@ -259,20 +264,84 @@ export function attach(
}
};
// Keep this function in sync with getDataForFiber()
// Configurable Components tree filters.
const hideElementsWithDisplayNames: Set<RegExp> = new Set();
const hideElementsWithPaths: Set<RegExp> = new Set();
const hideElementsWithTypes: Set<ElementType> = new Set();
function applyComponentFilters(componentFilters: Array<ComponentFilter>) {
hideElementsWithTypes.clear();
hideElementsWithDisplayNames.clear();
hideElementsWithPaths.clear();
componentFilters.forEach(componentFilter => {
if (!componentFilter.isEnabled) {
return;
}
switch (componentFilter.type) {
case ComponentFilterDisplayName:
if (componentFilter.isValid && componentFilter.value !== '') {
hideElementsWithDisplayNames.add(
new RegExp(componentFilter.value, 'i')
);
}
break;
case ComponentFilterElementType:
hideElementsWithTypes.add(componentFilter.value);
break;
case ComponentFilterLocation:
if (componentFilter.isValid && componentFilter.value !== '') {
hideElementsWithPaths.add(new RegExp(componentFilter.value, 'i'));
}
break;
default:
console.warn(
`Invalid component filter type "${componentFilter.type}"`
);
break;
}
});
}
applyComponentFilters(getSavedComponentFilters());
// If necessary, we can revisit optimizing this operation.
// For example, we could add a new recursive unmount tree operation.
// The unmount operations are already significantly smaller than mount opreations though.
// This is something to keep in mind for later.
function updateComponentFilters(componentFilters: Array<ComponentFilter>) {
if (this._isProfiling) {
// Re-mounting a tree while profiling is in progress might break a lot of assumptions.
// If necessary, we could support this- but it doesn't seem like a necessary use case.
throw Error('Cannot modify filter preferences while profiling');
}
// Recursively unmount all roots.
hook.getFiberRoots(rendererID).forEach(root => {
currentRootID = getFiberID(getPrimaryFiber(root.current));
unmountFiberChildrenRecursively(root.current);
recordUnmount(root.current, false);
currentRootID = -1;
});
applyComponentFilters(componentFilters);
// Recursively re-mount all roots with new filter criteria applied.
hook.getFiberRoots(rendererID).forEach(root => {
currentRootID = getFiberID(getPrimaryFiber(root.current));
setRootPseudoKey(currentRootID, root.current);
mountFiberRecursively(root.current, null);
flushPendingEvents(root);
currentRootID = -1;
});
}
// NOTICE Keep in sync with get*ForFiber methods
function shouldFilterFiber(fiber: Fiber): boolean {
const { tag } = fiber;
const { _debugSource, tag, type } = fiber;
switch (tag) {
case ClassComponent:
case FunctionComponent:
case IncompleteClassComponent:
case IndeterminateComponent:
case ForwardRef:
case HostRoot:
case MemoComponent:
case SimpleMemoComponent:
return false;
case DehydratedSuspenseComponent:
// TODO: ideally we would show dehydrated Suspense immediately.
// However, it has some special behavior (like disconnecting
@@ -282,12 +351,14 @@ export function attach(
return true;
case EventComponent:
case HostPortal:
case HostComponent:
case HostText:
case Fragment:
return true;
case HostRoot:
// It is never valid to filter the root element.
return false;
default:
const typeSymbol = getTypeSymbol(fiber.type);
const typeSymbol = getTypeSymbol(type);
switch (typeSymbol) {
case CONCURRENT_MODE_NUMBER:
@@ -296,20 +367,37 @@ export function attach(
case STRICT_MODE_NUMBER:
case STRICT_MODE_SYMBOL_STRING:
return true;
case CONTEXT_PROVIDER_NUMBER:
case CONTEXT_PROVIDER_SYMBOL_STRING:
case CONTEXT_CONSUMER_NUMBER:
case CONTEXT_CONSUMER_SYMBOL_STRING:
case SUSPENSE_NUMBER:
case SUSPENSE_SYMBOL_STRING:
case DEPRECATED_PLACEHOLDER_SYMBOL_STRING:
case PROFILER_NUMBER:
case PROFILER_SYMBOL_STRING:
return false;
default:
return false;
break;
}
}
const elementType = getElementTypeForFiber(fiber);
if (hideElementsWithTypes.has(elementType)) {
return true;
}
if (hideElementsWithDisplayNames.size > 0) {
const displayName = getDisplayNameForFiber(fiber);
if (displayName != null) {
for (let displayNameRegExp of hideElementsWithDisplayNames) {
if (displayNameRegExp.test(displayName)) {
return true;
}
}
}
}
if (_debugSource != null && hideElementsWithPaths.size > 0) {
const { fileName } = _debugSource;
for (let pathRegExp of hideElementsWithPaths) {
if (pathRegExp.test(fileName)) {
return true;
}
}
}
return false;
}
function getTypeSymbol(type: any): Symbol | number {
@@ -321,10 +409,9 @@ export function attach(
: symbolOrNumber;
}
// TODO: we might want to change the data structure once we no longer suppport Stack versions of `getData`.
// TODO: Keep in sync with getElementType()
function getDataForFiber(fiber: Fiber): FiberData {
const { elementType, type, key, tag } = fiber;
// NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods
function getDisplayNameForFiber(fiber: Fiber): string | null {
const { elementType, type, tag } = fiber;
// This is to support lazy components with a Promise as the type.
// see https://github.com/facebook/react/pull/13397
@@ -335,91 +422,47 @@ export function attach(
}
}
let fiberData: FiberData = ((null: any): FiberData);
let displayName: string = ((null: any): string);
let resolvedContext: any = null;
switch (tag) {
case ClassComponent:
case IncompleteClassComponent:
fiberData = {
displayName: getDisplayName(resolvedType),
key,
type: ElementTypeClass,
};
break;
return getDisplayName(resolvedType);
case FunctionComponent:
case IndeterminateComponent:
fiberData = {
displayName: getDisplayName(resolvedType),
key,
type: ElementTypeFunction,
};
break;
return getDisplayName(resolvedType);
case EventComponent:
fiberData = {
displayName: null,
key,
type: ElementTypeEventComponent,
};
break;
return null;
case EventTarget:
switch (getTypeSymbol(elementType.type)) {
case EVENT_TARGET_TOUCH_HIT_NUMBER:
case EVENT_TARGET_TOUCH_HIT_STRING:
displayName = 'TouchHitTarget';
break;
return 'TouchHitTarget';
default:
displayName = 'EventTarget';
break;
return 'EventTarget';
}
fiberData = {
displayName,
key,
type: ElementTypeEventTarget,
};
break;
case ForwardRef:
const functionName = getDisplayName(resolvedType.render, '');
displayName =
return (
resolvedType.displayName ||
(functionName !== '' ? `ForwardRef(${functionName})` : 'ForwardRef');
fiberData = {
displayName,
key,
type: ElementTypeForwardRef,
};
break;
(functionName !== '' ? `ForwardRef(${functionName})` : 'ForwardRef')
);
case HostRoot:
return {
displayName: null,
key: null,
type: ElementTypeRoot,
};
case HostPortal:
return null;
case HostComponent:
return type;
case HostPortal:
case HostText:
case Fragment:
return {
displayName: null,
key,
type: ElementTypeOtherOrUnknown,
};
return null;
case MemoComponent:
case SimpleMemoComponent:
if (elementType.displayName) {
displayName = elementType.displayName;
return elementType.displayName;
} else {
displayName = type.displayName || type.name;
displayName = displayName ? `Memo(${displayName})` : 'Memo';
const displayName = type.displayName || type.name;
return displayName ? `Memo(${displayName})` : 'Memo';
}
fiberData = {
displayName,
key,
type: ElementTypeMemo,
};
break;
default:
const typeSymbol = getTypeSymbol(type);
@@ -427,83 +470,98 @@ export function attach(
case CONCURRENT_MODE_NUMBER:
case CONCURRENT_MODE_SYMBOL_STRING:
case DEPRECATED_ASYNC_MODE_SYMBOL_STRING:
return {
displayName: null,
key: null,
type: ElementTypeOtherOrUnknown,
};
return null;
case CONTEXT_PROVIDER_NUMBER:
case CONTEXT_PROVIDER_SYMBOL_STRING:
// 16.3.0 exposed the context object as "context"
// PR #12501 changed it to "_context" for 16.3.1+
// NOTE Keep in sync with inspectElement()
// NOTE Keep in sync with inspectElementRaw()
resolvedContext = fiber.type._context || fiber.type.context;
displayName = `${resolvedContext.displayName ||
'Context'}.Provider`;
fiberData = {
displayName,
key,
type: ElementTypeContext,
};
break;
return `${resolvedContext.displayName || 'Context'}.Provider`;
case CONTEXT_CONSUMER_NUMBER:
case CONTEXT_CONSUMER_SYMBOL_STRING:
// 16.3-16.5 read from "type" because the Consumer is the actual context object.
// 16.6+ should read from "type._context" because Consumer can be different (in DEV).
// NOTE Keep in sync with inspectElement()
// NOTE Keep in sync with inspectElementRaw()
resolvedContext = fiber.type._context || fiber.type;
// NOTE: TraceUpdatesBackendManager depends on the name ending in '.Consumer'
// If you change the name, figure out a more resilient way to detect it.
displayName = `${resolvedContext.displayName ||
'Context'}.Consumer`;
fiberData = {
displayName,
key,
type: ElementTypeContext,
};
break;
return `${resolvedContext.displayName || 'Context'}.Consumer`;
case STRICT_MODE_NUMBER:
case STRICT_MODE_SYMBOL_STRING:
fiberData = {
displayName: null,
key,
type: ElementTypeOtherOrUnknown,
};
break;
return null;
case SUSPENSE_NUMBER:
case SUSPENSE_SYMBOL_STRING:
case DEPRECATED_PLACEHOLDER_SYMBOL_STRING:
fiberData = {
displayName: 'Suspense',
key,
type: ElementTypeSuspense,
};
break;
return 'Suspense';
case PROFILER_NUMBER:
case PROFILER_SYMBOL_STRING:
fiberData = {
displayName: `Profiler(${fiber.memoizedProps.id})`,
key,
type: ElementTypeProfiler,
};
break;
return `Profiler(${fiber.memoizedProps.id})`;
default:
// Unknown element type.
// This may mean a new element type that has not yet been added to DevTools.
fiberData = {
displayName: null,
key,
type: ElementTypeOtherOrUnknown,
};
break;
return null;
}
break;
}
}
return fiberData;
// NOTICE Keep in sync with shouldFilterFiber() and other get*ForFiber methods
function getElementTypeForFiber(fiber: Fiber): ElementType {
const { type, tag } = fiber;
switch (tag) {
case ClassComponent:
case IncompleteClassComponent:
return ElementTypeClass;
case FunctionComponent:
case IndeterminateComponent:
return ElementTypeFunction;
case EventComponent:
return ElementTypeEventComponent;
case EventTarget:
return ElementTypeEventTarget;
case ForwardRef:
return ElementTypeForwardRef;
case HostRoot:
return ElementTypeRoot;
case HostComponent:
return ElementTypeHostComponent;
case HostPortal:
case HostText:
case Fragment:
return ElementTypeOtherOrUnknown;
case MemoComponent:
case SimpleMemoComponent:
return ElementTypeMemo;
default:
const typeSymbol = getTypeSymbol(type);
switch (typeSymbol) {
case CONCURRENT_MODE_NUMBER:
case CONCURRENT_MODE_SYMBOL_STRING:
case DEPRECATED_ASYNC_MODE_SYMBOL_STRING:
return ElementTypeOtherOrUnknown;
case CONTEXT_PROVIDER_NUMBER:
case CONTEXT_PROVIDER_SYMBOL_STRING:
return ElementTypeContext;
case CONTEXT_CONSUMER_NUMBER:
case CONTEXT_CONSUMER_SYMBOL_STRING:
return ElementTypeContext;
case STRICT_MODE_NUMBER:
case STRICT_MODE_SYMBOL_STRING:
return ElementTypeOtherOrUnknown;
case SUSPENSE_NUMBER:
case SUSPENSE_SYMBOL_STRING:
case DEPRECATED_PLACEHOLDER_SYMBOL_STRING:
return ElementTypeSuspense;
case PROFILER_NUMBER:
case PROFILER_SYMBOL_STRING:
return ElementTypeProfiler;
default:
return ElementTypeOtherOrUnknown;
}
}
}
// This is a slightly annoying indirection.
@@ -727,7 +785,9 @@ export function attach(
pushOperation(isProfilingSupported ? 1 : 0);
pushOperation(hasOwnerMetadata ? 1 : 0);
} else {
const { displayName, key, type } = getDataForFiber(fiber);
const { key } = fiber;
const displayName = getDisplayNameForFiber(fiber);
const elementType = getElementTypeForFiber(fiber);
const { _debugOwner } = fiber;
const ownerID =
@@ -738,7 +798,7 @@ export function attach(
let keyStringID = getStringID(key);
pushOperation(TREE_OPERATION_ADD);
pushOperation(id);
pushOperation(type);
pushOperation(elementType);
pushOperation(parentID);
pushOperation(ownerID);
pushOperation(displayNameStringID);
@@ -1587,7 +1647,7 @@ export function attach(
) {
// 16.3-16.5 read from "type" because the Consumer is the actual context object.
// 16.6+ should read from "type._context" because Consumer can be different (in DEV).
// NOTE Keep in sync with getDataForFiber()
// NOTE Keep in sync with getDisplayNameForFiber()
const consumerResolvedContext = type._context || type;
// Global context value.
@@ -1604,7 +1664,7 @@ export function attach(
) {
// 16.3.0 exposed the context object as "context"
// PR #12501 changed it to "_context" for 16.3.1+
// NOTE Keep in sync with getDataForFiber()
// NOTE Keep in sync with getDisplayNameForFiber()
const providerResolvedContext =
currentType._context || currentType.context;
if (providerResolvedContext === consumerResolvedContext) {
@@ -1629,7 +1689,7 @@ export function attach(
let owner = _debugOwner;
while (owner !== null) {
owners.push({
displayName: getDataForFiber(owner).displayName || 'Unknown',
displayName: getDisplayNameForFiber(owner) || 'Unknown',
id: getFiberID(getPrimaryFiber(owner)),
});
owner = owner._debugOwner;
@@ -1659,7 +1719,7 @@ export function attach(
// Can view component source location.
canViewSource,
displayName: getDataForFiber(fiber).displayName,
displayName: getDisplayNameForFiber(fiber),
// Inspectable properties.
// TODO Review sanitization approach for the below inspectable values.
@@ -2123,7 +2183,7 @@ export function attach(
if (child === null) {
break;
}
const displayName = getDataForFiber(child).displayName;
const displayName = getDisplayNameForFiber(child);
if (displayName !== null) {
// Prefer display names that we get from user-defined components.
// We want to avoid using e.g. 'Suspense' unless we find nothing else.
@@ -2166,7 +2226,8 @@ export function attach(
}
function getPathFrame(fiber: Fiber): PathFrame {
let { displayName, key } = getDataForFiber(fiber);
const { key } = fiber;
let displayName = getDisplayNameForFiber(fiber);
const index = fiber.index;
switch (fiber.tag) {
case HostRoot:
@@ -2260,5 +2321,6 @@ export function attach(
setTrackedPath,
startProfiling,
stopProfiling,
updateComponentFilters,
};
}
+2 -1
View File
@@ -1,6 +1,6 @@
// @flow
import type { ElementType } from 'src/devtools/types';
import type { ComponentFilter, ElementType } from 'src/types';
import type { InspectedElement } from 'src/devtools/views/Components/types';
type BundleType =
@@ -139,6 +139,7 @@ export type RendererInterface = {
setTrackedPath: (path: Array<PathFrame> | null) => void,
startProfiling: () => void,
stopProfiling: () => void,
updateComponentFilters: (somponentFilters: Array<ComponentFilter>) => void,
};
export type Handler = (data: any) => void;
+3
View File
@@ -5,6 +5,9 @@ export const TREE_OPERATION_REMOVE = 2;
export const TREE_OPERATION_REORDER_CHILDREN = 3;
export const TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4;
export const LOCAL_STORAGE_FILTER_PREFERENCES_KEY =
'React::DevTools::componentFilters';
export const LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY =
'React::DevTools::reloadAndProfile';
+33 -4
View File
@@ -9,19 +9,22 @@ import {
TREE_OPERATION_REORDER_CHILDREN,
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
} from '../constants';
import { ElementTypeRoot } from './types';
import { utfDecodeString } from '../utils';
import { ElementTypeRoot } from '../types';
import {
getSavedComponentFilters,
saveComponentFilters,
utfDecodeString,
} from '../utils';
import { __DEBUG__ } from '../constants';
import ProfilingCache from './ProfilingCache';
import { printStore } from 'src/__tests__/storeSerializer';
import type { ElementType } from './types';
import type { Element } from './views/Components/types';
import type {
ImportedProfilingData,
ProfilingSnapshotNode,
} from './views/Profiler/types';
import type { Bridge } from '../types';
import type { Bridge, ComponentFilter, ElementType } from '../types';
const debug = (methodName, ...args) => {
if (__DEBUG__) {
@@ -66,6 +69,8 @@ export default class Store extends EventEmitter {
// Should new nodes be collapsed by default when added to the tree?
_collapseNodesByDefault: boolean = true;
_componentFilters: Array<ComponentFilter>;
// At least one of the injected renderers contains (DEV only) owner metadata.
_hasOwnerMetadata: boolean = false;
@@ -138,6 +143,8 @@ export default class Store extends EventEmitter {
localStorage.getItem(LOCAL_STORAGE_COLLAPSE_ROOTS_BY_DEFAULT_KEY) !==
'false';
this._componentFilters = getSavedComponentFilters();
if (config != null) {
const {
isProfiling,
@@ -232,6 +239,28 @@ export default class Store extends EventEmitter {
this.emit('collapseNodesByDefault');
}
get componentFilters(): Array<ComponentFilter> {
return this._componentFilters;
}
set componentFilters(value: Array<ComponentFilter>): void {
if (this._isProfiling) {
// Re-mounting a tree while profiling is in progress might break a lot of assumptions.
// If necessary, we could support this- but it doesn't seem like a necessary use case.
throw Error('Cannot modify filter preferences while profiling');
}
this._componentFilters = value;
// Update persisted filter preferences stored in localStorage.
saveComponentFilters(value);
// Notify the renderer that filter prefernces have changed.
// This is an expensive opreation; it unmounts and remounts the entire tree.
this._bridge.send('updateComponentFilters', value);
this.emit('componentFilters');
}
get hasOwnerMetadata(): boolean {
return this._hasOwnerMetadata;
}
-18
View File
@@ -1,18 +0,0 @@
// @flow
export const ElementTypeClass = 1;
export const ElementTypeEventComponent = 2;
export const ElementTypeEventTarget = 3;
export const ElementTypeFunction = 4;
export const ElementTypeContext = 5;
export const ElementTypeForwardRef = 6;
export const ElementTypeMemo = 7;
export const ElementTypeOtherOrUnknown = 8;
export const ElementTypeProfiler = 9;
export const ElementTypeRoot = 10;
export const ElementTypeSuspense = 11;
// Different types of elements displayed in the Elements tree.
// These types may be used to visually distinguish types,
// or to enable/disable certain functionality.
export type ElementType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11;
+2 -2
View File
@@ -9,13 +9,13 @@ import tooltipStyles from './Tooltip.css';
type Props = {
children: React$Node,
className?: string,
title: string,
title?: string,
};
export default function Button({
children,
className = '',
title,
title = '',
...rest
}: Props) {
let button = (
+48 -2
View File
@@ -4,10 +4,13 @@ import React from 'react';
import styles from './ButtonIcon.css';
export type IconType =
| 'add'
| 'cancel'
| 'clear'
| 'close'
| 'collapsed'
| 'copy'
| 'delete'
| 'down'
| 'expanded'
| 'export'
@@ -19,22 +22,31 @@ export type IconType =
| 'previous'
| 'record'
| 'reload'
| 'save'
| 'search'
| 'settings'
| 'undo'
| 'up'
| 'view-dom'
| 'view-source';
type Props = {|
className?: string,
type: IconType,
|};
export default function ButtonIcon({ type }: Props) {
export default function ButtonIcon({ className = '', type }: Props) {
let pathData = null;
switch (type) {
case 'add':
pathData = PATH_ADD;
break;
case 'cancel':
pathData = PATH_CANCEL;
break;
case 'clear':
pathData = PATH_CLEAR;
break;
case 'close':
pathData = PATH_CLOSE;
break;
@@ -44,6 +56,9 @@ export default function ButtonIcon({ type }: Props) {
case 'copy':
pathData = PATH_COPY;
break;
case 'delete':
pathData = PATH_DELETE;
break;
case 'down':
pathData = PATH_DOWN;
break;
@@ -77,9 +92,15 @@ export default function ButtonIcon({ type }: Props) {
case 'reload':
pathData = PATH_RELOAD;
break;
case 'save':
pathData = PATH_SAVE;
break;
case 'search':
pathData = PATH_SEARCH;
break;
case 'settings':
pathData = PATH_SETTINGS;
break;
case 'undo':
pathData = PATH_UNDO;
break;
@@ -100,7 +121,7 @@ export default function ButtonIcon({ type }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={styles.ButtonIcon}
className={`${styles.ButtonIcon} ${className}`}
width="24"
height="24"
viewBox="0 0 24 24"
@@ -111,7 +132,14 @@ export default function ButtonIcon({ type }: Props) {
);
}
const PATH_ADD =
'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z';
const PATH_CANCEL = `
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_CLEAR = `
M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69
16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z
`;
@@ -126,6 +154,11 @@ const PATH_COPY = `
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
`;
const PATH_DELETE = `
M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12
13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z
`;
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';
@@ -161,11 +194,24 @@ const PATH_RELOAD = `
3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z
`;
const PATH_SAVE = `
M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z
`;
const PATH_SEARCH = `
M8.5,22H3.7l-1.4-1.5V3.8l1.3-1.5h17.2l1,1.5v4.9h-1.3V4.3l-0.4-0.6H4.2L3.6,4.3V20l0.7,0.7h4.2V22z
M23,13.9l-4.6,3.6l4.6,4.6l-1.1,1.1l-4.7-4.4l-3.3,4.4l-3.2-12.3L23,13.9z
`;
const PATH_SETTINGS = `
M15.95 10.78c.03-.25.05-.51.05-.78s-.02-.53-.06-.78l1.69-1.32c.15-.12.19-.34.1-.51l-1.6-2.77c-.1-.18-.31-.24-.49-.18l-1.99.8c-.42-.32-.86-.58-1.35-.78L12
2.34c-.03-.2-.2-.34-.4-.34H8.4c-.2 0-.36.14-.39.34l-.3 2.12c-.49.2-.94.47-1.35.78l-1.99-.8c-.18-.07-.39
0-.49.18l-1.6 2.77c-.1.18-.06.39.1.51l1.69
1.32c-.04.25-.07.52-.07.78s.02.53.06.78L2.37 12.1c-.15.12-.19.34-.1.51l1.6 2.77c.1.18.31.24.49.18l1.99-.8c.42.32.86.58
1.35.78l.3 2.12c.04.2.2.34.4.34h3.2c.2 0 .37-.14.39-.34l.3-2.12c.49-.2.94-.47 1.35-.78l1.99.8c.18.07.39 0
.49-.18l1.6-2.77c.1-.18.06-.39-.1-.51l-1.67-1.32zM10 13c-1.65 0-3-1.35-3-3s1.35-3 3-3 3 1.35 3 3-1.35 3-3 3z
`;
const PATH_UNDO = `
M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88
3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z
@@ -0,0 +1,119 @@
.Background {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 1rem;
background-color: var(--color-modal-background);
overflow: auto;
}
.Modal {
position: relative;
z-index: 3;
min-width: 20rem;
max-width: 100%;
display: inline-block;
background-color: var(--color-background);
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 0.25rem;
}
.LeftRight {
display: flex;
}
.Left {
}
.Right {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
}
.ButtonIcon {
margin-right: 0.25rem;
}
.NoFiltersCell {
padding: 0.25rem 0;
color: var(--color-dim);
}
.Table {
min-width: 20rem;
margin-top: 0.5rem;
border-spacing: 0;
}
.TableRow {
padding-bottom: 0.5rem;
}
.TableCell {
padding: 0;
padding-right: 0.5rem;
}
.TableCell:last-of-type {
text-align: right;
padding-right: 0;
}
.Select {
}
.Input {
border: 1px solid var(--color-border);
border-radius: 0.125rem;
padding: 0.125rem;
}
.CancelButton {
margin-right: 0.25rem;
}
.InvalidRegExp,
.InvalidRegExp:active,
.InvalidRegExp:focus,
.InvalidRegExp:hover {
color: var(--color-value-invalid);
}
.ToggleOffInvalid,
.ToggleOnInvalid,
.ToggleOff,
.ToggleOn {
border-radius: 0.75rem;
width: 1rem;
height: 0.625rem;
display: flex;
align-items: center;
padding: 0.125rem;
}
.ToggleOffInvalid {
background-color: var(--color-toggle-background-invalid);
justify-content: flex-start;
}
.ToggleOnInvalid {
background-color: var(--color-toggle-background-invalid);
justify-content: flex-end;
}
.ToggleOff {
background-color: var(--color-toggle-background-off);
justify-content: flex-start;
}
.ToggleOn {
background-color: var(--color-toggle-background-on);
justify-content: flex-end;
}
.ToggleInsideOff,
.ToggleInsideOn {
border-radius: 0.375rem;
width: 0.375rem;
height: 0.375rem;
background-color: var(--color-toggle-text);
}
@@ -0,0 +1,426 @@
// @flow
import React, {
useCallback,
useContext,
useMemo,
useRef,
useState,
} from 'react';
import { useModalDismissSignal, useSubscription } from '../hooks';
import { ComponentFiltersModalContext } from './ComponentFiltersModalContext';
import { StoreContext } from '../context';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import Toggle from '../Toggle';
import Store from 'src/devtools/store';
import {
ComponentFilterElementType,
ComponentFilterDisplayName,
ComponentFilterLocation,
ElementTypeClass,
ElementTypeContext,
ElementTypeEventTarget,
ElementTypeFunction,
ElementTypeForwardRef,
ElementTypeHostComponent,
ElementTypeMemo,
ElementTypeOtherOrUnknown,
ElementTypeProfiler,
ElementTypeSuspense,
} from 'src/types';
import styles from './ComponentFiltersModal.css';
import type {
ComponentFilter,
ComponentFilterType,
ElementType,
ElementTypeComponentFilter,
RegExpComponentFilter,
} from 'src/types';
export default function ComponentFiltersModalWrapper(_: {||}) {
const store = useContext(StoreContext);
const { isModalShowing, setIsModalShowing } = useContext(
ComponentFiltersModalContext
);
// Re-mounting a tree while profiling is in progress might break a lot of assumptions.
// If necessary, we could support this- but it doesn't seem like a necessary use case.
const isProfilingSubscription = useMemo(
() => ({
getCurrentValue: () => store.isProfiling,
subscribe: (callback: Function) => {
store.addListener('isProfiling', callback);
return () => store.removeListener('isProfiling', callback);
},
}),
[store]
);
const isProfiling = useSubscription<boolean, Store>(isProfilingSubscription);
if (isProfiling && isModalShowing) {
setIsModalShowing(false);
}
return isModalShowing ? (
<ComponentFiltersModal
store={store}
setIsModalShowing={setIsModalShowing}
/>
) : null;
}
type Props = {|
store: Store,
setIsModalShowing: (value: boolean) => void,
|};
function ComponentFiltersModal({ store, setIsModalShowing }: Props) {
const dismissModal = useCallback(() => setIsModalShowing(false), [
setIsModalShowing,
]);
const modalRef = useRef<HTMLDivElement | null>(null);
useModalDismissSignal(modalRef, dismissModal);
const {
addFilter,
changeFilterType,
updateFilterValueElementType,
updateFilterValueRegExp,
componentFilters,
removeFilter,
saveFilters,
toggleFilterIsEnabled,
} = useComponentFilters();
const saveAndClose = useCallback(() => {
saveFilters();
dismissModal();
}, [dismissModal, saveFilters]);
return (
<div className={styles.Background}>
<div className={styles.Modal} ref={modalRef}>
<div className={styles.LeftRight}>
<div className={styles.Left}>Hide components where...</div>
<div className={styles.Right}>
<Button onClick={addFilter}>
<ButtonIcon className={styles.ButtonIcon} type="add" />
Add filter
</Button>
</div>
</div>
<table className={styles.Table}>
<tbody>
{componentFilters.length === 0 && (
<tr className={styles.TableRow}>
<td className={styles.NoFiltersCell}>
No filters have been added.
</td>
</tr>
)}
{componentFilters.map((componentFilter, index) => (
<tr className={styles.TableRow} key={index}>
<td className={styles.TableCell}>
<Toggle
className={
componentFilter.isValid !== false
? ''
: styles.InvalidRegExp
}
isChecked={componentFilter.isEnabled}
onChange={isEnabled =>
toggleFilterIsEnabled(componentFilter, isEnabled)
}
title={
componentFilter.isValid === false
? 'Filter invalid'
: componentFilter.isEnabled
? 'Filter enabled'
: 'Filter disabled'
}
>
<ToggleIcon
isEnabled={componentFilter.isEnabled}
isValid={
componentFilter.isValid == null ||
componentFilter.isValid === true
}
/>
</Toggle>
</td>
<td className={styles.TableCell}>
<select
className={styles.Select}
value={componentFilter.type}
onChange={({ currentTarget }) =>
changeFilterType(
componentFilter,
((parseInt(
currentTarget.value,
10
): any): ComponentFilterType)
)
}
>
<option value={ComponentFilterLocation}>location</option>
<option value={ComponentFilterDisplayName}>name</option>
<option value={ComponentFilterElementType}>type</option>
</select>
</td>
<td className={styles.TableCell}>
{componentFilter.type === ComponentFilterElementType
? 'equals'
: 'matches'}
</td>
<td className={styles.TableCell}>
{componentFilter.type === ComponentFilterElementType ? (
<select
className={styles.Select}
value={componentFilter.value}
onChange={({ currentTarget }) =>
updateFilterValueElementType(
componentFilter,
((parseInt(
currentTarget.value,
10
): any): ElementType)
)
}
>
<option value={ElementTypeClass}>class</option>
<option value={ElementTypeContext}>context</option>
<option value={ElementTypeEventTarget}>event</option>
<option value={ElementTypeFunction}>function</option>
<option value={ElementTypeForwardRef}>forward ref</option>
<option value={ElementTypeHostComponent}>
host (e.g. &lt;div&gt;)
</option>
<option value={ElementTypeMemo}>memo</option>
<option value={ElementTypeOtherOrUnknown}>other</option>
<option value={ElementTypeProfiler}>profiler</option>
<option value={ElementTypeSuspense}>suspense</option>
</select>
) : (
<input
className={styles.Input}
type="text"
placeholder="Regular expression"
onChange={({ currentTarget }) =>
updateFilterValueRegExp(
componentFilter,
currentTarget.value
)
}
value={componentFilter.value}
/>
)}
</td>
<td className={styles.TableCell}>
<Button
onClick={() => removeFilter(index)}
title="Delete filter"
>
<ButtonIcon type="delete" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
<div className={styles.LeftRight}>
<div className={styles.Right}>
<Button className={styles.CancelButton} onClick={dismissModal}>
Cancel
</Button>
<Button onClick={saveAndClose}>Save Changes</Button>
</div>
</div>
</div>
</div>
);
}
type ToggleIconProps = {|
isEnabled: boolean,
isValid: boolean,
|};
function ToggleIcon({ isEnabled, isValid }: ToggleIconProps) {
let className;
if (isValid) {
className = isEnabled ? styles.ToggleOn : styles.ToggleOff;
} else {
className = isEnabled ? styles.ToggleOnInvalid : styles.ToggleOffInvalid;
}
return (
<div className={className}>
<div
className={isEnabled ? styles.ToggleInsideOn : styles.ToggleInsideOff}
/>
</div>
);
}
function useComponentFilters() {
const store = useContext(StoreContext);
const [componentFilters, setComponentFilters] = useState<
Array<ComponentFilter>
>(() => [...store.componentFilters]);
const addFilter = useCallback(() => {
setComponentFilters(componentFilters => {
return [
...componentFilters,
{
type: ComponentFilterElementType,
value: ElementTypeHostComponent,
isEnabled: true,
},
];
});
}, []);
const changeFilterType = useCallback(
(componentFilter: ComponentFilter, type: ComponentFilterType) => {
setComponentFilters(componentFilters => {
const cloned: Array<ComponentFilter> = [...componentFilters];
const index = componentFilters.indexOf(componentFilter);
if (index >= 0) {
if (type === ComponentFilterElementType) {
cloned[index] = {
type: ComponentFilterElementType,
isEnabled: componentFilter.isEnabled,
value: ElementTypeHostComponent,
};
} else if (type === ComponentFilterDisplayName) {
cloned[index] = {
type: ComponentFilterDisplayName,
isEnabled: componentFilter.isEnabled,
isValid: true,
value: '',
};
} else if (type === ComponentFilterLocation) {
cloned[index] = {
type: ComponentFilterLocation,
isEnabled: componentFilter.isEnabled,
isValid: true,
value: '',
};
}
}
return cloned;
});
},
[]
);
const updateFilterValueElementType = useCallback(
(componentFilter: ComponentFilter, value: ElementType) => {
if (componentFilter.type !== ComponentFilterElementType) {
throw Error('Invalid value for element type filter');
}
setComponentFilters(componentFilters => {
const cloned: Array<ComponentFilter> = [...componentFilters];
if (componentFilter.type === ComponentFilterElementType) {
const index = componentFilters.indexOf(componentFilter);
if (index >= 0) {
cloned[index] = {
...componentFilter,
value,
};
}
}
return cloned;
});
},
[]
);
const updateFilterValueRegExp = useCallback(
(componentFilter: ComponentFilter, value: string) => {
if (componentFilter.type === ComponentFilterElementType) {
throw Error('Invalid value for element type filter');
}
setComponentFilters(componentFilters => {
const cloned: Array<ComponentFilter> = [...componentFilters];
if (
componentFilter.type === ComponentFilterDisplayName ||
componentFilter.type === ComponentFilterLocation
) {
const index = componentFilters.indexOf(componentFilter);
if (index >= 0) {
let isValid = true;
try {
new RegExp(value);
} catch (error) {
isValid = false;
}
cloned[index] = {
...componentFilter,
isValid,
value,
};
}
}
return cloned;
});
},
[]
);
const removeFilter = useCallback((index: number) => {
setComponentFilters(componentFilters => {
const cloned: Array<ComponentFilter> = [...componentFilters];
cloned.splice(index, 1);
return cloned;
});
}, []);
const saveFilters = useCallback(() => {
store.componentFilters = [...componentFilters];
}, [componentFilters, store]);
const toggleFilterIsEnabled = useCallback(
(componentFilter: ComponentFilter, isEnabled: boolean) => {
setComponentFilters(componentFilters => {
const cloned: Array<ComponentFilter> = [...componentFilters];
const index = componentFilters.indexOf(componentFilter);
if (index >= 0) {
if (componentFilter.type === ComponentFilterElementType) {
cloned[index] = {
...((cloned[index]: any): ElementTypeComponentFilter),
isEnabled,
};
} else if (
componentFilter.type === ComponentFilterDisplayName ||
componentFilter.type === ComponentFilterLocation
) {
cloned[index] = {
...((cloned[index]: any): RegExpComponentFilter),
isEnabled,
};
}
}
return cloned;
});
},
[]
);
return {
addFilter,
changeFilterType,
componentFilters,
removeFilter,
saveFilters,
toggleFilterIsEnabled,
updateFilterValueElementType,
updateFilterValueRegExp,
};
}
@@ -0,0 +1,37 @@
// @flow
import React, { createContext, useMemo, useState } from 'react';
type Context = {|
isModalShowing: boolean,
setIsModalShowing: (value: boolean) => void,
|};
const ComponentFiltersModalContext = createContext<Context>(
((null: any): Context)
);
ComponentFiltersModalContext.displayName = 'ComponentFiltersModalContext';
type Props = {|
children: React$Node,
|};
function ComponentFiltersModalContextController({ children }: Props) {
const [isModalShowing, setIsModalShowing] = useState(false);
const value = useMemo(
() => ({
isModalShowing,
setIsModalShowing,
}),
[isModalShowing]
);
return (
<ComponentFiltersModalContext.Provider value={value}>
{children}
</ComponentFiltersModalContext.Provider>
);
}
export { ComponentFiltersModalContext, ComponentFiltersModalContextController };
+1 -1
View File
@@ -9,7 +9,7 @@ import React, {
useRef,
useState,
} from 'react';
import { ElementTypeClass, ElementTypeFunction } from 'src/devtools/types';
import { ElementTypeClass, ElementTypeFunction } from 'src/types';
import Store from 'src/devtools/store';
import ButtonIcon from '../ButtonIcon';
import { createRegExp } from '../utils';
@@ -16,7 +16,7 @@ import {
ElementTypeFunction,
ElementTypeMemo,
ElementTypeSuspense,
} from '../../types';
} from 'src/types';
import type { Element, InspectedElement } from './types';
@@ -0,0 +1,42 @@
// @flow
import React, { useContext, useMemo } from 'react';
import { useSubscription } from '../hooks';
import { ComponentFiltersModalContext } from './ComponentFiltersModalContext';
import { StoreContext } from '../context';
import Toggle from '../Toggle';
import ButtonIcon from '../ButtonIcon';
import Store from 'src/devtools/store';
export default function ToggleCommitFilterModalButton() {
const store = useContext(StoreContext);
const { isModalShowing, setIsModalShowing } = useContext(
ComponentFiltersModalContext
);
// Re-mounting a tree while profiling is in progress might break a lot of assumptions.
// If necessary, we could support this- but it doesn't seem like a necessary use case.
const isProfilingSubscription = useMemo(
() => ({
getCurrentValue: () => store.isProfiling,
subscribe: (callback: Function) => {
store.addListener('isProfiling', callback);
return () => store.removeListener('isProfiling', callback);
},
}),
[store]
);
const isProfiling = useSubscription<boolean, Store>(isProfilingSubscription);
return (
<Toggle
isChecked={isModalShowing}
isDisabled={isProfiling}
onChange={setIsModalShowing}
title="Filter preferences"
>
<ButtonIcon type="filter" />
</Toggle>
);
}
+1
View File
@@ -1,4 +1,5 @@
.Tree {
position: relative;
height: 100%;
width: 100%;
display: flex;
+43 -35
View File
@@ -18,6 +18,9 @@ import ElementView from './Element';
import InspectHostNodesToggle from './InspectHostNodesToggle';
import OwnersStack from './OwnersStack';
import SearchInput from './SearchInput';
import { ComponentFiltersModalContextController } from './ComponentFiltersModalContext';
import ToggleComponentFiltersModalButton from './ToggleComponentFiltersModalButton';
import ComponentFiltersModal from './ComponentFiltersModal';
import styles from './Tree.css';
@@ -269,42 +272,47 @@ export default function Tree(props: Props) {
);
return (
<div className={styles.Tree} ref={treeRef}>
<div className={styles.SearchInput}>
<InspectHostNodesToggle />
<div className={styles.VRule} />
{ownerStack.length > 0 ? <OwnersStack /> : <SearchInput />}
<ComponentFiltersModalContextController>
<div className={styles.Tree} ref={treeRef}>
<div className={styles.SearchInput}>
<InspectHostNodesToggle />
<div className={styles.VRule} />
{ownerStack.length > 0 ? <OwnersStack /> : <SearchInput />}
<div className={styles.VRule} />
<ToggleComponentFiltersModalButton />
</div>
<div
className={styles.AutoSizerWrapper}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyPress={handleKeyPress}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
ref={focusTargetRef}
tabIndex={0}
>
<AutoSizer>
{({ height, width }) => (
// $FlowFixMe https://github.com/facebook/flow/issues/7341
<FixedSizeList
className={styles.List}
height={height}
innerElementType={InnerElementType}
itemCount={numElements}
itemData={itemData}
itemSize={lineHeight}
overscanCount={3}
ref={listRef}
width={width}
>
{ElementView}
</FixedSizeList>
)}
</AutoSizer>
</div>
<ComponentFiltersModal />
</div>
<div
className={styles.AutoSizerWrapper}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyPress={handleKeyPress}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
ref={focusTargetRef}
tabIndex={0}
>
<AutoSizer>
{({ height, width }) => (
// $FlowFixMe https://github.com/facebook/flow/issues/7341
<FixedSizeList
className={styles.List}
height={height}
innerElementType={InnerElementType}
itemCount={numElements}
itemData={itemData}
itemSize={lineHeight}
overscanCount={3}
ref={listRef}
width={width}
>
{ElementView}
</FixedSizeList>
)}
</AutoSizer>
</div>
</div>
</ComponentFiltersModalContextController>
);
}
+1 -1
View File
@@ -1,6 +1,6 @@
// @flow
import type { ElementType } from '../../types';
import type { ElementType } from 'src/types';
// Each element on the frontend corresponds to a Fiber on the backend.
// Some of its information (e.g. id, type, displayName) come from the backend.
@@ -18,7 +18,7 @@ export default function ClearProfilingDataButton() {
onClick={clear}
title="Clear profiling data"
>
<ButtonIcon type="cancel" />
<ButtonIcon type="clear" />
</Button>
);
}
@@ -18,7 +18,7 @@ import type { CommitDetailsFrontend, CommitTreeFrontend } from './types';
export type ItemData = {|
chartData: ChartData,
scaleX: (value: number, fallbackValue: number) => number,
selectedChartNode: ChartNode,
selectedChartNode: ChartNode | null,
selectedChartNodeIndex: number,
selectFiber: (id: number | null, name: string | null) => void,
width: number,
@@ -127,18 +127,20 @@ function CommitFlamegraph({
chartNode => chartNode.id === selectedFiberID
): any): ChartNode);
}
// The selected node might not be in the tree for this commit,
// so it's important that we have a fallback plan.
if (chartNode == null) {
return chartData.rows[0][0];
}
return chartNode;
}, [chartData, selectedFiberID, selectedChartNodeIndex]);
const itemData = useMemo<ItemData>(
() => ({
chartData,
scaleX: scale(0, selectedChartNode.treeBaseDuration, 0, width),
scaleX: scale(
0,
selectedChartNode !== null
? selectedChartNode.treeBaseDuration
: chartData.baseDuration,
0,
width
),
selectedChartNode,
selectedChartNodeIndex,
selectFiber,
@@ -41,7 +41,10 @@ function CommitFlamegraphListItem({ data, index, style }: Props) {
const row = rows[index];
let selectedNodeOffset = scaleX(selectedChartNode.offset, width);
let selectedNodeOffset = scaleX(
selectedChartNode !== null ? selectedChartNode.offset : 0,
width
);
return (
<Fragment>
@@ -8,10 +8,10 @@ import {
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
} from 'src/constants';
import { utfDecodeString } from 'src/utils';
import { ElementTypeRoot } from 'src/devtools/types';
import { ElementTypeRoot } from 'src/types';
import Store from 'src/devtools/store';
import type { ElementType } from 'src/devtools/types';
import type { ElementType } from 'src/types';
import type {
CommitTreeFrontend,
CommitTreeNodeFrontend,
@@ -16,6 +16,7 @@ export type ChartNode = {|
|};
export type ChartData = {|
baseDuration: number,
depth: number,
idToDepthMap: Map<number, number>,
maxSelfDuration: number,
@@ -108,10 +109,16 @@ export function getChartData({
throw Error(`Could not find root node with id "${rootID}" in commit tree`);
}
// TODO: Looks like there's an assumption here that a root has only one child. Is that so with a fragment in the root?
walkTree(root.children[0]);
// Don't assume a single root.
// Component filters or Fragments might lead to multiple "roots" in a flame graph.
let baseDuration = 0;
root.children.forEach(childID => {
const chartNode = walkTree(childID, baseDuration);
baseDuration += chartNode.treeBaseDuration;
});
const chartData = {
baseDuration,
depth: maxDepth,
idToDepthMap,
maxSelfDuration,
+37 -11
View File
@@ -13,48 +13,74 @@
}
.Section {
display: flex;
flex-direction: row;
align-items: center;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
width: 100%;
padding: 0.5rem 0;
border-top: 1px solid var(--color-border);
}
.Section:first-of-type {
padding-top: 0;
border-top: none;
}
.Header {
margin-right: 0.5rem;
margin-bottom: 0.5rem;
font-size: var(--font-size-sans-large);
}
.OptionGroup {
display: flex;
display: inline-flex;
flex-direction: row;
align-items: center;
user-select: none;
margin: 0 1rem 0.5rem 0;
}
.OptionGroup:last-of-type {
margin-right: 0;
}
.Option {
.OptionLabel {
margin-right: 0.5rem;
font-size: var(--font-size-sans-normal);
}
.RadioOption {
cursor: pointer;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-right: none;
}
.Option:hover {
.RadioOption:hover {
background-color: var(--color-background-hover);
}
.Option:first-of-type {
.RadioOption:first-of-type {
border-top-left-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}
.Option:last-of-type {
.RadioOption:last-of-type {
border-top-right-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
border-right: 1px solid var(--color-border);
}
.CheckboxOption {
display: block;
padding: 0 0 0.5rem;
}
.ScreenshotThrottling {
display: inline-block;
background-color: var(--color-background-hover);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.HRule {
height: 1px;
background-color: var(--color-border);
width: 100%;
border: none;
margin: 0.5rem 0;
}
+32 -32
View File
@@ -73,9 +73,10 @@ function Settings(_: {||}) {
return (
<div className={styles.Settings}>
<div className={styles.Section}>
<div className={styles.Header}>Theme</div>
<div className={styles.Header}>Display preferences</div>
<div className={styles.OptionGroup}>
<label className={styles.Option}>
<div className={styles.OptionLabel}>Theme</div>
<label className={styles.RadioOption}>
<input
type="radio"
name="Settings-theme"
@@ -85,7 +86,7 @@ function Settings(_: {||}) {
/>{' '}
Auto
</label>
<label className={styles.Option}>
<label className={styles.RadioOption}>
<input
type="radio"
name="Settings-theme"
@@ -95,7 +96,7 @@ function Settings(_: {||}) {
/>{' '}
Light
</label>
<label className={styles.Option}>
<label className={styles.RadioOption}>
<input
type="radio"
name="Settings-theme"
@@ -106,22 +107,9 @@ function Settings(_: {||}) {
Dark
</label>
</div>
</div>
<div className={styles.Section}>
<div className={styles.Header}>Components tree</div>
<label>
<input
type="checkbox"
checked={collapseNodesByDefault}
onChange={updateCollapseNodesByDefault}
/>{' '}
Collapse tree by default
</label>
</div>
<div className={styles.Section}>
<div className={styles.Header}>Display density</div>
<div className={styles.OptionGroup}>
<label className={styles.Option}>
<div className={styles.OptionLabel}>Display density</div>
<label className={styles.RadioOption}>
<input
type="radio"
name="Settings-displayDensity"
@@ -131,7 +119,7 @@ function Settings(_: {||}) {
/>{' '}
Compact
</label>
<label className={styles.Option}>
<label className={styles.RadioOption}>
<input
type="radio"
name="Settings-displayDensity"
@@ -143,19 +131,31 @@ function Settings(_: {||}) {
</label>
</div>
</div>
<div className={styles.Section}>
<div className={styles.Header}>Components tree</div>
<label className={styles.CheckboxOption}>
<input
type="checkbox"
checked={collapseNodesByDefault}
onChange={updateCollapseNodesByDefault}
/>{' '}
Collapse newly added components by default
</label>
</div>
{store.supportsCaptureScreenshots && (
<div>
<div className={styles.Section}>
<div className={styles.Header}>Profiler</div>
<label>
<input
type="checkbox"
checked={captureScreenshots}
onChange={updateCaptureScreenshotsWhileProfiling}
/>{' '}
Capture screenshots while profiling
</label>
</div>
<div className={styles.Section}>
<div className={styles.Header}>Profiler</div>
<label className={styles.CheckboxOption}>
<input
type="checkbox"
checked={captureScreenshots}
onChange={updateCaptureScreenshotsWhileProfiling}
/>{' '}
Capture screenshots while profiling
</label>
{captureScreenshots && (
<div className={styles.ScreenshotThrottling}>
Screenshots will be throttled in order to reduce the negative
@@ -247,6 +247,10 @@ function updateThemeVariables(
updateStyleHelper(theme, 'color-tab-selected-border', documentElements);
updateStyleHelper(theme, 'color-text', documentElements);
updateStyleHelper(theme, 'color-text-selected', documentElements);
updateStyleHelper(theme, 'color-toggle-background-invalid', documentElements);
updateStyleHelper(theme, 'color-toggle-background-on', documentElements);
updateStyleHelper(theme, 'color-toggle-background-off', documentElements);
updateStyleHelper(theme, 'color-toggle-text', documentElements);
updateStyleHelper(theme, 'color-tooltip-background', documentElements);
updateStyleHelper(theme, 'color-tooltip-text', documentElements);
+8 -2
View File
@@ -38,14 +38,20 @@ export function useIsOverflowing(
// Forked from https://usehooks.com/useLocalStorage/
export function useLocalStorage<T>(
key: string,
initialValue: T
initialValue: T | (() => T)
): [T, (value: T | (() => T)) => void] {
const getValueFromLocalStorage = useCallback(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
if (item != null) {
return JSON.parse(item);
}
} catch (error) {
console.log(error);
}
if (typeof initialValue === 'function') {
return ((initialValue: any): () => T)();
} else {
return initialValue;
}
}, [initialValue, key]);
+8
View File
@@ -48,6 +48,10 @@
--light-color-tab-selected-border: #0088fa;
--light-color-text: #000000;
--light-color-text-selected: #ffffff;
--light-color-toggle-background-invalid: #fc3a4b;
--light-color-toggle-background-on: #0088fa;
--light-color-toggle-background-off: #cfd1d5;
--light-color-toggle-text: #ffffff;
--light-color-tooltip-background: rgba(0, 0, 0, 0.9);
--light-color-tooltip-text: #ffffff;
@@ -96,6 +100,10 @@
--dark-color-tab-selected-border: #178fb9;
--dark-color-text: #ffffff;
--dark-color-text-selected: #ffffff;
--dark-color-toggle-background-invalid: #fc3a4b;
--dark-color-toggle-background-on: #178fb9;
--dark-color-toggle-background-off: #777d88;
--dark-color-toggle-text: #ffffff;
--dark-color-tooltip-background: rgba(255, 255, 255, 0.9);
--dark-color-tooltip-text: #000000;
+53
View File
@@ -11,3 +11,56 @@ export type Wall = {|
listen: (fn: Function) => Function,
send: (event: string, payload: any, transferable?: Array<any>) => void,
|};
// WARNING
// The values below are referenced by ComponentFilters (which are saved via localStorage).
// Do not change them or it will break previously saved user customizations.
// If new element types are added, use new numbers rather than re-ordering existing ones.
export const ElementTypeClass = 1;
export const ElementTypeContext = 2;
export const ElementTypeEventComponent = 3;
export const ElementTypeEventTarget = 4;
export const ElementTypeFunction = 5;
export const ElementTypeForwardRef = 6;
export const ElementTypeHostComponent = 7;
export const ElementTypeMemo = 8;
export const ElementTypeOtherOrUnknown = 9;
export const ElementTypeProfiler = 10;
export const ElementTypeRoot = 11;
export const ElementTypeSuspense = 12;
// Different types of elements displayed in the Elements tree.
// These types may be used to visually distinguish types,
// or to enable/disable certain functionality.
export type ElementType = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
// WARNING
// The values below are referenced by ComponentFilters (which are saved via localStorage).
// Do not change them or it will break previously saved user customizations.
// If new filter types are added, use new numbers rather than re-ordering existing ones.
export const ComponentFilterElementType = 1;
export const ComponentFilterDisplayName = 2;
export const ComponentFilterLocation = 3;
export type ComponentFilterType = 1 | 2 | 3;
// Hide all elements of types in this Set.
// We hide host components only by default.
export type ElementTypeComponentFilter = {|
isEnabled: boolean,
type: 1,
value: ElementType,
|};
// Hide all elements with displayNames or paths matching one or more of the RegExps in this Set.
// Path filters are only used when elements include debug source location.
export type RegExpComponentFilter = {|
isEnabled: boolean,
isValid: boolean,
type: 2 | 3,
value: string,
|};
export type ComponentFilter =
| ElementTypeComponentFilter
| RegExpComponentFilter;
+34 -1
View File
@@ -1,6 +1,10 @@
// @flow
const LRU = require('lru-cache');
import LRU from 'lru-cache';
import { LOCAL_STORAGE_FILTER_PREFERENCES_KEY } from './constants';
import { ComponentFilterElementType, ElementTypeHostComponent } from './types';
import type { ComponentFilter } from './types';
const FB_MODULE_RE = /^(.*) \[from (.*)\]$/;
const cachedDisplayNames: WeakMap<Function, string> = new WeakMap();
@@ -76,3 +80,32 @@ export function utfEncodeString(string: string): Uint32Array {
function toCodePoint(string: string) {
return string.codePointAt(0);
}
export function getDefaultComponentFilters(): Array<ComponentFilter> {
return [
{
type: ComponentFilterElementType,
value: ElementTypeHostComponent,
isEnabled: true,
},
];
}
export function getSavedComponentFilters(): Array<ComponentFilter> {
try {
const raw = localStorage.getItem(LOCAL_STORAGE_FILTER_PREFERENCES_KEY);
if (raw != null) {
return JSON.parse(raw);
}
} catch (error) {}
return getDefaultComponentFilters();
}
export function saveComponentFilters(
componentFilters: Array<ComponentFilter>
): void {
localStorage.setItem(
LOCAL_STORAGE_FILTER_PREFERENCES_KEY,
JSON.stringify(componentFilters)
);
}
+15
View File
@@ -383,6 +383,13 @@
dependencies:
"@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-jsx@^7.2.0":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.2.0.tgz#0b85a3b4bc7cdf4cc4b8bf236335b907ca22e7c7"
integrity sha512-VyN4QANJkRW6lDBmENzRszvZf3/4AXaj9YR7GwrWeeN9tEBPuXbmDYVU9bYBN0D70zCWVwUy0HWq2553VCb6Hw==
dependencies:
"@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.2.0":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.2.0.tgz#3b7a3e733510c57e820b9142a6579ac8b0dfad2e"
@@ -605,6 +612,14 @@
"@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-jsx" "^7.0.0"
"@babel/plugin-transform-react-jsx-source@^7.2.0":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.2.0.tgz#20c8c60f0140f5dd3cd63418d452801cf3f7180f"
integrity sha512-A32OkKTp4i5U6aE88GwwcuV4HAprUgHcTq0sSafLxjr6AW0QahrCRCjxogkbbcdtpbXkuTOlgpjophCxb6sh5g==
dependencies:
"@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-syntax-jsx" "^7.2.0"
"@babel/plugin-transform-react-jsx@^7.0.0":
version "7.1.6"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.1.6.tgz#e6188e7d2a2dcd2796d45a87f8b0a8c906f57d1a"