From 0545f366d4d6b5959f4bb172e810c745f74b9513 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Thu, 3 Oct 2019 11:07:18 -0700 Subject: [PATCH] Added trace updates feature (DOM only) (#16989) * Added trace updates feature (DOM only) * Updated DevTools CHANGELOG --- packages/react-devtools-core/src/backend.js | 2 +- .../react-devtools-extensions/src/backend.js | 4 + .../react-devtools-extensions/src/main.js | 12 ++ .../react-devtools-inline/src/frontend.js | 2 +- .../src/backend/agent.js | 27 ++++ .../src/backend/index.js | 1 + .../src/backend/legacy/renderer.js | 5 + .../src/backend/renderer.js | 109 ++++++++++++-- .../src/backend/types.js | 3 +- .../src/backend/views/Highlighter/Overlay.js | 120 +-------------- .../src/backend/views/TraceUpdates/canvas.js | 107 +++++++++++++ .../src/backend/views/TraceUpdates/index.js | 141 ++++++++++++++++++ .../src/backend/views/utils.js | 127 ++++++++++++++++ packages/react-devtools-shared/src/bridge.js | 2 + .../react-devtools-shared/src/constants.js | 3 + .../src/devtools/store.js | 11 +- .../views/Settings/GeneralSettings.js | 43 ++++-- .../views/Settings/SettingsContext.js | 32 +++- packages/react-devtools/CHANGELOG.md | 2 + 19 files changed, 604 insertions(+), 149 deletions(-) create mode 100644 packages/react-devtools-shared/src/backend/views/TraceUpdates/canvas.js create mode 100644 packages/react-devtools-shared/src/backend/views/TraceUpdates/index.js create mode 100644 packages/react-devtools-shared/src/backend/views/utils.js diff --git a/packages/react-devtools-core/src/backend.js b/packages/react-devtools-core/src/backend.js index e84a07fd3e16..df8cb0c07ee7 100644 --- a/packages/react-devtools-core/src/backend.js +++ b/packages/react-devtools-core/src/backend.js @@ -114,7 +114,7 @@ export function connectToDevTools(options: ?ConnectOptions) { } if (bridge !== null) { - bridge.emit('shutdown'); + bridge.shutdown(); } scheduleRetry(); diff --git a/packages/react-devtools-extensions/src/backend.js b/packages/react-devtools-extensions/src/backend.js index 072e8de09f0e..6a8fe9c3e5ca 100644 --- a/packages/react-devtools-extensions/src/backend.js +++ b/packages/react-devtools-extensions/src/backend.js @@ -67,6 +67,10 @@ function setup(hook) { initBackend(hook, agent, window); + // Let the frontend know that the backend has attached listeners and is ready for messages. + // This covers the case of of syncing saved values after reloading/navigating while DevTools remain open. + bridge.send('extensionBackendInitialized'); + // Setup React Native style editor if a renderer like react-native-web has injected it. if (hook.resolveRNStyle) { setupNativeStyleEditor( diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index d535ba7303b4..229eab5c6a0d 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -9,6 +9,7 @@ import { getBrowserName, getBrowserTheme, } from './utils'; +import {LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY} from 'react-devtools-shared/src/constants'; import { getSavedComponentFilters, getAppendComponentStack, @@ -125,10 +126,21 @@ function createPanelIfReactLoaded() { profilingData = store.profilerStore.profilingData; } + bridge.addListener('extensionBackendInitialized', () => { + // Initialize the renderer's trace-updates setting. + // This handles the case of navigating to a new page after the DevTools have already been shown. + bridge.send( + 'setTraceUpdatesEnabled', + localStorageGetItem(LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY) === + 'true', + ); + }); + store = new Store(bridge, { isProfiling, supportsReloadAndProfile: isChrome, supportsProfiling, + supportsTraceUpdates: true, }); store.profilerStore.profilingData = profilingData; diff --git a/packages/react-devtools-inline/src/frontend.js b/packages/react-devtools-inline/src/frontend.js index 286d1c28a65f..86f3300d4b1c 100644 --- a/packages/react-devtools-inline/src/frontend.js +++ b/packages/react-devtools-inline/src/frontend.js @@ -64,7 +64,7 @@ export function initialize( }, }); - const store: Store = new Store(bridge); + const store: Store = new Store(bridge, {supportsTraceUpdates: true}); const ForwardRef = forwardRef((props, ref) => ( diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 3e73c0f750e6..5161b434c5ba 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -21,6 +21,10 @@ import { sessionStorageSetItem, } from 'react-devtools-shared/src/storage'; import setupHighlighter from './views/Highlighter'; +import { + initialize as setupTraceUpdates, + toggleEnabled as setTraceUpdatesEnabled, +} from './views/TraceUpdates'; import {patch as patchConsole, unpatch as unpatchConsole} from './console'; import type {BackendBridge} from 'react-devtools-shared/src/bridge'; @@ -87,6 +91,7 @@ export default class Agent extends EventEmitter<{| hideNativeHighlight: [], showNativeHighlight: [NativeType], shutdown: [], + traceUpdates: [Set], |}> { _bridge: BackendBridge; _isProfiling: boolean = false; @@ -94,6 +99,7 @@ export default class Agent extends EventEmitter<{| _rendererInterfaces: {[key: RendererID]: RendererInterface} = {}; _persistedSelection: PersistedSelection | null = null; _persistedSelectionMatch: PathMatch | null = null; + _traceUpdatesEnabled: boolean = false; constructor(bridge: BackendBridge) { super(); @@ -131,6 +137,7 @@ export default class Agent extends EventEmitter<{| bridge.addListener('overrideState', this.overrideState); bridge.addListener('overrideSuspense', this.overrideSuspense); bridge.addListener('reloadAndProfile', this.reloadAndProfile); + bridge.addListener('setTraceUpdatesEnabled', this.setTraceUpdatesEnabled); bridge.addListener('startProfiling', this.startProfiling); bridge.addListener('stopProfiling', this.stopProfiling); bridge.addListener( @@ -159,6 +166,7 @@ export default class Agent extends EventEmitter<{| bridge.send('isBackendStorageAPISupported', isBackendStorageAPISupported); setupHighlighter(bridge, this); + setupTraceUpdates(this); } get rendererInterfaces(): {[key: RendererID]: RendererInterface} { @@ -340,6 +348,8 @@ export default class Agent extends EventEmitter<{| rendererInterface.startProfiling(this._recordChangeDescriptions); } + rendererInterface.setTraceUpdatesEnabled(this._traceUpdatesEnabled); + // When the renderer is attached, we need to tell it whether // we remember the previous selection that we'd like to restore. // It'll start tracking mounts for matches to the last selection path. @@ -349,6 +359,19 @@ export default class Agent extends EventEmitter<{| } } + setTraceUpdatesEnabled = (traceUpdatesEnabled: boolean) => { + this._traceUpdatesEnabled = traceUpdatesEnabled; + + setTraceUpdatesEnabled(traceUpdatesEnabled); + + for (let rendererID in this._rendererInterfaces) { + const renderer = ((this._rendererInterfaces[ + (rendererID: any) + ]: any): RendererInterface); + renderer.setTraceUpdatesEnabled(traceUpdatesEnabled); + } + }; + syncSelectionFromNativeElementsPanel = () => { const target = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0; if (target == null) { @@ -416,6 +439,10 @@ export default class Agent extends EventEmitter<{| } }; + onTraceUpdates = (nodes: Set) => { + this.emit('traceUpdates', nodes); + }; + onHookOperations = (operations: Array) => { if (__DEBUG__) { debug('onHookOperations', operations); diff --git a/packages/react-devtools-shared/src/backend/index.js b/packages/react-devtools-shared/src/backend/index.js index 73e6c9f8f251..4af370046f07 100644 --- a/packages/react-devtools-shared/src/backend/index.js +++ b/packages/react-devtools-shared/src/backend/index.js @@ -44,6 +44,7 @@ export function initBackend( }), hook.sub('operations', agent.onHookOperations), + hook.sub('traceUpdates', agent.onTraceUpdates), // TODO Add additional subscriptions required for profiling mode ]; diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index bc2fb30ccf8d..3e7232874504 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -912,6 +912,10 @@ export function attach( // Not implemented. } + function setTraceUpdatesEnabled(enabled: boolean) { + // Not implemented. + } + function setTrackedPath(path: Array | null) { // Not implemented. } @@ -945,6 +949,7 @@ export function attach( setInHook, setInProps, setInState, + setTraceUpdatesEnabled, setTrackedPath, startProfiling, stopProfiling, diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 7a5268f632bd..6f285495c07d 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -57,6 +57,7 @@ import type { InspectedElement, InspectedElementPayload, InstanceAndStyle, + NativeType, Owner, PathFrame, PathMatch, @@ -532,6 +533,10 @@ export function attach( const hideElementsWithPaths: Set = new Set(); const hideElementsWithTypes: Set = new Set(); + // Highlight updates + let traceUpdatesEnabled: boolean = false; + let traceUpdatesForNodes: Set = new Set(); + function applyComponentFilters(componentFilters: Array) { hideElementsWithTypes.clear(); hideElementsWithDisplayNames.clear(); @@ -613,7 +618,7 @@ export function attach( hook.getFiberRoots(rendererID).forEach(root => { currentRootID = getFiberID(getPrimaryFiber(root.current)); setRootPseudoKey(currentRootID, root.current); - mountFiberRecursively(root.current, null); + mountFiberRecursively(root.current, null, false, false); flushPendingEvents(root); currentRootID = -1; }); @@ -1227,7 +1232,8 @@ export function attach( function mountFiberRecursively( fiber: Fiber, parentFiber: Fiber | null, - traverseSiblings = false, + traverseSiblings: boolean, + traceNearestHostComponentUpdate: boolean, ) { if (__DEBUG__) { debug('mountFiberRecursively()', fiber, parentFiber); @@ -1244,6 +1250,20 @@ export function attach( recordMount(fiber, parentFiber); } + if (traceUpdatesEnabled) { + if (traceNearestHostComponentUpdate) { + const elementType = getElementTypeForFiber(fiber); + // If an ancestor updated, we should mark the nearest host nodes for highlighting. + if (elementType === ElementTypeHostComponent) { + traceUpdatesForNodes.add(fiber.stateNode); + traceNearestHostComponentUpdate = false; + } + } + + // We intentionally do not re-enable the traceNearestHostComponentUpdate flag in this branch, + // because we don't want to highlight every host node inside of a newly mounted subtree. + } + const isTimedOutSuspense = fiber.tag === ReactTypeOfWork.SuspenseComponent && fiber.memoizedState !== null; @@ -1264,6 +1284,7 @@ export function attach( fallbackChild, shouldIncludeInTree ? fiber : parentFiber, true, + traceNearestHostComponentUpdate, ); } } else { @@ -1272,6 +1293,7 @@ export function attach( fiber.child, shouldIncludeInTree ? fiber : parentFiber, true, + traceNearestHostComponentUpdate, ); } } @@ -1281,7 +1303,12 @@ export function attach( updateTrackedPathStateAfterMount(mightSiblingsBeOnTrackedPath); if (traverseSiblings && fiber.sibling !== null) { - mountFiberRecursively(fiber.sibling, parentFiber, true); + mountFiberRecursively( + fiber.sibling, + parentFiber, + true, + traceNearestHostComponentUpdate, + ); } } @@ -1430,11 +1457,35 @@ export function attach( nextFiber: Fiber, prevFiber: Fiber, parentFiber: Fiber | null, + traceNearestHostComponentUpdate: boolean, ): boolean { if (__DEBUG__) { debug('updateFiberRecursively()', nextFiber, parentFiber); } + if (traceUpdatesEnabled) { + const elementType = getElementTypeForFiber(nextFiber); + if (traceNearestHostComponentUpdate) { + // If an ancestor updated, we should mark the nearest host nodes for highlighting. + if (elementType === ElementTypeHostComponent) { + traceUpdatesForNodes.add(nextFiber.stateNode); + traceNearestHostComponentUpdate = false; + } + } else { + if ( + elementType === ElementTypeFunction || + elementType === ElementTypeClass || + elementType === ElementTypeContext + ) { + // Otherwise if this is a traced ancestor, flag for the nearest host descendant(s). + traceNearestHostComponentUpdate = didFiberRender( + prevFiber, + nextFiber, + ); + } + } + } + if ( mostRecentlyInspectedElement !== null && mostRecentlyInspectedElement.id === @@ -1481,6 +1532,7 @@ export function attach( nextFallbackChildSet, prevFallbackChildSet, nextFiber, + traceNearestHostComponentUpdate, ) ) { shouldResetChildren = true; @@ -1492,7 +1544,12 @@ export function attach( // 2. Mount primary set const nextPrimaryChildSet = nextFiber.child; if (nextPrimaryChildSet !== null) { - mountFiberRecursively(nextPrimaryChildSet, nextFiber, true); + mountFiberRecursively( + nextPrimaryChildSet, + nextFiber, + true, + traceNearestHostComponentUpdate, + ); } shouldResetChildren = true; } else if (!prevDidTimeout && nextDidTimeOut) { @@ -1507,7 +1564,12 @@ export function attach( ? nextFiberChild.sibling : null; if (nextFallbackChildSet != null) { - mountFiberRecursively(nextFallbackChildSet, nextFiber, true); + mountFiberRecursively( + nextFallbackChildSet, + nextFiber, + true, + traceNearestHostComponentUpdate, + ); shouldResetChildren = true; } } else { @@ -1530,6 +1592,7 @@ export function attach( nextChild, prevChild, shouldIncludeInTree ? nextFiber : parentFiber, + traceNearestHostComponentUpdate, ) ) { // If a nested tree child order changed but it can't handle its own @@ -1547,6 +1610,8 @@ export function attach( mountFiberRecursively( nextChild, shouldIncludeInTree ? nextFiber : parentFiber, + false, + traceNearestHostComponentUpdate, ); shouldResetChildren = true; } @@ -1562,6 +1627,19 @@ export function attach( if (prevChildAtSameIndex !== null) { shouldResetChildren = true; } + } else { + if (traceUpdatesEnabled) { + // If we're tracing updates and we've bailed out before reaching a host node, + // we should fall back to recursively marking the nearest host descendates for highlight. + if (traceNearestHostComponentUpdate) { + const hostFibers = findAllCurrentHostFibers( + getFiberID(getPrimaryFiber(nextFiber)), + ); + hostFibers.forEach(hostFiber => { + traceUpdatesForNodes.add(hostFiber.stateNode); + }); + } + } } } if (shouldIncludeInTree) { @@ -1645,7 +1723,7 @@ export function attach( }; } - mountFiberRecursively(root.current, null); + mountFiberRecursively(root.current, null, false, false); flushPendingEvents(root); currentRootID = -1; }); @@ -1671,6 +1749,10 @@ export function attach( mightBeOnTrackedPath = true; } + if (traceUpdatesEnabled) { + traceUpdatesForNodes.clear(); + } + // Checking root.memoizedInteractions handles multi-renderer edge-case- // where some v16 renderers support profiling and others don't. const isProfilingSupported = root.memoizedInteractions != null; @@ -1704,10 +1786,10 @@ export function attach( if (!wasMounted && isMounted) { // Mount a new root. setRootPseudoKey(currentRootID, current); - mountFiberRecursively(current, null); + mountFiberRecursively(current, null, false, false); } else if (wasMounted && isMounted) { // Update an existing root. - updateFiberRecursively(current, alternate, null); + updateFiberRecursively(current, alternate, null, false); } else if (wasMounted && !isMounted) { // Unmount an existing root. removeRootPseudoKey(currentRootID); @@ -1716,7 +1798,7 @@ export function attach( } else { // Mount a new root. setRootPseudoKey(currentRootID, current); - mountFiberRecursively(current, null); + mountFiberRecursively(current, null, false, false); } if (isProfiling && isProfilingSupported) { @@ -1738,6 +1820,10 @@ export function attach( // We're done here. flushPendingEvents(root); + if (traceUpdatesEnabled) { + hook.emit('traceUpdates', traceUpdatesForNodes); + } + currentRootID = -1; } @@ -3015,6 +3101,10 @@ export function attach( } }; + function setTraceUpdatesEnabled(isEnabled: boolean): void { + traceUpdatesEnabled = isEnabled; + } + return { cleanup, findNativeNodesForFiberID, @@ -3036,6 +3126,7 @@ export function attach( setInHook, setInProps, setInState, + setTraceUpdatesEnabled, setTrackedPath, startProfiling, stopProfiling, diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index bf6a4c728671..118049abc2d8 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -252,10 +252,11 @@ export type RendererInterface = { ) => void, setInProps: (id: number, path: Array, value: any) => void, setInState: (id: number, path: Array, value: any) => void, + setTraceUpdatesEnabled: (enabled: boolean) => void, setTrackedPath: (path: Array | null) => void, startProfiling: (recordChangeDescriptions: boolean) => void, stopProfiling: () => void, - updateComponentFilters: (somponentFilters: Array) => void, + updateComponentFilters: (componentFilters: Array) => void, }; export type Handler = (data: any) => void; diff --git a/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js b/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js index 4cf78b264cd8..cc2ac643d595 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/Overlay.js @@ -8,15 +8,9 @@ */ import assign from 'object-assign'; +import {getElementDimensions, getNestedBoundingClientRect} from '../utils'; -type Rect = { - bottom: number, - height: number, - left: number, - right: number, - top: number, - width: number, -}; +import type {Rect} from '../utils'; type Box = {|top: number, left: number, width: number, height: number|}; @@ -333,116 +327,6 @@ function findTipPos(dims, bounds, tipSize) { }; } -export function getElementDimensions(domElement: Element) { - const calculatedStyle = window.getComputedStyle(domElement); - return { - 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), - }; -} - -// Get the window object for the document that a node belongs to, -// or return null if it cannot be found (node not attached to DOM, -// etc). -function getOwnerWindow(node: HTMLElement): typeof window | null { - if (!node.ownerDocument) { - return null; - } - return node.ownerDocument.defaultView; -} - -// Get the iframe containing a node, or return null if it cannot -// be found (node not within iframe, etc). -function getOwnerIframe(node: HTMLElement): HTMLElement | null { - const nodeWindow = getOwnerWindow(node); - if (nodeWindow) { - return nodeWindow.frameElement; - } - return null; -} - -// Get a bounding client rect for a node, with an -// offset added to compensate for its border. -function getBoundingClientRectWithBorderOffset(node: HTMLElement) { - const dimensions = getElementDimensions(node); - return mergeRectOffsets([ - node.getBoundingClientRect(), - { - top: dimensions.borderTop, - left: dimensions.borderLeft, - bottom: dimensions.borderBottom, - right: dimensions.borderRight, - // This width and height won't get used by mergeRectOffsets (since this - // is not the first rect in the array), but we set them so that this - // object typechecks as a ClientRect. - width: 0, - height: 0, - }, - ]); -} - -// Add together the top, left, bottom, and right properties of -// each ClientRect, but keep the width and height of the first one. -function mergeRectOffsets(rects: Array): Rect { - return rects.reduce((previousRect, rect) => { - if (previousRect == null) { - return rect; - } - - return { - top: previousRect.top + rect.top, - left: previousRect.left + rect.left, - width: previousRect.width, - height: previousRect.height, - bottom: previousRect.bottom + rect.bottom, - right: previousRect.right + rect.right, - }; - }); -} - -// Calculate a boundingClientRect for a node relative to boundaryWindow, -// taking into account any offsets caused by intermediate iframes. -function getNestedBoundingClientRect( - node: HTMLElement, - boundaryWindow: typeof window, -): Rect { - const ownerIframe = getOwnerIframe(node); - if (ownerIframe && ownerIframe !== boundaryWindow) { - const rects = [node.getBoundingClientRect()]; - let currentIframe = ownerIframe; - let onlyOneMore = false; - while (currentIframe) { - const rect = getBoundingClientRectWithBorderOffset(currentIframe); - rects.push(rect); - currentIframe = getOwnerIframe(currentIframe); - - if (onlyOneMore) { - break; - } - // We don't want to calculate iframe offsets upwards beyond - // the iframe containing the boundaryWindow, but we - // need to calculate the offset relative to the boundaryWindow. - if (currentIframe && getOwnerWindow(currentIframe) === boundaryWindow) { - onlyOneMore = true; - } - } - - return mergeRectOffsets(rects); - } else { - return node.getBoundingClientRect(); - } -} - function boxWrap(dims, what, node) { assign(node.style, { borderTopWidth: dims[what + 'Top'] + 'px', diff --git a/packages/react-devtools-shared/src/backend/views/TraceUpdates/canvas.js b/packages/react-devtools-shared/src/backend/views/TraceUpdates/canvas.js new file mode 100644 index 000000000000..4acdbef21a4e --- /dev/null +++ b/packages/react-devtools-shared/src/backend/views/TraceUpdates/canvas.js @@ -0,0 +1,107 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Data} from './index'; +import type {Rect} from '../utils'; +import type {NativeType} from '../../types'; + +const OUTLINE_COLOR = '#f0f0f0'; + +// Note these colors are in sync with DevTools Profiler chart colors. +const COLORS = [ + '#37afa9', + '#63b19e', + '#80b393', + '#97b488', + '#abb67d', + '#beb771', + '#cfb965', + '#dfba57', + '#efbb49', + '#febc38', +]; + +let canvas: HTMLCanvasElement | null = null; + +export function draw(nodeToData: Map): void { + if (canvas === null) { + initialize(); + } + + const canvasFlow: HTMLCanvasElement = ((canvas: any): HTMLCanvasElement); + canvasFlow.width = window.screen.availWidth; + canvasFlow.height = window.screen.availHeight; + + const context = canvasFlow.getContext('2d'); + context.clearRect(0, 0, canvasFlow.width, canvasFlow.height); + + nodeToData.forEach(({count, rect}) => { + if (rect !== null) { + const colorIndex = Math.min(COLORS.length - 1, count - 1); + const color = COLORS[colorIndex]; + + drawBorder(context, rect, color); + } + }); +} + +function drawBorder( + context: CanvasRenderingContext2D, + rect: Rect, + color: string, +): void { + const {height, left, top, width} = rect; + + // outline + context.lineWidth = 1; + context.strokeStyle = OUTLINE_COLOR; + + context.strokeRect(left - 1, top - 1, width + 2, height + 2); + + // inset + context.lineWidth = 1; + context.strokeStyle = OUTLINE_COLOR; + context.strokeRect(left + 1, top + 1, width - 1, height - 1); + context.strokeStyle = color; + + context.setLineDash([0]); + + // border + context.lineWidth = 1; + context.strokeRect(left, top, width - 1, height - 1); + + context.setLineDash([0]); +} + +export function destroy(): void { + if (canvas !== null) { + if (canvas.parentNode != null) { + canvas.parentNode.removeChild(canvas); + } + canvas = null; + } +} + +function initialize(): void { + canvas = window.document.createElement('canvas'); + canvas.style.cssText = ` + xx-background-color: red; + xx-opacity: 0.5; + bottom: 0; + left: 0; + pointer-events: none; + position: fixed; + right: 0; + top: 0; + z-index: 1000000000; + `; + + const root = window.document.documentElement; + root.insertBefore(canvas, root.firstChild); +} diff --git a/packages/react-devtools-shared/src/backend/views/TraceUpdates/index.js b/packages/react-devtools-shared/src/backend/views/TraceUpdates/index.js new file mode 100644 index 000000000000..1861b7d155e1 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/views/TraceUpdates/index.js @@ -0,0 +1,141 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import Agent from 'react-devtools-shared/src/backend/agent'; +import {destroy as destroyCanvas, draw} from './canvas'; +import {getNestedBoundingClientRect} from '../utils'; + +import type {NativeType} from '../../types'; +import type {Rect} from '../utils'; + +// How long the rect should be shown for? +const DISPLAY_DURATION = 250; + +// What's the longest we are willing to show the overlay for? +// This can be important if we're getting a flurry of events (e.g. scroll update). +const MAX_DISPLAY_DURATION = 3000; + +// How long should a rect be considered valid for? +const REMEASUREMENT_AFTER_DURATION = 250; + +// Some environments (e.g. React Native / Hermes) don't support the performace API yet. +const getCurrentTime = + typeof performance === 'object' && typeof performance.now === 'function' + ? () => performance.now() + : () => Date.now(); + +export type Data = {| + count: number, + expirationTime: number, + lastMeasuredAt: number, + rect: Rect | null, +|}; + +const nodeToData: Map = new Map(); + +let agent: Agent = ((null: any): Agent); +let drawAnimationFrameID: AnimationFrameID | null = null; +let isEnabled: boolean = false; +let redrawTimeoutID: TimeoutID | null = null; + +export function initialize(injectedAgent: Agent): void { + agent = injectedAgent; + agent.addListener('traceUpdates', traceUpdates); +} + +export function toggleEnabled(value: boolean): void { + isEnabled = value; + + if (!isEnabled) { + nodeToData.clear(); + + if (drawAnimationFrameID !== null) { + cancelAnimationFrame(drawAnimationFrameID); + drawAnimationFrameID = null; + } + + if (redrawTimeoutID !== null) { + clearTimeout(redrawTimeoutID); + redrawTimeoutID = null; + } + + destroyCanvas(); + } +} + +function traceUpdates(nodes: Set): void { + if (!isEnabled) { + return; + } + + nodes.forEach(node => { + const data = nodeToData.get(node); + const now = getCurrentTime(); + + let lastMeasuredAt = data != null ? data.lastMeasuredAt : 0; + let rect = data != null ? data.rect : null; + if (rect === null || lastMeasuredAt + REMEASUREMENT_AFTER_DURATION < now) { + lastMeasuredAt = now; + rect = measureNode(node); + } + + nodeToData.set(node, { + count: data != null ? data.count + 1 : 1, + expirationTime: + data != null + ? Math.min( + now + MAX_DISPLAY_DURATION, + data.expirationTime + DISPLAY_DURATION, + ) + : now + DISPLAY_DURATION, + lastMeasuredAt, + rect, + }); + }); + + if (redrawTimeoutID !== null) { + clearTimeout(redrawTimeoutID); + redrawTimeoutID = null; + } + + if (drawAnimationFrameID === null) { + drawAnimationFrameID = requestAnimationFrame(prepareToDraw); + } +} + +function prepareToDraw(): void { + drawAnimationFrameID = null; + redrawTimeoutID = null; + + const now = getCurrentTime(); + let earliestExpiration = Number.MAX_VALUE; + + // Remove any items that have already expired. + nodeToData.forEach((data, node) => { + if (data.expirationTime < now) { + nodeToData.delete(node); + } else { + earliestExpiration = Math.min(earliestExpiration, data.expirationTime); + } + }); + + draw(nodeToData); + + redrawTimeoutID = setTimeout(prepareToDraw, earliestExpiration - now); +} + +function measureNode(node: Object): Rect | null { + if (!node || typeof node.getBoundingClientRect !== 'function') { + return null; + } + + let currentWindow = window.__REACT_DEVTOOLS_TARGET_WINDOW__ || window; + + return getNestedBoundingClientRect(node, currentWindow); +} diff --git a/packages/react-devtools-shared/src/backend/views/utils.js b/packages/react-devtools-shared/src/backend/views/utils.js new file mode 100644 index 000000000000..fa779a574d77 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/views/utils.js @@ -0,0 +1,127 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export type Rect = { + bottom: number, + height: number, + left: number, + right: number, + top: number, + width: number, +}; + +// Get the window object for the document that a node belongs to, +// or return null if it cannot be found (node not attached to DOM, +// etc). +export function getOwnerWindow(node: HTMLElement): typeof window | null { + if (!node.ownerDocument) { + return null; + } + return node.ownerDocument.defaultView; +} + +// Get the iframe containing a node, or return null if it cannot +// be found (node not within iframe, etc). +export function getOwnerIframe(node: HTMLElement): HTMLElement | null { + const nodeWindow = getOwnerWindow(node); + if (nodeWindow) { + return nodeWindow.frameElement; + } + return null; +} + +// Get a bounding client rect for a node, with an +// offset added to compensate for its border. +export function getBoundingClientRectWithBorderOffset(node: HTMLElement) { + const dimensions = getElementDimensions(node); + return mergeRectOffsets([ + node.getBoundingClientRect(), + { + top: dimensions.borderTop, + left: dimensions.borderLeft, + bottom: dimensions.borderBottom, + right: dimensions.borderRight, + // This width and height won't get used by mergeRectOffsets (since this + // is not the first rect in the array), but we set them so that this + // object typechecks as a ClientRect. + width: 0, + height: 0, + }, + ]); +} + +// Add together the top, left, bottom, and right properties of +// each ClientRect, but keep the width and height of the first one. +export function mergeRectOffsets(rects: Array): Rect { + return rects.reduce((previousRect, rect) => { + if (previousRect == null) { + return rect; + } + + return { + top: previousRect.top + rect.top, + left: previousRect.left + rect.left, + width: previousRect.width, + height: previousRect.height, + bottom: previousRect.bottom + rect.bottom, + right: previousRect.right + rect.right, + }; + }); +} + +// Calculate a boundingClientRect for a node relative to boundaryWindow, +// taking into account any offsets caused by intermediate iframes. +export function getNestedBoundingClientRect( + node: HTMLElement, + boundaryWindow: typeof window, +): Rect { + const ownerIframe = getOwnerIframe(node); + if (ownerIframe && ownerIframe !== boundaryWindow) { + const rects = [node.getBoundingClientRect()]; + let currentIframe = ownerIframe; + let onlyOneMore = false; + while (currentIframe) { + const rect = getBoundingClientRectWithBorderOffset(currentIframe); + rects.push(rect); + currentIframe = getOwnerIframe(currentIframe); + + if (onlyOneMore) { + break; + } + // We don't want to calculate iframe offsets upwards beyond + // the iframe containing the boundaryWindow, but we + // need to calculate the offset relative to the boundaryWindow. + if (currentIframe && getOwnerWindow(currentIframe) === boundaryWindow) { + onlyOneMore = true; + } + } + + return mergeRectOffsets(rects); + } else { + return node.getBoundingClientRect(); + } +} + +export function getElementDimensions(domElement: Element) { + const calculatedStyle = window.getComputedStyle(domElement); + return { + 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/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index 695121d08c25..f6b39afac7e2 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -70,6 +70,7 @@ type NativeStyleEditor_SetValueParams = {| |}; type BackendEvents = {| + extensionBackendInitialized: [], inspectedElement: [InspectedElementPayload], isBackendStorageAPISupported: [boolean], operations: [Array], @@ -108,6 +109,7 @@ type FrontendEvents = {| profilingData: [ProfilingDataBackend], reloadAndProfile: [boolean], selectFiber: [number], + setTraceUpdatesEnabled: [boolean], shutdown: [], startInspectingNative: [], startProfiling: [boolean], diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index d0f0a89807f9..e6e2a8ad3538 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -30,6 +30,9 @@ export const SESSION_STORAGE_RELOAD_AND_PROFILE_KEY = export const LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY = 'React::DevTools::appendComponentStack'; +export const LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY = + 'React::DevTools::traceUpdatesEnabled'; + export const PROFILER_EXPORT_VERSION = 4; export const CHANGE_LOG_URL = diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index efbcf0219688..5cb23a4d2e87 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -53,6 +53,7 @@ type Config = {| supportsNativeInspection?: boolean, supportsReloadAndProfile?: boolean, supportsProfiling?: boolean, + supportsTraceUpdates?: boolean, |}; export type Capabilities = {| @@ -125,6 +126,7 @@ export default class Store extends EventEmitter<{| _supportsNativeInspection: boolean = true; _supportsProfiling: boolean = false; _supportsReloadAndProfile: boolean = false; + _supportsTraceUpdates: boolean = false; _unsupportedRendererVersionDetected: boolean = false; @@ -157,6 +159,7 @@ export default class Store extends EventEmitter<{| supportsNativeInspection, supportsProfiling, supportsReloadAndProfile, + supportsTraceUpdates, } = config; this._supportsNativeInspection = supportsNativeInspection !== false; if (supportsProfiling) { @@ -165,6 +168,9 @@ export default class Store extends EventEmitter<{| if (supportsReloadAndProfile) { this._supportsReloadAndProfile = true; } + if (supportsTraceUpdates) { + this._supportsTraceUpdates = true; + } } this._bridge = bridge; @@ -336,7 +342,6 @@ export default class Store extends EventEmitter<{| get supportsProfiling(): boolean { return this._supportsProfiling; } - get supportsReloadAndProfile(): boolean { // Does the DevTools shell support reloading and eagerly injecting the renderer interface? // And if so, can the backend use the localStorage API? @@ -344,6 +349,10 @@ export default class Store extends EventEmitter<{| return this._supportsReloadAndProfile && this._isBackendStorageAPISupported; } + get supportsTraceUpdates(): boolean { + return this._supportsTraceUpdates; + } + get unsupportedRendererVersionDetected(): boolean { return this._unsupportedRendererVersionDetected; } diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js b/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js index c896a14152f1..4e7ce6357c63 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/GeneralSettings.js @@ -9,31 +9,33 @@ import React, {useContext} from 'react'; import {SettingsContext} from './SettingsContext'; +import {StoreContext} from '../context'; import {CHANGE_LOG_URL} from 'react-devtools-shared/src/constants'; import styles from './SettingsShared.css'; export default function GeneralSettings(_: {||}) { const { + appendComponentStack, displayDensity, + setAppendComponentStack, setDisplayDensity, - theme, setTheme, - appendComponentStack, - setAppendComponentStack, + setTraceUpdatesEnabled, + theme, + traceUpdatesEnabled, } = useContext(SettingsContext); - const updateDisplayDensity = ({currentTarget}) => - setDisplayDensity(currentTarget.value); - const updateTheme = ({currentTarget}) => setTheme(currentTarget.value); - const updateappendComponentStack = ({currentTarget}) => - setAppendComponentStack(currentTarget.checked); + const {supportsTraceUpdates} = useContext(StoreContext); return (
Theme
- setTheme(currentTarget.value)}> @@ -45,18 +47,37 @@ export default function GeneralSettings(_: {||}) {
+ {supportsTraceUpdates && ( +
+ +
+ )} +
diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js index ed536874ce08..a9c3c3c2b26c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js @@ -18,6 +18,7 @@ import { COMFORTABLE_LINE_HEIGHT, COMPACT_LINE_HEIGHT, LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY, + LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY, } from 'react-devtools-shared/src/constants'; import {useLocalStorage} from '../hooks'; import {BridgeContext} from '../context'; @@ -40,6 +41,9 @@ type Context = {| theme: Theme, setTheme(value: Theme): void, + + traceUpdatesEnabled: boolean, + setTraceUpdatesEnabled: (value: boolean) => void, |}; const SettingsContext = createContext(((null: any): Context)); @@ -73,6 +77,9 @@ function SettingsContextController({ const [appendComponentStack, setAppendComponentStack] = useLocalStorage< boolean, >(LOCAL_STORAGE_SHOULD_PATCH_CONSOLE_KEY, true); + const [traceUpdatesEnabled, setTraceUpdatesEnabled] = useLocalStorage< + boolean, + >(LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY, false); const documentElements = useMemo( () => { @@ -138,26 +145,37 @@ function SettingsContextController({ [bridge, appendComponentStack], ); + useEffect( + () => { + bridge.send('setTraceUpdatesEnabled', traceUpdatesEnabled); + }, + [bridge, traceUpdatesEnabled], + ); + const value = useMemo( () => ({ - displayDensity, - setDisplayDensity, - theme, - setTheme, appendComponentStack, - setAppendComponentStack, + displayDensity, lineHeight: displayDensity === 'compact' ? COMPACT_LINE_HEIGHT : COMFORTABLE_LINE_HEIGHT, + setAppendComponentStack, + setDisplayDensity, + setTheme, + setTraceUpdatesEnabled, + theme, + traceUpdatesEnabled, }), [ + appendComponentStack, displayDensity, + setAppendComponentStack, setDisplayDensity, setTheme, - appendComponentStack, - setAppendComponentStack, + setTraceUpdatesEnabled, theme, + traceUpdatesEnabled, ], ); diff --git a/packages/react-devtools/CHANGELOG.md b/packages/react-devtools/CHANGELOG.md index 51d05827d034..20fc2f7a3a98 100644 --- a/packages/react-devtools/CHANGELOG.md +++ b/packages/react-devtools/CHANGELOG.md @@ -8,6 +8,8 @@ +#### Features +* "Highlight updates" feature added for browser extensions and `react-devtools-inline` NPM package. ([bvaughn](https://github.com/bvaughn) in [#16989](https://github.com/facebook/react/pull/16989)) ## 4.1.3 (September 30, 2019)