Files
react/packages/react-devtools-shared/src/hook.js
Luna Ruan 60a30cf32e Console Logging for StrictMode Double Rendering (#22030)
React currently suppress console logs in StrictMode during double rendering. However, this causes a lot of confusion. This PR moves the console suppression logic from React into React Devtools. Now by default, we no longer suppress console logs. Instead, we gray out the logs in console during double render. We also add a setting in React Devtools to allow developers to hide console logs during double render if they choose.
2021-08-25 15:35:38 -07:00

514 lines
18 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 {Fiber} from 'react-reconciler/src/ReactInternalTypes';
import type {ReactRenderer} from './backend/types';
import type {BrowserTheme} from 'react-devtools-shared/src/devtools/views/DevTools';
import {
patch as patchConsole,
registerRenderer as registerRendererWithConsole,
} from './backend/console';
import type {DevToolsHook} from 'react-devtools-shared/src/backend/types';
declare var window: any;
export function installHook(target: any): DevToolsHook | null {
if (target.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')) {
return null;
}
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.
}
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 {
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 format(
maybeMessage: any,
...inputArgs: $ReadOnlyArray<any>
): string {
const args = inputArgs.slice();
// Symbols cannot be concatenated with Strings.
let formatted: string =
typeof maybeMessage === 'symbol'
? maybeMessage.toString()
: '' + maybeMessage;
// If the first argument is a string, check for substitutions.
if (typeof maybeMessage === 'string') {
if (args.length) {
const REGEXP = /(%?)(%([jds]))/g;
formatted = formatted.replace(REGEXP, (match, escaped, ptn, flag) => {
let arg = args.shift();
switch (flag) {
case 's':
arg += '';
break;
case 'd':
case 'i':
arg = parseInt(arg, 10).toString();
break;
case 'f':
arg = parseFloat(arg).toString();
break;
}
if (!escaped) {
return arg;
}
args.unshift(arg);
return match;
});
}
}
// Arguments that remain after formatting.
if (args.length) {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
// Symbols cannot be concatenated with Strings.
formatted += ' ' + (typeof arg === 'symbol' ? arg.toString() : arg);
}
}
// Update escaped %% values.
formatted = formatted.replace(/%{2,2}/g, '%');
return '' + formatted;
}
// NOTE: KEEP IN SYNC with src/backend/console.js:patch
function patchConsoleForInitialRenderInExtension(
renderer: ReactRenderer,
{
hideConsoleLogsInStrictMode,
browserTheme,
}: {hideConsoleLogsInStrictMode: boolean, browserTheme: BrowserTheme},
): void {
const overrideConsoleMethods = ['error', 'trace', 'warn', 'log'];
if (__EXTENSION__) {
const targetConsole = console;
const originalConsoleMethods = {};
overrideConsoleMethods.forEach(method => {
try {
const originalMethod = (originalConsoleMethods[
method
] = targetConsole[method].__REACT_DEVTOOLS_ORIGINAL_METHOD__
? targetConsole[method].__REACT_DEVTOOLS_ORIGINAL_METHOD__
: targetConsole[method]);
const overrideMethod = (...args) => {
let isInStrictMode = false;
// Search for the first renderer that has a current Fiber.
// We don't handle the edge case of stacks for more than one (e.g. interleaved renderers?)
const {getCurrentFiber, getIsStrictMode} = renderer;
if (typeof getCurrentFiber !== 'function') {
return;
}
const current: ?Fiber = getCurrentFiber();
if (current != null) {
try {
if (
typeof getIsStrictMode === 'function' &&
getIsStrictMode()
) {
isInStrictMode = true;
}
} catch (error) {
// Don't let a DevTools or React internal error interfere with logging.
}
}
if (isInStrictMode) {
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(`%c${format(...args)}`, `color: ${color}`);
} else {
throw Error('Console color is not defined');
}
}
} else {
originalMethod(...args);
}
};
overrideMethod.__REACT_DEVTOOLS_ORIGINAL_METHOD__ = originalMethod;
originalMethod.__REACT_DEVTOOLS_OVERRIDE_METHOD__ = overrideMethod;
// $FlowFixMe property error|warn is not writable.
targetConsole[method] = overrideMethod;
} catch (error) {}
});
}
}
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__) {
try {
const appendComponentStack =
window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ !== false;
const breakOnConsoleErrors =
window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ === true;
const showInlineWarningsAndErrors =
window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ !== false;
const hideConsoleLogsInStrictMode =
window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ === true;
const browserTheme = window.__REACT_DEVTOOLS_BROWSER_THEME__;
// 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.
if (!__EXTENSION__) {
registerRendererWithConsole(renderer);
patchConsole({
appendComponentStack,
breakOnConsoleErrors,
showInlineWarningsAndErrors,
hideConsoleLogsInStrictMode,
browserTheme,
});
} else {
patchConsoleForInitialRenderInExtension(renderer, {
hideConsoleLogsInStrictMode,
browserTheme,
});
}
} 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);
}
}
// 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,
};
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;
}