diff --git a/shells/browser/shared/src/contentScript.js b/shells/browser/shared/src/contentScript.js index 19a0ba7faa..68a44e21af 100644 --- a/shells/browser/shared/src/contentScript.js +++ b/shells/browser/shared/src/contentScript.js @@ -61,13 +61,17 @@ port.onDisconnect.addListener(handleDisconnect); window.addEventListener('message', handleMessageFromPage); +sayHelloToBackend(); + // The backend waits to install the global hook until notified by the content script. // In the event of a page reload, the content script might be loaded before the backend is injected. // Because of this we need to poll the backend until it has been initialized. -const intervalID = setInterval(() => { - if (backendInitialized || backendDisconnected) { - clearInterval(intervalID); - } else { - sayHelloToBackend(); - } -}, 500); +if (!backendInitialized) { + const intervalID = setInterval(() => { + if (backendInitialized || backendDisconnected) { + clearInterval(intervalID); + } else { + sayHelloToBackend(); + } + }, 500); +} diff --git a/shells/browser/shared/src/main.js b/shells/browser/shared/src/main.js index b010914979..b23beb289d 100644 --- a/shells/browser/shared/src/main.js +++ b/shells/browser/shared/src/main.js @@ -12,6 +12,8 @@ import { } from './utils'; import DevTools from 'src/devtools/views/DevTools'; +const SUPPORTS_PROFILING_KEY = 'React::DevTools::supportsProfiling'; + let panelCreated = false; function createPanelIfReactLoaded() { @@ -61,8 +63,24 @@ function createPanelIfReactLoaded() { } }, }); + bridge.addListener('reloadAppForProfiling', () => { + localStorage.setItem(SUPPORTS_PROFILING_KEY, 'true'); + chrome.devtools.inspectedWindow.eval('window.location.reload();'); + }); - store = new Store(bridge); + // This flag lets us tip the Store off early that we expect to be profiling. + // This avoids flashing a temporary "Profiling not supported" message in the Profiler tab, + // after a user has clicked the "reload and profile" button. + let supportsProfiling = false; + if (localStorage.getItem(SUPPORTS_PROFILING_KEY) === 'true') { + supportsProfiling = true; + localStorage.removeItem(SUPPORTS_PROFILING_KEY); + } + + store = new Store(bridge, { + supportsReloadAndProfile: true, + supportsProfiling, + }); // Initialize the backend only once the Store has been initialized. // Otherwise the Store may miss important initial tree op codes. diff --git a/src/backend/agent.js b/src/backend/agent.js index fc113a9994..b8a5d15cd8 100644 --- a/src/backend/agent.js +++ b/src/backend/agent.js @@ -38,11 +38,23 @@ type SetInParams = {| value: any, |}; +const RELOAD_AND_PROFILE_KEY = 'React::DevTools::reloadAndProfile'; + export default class Agent extends EventEmitter { _bridge: Bridge = ((null: any): Bridge); _isProfiling: boolean = false; _rendererInterfaces: { [key: RendererID]: RendererInterface } = {}; + constructor() { + super(); + + if (localStorage.getItem(RELOAD_AND_PROFILE_KEY) === 'true') { + this._isProfiling = true; + + localStorage.removeItem(RELOAD_AND_PROFILE_KEY); + } + } + addBridge(bridge: Bridge) { this._bridge = bridge; @@ -56,6 +68,7 @@ export default class Agent extends EventEmitter { bridge.addListener('overrideHookState', this.overrideHookState); bridge.addListener('overrideProps', this.overrideProps); bridge.addListener('overrideState', this.overrideState); + bridge.addListener('reloadAndProfile', this.reloadAndProfile); bridge.addListener('selectElement', this.selectElement); bridge.addListener('startInspectingDOM', this.startInspectingDOM); bridge.addListener('startProfiling', this.startProfiling); @@ -63,6 +76,10 @@ export default class Agent extends EventEmitter { bridge.addListener('stopProfiling', this.stopProfiling); bridge.addListener('shutdown', this.shutdown); bridge.addListener('viewElementSource', this.viewElementSource); + + if (this._isProfiling) { + this._bridge.send('profilingStatus', true); + } } getIDForNode(node: Object): number | null { @@ -179,6 +196,15 @@ export default class Agent extends EventEmitter { } }; + reloadAndProfile = () => { + localStorage.setItem(RELOAD_AND_PROFILE_KEY, 'true'); + + // This code path should only be hit if the shell has explicitly told the Store that it supports profiling. + // In that case, the shell must also listen for this specific message to know when it needs to reload the app. + // The agent can't do this in a way that is renderer agnostic. + this._bridge.send('reloadAppForProfiling'); + }; + selectElement = ({ id, rendererID }: InspectSelectParams) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { @@ -235,6 +261,10 @@ export default class Agent extends EventEmitter { rendererInterface: RendererInterface ) { this._rendererInterfaces[rendererID] = rendererInterface; + + if (this._isProfiling) { + rendererInterface.startProfiling(); + } } shutdown = () => { diff --git a/src/backend/renderer.js b/src/backend/renderer.js index 8d9daf0fce..819d9ff757 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -639,6 +639,17 @@ export function attach( operation[1] = id; operation[2] = treeBaseDuration; addOperation(operation); + + const { actualDuration } = fiber; + if (actualDuration > 0) { + // If profiling is active, store durations for elements that were rendered during the commit. + const metadata = ((currentCommitProfilingMetadata: any): CommitProfilingData); + metadata.actualDurations.push(id, actualDuration); + metadata.maxActualDuration = Math.max( + metadata.maxActualDuration, + actualDuration + ); + } } } @@ -866,6 +877,23 @@ export function attach( // Hydrate all the roots for the first time. hook.getFiberRoots(rendererID).forEach(root => { currentRootID = getFiberID(getPrimaryFiber(root.current)); + + if (isProfiling) { + // If profiling is active, store commit time and duration, and the current interactions. + // The frontend may request this information after profiling has stopped. + currentCommitProfilingMetadata = { + actualDurations: [], + commitTime: performance.now() - profilingStartTime, + interactions: Array.from(root.memoizedInteractions).map( + (interaction: Interaction) => ({ + ...interaction, + timestamp: interaction.timestamp - profilingStartTime, + }) + ), + maxActualDuration: 0, + }; + } + mountFiber(root.current, null); flushPendingEvents(root); currentRootID = -1; diff --git a/src/devtools/store.js b/src/devtools/store.js index 9884897acf..3895689d76 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -27,6 +27,11 @@ const debug = (methodName, ...args) => { } }; +type Config = {| + supportsReloadAndProfile?: boolean, + supportsProfiling?: boolean, +|}; + type ProfilingSnapshotNode = {| id: number, children: Array, @@ -86,11 +91,22 @@ export default class Store extends EventEmitter { _supportsProfiling: boolean = false; - constructor(bridge: Bridge) { + _supportsReloadAndProfile: boolean = false; + + constructor(bridge: Bridge, config?: Config) { super(); debug('constructor', 'subscribing to Bridge'); + if (config != null) { + if (config.supportsProfiling) { + this._supportsProfiling = true; + } + if (config.supportsReloadAndProfile) { + this._supportsReloadAndProfile = true; + } + } + this._bridge = bridge; bridge.addListener('operations', this.onBridgeOperations); bridge.addListener('profilingStatus', this.onProfilingStatus); @@ -140,6 +156,10 @@ export default class Store extends EventEmitter { return this._supportsProfiling; } + get supportsReloadAndProfile(): boolean { + return this._supportsReloadAndProfile; + } + getElementAtIndex(index: number): Element | null { if (index < 0 || index >= this.numElements) { console.warn( diff --git a/src/devtools/views/Profiler/CommitTreeBuilder.js b/src/devtools/views/Profiler/CommitTreeBuilder.js index 944ecb08e0..6412aa819c 100644 --- a/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -44,71 +44,69 @@ export function getCommitTree({ rootID: number, store: Store, |}): CommitTree { - if (store.profilingSnapshot.has(rootID)) { - if (!rootToCommitTreeMap.has(rootID)) { - rootToCommitTreeMap.set(rootID, []); - } + if (!rootToCommitTreeMap.has(rootID)) { + rootToCommitTreeMap.set(rootID, []); + } - const commitTrees = ((rootToCommitTreeMap.get( - rootID - ): any): Array); + const commitTrees = ((rootToCommitTreeMap.get( + rootID + ): any): Array); - if (commitIndex < commitTrees.length) { - return commitTrees[commitIndex]; - } + if (commitIndex < commitTrees.length) { + return commitTrees[commitIndex]; + } - // Commits are generated sequentially and cached. - // If this is the very first commit, start with the cached snapshot and apply the first mutation. - // Otherwise load (or generate) the previous commit and append a mutation to it. - if (commitIndex === 0) { - const nodes = new Map(); + // Commits are generated sequentially and cached. + // If this is the very first commit, start with the cached snapshot and apply the first mutation. + // Otherwise load (or generate) the previous commit and append a mutation to it. + if (commitIndex === 0) { + const nodes = new Map(); - // Construct the initial tree. - recursivelyIniitliazeTree( - rootID, - 0, - nodes, - profilingSummary.initialTreeBaseDurations, - store + // Construct the initial tree. + recursivelyIniitliazeTree( + rootID, + 0, + nodes, + profilingSummary.initialTreeBaseDurations, + store + ); + + // Mutate the tree + const commitOperations = store.profilingOperations.get(rootID); + if (commitOperations != null && commitIndex < commitOperations.length) { + const commitTree = updateTree( + { nodes, rootID }, + commitOperations[commitIndex] ); - // Mutate the tree - const commitOperations = store.profilingOperations.get(rootID); - if (commitOperations != null && commitIndex < commitOperations.length) { - const commitTree = updateTree( - { nodes, rootID }, - commitOperations[commitIndex] - ); - - if (__DEBUG__) { - __printTree(commitTree); - } - - commitTrees.push(commitTree); - return commitTree; + if (__DEBUG__) { + __printTree(commitTree); } - } else { - const previousCommitTree = getCommitTree({ - commitIndex: commitIndex - 1, - profilingSummary, - rendererID, - rootID, - store, - }); - const commitOperations = store.profilingOperations.get(rootID); - if (commitOperations != null && commitIndex < commitOperations.length) { - const commitTree = updateTree( - previousCommitTree, - commitOperations[commitIndex] - ); - if (__DEBUG__) { - __printTree(commitTree); - } + commitTrees.push(commitTree); + return commitTree; + } + } else { + const previousCommitTree = getCommitTree({ + commitIndex: commitIndex - 1, + profilingSummary, + rendererID, + rootID, + store, + }); + const commitOperations = store.profilingOperations.get(rootID); + if (commitOperations != null && commitIndex < commitOperations.length) { + const commitTree = updateTree( + previousCommitTree, + commitOperations[commitIndex] + ); - commitTrees.push(commitTree); - return commitTree; + if (__DEBUG__) { + __printTree(commitTree); } + + commitTrees.push(commitTree); + return commitTree; } } @@ -129,26 +127,27 @@ function recursivelyIniitliazeTree( initialTreeBaseDurations: Map, store: Store ): void { - const node = ((store.profilingSnapshot.get(id): any): Node); - - nodes.set(id, { - id, - children: node.children, - displayName: node.displayName, - key: node.key, - parentID, - treeBaseDuration: ((initialTreeBaseDurations.get(id): any): number), - }); - - node.children.forEach(childID => - recursivelyIniitliazeTree( - childID, + const node = store.profilingSnapshot.get(id); + if (node != null) { + nodes.set(id, { id, - nodes, - initialTreeBaseDurations, - store - ) - ); + children: node.children, + displayName: node.displayName, + key: node.key, + parentID, + treeBaseDuration: ((initialTreeBaseDurations.get(id): any): number), + }); + + node.children.forEach(childID => + recursivelyIniitliazeTree( + childID, + id, + nodes, + initialTreeBaseDurations, + store + ) + ); + } } function updateTree( @@ -183,7 +182,26 @@ function updateTree( i = i + 3; if (type === ElementTypeRoot) { - // No-op + i++; // supportsProfiling flag + + debug('Add', `new root fiber ${id}`); + + if (nodes.has(id)) { + // The renderer's tree walking approach sometimes mounts the same Fiber twice with Suspense and Lazy. + // For now, we avoid adding it to the tree twice by checking if it's already been mounted. + // Maybe in the future we'll revisit this. + } else { + const node: Node = { + children: [], + displayName: null, + id, + key: null, + parentID: 0, + treeBaseDuration: 0, // This will be updated by a subsequent operation + }; + + nodes.set(id, node); + } } else { parentID = ((operations[i]: any): number); i++; @@ -213,14 +231,14 @@ function updateTree( // For now, we avoid adding it to the tree twice by checking if it's already been mounted. // Maybe in the future we'll revisit this. } else { - parentNode = getClonedNode(parentID); - parentNode.children = parentNode.children.concat(id); - debug( 'Add', `fiber ${id} (${displayName || 'null'}) as child of ${parentID}` ); + parentNode = getClonedNode(parentID); + parentNode.children = parentNode.children.concat(id); + const node: Node = { children: [], displayName, diff --git a/src/devtools/views/Profiler/ReloadAndProfileButton.js b/src/devtools/views/Profiler/ReloadAndProfileButton.js index 18a940c11c..5af7d8bf9e 100644 --- a/src/devtools/views/Profiler/ReloadAndProfileButton.js +++ b/src/devtools/views/Profiler/ReloadAndProfileButton.js @@ -1,13 +1,24 @@ // @flow -import React from 'react'; +import React, { useCallback, useContext } from 'react'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; +import { BridgeContext, StoreContext } from '../context'; export default function ReloadAndProfileButton() { - // TODO (profiling) Wire up reload button + const bridge = useContext(BridgeContext); + const store = useContext(StoreContext); + + const reloadAndProfile = useCallback(() => bridge.send('reloadAndProfile'), [ + bridge, + ]); + + if (!store.supportsReloadAndProfile) { + return null; + } + return ( - );