Merge pull request #35 from bvaughn/reload-and-profile

Eagerly inject renderer (before document) to support reload-and-profile
This commit is contained in:
Brian Vaughn
2019-04-01 14:47:51 -07:00
committed by GitHub
18 changed files with 182 additions and 56 deletions
+6 -1
View File
@@ -27,7 +27,12 @@
"devtools_page": "main.html",
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"web_accessible_resources": ["main.html", "panel.html", "build/backend.js"],
"web_accessible_resources": [
"main.html",
"panel.html",
"build/backend.js",
"build/renderer.js"
],
"background": {
"scripts": ["build/background.js"],
+6 -1
View File
@@ -33,7 +33,12 @@
"devtools_page": "main.html",
"content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
"web_accessible_resources": ["main.html", "panel.html", "build/backend.js"],
"web_accessible_resources": [
"main.html",
"panel.html",
"build/backend.js",
"build/renderer.js"
],
"background": {
"scripts": ["build/background.js"],
+37 -13
View File
@@ -2,13 +2,24 @@
import nullthrows from 'nullthrows';
import { installHook } from 'src/hook';
import { RELOAD_AND_PROFILE_KEY } from 'src/constants';
function injectCode(code) {
const script = document.createElement('script');
script.textContent = code;
// This script runs before the <head> element is created,
// so we add the script to <html> instead.
nullthrows(document.documentElement).appendChild(script);
nullthrows(script.parentNode).removeChild(script);
}
let lastDetectionResult;
// We want to detect when a renderer attaches, and notify the "background
// page" (which is shared between tabs and can highlight the React icon).
// Currently we are in "content script" context, so we can't listen
// to the hook directly (it will be injected directly into the page).
// We want to detect when a renderer attaches, and notify the "background page"
// (which is shared between tabs and can highlight the React icon).
// Currently we are in "content script" context, so we can't listen to the hook directly
// (it will be injected directly into the page).
// So instead, the hook will use postMessage() to pass message to us here.
// And when this happens, we'll send a message to the "background page".
window.addEventListener('message', function(evt) {
@@ -51,14 +62,27 @@ window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeWeakMap = WeakMap;
window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeSet = Set;
`;
// If we have just reloaded to profile, we need to inject the renderer interface before the app loads.
if (localStorage.getItem(RELOAD_AND_PROFILE_KEY) === 'true') {
const rendererURL = chrome.runtime.getURL('build/renderer.js');
let rendererCode;
// We need to inject in time to catch the initial mount.
// This means we need to synchronously read the renderer code itself,
// and synchronously inject it into the page.
// There are very few ways to actually do this.
// This seems to be the best approach.
const request = new XMLHttpRequest();
request.addEventListener('load', function() {
rendererCode = this.responseText;
});
request.open('GET', rendererURL, false);
request.send();
injectCode(rendererCode);
}
// Inject a `__REACT_DEVTOOLS_GLOBAL_HOOK__` global so that React can detect that the
// devtools are installed (and skip its suggestion to install the devtools).
const js =
';(' + installHook.toString() + '(window))' + saveNativeValues + detectReact;
// This script runs before the <head> element is created, so we add the script
// to <html> instead.
const script = document.createElement('script');
script.textContent = js;
nullthrows(document.documentElement).appendChild(script);
nullthrows(script.parentNode).removeChild(script);
injectCode(
';(' + installHook.toString() + '(window))' + saveNativeValues + detectReact
);
+3
View File
@@ -78,15 +78,18 @@ function createPanelIfReactLoaded() {
// 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 isProfiling = false;
let supportsProfiling = false;
if (localStorage.getItem(SUPPORTS_PROFILING_KEY) === 'true') {
supportsProfiling = true;
isProfiling = true;
localStorage.removeItem(SUPPORTS_PROFILING_KEY);
}
const browserName = getBrowserName();
store = new Store(bridge, {
isProfiling,
supportsFileDownloads: browserName === 'Chrome',
supportsReloadAndProfile: true,
supportsProfiling,
+22
View File
@@ -0,0 +1,22 @@
/**
* In order to support reload-and-profile functionality, the renderer needs to be injected before any other scripts.
* Since it is a complex file (with imports) we can't just toString() it like we do with the hook itself,
* So this entry point (one of the web_accessible_resources) provcides a way to eagerly inject it.
* The hook will look for the presence of a global __REACT_DEVTOOLS_ATTACH__ and attach an injected renderer early.
* The normal case (not a reload-and-profile) will not make use of this entry point though.
*
* @flow
*/
import { attach } from 'src/backend/renderer';
Object.defineProperty(
window,
'__REACT_DEVTOOLS_ATTACH__',
({
enumerable: false,
get() {
return attach;
},
}: Object)
);
+1
View File
@@ -18,6 +18,7 @@ module.exports = {
inject: './src/GlobalHook.js',
main: './src/main.js',
panel: './src/panel.js',
renderer: './src/renderer.js',
},
output: {
path: __dirname + '/build',
+1 -3
View File
@@ -1,7 +1,7 @@
// @flow
import EventEmitter from 'events';
import { __DEBUG__ } from '../constants';
import { RELOAD_AND_PROFILE_KEY, __DEBUG__ } from '../constants';
import { hideOverlay, showOverlay } from './views/Highlighter';
import type { RendererID, RendererInterface } from './types';
@@ -38,8 +38,6 @@ 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;
+15 -3
View File
@@ -23,7 +23,10 @@ export function initBackend(
rendererInterface: RendererInterface,
}) => {
agent.setRendererInterface(id, rendererInterface);
rendererInterface.walkTree();
// Now that the Store and the renderer interface are connected,
// it's time to flush the pending operation codes to the frontend.
rendererInterface.flushInitialOperations();
}
),
@@ -33,8 +36,17 @@ export function initBackend(
];
const attachRenderer = (id: number, renderer: ReactRenderer) => {
const rendererInterface = attach(hook, id, renderer, global);
hook.rendererInterfaces.set(id, rendererInterface);
let rendererInterface = hook.rendererInterfaces.get(id);
// Inject any not-yet-injected renderers (if we didn't reload-and-profile)
if (!rendererInterface) {
rendererInterface = attach(hook, id, renderer, global);
hook.rendererInterfaces.set(id, rendererInterface);
}
// Notify the DevTools frontend about new renderers.
// This includes any that were attached early (via __REACT_DEVTOOLS_ATTACH__).
hook.emit('renderer-attached', {
id,
renderer,
+60 -25
View File
@@ -18,6 +18,7 @@ import { getDisplayName, utfEncodeString } from '../utils';
import { cleanForBridge, copyWithSet, setInObject } from './utils';
import {
__DEBUG__,
RELOAD_AND_PROFILE_KEY,
TREE_OPERATION_ADD,
TREE_OPERATION_REMOVE,
TREE_OPERATION_RESET_CHILDREN,
@@ -566,6 +567,7 @@ export function attach(
}
let pendingOperations: Uint32Array = new Uint32Array(0);
let pendingOperationsQueue: Array<Uint32Array> | null = [];
function addOperation(
newAction: Uint32Array,
@@ -603,7 +605,16 @@ export function attach(
// Let the frontend know about tree operations.
// The first value in this array will identify which root it corresponds to,
// so we do no longer need to dispatch a separate root-committed event.
hook.emit('operations', pendingOperations);
if (pendingOperationsQueue !== null) {
// Until the frontend has been connected, store the tree operations.
// This will let us avoid walking the tree later when the frontend connects,
// and it enables the Profiler's reload-and-profile functionality to work as well.
pendingOperationsQueue.push(pendingOperations);
} else {
// If we've already connected to the frontend, just pass the operations through.
hook.emit('operations', pendingOperations);
}
pendingOperations = new Uint32Array(0);
}
@@ -913,31 +924,46 @@ export function attach(
// We don't patch any methods so there is no cleanup.
}
function walkTree() {
// Hydrate all the roots for the first time.
hook.getFiberRoots(rendererID).forEach(root => {
currentRootID = getFiberID(getPrimaryFiber(root.current));
function flushInitialOperations() {
const localPendingOperationsQueue = pendingOperationsQueue;
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,
};
}
pendingOperationsQueue = null;
mountFiber(root.current, null);
flushPendingEvents(root);
currentRootID = -1;
});
if (
localPendingOperationsQueue !== null &&
localPendingOperationsQueue.length > 0
) {
// We may have already queued up some operations before the frontend connected
// If so, let the frontend know about them.
localPendingOperationsQueue.forEach(pendingOperations => {
hook.emit('operations', pendingOperations);
});
} else {
// If we have not been profiling, then we can just walk the tree and build up its current state as-is.
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;
});
}
}
function handleCommitFiberUnmount(fiber) {
@@ -1595,6 +1621,10 @@ export function attach(
}
function startProfiling() {
if (isProfiling) {
return;
}
// Capture initial values as of the time profiling starts.
// It's important we snapshot both the durations and the id-to-root map,
// since either of these may change during the profiling session
@@ -1611,8 +1641,14 @@ export function attach(
isProfiling = false;
}
// Automatically start profiling so that we don't miss timing info from initial "mount".
if (localStorage.getItem(RELOAD_AND_PROFILE_KEY) === 'true') {
startProfiling();
}
return {
cleanup,
flushInitialOperations,
getCommitDetails,
getFiberIDFromNative,
getInteractions,
@@ -1631,6 +1667,5 @@ export function attach(
setInState,
startProfiling,
stopProfiling,
walkTree,
};
}
+1 -1
View File
@@ -82,6 +82,7 @@ export type ProfilingSummary = {|
export type RendererInterface = {
cleanup: () => void,
flushInitialOperations: () => void,
getCommitDetails: (rootID: number, commitIndex: number) => CommitDetails,
getNativeFromReactElement?: ?(component: Fiber) => ?NativeType,
getFiberIDFromNative: (
@@ -108,7 +109,6 @@ export type RendererInterface = {
setInState: (id: number, path: Array<string | number>, value: any) => void,
startProfiling: () => void,
stopProfiling: () => void,
walkTree: () => void,
};
export type Handler = (data: any) => void;
+2
View File
@@ -5,4 +5,6 @@ export const TREE_OPERATION_REMOVE = 2;
export const TREE_OPERATION_RESET_CHILDREN = 3;
export const TREE_OPERATION_UPDATE_TREE_BASE_DURATION = 4;
export const RELOAD_AND_PROFILE_KEY = 'React::DevTools::reloadAndProfile';
export const __DEBUG__ = false;
+4
View File
@@ -103,10 +103,14 @@ export default class Store extends EventEmitter {
if (config != null) {
const {
isProfiling,
supportsFileDownloads,
supportsProfiling,
supportsReloadAndProfile,
} = config;
if (isProfiling) {
this._isProfiling = true;
}
if (supportsFileDownloads) {
this._supportsFileDownloads = true;
}
@@ -112,14 +112,9 @@ export function getCommitTree({
}
}
console.error(
throw Error(
`getCommitTree(): Unable to reconstruct tree for root "${rootID}" and commit ${commitIndex}`
);
return {
nodes: new Map(),
rootID,
};
}
function recursivelyIniitliazeTree(
@@ -56,6 +56,11 @@ export function getChartData({
idToDepthMap.set(id, currentDepth);
const node = ((nodes.get(id): any): Node);
if (node == null) {
throw Error(`Could not find node with id "${id}" in commit tree`);
}
const name = node.displayName || 'Unknown';
const selfDuration = calculateSelfDuration(id, commitTree, commitDetails);
@@ -130,7 +130,7 @@ function ProfilerContextController({ children }: Props) {
const [
isCommitFilterEnabled,
setIsCommitFilterEnabled,
] = useLocalStorage<boolean>('isCommitFilterEnabled', false);
] = useLocalStorage<boolean>('React::DevTools::isCommitFilterEnabled', false);
const [minCommitDuration, setMinCommitDuration] = useLocalStorage<number>(
'minCommitDuration',
0
@@ -41,6 +41,10 @@ export function getChartData({
actualDurations.forEach((actualDuration, id) => {
const node = ((nodes.get(id): any): Node);
if (node == null) {
throw Error(`Could not find node with id "${id}" in commit tree`);
}
// Don't show the root node in this chart.
if (node.parentID === 0) {
return;
@@ -41,10 +41,13 @@ function SettingsContextController({
settingsPortalContainer,
}: Props) {
const [displayDensity, setDisplayDensity] = useLocalStorage<DisplayDensity>(
'displayDensity',
'React::DevTools::displayDensity',
'compact'
);
const [theme, setTheme] = useLocalStorage<Theme>('theme', 'auto');
const [theme, setTheme] = useLocalStorage<Theme>(
'React::DevTools::theme',
'auto'
);
const documentElements = useMemo<DocumentElements>(() => {
const array: Array<HTMLElement> = [
+8
View File
@@ -77,6 +77,14 @@ export function installHook(target: any): DevToolsHook | null {
? 'deadcode'
: detectReactBuildType(renderer);
// If we have just reloaded to profile, we need to inject the renderer interface before the app loads.
// Otherwise the renderer won't yet exist and we can skip this step.
const attach = target.__REACT_DEVTOOLS_ATTACH__;
if (typeof attach === 'function') {
const rendererInterface = attach(hook, id, renderer, target);
hook.rendererInterfaces.set(id, rendererInterface);
}
hook.emit('renderer', { id, renderer, reactBuildType });
return id;