diff --git a/OVERVIEW.md b/OVERVIEW.md index 05069783d8..a2d8a67fa9 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -83,7 +83,7 @@ For example, adding a function component `` 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 diff --git a/babel.config.js b/babel.config.js index 59bfbc27e2..12a0e78baf 100644 --- a/babel.config.js +++ b/babel.config.js @@ -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 }], diff --git a/package.json b/package.json index 4becfc24d1..7bf761f93b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/__snapshots__/storeComponentFilters-test.js.snap b/src/__tests__/__snapshots__/storeComponentFilters-test.js.snap new file mode 100644 index 0000000000..86dbf043da --- /dev/null +++ b/src/__tests__/__snapshots__/storeComponentFilters-test.js.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Store component filters should filter by display name: 1: mount 1`] = ` +[root] + ▾ + + ▾ + + ▾ + +`; + +exports[`Store component filters should filter by display name: 2: filter "Foo" 1`] = ` +[root] + + ▾ + + ▾ + +`; + +exports[`Store component filters should filter by display name: 3: filter "Ba" 1`] = ` +[root] + ▾ + + + +`; + +exports[`Store component filters should filter by display name: 4: filter "B.z" 1`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`Store component filters should filter by path: 1: mount 1`] = ` +[root] + ▾ +
+`; + +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] + ▾ +
+`; + +exports[`Store component filters should ignore invalid ElementTypeRoot filter: 1: mount 1`] = ` +[root] + ▾ +
+`; + +exports[`Store component filters should ignore invalid ElementTypeRoot filter: 2: add invalid filter 1`] = ` +[root] + ▾ +
+`; + +exports[`Store component filters should support filtering by element type: 1: mount 1`] = ` +[root] + ▾ + ▾
+ ▾ +
+`; + +exports[`Store component filters should support filtering by element type: 2: hide host components 1`] = ` +[root] + ▾ + +`; + +exports[`Store component filters should support filtering by element type: 3: hide class components 1`] = ` +[root] + ▾
+ ▾ +
+`; + +exports[`Store component filters should support filtering by element type: 4: hide class and function components 1`] = ` +[root] + ▾
+
+`; + +exports[`Store component filters should support filtering by element type: 5: disable all filters 1`] = ` +[root] + ▾ + ▾
+ ▾ +
+`; diff --git a/src/__tests__/storeComponentFilters-test.js b/src/__tests__/storeComponentFilters-test.js new file mode 100644 index 0000000000..8f408f49f7 --- /dev/null +++ b/src/__tests__/storeComponentFilters-test.js @@ -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
{this.props.children}
; + } + } + const Component = () =>
Hi
; + + act(() => + ReactDOM.render( + + + , + 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 = () =>
Hi
; + + act(() => ReactDOM.render(, 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 = () => ; + const Bar = () => ; + const Baz = () => ; + + act(() => + ReactDOM.render( + + + + + , + 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 = () =>
Hi
; + + act(() => ReactDOM.render(, 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'); + }); +}); diff --git a/src/backend/agent.js b/src/backend/agent.js index 5364d048c5..32982814df 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -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) => { + 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) { diff --git a/src/backend/renderer.js b/src/backend/renderer.js index 3b029be297..6b79b12210 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -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 = new Set(); + const hideElementsWithPaths: Set = new Set(); + const hideElementsWithTypes: Set = new Set(); + + function applyComponentFilters(componentFilters: Array) { + 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) { + 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, }; } diff --git a/src/backend/types.js b/src/backend/types.js index d1946d164a..1675416f96 100644 --- a/src/backend/types.js +++ b/src/backend/types.js @@ -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 | null) => void, startProfiling: () => void, stopProfiling: () => void, + updateComponentFilters: (somponentFilters: Array) => void, }; export type Handler = (data: any) => void; diff --git a/src/constants.js b/src/constants.js index 588c8d4219..fcbf9cb65e 100644 --- a/src/constants.js +++ b/src/constants.js @@ -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'; diff --git a/src/devtools/store.js b/src/devtools/store.js index ecdf1683df..6da87d3032 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -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; + // 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 { + return this._componentFilters; + } + set componentFilters(value: Array): 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; } diff --git a/src/devtools/types.js b/src/devtools/types.js deleted file mode 100644 index 40425b7275..0000000000 --- a/src/devtools/types.js +++ /dev/null @@ -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; diff --git a/src/devtools/views/Button.js b/src/devtools/views/Button.js index 78a9c8201f..bb55bca22f 100644 --- a/src/devtools/views/Button.js +++ b/src/devtools/views/Button.js @@ -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 = ( diff --git a/src/devtools/views/ButtonIcon.js b/src/devtools/views/ButtonIcon.js index a42e02bdbc..6e67143f89 100644 --- a/src/devtools/views/ButtonIcon.js +++ b/src/devtools/views/ButtonIcon.js @@ -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 ( ({ + getCurrentValue: () => store.isProfiling, + subscribe: (callback: Function) => { + store.addListener('isProfiling', callback); + return () => store.removeListener('isProfiling', callback); + }, + }), + [store] + ); + const isProfiling = useSubscription(isProfilingSubscription); + if (isProfiling && isModalShowing) { + setIsModalShowing(false); + } + + return isModalShowing ? ( + + ) : null; +} + +type Props = {| + store: Store, + setIsModalShowing: (value: boolean) => void, +|}; + +function ComponentFiltersModal({ store, setIsModalShowing }: Props) { + const dismissModal = useCallback(() => setIsModalShowing(false), [ + setIsModalShowing, + ]); + + const modalRef = useRef(null); + + useModalDismissSignal(modalRef, dismissModal); + + const { + addFilter, + changeFilterType, + updateFilterValueElementType, + updateFilterValueRegExp, + componentFilters, + removeFilter, + saveFilters, + toggleFilterIsEnabled, + } = useComponentFilters(); + + const saveAndClose = useCallback(() => { + saveFilters(); + dismissModal(); + }, [dismissModal, saveFilters]); + + return ( +
+
+
+
Hide components where...
+
+ +
+
+ + + {componentFilters.length === 0 && ( + + + + )} + {componentFilters.map((componentFilter, index) => ( + + + + + + + + ))} + +
+ No filters have been added. +
+ + toggleFilterIsEnabled(componentFilter, isEnabled) + } + title={ + componentFilter.isValid === false + ? 'Filter invalid' + : componentFilter.isEnabled + ? 'Filter enabled' + : 'Filter disabled' + } + > + + + + + + {componentFilter.type === ComponentFilterElementType + ? 'equals' + : 'matches'} + + {componentFilter.type === ComponentFilterElementType ? ( + + ) : ( + + updateFilterValueRegExp( + componentFilter, + currentTarget.value + ) + } + value={componentFilter.value} + /> + )} + + +
+
+
+ + +
+
+
+
+ ); +} + +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 ( +
+
+
+ ); +} + +function useComponentFilters() { + const store = useContext(StoreContext); + + const [componentFilters, setComponentFilters] = useState< + Array + >(() => [...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 = [...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 = [...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 = [...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 = [...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 = [...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, + }; +} diff --git a/src/devtools/views/Components/ComponentFiltersModalContext.js b/src/devtools/views/Components/ComponentFiltersModalContext.js new file mode 100644 index 0000000000..6d2c46452a --- /dev/null +++ b/src/devtools/views/Components/ComponentFiltersModalContext.js @@ -0,0 +1,37 @@ +// @flow + +import React, { createContext, useMemo, useState } from 'react'; + +type Context = {| + isModalShowing: boolean, + setIsModalShowing: (value: boolean) => void, +|}; + +const ComponentFiltersModalContext = createContext( + ((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 ( + + {children} + + ); +} + +export { ComponentFiltersModalContext, ComponentFiltersModalContextController }; diff --git a/src/devtools/views/Components/Element.js b/src/devtools/views/Components/Element.js index bfe0ce65ce..322726784d 100644 --- a/src/devtools/views/Components/Element.js +++ b/src/devtools/views/Components/Element.js @@ -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'; diff --git a/src/devtools/views/Components/SelectedElement.js b/src/devtools/views/Components/SelectedElement.js index c2b62a6389..8740480a98 100644 --- a/src/devtools/views/Components/SelectedElement.js +++ b/src/devtools/views/Components/SelectedElement.js @@ -16,7 +16,7 @@ import { ElementTypeFunction, ElementTypeMemo, ElementTypeSuspense, -} from '../../types'; +} from 'src/types'; import type { Element, InspectedElement } from './types'; diff --git a/src/devtools/views/Components/ToggleComponentFiltersModalButton.js b/src/devtools/views/Components/ToggleComponentFiltersModalButton.js new file mode 100644 index 0000000000..6268492030 --- /dev/null +++ b/src/devtools/views/Components/ToggleComponentFiltersModalButton.js @@ -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(isProfilingSubscription); + + return ( + + + + ); +} diff --git a/src/devtools/views/Components/Tree.css b/src/devtools/views/Components/Tree.css index 98b9b85870..3ce44f0828 100644 --- a/src/devtools/views/Components/Tree.css +++ b/src/devtools/views/Components/Tree.css @@ -1,4 +1,5 @@ .Tree { + position: relative; height: 100%; width: 100%; display: flex; diff --git a/src/devtools/views/Components/Tree.js b/src/devtools/views/Components/Tree.js index 0a96f163b0..8c3c4031f7 100644 --- a/src/devtools/views/Components/Tree.js +++ b/src/devtools/views/Components/Tree.js @@ -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 ( -
-
- -
- {ownerStack.length > 0 ? : } + +
+
+ +
+ {ownerStack.length > 0 ? : } +
+ +
+
+ + {({ height, width }) => ( + // $FlowFixMe https://github.com/facebook/flow/issues/7341 + + {ElementView} + + )} + +
+
-
- - {({ height, width }) => ( - // $FlowFixMe https://github.com/facebook/flow/issues/7341 - - {ElementView} - - )} - -
-
+ ); } diff --git a/src/devtools/views/Components/types.js b/src/devtools/views/Components/types.js index ca3c7e3df0..0849ec019a 100644 --- a/src/devtools/views/Components/types.js +++ b/src/devtools/views/Components/types.js @@ -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. diff --git a/src/devtools/views/Profiler/ClearProfilingDataButton.js b/src/devtools/views/Profiler/ClearProfilingDataButton.js index 082d3ec7b1..a8be6b0f6c 100644 --- a/src/devtools/views/Profiler/ClearProfilingDataButton.js +++ b/src/devtools/views/Profiler/ClearProfilingDataButton.js @@ -18,7 +18,7 @@ export default function ClearProfilingDataButton() { onClick={clear} title="Clear profiling data" > - + ); } diff --git a/src/devtools/views/Profiler/CommitFlamegraph.js b/src/devtools/views/Profiler/CommitFlamegraph.js index 84a1ca5134..881da0e77b 100644 --- a/src/devtools/views/Profiler/CommitFlamegraph.js +++ b/src/devtools/views/Profiler/CommitFlamegraph.js @@ -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( () => ({ chartData, - scaleX: scale(0, selectedChartNode.treeBaseDuration, 0, width), + scaleX: scale( + 0, + selectedChartNode !== null + ? selectedChartNode.treeBaseDuration + : chartData.baseDuration, + 0, + width + ), selectedChartNode, selectedChartNodeIndex, selectFiber, diff --git a/src/devtools/views/Profiler/CommitFlamegraphListItem.js b/src/devtools/views/Profiler/CommitFlamegraphListItem.js index 8d55d781b0..171ebebb45 100644 --- a/src/devtools/views/Profiler/CommitFlamegraphListItem.js +++ b/src/devtools/views/Profiler/CommitFlamegraphListItem.js @@ -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 ( diff --git a/src/devtools/views/Profiler/CommitTreeBuilder.js b/src/devtools/views/Profiler/CommitTreeBuilder.js index fdf98533c2..b9bf40650a 100644 --- a/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -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, diff --git a/src/devtools/views/Profiler/FlamegraphChartBuilder.js b/src/devtools/views/Profiler/FlamegraphChartBuilder.js index 8096972175..435c6f8806 100644 --- a/src/devtools/views/Profiler/FlamegraphChartBuilder.js +++ b/src/devtools/views/Profiler/FlamegraphChartBuilder.js @@ -16,6 +16,7 @@ export type ChartNode = {| |}; export type ChartData = {| + baseDuration: number, depth: number, idToDepthMap: Map, 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, diff --git a/src/devtools/views/Settings/Settings.css b/src/devtools/views/Settings/Settings.css index 581facc0e9..8f3d567346 100644 --- a/src/devtools/views/Settings/Settings.css +++ b/src/devtools/views/Settings/Settings.css @@ -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; +} diff --git a/src/devtools/views/Settings/Settings.js b/src/devtools/views/Settings/Settings.js index 1c1ed43bcf..645ee25422 100644 --- a/src/devtools/views/Settings/Settings.js +++ b/src/devtools/views/Settings/Settings.js @@ -73,9 +73,10 @@ function Settings(_: {||}) { return (
-
Theme
+
Display preferences
-
-
-
-
Components tree
- -
-
-
Display density
-
+ +
+
Components tree
+ + +
+ {store.supportsCaptureScreenshots && ( -
-
-
Profiler
- -
+
+
Profiler
+ {captureScreenshots && (
Screenshots will be throttled in order to reduce the negative diff --git a/src/devtools/views/Settings/SettingsContext.js b/src/devtools/views/Settings/SettingsContext.js index 0cc5dfb9a4..0136c8bbc8 100644 --- a/src/devtools/views/Settings/SettingsContext.js +++ b/src/devtools/views/Settings/SettingsContext.js @@ -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); diff --git a/src/devtools/views/hooks.js b/src/devtools/views/hooks.js index 6d48915d20..c6d8ce0984 100644 --- a/src/devtools/views/hooks.js +++ b/src/devtools/views/hooks.js @@ -38,14 +38,20 @@ export function useIsOverflowing( // Forked from https://usehooks.com/useLocalStorage/ export function useLocalStorage( 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]); diff --git a/src/devtools/views/root.css b/src/devtools/views/root.css index ca0582a171..296d7dd1a3 100644 --- a/src/devtools/views/root.css +++ b/src/devtools/views/root.css @@ -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; diff --git a/src/types.js b/src/types.js index 9e5a9de04e..7f89612cc7 100644 --- a/src/types.js +++ b/src/types.js @@ -11,3 +11,56 @@ export type Wall = {| listen: (fn: Function) => Function, send: (event: string, payload: any, transferable?: Array) => 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; diff --git a/src/utils.js b/src/utils.js index f085f550d5..499a72bb1d 100644 --- a/src/utils.js +++ b/src/utils.js @@ -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 = new WeakMap(); @@ -76,3 +80,32 @@ export function utfEncodeString(string: string): Uint32Array { function toCodePoint(string: string) { return string.codePointAt(0); } + +export function getDefaultComponentFilters(): Array { + return [ + { + type: ComponentFilterElementType, + value: ElementTypeHostComponent, + isEnabled: true, + }, + ]; +} + +export function getSavedComponentFilters(): Array { + 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 +): void { + localStorage.setItem( + LOCAL_STORAGE_FILTER_PREFERENCES_KEY, + JSON.stringify(componentFilters) + ); +} diff --git a/yarn.lock b/yarn.lock index 2aee4bbdbd..6ed41c6740 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"