570 lines
19 KiB
JavaScript
570 lines
19 KiB
JavaScript
/**
|
|
* Install the hook on window, which is an event emitter.
|
|
* Note because Chrome content scripts cannot directly modify the window object,
|
|
* we are evaling this function by inserting a script tag.
|
|
* That's why we have to inline the whole event emitter implementation,
|
|
* the string format implementation, and part of the console implementation here.
|
|
*
|
|
* @flow
|
|
*/
|
|
|
|
import type {BrowserTheme} from 'react-devtools-shared/src/devtools/views/DevTools';
|
|
import type {DevToolsHook} from 'react-devtools-shared/src/backend/types';
|
|
|
|
import {
|
|
patchConsoleUsingWindowValues,
|
|
registerRenderer as registerRendererWithConsole,
|
|
} from './backend/console';
|
|
|
|
declare var window: any;
|
|
|
|
export function installHook(target: any): DevToolsHook | null {
|
|
if (target.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')) {
|
|
return null;
|
|
}
|
|
|
|
let targetConsole: Object = console;
|
|
let targetConsoleMethods = {};
|
|
for (const method in console) {
|
|
targetConsoleMethods[method] = console[method];
|
|
}
|
|
|
|
function dangerous_setTargetConsoleForTesting(
|
|
targetConsoleForTesting: Object,
|
|
): void {
|
|
targetConsole = targetConsoleForTesting;
|
|
|
|
targetConsoleMethods = {};
|
|
for (const method in targetConsole) {
|
|
targetConsoleMethods[method] = console[method];
|
|
}
|
|
}
|
|
|
|
function detectReactBuildType(renderer) {
|
|
try {
|
|
if (typeof renderer.version === 'string') {
|
|
// React DOM Fiber (16+)
|
|
if (renderer.bundleType > 0) {
|
|
// This is not a production build.
|
|
// We are currently only using 0 (PROD) and 1 (DEV)
|
|
// but might add 2 (PROFILE) in the future.
|
|
return 'development';
|
|
}
|
|
|
|
// React 16 uses flat bundles. If we report the bundle as production
|
|
// version, it means we also minified and envified it ourselves.
|
|
return 'production';
|
|
// Note: There is still a risk that the CommonJS entry point has not
|
|
// been envified or uglified. In this case the user would have *both*
|
|
// development and production bundle, but only the prod one would run.
|
|
// This would be really bad. We have a separate check for this because
|
|
// it happens *outside* of the renderer injection. See `checkDCE` below.
|
|
}
|
|
|
|
// $FlowFixMe[method-unbinding]
|
|
const toString = Function.prototype.toString;
|
|
if (renderer.Mount && renderer.Mount._renderNewRootComponent) {
|
|
// React DOM Stack
|
|
const renderRootCode = toString.call(
|
|
renderer.Mount._renderNewRootComponent,
|
|
);
|
|
// Filter out bad results (if that is even possible):
|
|
if (renderRootCode.indexOf('function') !== 0) {
|
|
// Hope for the best if we're not sure.
|
|
return 'production';
|
|
}
|
|
// Check for React DOM Stack < 15.1.0 in development.
|
|
// If it contains "storedMeasure" call, it's wrapped in ReactPerf (DEV only).
|
|
// This would be true even if it's minified, as method name still matches.
|
|
if (renderRootCode.indexOf('storedMeasure') !== -1) {
|
|
return 'development';
|
|
}
|
|
// For other versions (and configurations) it's not so easy.
|
|
// Let's quickly exclude proper production builds.
|
|
// If it contains a warning message, it's either a DEV build,
|
|
// or an PROD build without proper dead code elimination.
|
|
if (renderRootCode.indexOf('should be a pure function') !== -1) {
|
|
// Now how do we tell a DEV build from a bad PROD build?
|
|
// If we see NODE_ENV, we're going to assume this is a dev build
|
|
// because most likely it is referring to an empty shim.
|
|
if (renderRootCode.indexOf('NODE_ENV') !== -1) {
|
|
return 'development';
|
|
}
|
|
// If we see "development", we're dealing with an envified DEV build
|
|
// (such as the official React DEV UMD).
|
|
if (renderRootCode.indexOf('development') !== -1) {
|
|
return 'development';
|
|
}
|
|
// I've seen process.env.NODE_ENV !== 'production' being smartly
|
|
// replaced by `true` in DEV by Webpack. I don't know how that
|
|
// works but we can safely guard against it because `true` was
|
|
// never used in the function source since it was written.
|
|
if (renderRootCode.indexOf('true') !== -1) {
|
|
return 'development';
|
|
}
|
|
// By now either it is a production build that has not been minified,
|
|
// or (worse) this is a minified development build using non-standard
|
|
// environment (e.g. "staging"). We're going to look at whether
|
|
// the function argument name is mangled:
|
|
if (
|
|
// 0.13 to 15
|
|
renderRootCode.indexOf('nextElement') !== -1 ||
|
|
// 0.12
|
|
renderRootCode.indexOf('nextComponent') !== -1
|
|
) {
|
|
// We can't be certain whether this is a development build or not,
|
|
// but it is definitely unminified.
|
|
return 'unminified';
|
|
} else {
|
|
// This is likely a minified development build.
|
|
return 'development';
|
|
}
|
|
}
|
|
// By now we know that it's envified and dead code elimination worked,
|
|
// but what if it's still not minified? (Is this even possible?)
|
|
// Let's check matches for the first argument name.
|
|
if (
|
|
// 0.13 to 15
|
|
renderRootCode.indexOf('nextElement') !== -1 ||
|
|
// 0.12
|
|
renderRootCode.indexOf('nextComponent') !== -1
|
|
) {
|
|
return 'unminified';
|
|
}
|
|
// Seems like we're using the production version.
|
|
// However, the branch above is Stack-only so this is 15 or earlier.
|
|
return 'outdated';
|
|
}
|
|
} catch (err) {
|
|
// Weird environments may exist.
|
|
// This code needs a higher fault tolerance
|
|
// because it runs even with closed DevTools.
|
|
// TODO: should we catch errors in all injected code, and not just this part?
|
|
}
|
|
return 'production';
|
|
}
|
|
|
|
function checkDCE(fn: Function) {
|
|
// This runs for production versions of React.
|
|
// Needs to be super safe.
|
|
try {
|
|
// $FlowFixMe[method-unbinding]
|
|
const toString = Function.prototype.toString;
|
|
const code = toString.call(fn);
|
|
|
|
// This is a string embedded in the passed function under DEV-only
|
|
// condition. However the function executes only in PROD. Therefore,
|
|
// if we see it, dead code elimination did not work.
|
|
if (code.indexOf('^_^') > -1) {
|
|
// Remember to report during next injection.
|
|
hasDetectedBadDCE = true;
|
|
|
|
// Bonus: throw an exception hoping that it gets picked up by a reporting system.
|
|
// Not synchronously so that it doesn't break the calling code.
|
|
setTimeout(function() {
|
|
throw new Error(
|
|
'React is running in production mode, but dead code ' +
|
|
'elimination has not been applied. Read how to correctly ' +
|
|
'configure React for production: ' +
|
|
'https://reactjs.org/link/perf-use-production-build',
|
|
);
|
|
});
|
|
}
|
|
} catch (err) {}
|
|
}
|
|
|
|
// NOTE: KEEP IN SYNC with src/backend/utils.js
|
|
function formatWithStyles(
|
|
inputArgs: $ReadOnlyArray<any>,
|
|
style?: string,
|
|
): $ReadOnlyArray<any> {
|
|
if (
|
|
inputArgs === undefined ||
|
|
inputArgs === null ||
|
|
inputArgs.length === 0 ||
|
|
// Matches any of %c but not %%c
|
|
(typeof inputArgs[0] === 'string' &&
|
|
inputArgs[0].match(/([^%]|^)(%c)/g)) ||
|
|
style === undefined
|
|
) {
|
|
return inputArgs;
|
|
}
|
|
|
|
// Matches any of %(o|O|d|i|s|f), but not %%(o|O|d|i|s|f)
|
|
const REGEXP = /([^%]|^)((%%)*)(%([oOdisf]))/g;
|
|
if (typeof inputArgs[0] === 'string' && inputArgs[0].match(REGEXP)) {
|
|
return [`%c${inputArgs[0]}`, style, ...inputArgs.slice(1)];
|
|
} else {
|
|
const firstArg = inputArgs.reduce((formatStr, elem, i) => {
|
|
if (i > 0) {
|
|
formatStr += ' ';
|
|
}
|
|
switch (typeof elem) {
|
|
case 'string':
|
|
case 'boolean':
|
|
case 'symbol':
|
|
return (formatStr += '%s');
|
|
case 'number':
|
|
const formatting = Number.isInteger(elem) ? '%i' : '%f';
|
|
return (formatStr += formatting);
|
|
default:
|
|
return (formatStr += '%o');
|
|
}
|
|
}, '%c');
|
|
return [firstArg, style, ...inputArgs];
|
|
}
|
|
}
|
|
|
|
let unpatchFn = null;
|
|
|
|
// NOTE: KEEP IN SYNC with src/backend/console.js:patchForStrictMode
|
|
// This function hides or dims console logs during the initial double renderer
|
|
// in Strict Mode. We need this function because during initial render,
|
|
// React and DevTools are connecting and the renderer interface isn't avaiable
|
|
// and we want to be able to have consistent logging behavior for double logs
|
|
// during the initial renderer.
|
|
function patchConsoleForInitialRenderInStrictMode({
|
|
hideConsoleLogsInStrictMode,
|
|
browserTheme,
|
|
}: {
|
|
hideConsoleLogsInStrictMode: boolean,
|
|
browserTheme: BrowserTheme,
|
|
}) {
|
|
const overrideConsoleMethods = [
|
|
'error',
|
|
'group',
|
|
'groupCollapsed',
|
|
'info',
|
|
'log',
|
|
'trace',
|
|
'warn',
|
|
];
|
|
|
|
if (unpatchFn !== null) {
|
|
// Don't patch twice.
|
|
return;
|
|
}
|
|
|
|
const originalConsoleMethods = {};
|
|
|
|
unpatchFn = () => {
|
|
for (const method in originalConsoleMethods) {
|
|
try {
|
|
// $FlowFixMe property error|warn is not writable.
|
|
targetConsole[method] = originalConsoleMethods[method];
|
|
} catch (error) {}
|
|
}
|
|
};
|
|
|
|
overrideConsoleMethods.forEach(method => {
|
|
try {
|
|
const originalMethod = (originalConsoleMethods[method] = targetConsole[
|
|
method
|
|
].__REACT_DEVTOOLS_STRICT_MODE_ORIGINAL_METHOD__
|
|
? targetConsole[method].__REACT_DEVTOOLS_STRICT_MODE_ORIGINAL_METHOD__
|
|
: targetConsole[method]);
|
|
|
|
const overrideMethod = (...args) => {
|
|
if (!hideConsoleLogsInStrictMode) {
|
|
// Dim the text color of the double logs if we're not
|
|
// hiding them.
|
|
let color;
|
|
switch (method) {
|
|
case 'warn':
|
|
color =
|
|
browserTheme === 'light'
|
|
? process.env.LIGHT_MODE_DIMMED_WARNING_COLOR
|
|
: process.env.DARK_MODE_DIMMED_WARNING_COLOR;
|
|
break;
|
|
case 'error':
|
|
color =
|
|
browserTheme === 'light'
|
|
? process.env.LIGHT_MODE_DIMMED_ERROR_COLOR
|
|
: process.env.DARK_MODE_DIMMED_ERROR_COLOR;
|
|
break;
|
|
case 'log':
|
|
default:
|
|
color =
|
|
browserTheme === 'light'
|
|
? process.env.LIGHT_MODE_DIMMED_LOG_COLOR
|
|
: process.env.DARK_MODE_DIMMED_LOG_COLOR;
|
|
break;
|
|
}
|
|
|
|
if (color) {
|
|
originalMethod(...formatWithStyles(args, `color: ${color}`));
|
|
} else {
|
|
throw Error('Console color is not defined');
|
|
}
|
|
}
|
|
};
|
|
|
|
overrideMethod.__REACT_DEVTOOLS_STRICT_MODE_ORIGINAL_METHOD__ = originalMethod;
|
|
originalMethod.__REACT_DEVTOOLS_STRICT_MODE_OVERRIDE_METHOD__ = overrideMethod;
|
|
|
|
// $FlowFixMe property error|warn is not writable.
|
|
targetConsole[method] = overrideMethod;
|
|
} catch (error) {}
|
|
});
|
|
}
|
|
|
|
// NOTE: KEEP IN SYNC with src/backend/console.js:unpatchForStrictMode
|
|
function unpatchConsoleForInitialRenderInStrictMode() {
|
|
if (unpatchFn !== null) {
|
|
unpatchFn();
|
|
unpatchFn = null;
|
|
}
|
|
}
|
|
|
|
let uidCounter = 0;
|
|
|
|
function inject(renderer) {
|
|
const id = ++uidCounter;
|
|
renderers.set(id, renderer);
|
|
|
|
const reactBuildType = hasDetectedBadDCE
|
|
? 'deadcode'
|
|
: detectReactBuildType(renderer);
|
|
|
|
// Patching the console enables DevTools to do a few useful things:
|
|
// * Append component stacks to warnings and error messages
|
|
// * Disabling or marking logs during a double render in Strict Mode
|
|
// * Disable logging during re-renders to inspect hooks (see inspectHooksOfFiber)
|
|
//
|
|
// For React Native, we intentionally patch early (during injection).
|
|
// This provides React Native developers with components stacks even if they don't run DevTools.
|
|
//
|
|
// This won't work for DOM though, since this entire file is eval'ed and inserted as a script tag.
|
|
// In that case, we'll only patch parts of the console that are needed during the first render
|
|
// and patch everything else later (when the frontend attaches).
|
|
//
|
|
// Don't patch in test environments because we don't want to interfere with Jest's own console overrides.
|
|
//
|
|
// Note that because this function is inlined, this conditional check must only use static booleans.
|
|
// Otherwise the extension will throw with an undefined error.
|
|
// (See comments in the try/catch below for more context on inlining.)
|
|
if (!__TEST__ && !__EXTENSION__) {
|
|
try {
|
|
// The installHook() function is injected by being stringified in the browser,
|
|
// so imports outside of this function do not get included.
|
|
//
|
|
// Normally we could check "typeof patchConsole === 'function'",
|
|
// but Webpack wraps imports with an object (e.g. _backend_console__WEBPACK_IMPORTED_MODULE_0__)
|
|
// and the object itself will be undefined as well for the reasons mentioned above,
|
|
// so we use try/catch instead.
|
|
registerRendererWithConsole(renderer);
|
|
patchConsoleUsingWindowValues();
|
|
} catch (error) {}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
let hasDetectedBadDCE = false;
|
|
|
|
function sub(event, fn) {
|
|
hook.on(event, fn);
|
|
return () => hook.off(event, fn);
|
|
}
|
|
|
|
function on(event, fn) {
|
|
if (!listeners[event]) {
|
|
listeners[event] = [];
|
|
}
|
|
listeners[event].push(fn);
|
|
}
|
|
|
|
function off(event, fn) {
|
|
if (!listeners[event]) {
|
|
return;
|
|
}
|
|
const index = listeners[event].indexOf(fn);
|
|
if (index !== -1) {
|
|
listeners[event].splice(index, 1);
|
|
}
|
|
if (!listeners[event].length) {
|
|
delete listeners[event];
|
|
}
|
|
}
|
|
|
|
function emit(event, data) {
|
|
if (listeners[event]) {
|
|
listeners[event].map(fn => fn(data));
|
|
}
|
|
}
|
|
|
|
function getFiberRoots(rendererID) {
|
|
const roots = fiberRoots;
|
|
if (!roots[rendererID]) {
|
|
roots[rendererID] = new Set();
|
|
}
|
|
return roots[rendererID];
|
|
}
|
|
|
|
function onCommitFiberUnmount(rendererID, fiber) {
|
|
const rendererInterface = rendererInterfaces.get(rendererID);
|
|
if (rendererInterface != null) {
|
|
rendererInterface.handleCommitFiberUnmount(fiber);
|
|
}
|
|
}
|
|
|
|
function onCommitFiberRoot(rendererID, root, priorityLevel) {
|
|
const mountedRoots = hook.getFiberRoots(rendererID);
|
|
const current = root.current;
|
|
const isKnownRoot = mountedRoots.has(root);
|
|
const isUnmounting =
|
|
current.memoizedState == null || current.memoizedState.element == null;
|
|
|
|
// Keep track of mounted roots so we can hydrate when DevTools connect.
|
|
if (!isKnownRoot && !isUnmounting) {
|
|
mountedRoots.add(root);
|
|
} else if (isKnownRoot && isUnmounting) {
|
|
mountedRoots.delete(root);
|
|
}
|
|
const rendererInterface = rendererInterfaces.get(rendererID);
|
|
if (rendererInterface != null) {
|
|
rendererInterface.handleCommitFiberRoot(root, priorityLevel);
|
|
}
|
|
}
|
|
|
|
function onPostCommitFiberRoot(rendererID, root) {
|
|
const rendererInterface = rendererInterfaces.get(rendererID);
|
|
if (rendererInterface != null) {
|
|
rendererInterface.handlePostCommitFiberRoot(root);
|
|
}
|
|
}
|
|
|
|
function setStrictMode(rendererID, isStrictMode) {
|
|
const rendererInterface = rendererInterfaces.get(rendererID);
|
|
if (rendererInterface != null) {
|
|
if (isStrictMode) {
|
|
rendererInterface.patchConsoleForStrictMode();
|
|
} else {
|
|
rendererInterface.unpatchConsoleForStrictMode();
|
|
}
|
|
} else {
|
|
// This should only happen during initial render in the extension before DevTools
|
|
// finishes its handshake with the injected renderer
|
|
if (isStrictMode) {
|
|
const hideConsoleLogsInStrictMode =
|
|
window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ === true;
|
|
const browserTheme = window.__REACT_DEVTOOLS_BROWSER_THEME__;
|
|
|
|
patchConsoleForInitialRenderInStrictMode({
|
|
hideConsoleLogsInStrictMode,
|
|
browserTheme,
|
|
});
|
|
} else {
|
|
unpatchConsoleForInitialRenderInStrictMode();
|
|
}
|
|
}
|
|
}
|
|
|
|
type StackFrameString = string;
|
|
|
|
const openModuleRangesStack: Array<StackFrameString> = [];
|
|
const moduleRanges: Array<[StackFrameString, StackFrameString]> = [];
|
|
|
|
function getTopStackFrameString(error: Error): StackFrameString | null {
|
|
const frames = error.stack.split('\n');
|
|
const frame = frames.length > 1 ? frames[1] : null;
|
|
return frame;
|
|
}
|
|
|
|
function getInternalModuleRanges(): Array<
|
|
[StackFrameString, StackFrameString],
|
|
> {
|
|
return moduleRanges;
|
|
}
|
|
|
|
function registerInternalModuleStart(error: Error) {
|
|
const startStackFrame = getTopStackFrameString(error);
|
|
if (startStackFrame !== null) {
|
|
openModuleRangesStack.push(startStackFrame);
|
|
}
|
|
}
|
|
|
|
function registerInternalModuleStop(error: Error) {
|
|
if (openModuleRangesStack.length > 0) {
|
|
const startStackFrame = openModuleRangesStack.pop();
|
|
const stopStackFrame = getTopStackFrameString(error);
|
|
if (stopStackFrame !== null) {
|
|
moduleRanges.push([startStackFrame, stopStackFrame]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// TODO: More meaningful names for "rendererInterfaces" and "renderers".
|
|
const fiberRoots = {};
|
|
const rendererInterfaces = new Map();
|
|
const listeners = {};
|
|
const renderers = new Map();
|
|
|
|
const hook: DevToolsHook = {
|
|
rendererInterfaces,
|
|
listeners,
|
|
|
|
// Fast Refresh for web relies on this.
|
|
renderers,
|
|
|
|
emit,
|
|
getFiberRoots,
|
|
inject,
|
|
on,
|
|
off,
|
|
sub,
|
|
|
|
// This is a legacy flag.
|
|
// React v16 checks the hook for this to ensure DevTools is new enough.
|
|
supportsFiber: true,
|
|
|
|
// React calls these methods.
|
|
checkDCE,
|
|
onCommitFiberUnmount,
|
|
onCommitFiberRoot,
|
|
onPostCommitFiberRoot,
|
|
setStrictMode,
|
|
|
|
// Schedule Profiler runtime helpers.
|
|
// These internal React modules to report their own boundaries
|
|
// which in turn enables the profiler to dim or filter internal frames.
|
|
getInternalModuleRanges,
|
|
registerInternalModuleStart,
|
|
registerInternalModuleStop,
|
|
};
|
|
|
|
if (__TEST__) {
|
|
hook.dangerous_setTargetConsoleForTesting = dangerous_setTargetConsoleForTesting;
|
|
}
|
|
|
|
Object.defineProperty(
|
|
target,
|
|
'__REACT_DEVTOOLS_GLOBAL_HOOK__',
|
|
({
|
|
// This property needs to be configurable for the test environment,
|
|
// else we won't be able to delete and recreate it between tests.
|
|
configurable: __DEV__,
|
|
enumerable: false,
|
|
get() {
|
|
return hook;
|
|
},
|
|
}: Object),
|
|
);
|
|
|
|
return hook;
|
|
}
|