diff --git a/.flowconfig b/.flowconfig index 35306bd54a..c1209df273 100644 --- a/.flowconfig +++ b/.flowconfig @@ -4,7 +4,6 @@ .*node_modules/archiver-utils .*node_modules/babel.* .*node_modules/browserify-zlib/.* -.*node_modules/classnames.* .*node_modules/gh-pages/.* .*node_modules/invariant/.* .*node_modules/json-loader.* diff --git a/.gitignore b/.gitignore index 514ad873cd..ee4e445f06 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ npm-debug.log yarn-error.log .DS_Store yarn-error.log -.vscode \ No newline at end of file +.vscode +.idea diff --git a/package.json b/package.json index e843d0f133..68c3bc693e 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "cli-spinners": "^1.0.0", "clipboard-js": "^0.3.6", "css-loader": "^1.0.1", - "html2canvas": "^1.0.0-alpha.12", "error-stack-parser": "^2.0.2", "es6-symbol": "3.0.2", "escape-string-regexp": "^1.0.5", @@ -78,13 +77,15 @@ "fbjs": "0.5.1", "fbjs-scripts": "0.7.0", "firefox-profile": "^1.0.2", - "flow-bin": "^0.94.0", + "flow-bin": "^0.96.0", "fs-extra": "^3.0.1", "gh-pages": "^1.0.0", + "html2canvas": "^1.0.0-alpha.12", "immutable": "3.7.6", "jest": "22.1.4", "lerna": "^2.8.0", "lint-staged": "^7.0.5", + "lodash.throttle": "^4.1.1", "log-update": "^2.0.0", "lru-cache": "^4.1.3", "memoize-one": "^3.1.1", diff --git a/shells/browser/shared/src/main.js b/shells/browser/shared/src/main.js index e96b10fd7c..ab86699f19 100644 --- a/shells/browser/shared/src/main.js +++ b/shells/browser/shared/src/main.js @@ -154,6 +154,14 @@ function createPanelIfReactLoaded() { initBridgeAndStore(); + function ensureInitialHTMLIsCleared(container) { + if (container._hasInitialHTMLBeenCleared) { + return; + } + container.innerHTML = ''; + container._hasInitialHTMLBeenCleared = true; + } + let currentPanel = null; chrome.devtools.panels.create('⚛ Components', '', 'panel.html', panel => { @@ -166,7 +174,7 @@ function createPanelIfReactLoaded() { componentsPortalContainer = panel.container; if (componentsPortalContainer != null) { - componentsPortalContainer.innerHTML = ''; + ensureInitialHTMLIsCleared(componentsPortalContainer); render('components'); panel.injectStyles(cloneStyleTags); } @@ -188,7 +196,7 @@ function createPanelIfReactLoaded() { profilerPortalContainer = panel.container; if (profilerPortalContainer != null) { - profilerPortalContainer.innerHTML = ''; + ensureInitialHTMLIsCleared(profilerPortalContainer); render('profiler'); panel.injectStyles(cloneStyleTags); } @@ -205,7 +213,7 @@ function createPanelIfReactLoaded() { settingsPortalContainer = panel.container; if (settingsPortalContainer != null) { - settingsPortalContainer.innerHTML = ''; + ensureInitialHTMLIsCleared(settingsPortalContainer); render('settings'); panel.injectStyles(cloneStyleTags); } diff --git a/src/backend/agent.js b/src/backend/agent.js index 98cbb9acfa..a50598010a 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -66,13 +66,19 @@ export default class Agent extends EventEmitter { this._bridge = bridge; bridge.addListener('captureScreenshot', this.captureScreenshot); + bridge.addListener( + 'clearHighlightedElementInDOM', + this.clearHighlightedElementInDOM + ); bridge.addListener('exportProfilingSummary', this.exportProfilingSummary); bridge.addListener('getCommitDetails', this.getCommitDetails); + bridge.addListener('getFiberCommits', this.getFiberCommits); bridge.addListener('getInteractions', this.getInteractions); bridge.addListener('getProfilingStatus', this.getProfilingStatus); bridge.addListener('getProfilingSummary', this.getProfilingSummary); bridge.addListener('highlightElementInDOM', this.highlightElementInDOM); bridge.addListener('inspectElement', this.inspectElement); + bridge.addListener('logElementToConsole', this.logElementToConsole); bridge.addListener('overrideContext', this.overrideContext); bridge.addListener('overrideHookState', this.overrideHookState); bridge.addListener('overrideProps', this.overrideProps); @@ -161,6 +167,26 @@ export default class Agent extends EventEmitter { } }; + getFiberCommits = ({ + fiberID, + rendererID, + rootID, + }: { + fiberID: number, + rendererID: number, + rootID: number, + }) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}"`); + } else { + this._bridge.send( + 'fiberCommits', + renderer.getFiberCommits(rootID, fiberID) + ); + } + }; + getInteractions = ({ rendererID, rootID, @@ -198,14 +224,22 @@ export default class Agent extends EventEmitter { } }; + clearHighlightedElementInDOM = () => { + hideOverlay(); + }; + highlightElementInDOM = ({ displayName, + hideAfterTimeout, id, rendererID, + scrollIntoView, }: { displayName: string, + hideAfterTimeout: boolean, id: number, rendererID: number, + scrollIntoView: boolean, }) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { @@ -218,13 +252,12 @@ export default class Agent extends EventEmitter { } if (node != null) { - if (typeof node.scrollIntoView === 'function') { + if (scrollIntoView && typeof node.scrollIntoView === 'function') { // If the node isn't visible show it before highlighting it. // We may want to reconsider this; it might be a little disruptive. node.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } - - showOverlay(((node: any): HTMLElement), displayName); + showOverlay(((node: any): HTMLElement), displayName, hideAfterTimeout); } else { hideOverlay(); } @@ -239,6 +272,15 @@ export default class Agent extends EventEmitter { } }; + logElementToConsole = ({ id, rendererID }: InspectSelectParams) => { + const renderer = this._rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + } else { + renderer.logElementToConsole(id); + } + }; + reloadAndProfile = () => { localStorage.setItem(LOCAL_STORAGE_RELOAD_AND_PROFILE_KEY, 'true'); @@ -452,7 +494,7 @@ export default class Agent extends EventEmitter { const target = ((event.target: any): HTMLElement); // Don't pass the name explicitly. // It will be inferred from DOM tag and Fiber owner. - showOverlay(target); + showOverlay(target, null, false); this._lastInspectedNode = target; }; diff --git a/src/backend/renderer.js b/src/backend/renderer.js index 5d3c5b9991..0607b7c2ad 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -32,6 +32,7 @@ import type { CommitDetails, DevToolsHook, Fiber, + FiberCommits, FiberData, Interaction, Interactions, @@ -810,21 +811,6 @@ export function attach( } } - function unmountFiberRecursively(fiber, traverseSiblings = false) { - if (__DEBUG__) { - debug('unmountFiberRecursively()', fiber, traverseSiblings); - } - if (!shouldFilterFiber(fiber)) { - recordUnmount(fiber); - } - if (fiber.child !== null) { - unmountFiberRecursively(fiber.child, true); - } - if (traverseSiblings && fiber.sibling !== null) { - unmountFiberRecursively(fiber.sibling, true); - } - } - function maybeRecordUpdate(fiber: Fiber, hasChildOrderChanged: boolean) { if (__DEBUG__) { debug('maybeRecordUpdate()', fiber); @@ -1424,7 +1410,7 @@ export function attach( } } - function inspectElement(id: number): InspectedElement | null { + function inspectElementRaw(id: number): InspectedElement | null { let fiber = idToFiberMap.get(id); if (fiber == null) { @@ -1505,7 +1491,7 @@ export function attach( if (context !== null) { // To simplify hydration and display logic for context, wrap in a value object. // Otherwise simple values (e.g. strings, booleans) become harder to handle. - context = cleanForBridge({ value: context }); + context = { value: context }; } let owners = null; @@ -1544,16 +1530,16 @@ export function attach( // Can view component source location. canViewSource, + displayName: getDataForFiber(fiber).displayName, + // Inspectable properties. // TODO Review sanitization approach for the below inspectable values. context, hooks: usesHooks - ? cleanForBridge( - inspectHooksOfFiber(fiber, (renderer.currentDispatcherRef: any)) - ) + ? inspectHooksOfFiber(fiber, (renderer.currentDispatcherRef: any)) : null, - props: cleanForBridge(memoizedProps), - state: usesHooks ? null : cleanForBridge(memoizedState), + props: memoizedProps, + state: usesHooks ? null : memoizedState, // List of owners owners, @@ -1563,6 +1549,56 @@ export function attach( }; } + function inspectElement(id: number): InspectedElement | null { + let result = inspectElementRaw(id); + if (result === null) { + return null; + } + // TODO Review sanitization approach for the below inspectable values. + result.context = cleanForBridge(result.context); + result.hooks = cleanForBridge(result.hooks); + result.props = cleanForBridge(result.props); + result.state = cleanForBridge(result.state); + return result; + } + + function logElementToConsole(id) { + const result = inspectElementRaw(id); + if (result === null) { + console.warn(`Could not find Fiber with id "${id}"`); + return; + } + + const supportsGroup = typeof console.groupCollapsed === 'function'; + const label = + '[Click to expand] <' + (result.displayName || 'Component') + ' />'; + + if (supportsGroup) { + console.groupCollapsed(label); + } + if (result.props !== null) { + console.log('Props:', result.props); + } + if (result.state !== null) { + console.log('State:', result.state); + } + if (result.hooks !== null) { + console.log('Hooks:', result.hooks); + } + const nativeNode = findNativeByFiberID(id); + if (nativeNode !== null) { + console.log('Node:', nativeNode); + } + if (window.chrome || /firefox/i.test(navigator.userAgent)) { + console.log( + 'Right-click any value to save it as a global variable for further inspection.' + ); + } + if (supportsGroup) { + console.groupEnd(); + } + } + function setInHook( id: number, index: number, @@ -1667,6 +1703,39 @@ export function attach( }; } + function getFiberCommits(rootID: number, fiberID: number): FiberCommits { + const commitProfilingMetadata = ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).get( + rootID + ); + if (commitProfilingMetadata != null) { + const commitDurations = []; + commitProfilingMetadata.forEach(({ actualDurations }, commitIndex) => { + for (let i = 0; i < actualDurations.length; i += 2) { + if (actualDurations[i] === fiberID) { + commitDurations.push(commitIndex, actualDurations[i + 1]); + break; + } + } + }); + + return { + commitDurations, + fiberID, + rootID, + }; + } + + console.warn( + `getFiberCommits(): No profiling info recorded for root "${rootID}"` + ); + + return { + commitDurations: [], + fiberID, + rootID, + }; + } + function getInteractions(rootID: number): Interactions { const commitProfilingMetadata = ((rootToCommitProfilingMetadataMap: any): CommitProfilingMetadataMap).get( rootID @@ -1832,6 +1901,7 @@ export function attach( flushInitialOperations, getCommitDetails, getFiberIDFromNative, + getFiberCommits, getInteractions, findNativeByFiberID, getProfilingDataForDownload, @@ -1839,6 +1909,7 @@ export function attach( handleCommitFiberRoot, handleCommitFiberUnmount, inspectElement, + logElementToConsole, prepareViewElementSource, overrideSuspense, renderer, diff --git a/src/backend/types.js b/src/backend/types.js index 0ee472a015..bd4562eef7 100644 --- a/src/backend/types.js +++ b/src/backend/types.js @@ -66,6 +66,12 @@ export type CommitDetails = {| rootID: number, |}; +export type FiberCommits = {| + commitDurations: Array, + fiberID: number, + rootID: number, +|}; + export type InteractionWithCommits = {| ...Interaction, commits: Array, @@ -93,12 +99,14 @@ export type RendererInterface = { component: NativeType, findNearestUnfilteredAncestor?: boolean ) => number | null, + getFiberCommits: (rootID: number, fiberID: number) => FiberCommits, getInteractions: (rootID: number) => Interactions, getProfilingDataForDownload: (rootID: number) => Object, getProfilingSummary: (rootID: number) => ProfilingSummary, handleCommitFiberRoot: (fiber: Object) => void, handleCommitFiberUnmount: (fiber: Object) => void, inspectElement: (id: number) => InspectedElement | null, + logElementToConsole: (id: number) => void, overrideSuspense: (id: number, forceFallback: boolean) => void, prepareViewElementSource: (id: number) => void, renderer: ReactRenderer | null, diff --git a/src/backend/views/Highlighter.js b/src/backend/views/Highlighter.js index fc98d4a134..c5339f6763 100644 --- a/src/backend/views/Highlighter.js +++ b/src/backend/views/Highlighter.js @@ -18,7 +18,8 @@ export function hideOverlay() { export function showOverlay( element: HTMLElement | null, - componentName: string = '' + componentName: string | null, + hideAfterTimeout: boolean ) { if (timeoutID !== null) { clearTimeout(timeoutID); @@ -34,5 +35,7 @@ export function showOverlay( overlay.inspect(element, componentName); - timeoutID = setTimeout(hideOverlay, SHOW_DURATION); + if (hideAfterTimeout) { + timeoutID = setTimeout(hideOverlay, SHOW_DURATION); + } } diff --git a/src/backend/views/Overlay.js b/src/backend/views/Overlay.js index 24b0933a43..60d0cb8156 100644 --- a/src/backend/views/Overlay.js +++ b/src/backend/views/Overlay.js @@ -215,21 +215,21 @@ function findTipPos(dims, win) { return { top, left: dims.left + margin + 'px' }; } -function getElementDimensions(domElement) { +export function getElementDimensions(domElement: Element) { const calculatedStyle = window.getComputedStyle(domElement); return { - borderLeft: +calculatedStyle.borderLeftWidth.match(/[0-9]*/)[0], - borderRight: +calculatedStyle.borderRightWidth.match(/[0-9]*/)[0], - borderTop: +calculatedStyle.borderTopWidth.match(/[0-9]*/)[0], - borderBottom: +calculatedStyle.borderBottomWidth.match(/[0-9]*/)[0], - marginLeft: +calculatedStyle.marginLeft.match(/[0-9]*/)[0], - marginRight: +calculatedStyle.marginRight.match(/[0-9]*/)[0], - marginTop: +calculatedStyle.marginTop.match(/[0-9]*/)[0], - marginBottom: +calculatedStyle.marginBottom.match(/[0-9]*/)[0], - paddingLeft: +calculatedStyle.paddingLeft.match(/[0-9]*/)[0], - paddingRight: +calculatedStyle.paddingRight.match(/[0-9]*/)[0], - paddingTop: +calculatedStyle.paddingTop.match(/[0-9]*/)[0], - paddingBottom: +calculatedStyle.paddingBottom.match(/[0-9]*/)[0], + borderLeft: parseInt(calculatedStyle.borderLeftWidth, 10), + borderRight: parseInt(calculatedStyle.borderRightWidth, 10), + borderTop: parseInt(calculatedStyle.borderTopWidth, 10), + borderBottom: parseInt(calculatedStyle.borderBottomWidth, 10), + marginLeft: parseInt(calculatedStyle.marginLeft, 10), + marginRight: parseInt(calculatedStyle.marginRight, 10), + marginTop: parseInt(calculatedStyle.marginTop, 10), + marginBottom: parseInt(calculatedStyle.marginBottom, 10), + paddingLeft: parseInt(calculatedStyle.paddingLeft, 10), + paddingRight: parseInt(calculatedStyle.paddingRight, 10), + paddingTop: parseInt(calculatedStyle.paddingTop, 10), + paddingBottom: parseInt(calculatedStyle.paddingBottom, 10), }; } diff --git a/src/devtools/ProfilingCache.js b/src/devtools/ProfilingCache.js index 1699f95f8c..e0b4cfabc9 100644 --- a/src/devtools/ProfilingCache.js +++ b/src/devtools/ProfilingCache.js @@ -23,11 +23,13 @@ import type { Resource } from './cache'; import type { Bridge } from '../types'; import type { CommitDetails as CommitDetailsBackend, + FiberCommits as FiberCommitsBackend, Interactions as InteractionsBackend, ProfilingSummary as ProfilingSummaryBackend, } from 'src/backend/types'; import type { CommitDetails as CommitDetailsFrontend, + FiberCommits as FiberCommitsFrontend, Interactions as InteractionsFrontend, InteractionWithCommits, CommitTree as CommitTreeFrontend, @@ -39,13 +41,19 @@ import type { ChartData as RankedChartData } from 'src/devtools/views/Profiler/R type CommitDetailsParams = {| commitIndex: number, - rootID: number, rendererID: number, + rootID: number, +|}; + +type FiberCommitsParams = {| + fiberID: number, + rendererID: number, + rootID: number, |}; type InteractionsParams = {| - rootID: number, rendererID: number, + rootID: number, |}; type GetCommitTreeParams = {| @@ -54,8 +62,8 @@ type GetCommitTreeParams = {| |}; type ProfilingSummaryParams = {| - rootID: number, rendererID: number, + rootID: number, |}; export default class ProfilingCache { @@ -67,6 +75,11 @@ export default class ProfilingCache { (commitDetails: CommitDetailsFrontend) => void > = new Map(); + _pendingFiberCommitsMap: Map< + string, + (fiberCommits: FiberCommitsFrontend) => void + > = new Map(); + _pendingInteractionsMap: Map< number, (interactions: InteractionsFrontend) => void @@ -121,6 +134,38 @@ export default class ProfilingCache { `${rootID}-${commitIndex}` ); + FiberCommits: Resource< + FiberCommitsParams, + FiberCommitsFrontend + > = createResource( + ({ fiberID, rendererID, rootID }: FiberCommitsParams) => { + return new Promise(resolve => { + const importedProfilingData = this._store.importedProfilingData; + if (importedProfilingData !== null) { + // TODO (profiling) commit details + // Copy from renderer getFiberCommits() + } else if (this._store.profilingOperations.has(rootID)) { + this._pendingFiberCommitsMap.set(`${rootID}-${fiberID}`, resolve); + this._bridge.send('getFiberCommits', { + fiberID, + rendererID, + rootID, + }); + return; + } + + // If no profiling data was recorded for this root, skip the round trip. + resolve({ + commitDurations: [], + fiberID, + rootID, + }); + }); + }, + ({ fiberID, rendererID, rootID }: FiberCommitsParams) => + `${rootID}-${fiberID}` + ); + Interactions: Resource< InteractionsParams, InteractionsFrontend @@ -192,6 +237,7 @@ export default class ProfilingCache { this._store = store; bridge.addListener('commitDetails', this.onCommitDetails); + bridge.addListener('fiberCommits', this.onFiberCommits); bridge.addListener('interactions', this.onInteractions); bridge.addListener('profilingSummary', this.onProfileSummary); } @@ -285,6 +331,24 @@ export default class ProfilingCache { } }; + onFiberCommits = ({ + commitDurations, + fiberID, + rootID, + }: FiberCommitsBackend) => { + const key = `${rootID}-${fiberID}`; + const resolve = this._pendingFiberCommitsMap.get(key); + if (resolve != null) { + this._pendingFiberCommitsMap.delete(key); + + resolve({ + commitDurations, + fiberID, + rootID, + }); + } + }; + onInteractions = ({ interactions, rootID }: InteractionsBackend) => { const resolve = this._pendingInteractionsMap.get(rootID); if (resolve != null) { @@ -304,6 +368,7 @@ export default class ProfilingCache { const resolve = this._pendingProfileSummaryMap.get(rootID); if (resolve != null) { this._pendingProfileSummaryMap.delete(rootID); + const initialTreeBaseDurationsMap = new Map(); for (let i = 0; i < initialTreeBaseDurations.length; i += 2) { initialTreeBaseDurationsMap.set( diff --git a/src/devtools/store.js b/src/devtools/store.js index 69dbf45133..225f9a8ac8 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -1,6 +1,8 @@ // @flow import EventEmitter from 'events'; +import memoize from 'memoize-one'; +import throttle from 'lodash.throttle'; import { TREE_OPERATION_ADD, TREE_OPERATION_RECURSIVE_REMOVE_CHILDREN, @@ -35,6 +37,8 @@ const debug = (methodName, ...args) => { const LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY = 'React::DevTools::captureScreenshots'; +const THROTTLE_CAPTURE_SCREENSHOT_DURATION = 500; + type Config = {| isProfiling?: boolean, supportsCaptureScreenshots?: boolean, @@ -129,8 +133,8 @@ export default class Store extends EventEmitter { if (supportsCaptureScreenshots) { this._supportsCaptureScreenshots = true; this._captureScreenshots = - localStorage.getItem(LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY) !== - 'false'; + localStorage.getItem(LOCAL_STORAGE_CAPTURE_SCREENSHOTS_KEY) === + 'true'; } if (supportsFileDownloads) { this._supportsFileDownloads = true; @@ -416,6 +420,13 @@ export default class Store extends EventEmitter { this.emit('isProfiling'); } + _captureScreenshot = throttle( + memoize((commitIndex: number) => { + this._bridge.send('captureScreenshot', { commitIndex }); + }), + THROTTLE_CAPTURE_SCREENSHOT_DURATION + ); + _takeProfilingSnapshotRecursive = (id: number) => { const element = this.getElementByID(id); if (element !== null) { @@ -454,10 +465,9 @@ export default class Store extends EventEmitter { profilingOperations.push(operations); } - const commitIndex = profilingOperations.length - 1; - if (this._captureScreenshots) { - this._bridge.send('captureScreenshot', { commitIndex }); + const commitIndex = profilingOperations.length - 1; + this._captureScreenshot(commitIndex); } } diff --git a/src/devtools/views/ButtonIcon.js b/src/devtools/views/ButtonIcon.js index 6d423dca00..7aa08b3c6c 100644 --- a/src/devtools/views/ButtonIcon.js +++ b/src/devtools/views/ButtonIcon.js @@ -12,6 +12,7 @@ export type IconType = | 'export' | 'filter' | 'import' + | 'log-data' | 'more' | 'next' | 'previous' @@ -54,6 +55,9 @@ export default function ButtonIcon({ type }: Props) { case 'import': pathData = PATH_IMPORT; break; + case 'log-data': + pathData = PATH_LOG_DATA; + break; case 'more': pathData = PATH_MORE; break; @@ -130,11 +134,16 @@ const PATH_FILTER = 'M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z'; const PATH_IMPORT = 'M8.18,18.13v-7H3l9-8.95,9,9H15.82v7ZM3,20.13H21v1.73H3Z'; +const PATH_LOG_DATA = ` + M20 8h-2.81c-.45-.78-1.07-1.45-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5c-.49 0-.96.06-1.41.17L8.41 + 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 + 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 + 8h-4v-2h4v2zm0-4h-4v-2h4v2z +`; + const PATH_MORE = ` - M22 3H7c-.69 0-1.23.35-1.59.88L0 12l5.41 8.11c.36.53.97.89 1.66.89H22c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zM9 - 13.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5 0c-.83 - 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm5 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 - 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z + M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 + 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z `; const PATH_NEXT = 'M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z'; diff --git a/src/devtools/views/Components/Element.js b/src/devtools/views/Components/Element.js index eef967d931..01f983c217 100644 --- a/src/devtools/views/Components/Element.js +++ b/src/devtools/views/Components/Element.js @@ -4,24 +4,27 @@ import React, { Fragment, useCallback, useContext, - useEffect, + useLayoutEffect, useMemo, useRef, } from 'react'; import { ElementTypeClass, ElementTypeFunction } from 'src/devtools/types'; import { createRegExp } from '../utils'; import { TreeContext } from './TreeContext'; +import { BridgeContext, StoreContext } from '../context'; +import type { ItemData } from './Tree'; import type { Element } from './types'; import styles from './Element.css'; type Props = { + data: ItemData, index: number, style: Object, }; -export default function ElementView({ index, style }: Props) { +export default function ElementView({ data, index, style }: Props) { const { baseDepth, getElementAtIndex, @@ -29,11 +32,14 @@ export default function ElementView({ index, style }: Props) { selectedElementID, selectElementByID, } = useContext(TreeContext); + const bridge = useContext(BridgeContext); + const store = useContext(StoreContext); const element = getElementAtIndex(index); const id = element === null ? null : element.id; const isSelected = selectedElementID === id; + const lastScrolledIDRef = data.lastScrolledIDRef; const handleDoubleClick = useCallback(() => { if (id !== null) { @@ -43,8 +49,22 @@ export default function ElementView({ index, style }: Props) { const ref = useRef(null); - useEffect(() => { + // The tree above has its own autoscrolling, but it only works for rows. + // However, even when the row gets into the viewport, the component name + // might be too far left or right on the screen. Adjust it in this case. + useLayoutEffect(() => { if (isSelected) { + // Don't select the same item twice. + // A row may appear and disappear just by scrolling: + // https://github.com/bvaughn/react-devtools-experimental/issues/67 + // It doesn't necessarily indicate a user action. + // TODO: we might want to revamp the autoscroll logic + // to only happen explicitly for user-initiated events. + if (lastScrolledIDRef.current === id) { + return; + } + lastScrolledIDRef.current = id; + if (ref.current !== null) { ref.current.scrollIntoView({ behavior: 'auto', @@ -53,7 +73,7 @@ export default function ElementView({ index, style }: Props) { }); } } - }, [isSelected]); + }, [id, isSelected, lastScrolledIDRef]); // TODO Add click and key handlers for toggling element open/close state. @@ -66,6 +86,21 @@ export default function ElementView({ index, style }: Props) { [id, selectElementByID] ); + const rendererID = id !== null ? store.getRendererIDForElement(id) : null; + // Individual elements don't have a corresponding leave handler. + // Instead, it's implemented on the tree level. + const handleMouseEnter = useCallback(() => { + if (element !== null && id !== null && rendererID !== null) { + bridge.send('highlightElementInDOM', { + displayName: element.displayName, + hideAfterTimeout: false, + id, + rendererID, + scrollIntoView: false, + }); + } + }, [bridge, element, id, rendererID]); + // Handle elements that are removed from the tree while an async render is in progress. if (element == null) { console.warn(` Could not find element at index ${index}`); @@ -84,6 +119,7 @@ export default function ElementView({ index, style }: Props) { return (
( - - )); + const [elementsTotalWidth, setElementsTotalWidth] = useState(0); + const elementsBarRef = useRef(null); + const isOverflowing = useIsOverflowing(elementsBarRef, elementsTotalWidth); + + useLayoutEffect(() => { + // If we're already overflowing, then we don't need to re-measure items. + // That's because once the owners stack is open, it can only get larger (by driling in). + // A totally new stack can only be reached by exiting this mode and re-entering it. + if (elementsBarRef.current === null || isOverflowing) { + return () => {}; + } + + let elementsTotalWidth = 0; + for (let i = 0; i < ownerStack.length; i++) { + const element = elementsBarRef.current.children[i]; + const computedStyle = getComputedStyle(element); + + elementsTotalWidth += + element.offsetWidth + + parseInt(computedStyle.marginLeft, 10) + + parseInt(computedStyle.marginRight, 10); + } + + setElementsTotalWidth(elementsTotalWidth); + }, [elementsBarRef, isOverflowing, ownerStack.length]); return (
@@ -27,19 +59,97 @@ export default function OwnerStack() {
- {elements} +
+ {isOverflowing && ( + + )} + {isOverflowing ? ( + + ) : ( + ownerStack.map((id, index) => ( + + )) + )} +
); } -type Props = { - id: number, - index: number, +type ElementsDropdownProps = { + ownerStack: Array, + ownerStackIndex: number | null, }; - -function ElementView({ id, index }: Props) { - const { ownerStackIndex, selectOwner } = useContext(TreeContext); +function ElementsDropdown({ + ownerStack, + ownerStackIndex, +}: ElementsDropdownProps) { const store = useContext(StoreContext); + const { selectOwner } = useContext(TreeContext); + + const [isDropdownVisible, setIsDropdownVisible] = useState(false); + + const handleDropdownButtonClick = useCallback(() => { + setIsDropdownVisible(!isDropdownVisible); + }, [isDropdownVisible, setIsDropdownVisible]); + + const handleElementClick = useCallback( + (id: number) => { + selectOwner(id); + setIsDropdownVisible(false); + }, + [selectOwner, setIsDropdownVisible] + ); + + const modalRef = useRef(null); + const dismissModal = useCallback(() => setIsDropdownVisible(false)); + + useModalDismissSignal(modalRef, dismissModal); + + return ( + + + + + {isDropdownVisible && ( +
+ {ownerStack.map((id, index) => ( + + ))} +
+ )} +
+ ); +} + +type ElementViewProps = { + id: number, + index: number | null, +}; +function ElementView({ id, index }: ElementViewProps) { + const store = useContext(StoreContext); + const { ownerStackIndex, selectOwner } = useContext(TreeContext); + const { displayName } = ((store.getElementByID(id): any): Element); const isSelected = ownerStackIndex === index; @@ -52,7 +162,7 @@ function ElementView({ id, index }: Props) { return ( + ))} - +
- {screenshot != null && ( + {captureScreenshots && (
  • - Screenshot + : + {screenshot != null ? ( + Screenshot + ) : ( +
    + No screenshot available +
    + )}
  • )} {screenshot != null && isScreenshotModalVisible && ( diff --git a/src/devtools/views/Profiler/SidebarSelectedFiberInfo.css b/src/devtools/views/Profiler/SidebarSelectedFiberInfo.css new file mode 100644 index 0000000000..a3759a1a6d --- /dev/null +++ b/src/devtools/views/Profiler/SidebarSelectedFiberInfo.css @@ -0,0 +1,57 @@ +.Toolbar { + height: 2.25rem; + padding: 0 0.5rem; + flex: 0 0 auto; + display: flex; + align-items: center; +} + +.Content { + padding: 0.5rem; + user-select: none; + border-top: 1px solid var(--color-border); +} + +.Component { + flex: 1; + color: var(--color-component-name); +} +.Component:before { + white-space: nowrap; + content: '<'; + color: var(--color-jsx-arrow-brackets); +} +.Component:after { + white-space: nowrap; + content: '>'; + color: var(--color-jsx-arrow-brackets); +} + +.Label { + font-weight: bold; + margin-bottom: 0.5rem; +} + +.CurrentCommit, +.Commit { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + padding: 0.25rem 0.5rem; + color: var(--color-text); +} +.Commit:focus, +.Commit:hover { + outline: none; + background-color: var(--color-hover-background); +} + +.CurrentCommit { + background-color: var(--color-selected-background); + color: var(--color-selected-foreground); +} +.CurrentCommit:focus { + outline: none; +} diff --git a/src/devtools/views/Profiler/SidebarSelectedFiberInfo.js b/src/devtools/views/Profiler/SidebarSelectedFiberInfo.js new file mode 100644 index 0000000000..8739509575 --- /dev/null +++ b/src/devtools/views/Profiler/SidebarSelectedFiberInfo.js @@ -0,0 +1,78 @@ +// @flow + +import React, { Fragment, useContext } from 'react'; +import { ProfilerContext } from './ProfilerContext'; +import { formatDuration, formatTime } from './utils'; +import { StoreContext } from '../context'; +import Button from '../Button'; +import ButtonIcon from '../ButtonIcon'; + +import styles from './SidebarSelectedFiberInfo.css'; + +export type Props = {||}; + +export default function SidebarSelectedFiberInfo(_: Props) { + const { profilingCache } = useContext(StoreContext); + const { + rendererID, + rootID, + selectCommitIndex, + selectedCommitIndex, + selectedFiberID, + selectedFiberName, + selectFiber, + } = useContext(ProfilerContext); + + const { commitTimes } = profilingCache.ProfilingSummary.read({ + rendererID: ((rendererID: any): number), + rootID: ((rootID: any): number), + }); + + const { commitDurations } = profilingCache.FiberCommits.read({ + fiberID: ((selectedFiberID: any): number), + rendererID: ((rendererID: any): number), + rootID: ((rootID: any): number), + }); + + const listItems = []; + for (let i = 0; i < commitDurations.length; i += 2) { + const commitIndex = commitDurations[i]; + const duration = commitDurations[i + 1]; + const time = commitTimes[commitIndex]; + + listItems.push( + + ); + } + + return ( + +
    +
    + {selectedFiberName || 'Selected component'} +
    + + +
    +
    + : {listItems} +
    +
    + ); +} diff --git a/src/devtools/views/Profiler/SnapshotSelector.css b/src/devtools/views/Profiler/SnapshotSelector.css index 4a19d964bf..e4f8aec57d 100644 --- a/src/devtools/views/Profiler/SnapshotSelector.css +++ b/src/devtools/views/Profiler/SnapshotSelector.css @@ -11,6 +11,7 @@ } .Commits:focus { outline: none; + background-color: var(--color-button-background-focus); } .IndexLabel { diff --git a/src/devtools/views/Profiler/types.js b/src/devtools/views/Profiler/types.js index fc7d22e770..eb9dd1b494 100644 --- a/src/devtools/views/Profiler/types.js +++ b/src/devtools/views/Profiler/types.js @@ -34,6 +34,12 @@ export type CommitDetails = {| interactions: Array, |}; +export type FiberCommits = {| + commitDurations: Array, + fiberID: number, + rootID: number, +|}; + export type ProfilingSummary = {| rootID: number, diff --git a/src/devtools/views/Settings/Settings.css b/src/devtools/views/Settings/Settings.css index 1b29a756a5..01afd5a23d 100644 --- a/src/devtools/views/Settings/Settings.css +++ b/src/devtools/views/Settings/Settings.css @@ -51,3 +51,9 @@ border-bottom-right-radius: 0.25rem; border-right: 1px solid var(--color-border); } + +.ScreenshotThrottling { + background-color: var(--color-hover-background); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; +} diff --git a/src/devtools/views/Settings/Settings.js b/src/devtools/views/Settings/Settings.js index 808697ea4c..f647162a8a 100644 --- a/src/devtools/views/Settings/Settings.js +++ b/src/devtools/views/Settings/Settings.js @@ -117,16 +117,20 @@ export default function Settings({ portalContainer }: Props) { {store.supportsCaptureScreenshots && (
    Profiler
    -
    - -
    +
    )}
    diff --git a/src/devtools/views/TabBar.css b/src/devtools/views/TabBar.css index 07362e6bde..e1a536e07f 100644 --- a/src/devtools/views/TabBar.css +++ b/src/devtools/views/TabBar.css @@ -15,6 +15,10 @@ .TabCurrent:hover { background-color: var(--color-hover-background); } +.Tab:focus-within, +.TabCurrent:focus-within { + background-color: var(--color-hover-background); +} .TabCurrent { border-bottom: 3px solid var(--color-selected-border); diff --git a/src/devtools/views/TabBar.js b/src/devtools/views/TabBar.js index 81611fb7a2..d5ba8c5cb6 100644 --- a/src/devtools/views/TabBar.js +++ b/src/devtools/views/TabBar.js @@ -1,5 +1,6 @@ // @flow +import classNames from 'classnames'; import React, { Fragment, useCallback } from 'react'; import Icon from './Icon'; @@ -60,9 +61,11 @@ export default function TabBar({ {tabs.map(({ icon, id, label, title }) => (