refactor[react-devtools]: move console patching to global hook (#30596)

Stacked on https://github.com/facebook/react/pull/30566 and whats under
it. See [this
commit](https://github.com/facebook/react/pull/30596/commits/374fd737e4b0b7028afb765838db7c0e22def865).

It is mostly copying code from one place to another and updating tests.
With these changes, for every console method that we patch, there is
going to be a single applied patch:
- For `error`, `warn`, and `trace` we are patching when hook is
installed. This guarantees that component stacks are going to be
appended even if browser DevTools are not opened. We pay some price for
it, though: if user has browser DevTools closed and if at this point
some warning or error is emitted (logged), the next time user opens
browser DevTools, they are going to see `hook.js` as the source frame.
Unfortunately, ignore listing from source maps is not applied
retroactively, and I don't know if its a bug or just a design
limitations. Once browser DevTools are opened, source maps will be
loaded and ignore listing will be applied for all emitted logs in the
future.
- For `log`, `info`, `group`, `groupCollapsed` we are only patching when
React notifies React DevTools about running in StrictMode. We unpatch
the methods right after it.
This commit is contained in:
Ruslan Lesiutin
2024-09-18 18:12:18 +01:00
committed by GitHub
parent de61dbe829
commit 6d3d0326e0
13 changed files with 692 additions and 1395 deletions
-4
View File
@@ -11,7 +11,6 @@ import Agent from 'react-devtools-shared/src/backend/agent';
import Bridge from 'react-devtools-shared/src/bridge';
import {installHook} from 'react-devtools-shared/src/hook';
import {initBackend} from 'react-devtools-shared/src/backend';
import {installConsoleFunctionsToWindow} from 'react-devtools-shared/src/backend/console';
import {__DEBUG__} from 'react-devtools-shared/src/constants';
import setupNativeStyleEditor from 'react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor';
import {getDefaultComponentFilters} from 'react-devtools-shared/src/utils';
@@ -41,9 +40,6 @@ type ConnectOptions = {
devToolsSettingsManager: ?DevToolsSettingsManager,
};
// Install a global variable to allow patching console early (during injection).
// This provides React Native developers with components stacks even if they don't run DevTools.
installConsoleFunctionsToWindow();
installHook(window);
const hook: ?DevToolsHook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
-4
View File
@@ -3,7 +3,6 @@
import Agent from 'react-devtools-shared/src/backend/agent';
import Bridge from 'react-devtools-shared/src/bridge';
import {initBackend} from 'react-devtools-shared/src/backend';
import {installConsoleFunctionsToWindow} from 'react-devtools-shared/src/backend/console';
import {installHook} from 'react-devtools-shared/src/hook';
import setupNativeStyleEditor from 'react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor';
@@ -120,8 +119,5 @@ export function createBridge(contentWindow: any, wall?: Wall): BackendBridge {
}
export function initialize(contentWindow: any): void {
// Install a global variable to allow patching console early (during injection).
// This provides React Native developers with components stacks even if they don't run DevTools.
installConsoleFunctionsToWindow();
installHook(contentWindow);
}
@@ -7,27 +7,17 @@
* @flow
*/
import {getVersionedRenderImplementation, normalizeCodeLocInfo} from './utils';
import {
getVersionedRenderImplementation,
normalizeCodeLocInfo,
} from 'react-devtools-shared/src/__tests__/utils';
describe('component stack', () => {
let React;
let act;
let mockError;
let mockWarn;
let supportsOwnerStacks;
beforeEach(() => {
// Intercept native console methods before DevTools bootstraps.
// Normalize component stack locations.
mockError = jest.fn();
mockWarn = jest.fn();
console.error = (...args) => {
mockError(...args.map(normalizeCodeLocInfo));
};
console.warn = (...args) => {
mockWarn(...args.map(normalizeCodeLocInfo));
};
const utils = require('./utils');
act = utils.act;
@@ -54,18 +44,22 @@ describe('component stack', () => {
act(() => render(<Grandparent />));
expect(mockError).toHaveBeenCalledWith(
expect(
global.consoleErrorMock.mock.calls[0].map(normalizeCodeLocInfo),
).toEqual([
'Test error.',
'\n in Child (at **)' +
'\n in Parent (at **)' +
'\n in Grandparent (at **)',
);
expect(mockWarn).toHaveBeenCalledWith(
]);
expect(
global.consoleWarnMock.mock.calls[0].map(normalizeCodeLocInfo),
).toEqual([
'Test warning.',
'\n in Child (at **)' +
'\n in Parent (at **)' +
'\n in Grandparent (at **)',
);
]);
});
// This test should have caught #19911
@@ -89,13 +83,15 @@ describe('component stack', () => {
expect(useEffectCount).toBe(1);
expect(mockWarn).toHaveBeenCalledWith(
expect(
global.consoleWarnMock.mock.calls[0].map(normalizeCodeLocInfo),
).toEqual([
'Warning to trigger appended component stacks.',
'\n in Example (at **)',
);
]);
});
// @reactVersion >=18.3
// @reactVersion >= 18.3
it('should log the current component stack with debug info from promises', () => {
const Child = () => {
console.error('Test error.');
@@ -117,23 +113,27 @@ describe('component stack', () => {
act(() => render(<Grandparent />));
expect(mockError).toHaveBeenCalledWith(
expect(
global.consoleErrorMock.mock.calls[0].map(normalizeCodeLocInfo),
).toEqual([
'Test error.',
supportsOwnerStacks
? '\n in Child (at **)'
: '\n in Child (at **)' +
'\n in ServerComponent (at **)' +
'\n in Parent (at **)' +
'\n in Grandparent (at **)',
);
expect(mockWarn).toHaveBeenCalledWith(
'\n in ServerComponent (at **)' +
'\n in Parent (at **)' +
'\n in Grandparent (at **)',
]);
expect(
global.consoleWarnMock.mock.calls[0].map(normalizeCodeLocInfo),
).toEqual([
'Test warning.',
supportsOwnerStacks
? '\n in Child (at **)'
: '\n in Child (at **)' +
'\n in ServerComponent (at **)' +
'\n in Parent (at **)' +
'\n in Grandparent (at **)',
);
'\n in ServerComponent (at **)' +
'\n in Parent (at **)' +
'\n in Grandparent (at **)',
]);
});
});
File diff suppressed because it is too large Load Diff
+97 -65
View File
@@ -13,6 +13,7 @@ import type {
BackendBridge,
FrontendBridge,
} from 'react-devtools-shared/src/bridge';
const {getTestFlags} = require('../../../../scripts/jest/TestFlags');
// Argument is serialized when passed from jest-cli script through to setupTests.
@@ -103,7 +104,91 @@ global.gate = fn => {
return fn(flags);
};
function shouldIgnoreConsoleErrorOrWarn(args) {
let firstArg = args[0];
if (
firstArg !== null &&
typeof firstArg === 'object' &&
String(firstArg).indexOf('Error: Uncaught [') === 0
) {
firstArg = String(firstArg);
} else if (typeof firstArg !== 'string') {
return false;
}
return global._ignoredErrorOrWarningMessages.some(errorOrWarningMessage => {
return firstArg.indexOf(errorOrWarningMessage) !== -1;
});
}
function patchConsoleForTestingBeforeHookInstallation() {
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
const originalConsoleLog = console.log;
const consoleErrorMock = jest.fn();
const consoleWarnMock = jest.fn();
const consoleLogMock = jest.fn();
global.consoleErrorMock = consoleErrorMock;
global.consoleWarnMock = consoleWarnMock;
global.consoleLogMock = consoleLogMock;
console.error = (...args) => {
let firstArg = args[0];
if (typeof firstArg === 'string' && firstArg.startsWith('Warning: ')) {
// Older React versions might use the Warning: prefix. I'm not sure
// if they use this code path.
firstArg = firstArg.slice(9);
}
if (firstArg === 'React instrumentation encountered an error: %s') {
// Rethrow errors from React.
throw args[1];
} else if (
typeof firstArg === 'string' &&
(firstArg.startsWith("It looks like you're using the wrong act()") ||
firstArg.startsWith(
'The current testing environment is not configured to support act',
) ||
firstArg.startsWith('You seem to have overlapping act() calls'))
) {
// DevTools intentionally wraps updates with acts from both DOM and test-renderer,
// since test updates are expected to impact both renderers.
return;
} else if (shouldIgnoreConsoleErrorOrWarn(args)) {
// Allows testing how DevTools behaves when it encounters console.error without cluttering the test output.
// Errors can be ignored by running in a special context provided by utils.js#withErrorsOrWarningsIgnored
return;
}
consoleErrorMock(...args);
originalConsoleError.apply(console, args);
};
console.warn = (...args) => {
if (shouldIgnoreConsoleErrorOrWarn(args)) {
// Allows testing how DevTools behaves when it encounters console.warn without cluttering the test output.
// Warnings can be ignored by running in a special context provided by utils.js#withErrorsOrWarningsIgnored
return;
}
consoleWarnMock(...args);
originalConsoleWarn.apply(console, args);
};
console.log = (...args) => {
consoleLogMock(...args);
originalConsoleLog.apply(console, args);
};
}
function unpatchConsoleAfterTesting() {
delete global.consoleErrorMock;
delete global.consoleWarnMock;
delete global.consoleLogMock;
}
beforeEach(() => {
patchConsoleForTestingBeforeHookInstallation();
global.mockClipboardCopy = jest.fn();
// Test environment doesn't support document methods like execCommand()
@@ -137,64 +222,6 @@ beforeEach(() => {
global._ignoredErrorOrWarningMessages = [
'react-test-renderer is deprecated.',
];
function shouldIgnoreConsoleErrorOrWarn(args) {
let firstArg = args[0];
if (
firstArg !== null &&
typeof firstArg === 'object' &&
String(firstArg).indexOf('Error: Uncaught [') === 0
) {
firstArg = String(firstArg);
} else if (typeof firstArg !== 'string') {
return false;
}
const shouldFilter = global._ignoredErrorOrWarningMessages.some(
errorOrWarningMessage => {
return firstArg.indexOf(errorOrWarningMessage) !== -1;
},
);
return shouldFilter;
}
const originalConsoleError = console.error;
console.error = (...args) => {
let firstArg = args[0];
if (typeof firstArg === 'string' && firstArg.startsWith('Warning: ')) {
// Older React versions might use the Warning: prefix. I'm not sure
// if they use this code path.
firstArg = firstArg.slice(9);
}
if (firstArg === 'React instrumentation encountered an error: %s') {
// Rethrow errors from React.
throw args[1];
} else if (
typeof firstArg === 'string' &&
(firstArg.startsWith("It looks like you're using the wrong act()") ||
firstArg.startsWith(
'The current testing environment is not configured to support act',
) ||
firstArg.startsWith('You seem to have overlapping act() calls'))
) {
// DevTools intentionally wraps updates with acts from both DOM and test-renderer,
// since test updates are expected to impact both renderers.
return;
} else if (shouldIgnoreConsoleErrorOrWarn(args)) {
// Allows testing how DevTools behaves when it encounters console.error without cluttering the test output.
// Errors can be ignored by running in a special context provided by utils.js#withErrorsOrWarningsIgnored
return;
}
originalConsoleError.apply(console, args);
};
const originalConsoleWarn = console.warn;
console.warn = (...args) => {
if (shouldIgnoreConsoleErrorOrWarn(args)) {
// Allows testing how DevTools behaves when it encounters console.warn without cluttering the test output.
// Warnings can be ignored by running in a special context provided by utils.js#withErrorsOrWarningsIgnored
return;
}
originalConsoleWarn.apply(console, args);
};
// Initialize filters to a known good state.
setSavedComponentFilters(getDefaultComponentFilters());
@@ -203,7 +230,12 @@ beforeEach(() => {
// Also initialize inline warnings so that we can test them.
global.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = true;
installHook(global);
installHook(global, {
appendComponentStack: true,
breakOnConsoleErrors: false,
showInlineWarningsAndErrors: true,
hideConsoleLogsInStrictMode: false,
});
const bridgeListeners = [];
const bridge = new Bridge({
@@ -221,14 +253,12 @@ beforeEach(() => {
},
});
const agent = new Agent(((bridge: any): BackendBridge));
const hook = global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
initBackend(hook, agent, global);
const store = new Store(((bridge: any): FrontendBridge));
const agent = new Agent(((bridge: any): BackendBridge));
const hook = global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
initBackend(hook, agent, global);
global.agent = agent;
global.bridge = bridge;
global.store = store;
@@ -243,8 +273,10 @@ beforeEach(() => {
}
global.fetch = mockFetch;
});
afterEach(() => {
delete global.__REACT_DEVTOOLS_GLOBAL_HOOK__;
unpatchConsoleAfterTesting();
// It's important to reset modules between test runs;
// Without this, ReactDOM won't re-inject itself into the new hook.
+1 -9
View File
@@ -24,7 +24,6 @@ import {
initialize as setupTraceUpdates,
toggleEnabled as setTraceUpdatesEnabled,
} from './views/TraceUpdates';
import {patch as patchConsole} from './console';
import {currentBridgeProtocol} from 'react-devtools-shared/src/bridge';
import type {BackendBridge} from 'react-devtools-shared/src/bridge';
@@ -36,7 +35,6 @@ import type {
PathMatch,
RendererID,
RendererInterface,
ConsolePatchSettings,
DevToolsHookSettings,
} from './types';
import type {ComponentFilter} from 'react-devtools-shared/src/frontend/types';
@@ -805,7 +803,7 @@ export default class Agent extends EventEmitter<{
};
updateConsolePatchSettings: (
settings: $ReadOnly<ConsolePatchSettings>,
settings: $ReadOnly<DevToolsHookSettings>,
) => void = settings => {
// Propagate the settings, so Backend can subscribe to it and modify hook
this.emit('updateHookSettings', {
@@ -814,12 +812,6 @@ export default class Agent extends EventEmitter<{
showInlineWarningsAndErrors: settings.showInlineWarningsAndErrors,
hideConsoleLogsInStrictMode: settings.hideConsoleLogsInStrictMode,
});
// If the frontend preferences have changed,
// or in the case of React Native- if the backend is just finding out the preferences-
// then reinstall the console overrides.
// It's safe to call `patchConsole` multiple times.
patchConsole(settings);
};
updateComponentFilters: (componentFilters: Array<ComponentFilter>) => void =
+1 -417
View File
@@ -7,416 +7,7 @@
* @flow
*/
import type {
ConsolePatchSettings,
OnErrorOrWarning,
GetComponentStack,
} from './types';
import {
formatConsoleArguments,
formatWithStyles,
} from 'react-devtools-shared/src/backend/utils';
import {
FIREFOX_CONSOLE_DIMMING_COLOR,
ANSI_STYLE_DIMMING_TEMPLATE,
ANSI_STYLE_DIMMING_TEMPLATE_WITH_COMPONENT_STACK,
} from 'react-devtools-shared/src/constants';
import {castBool} from '../utils';
const OVERRIDE_CONSOLE_METHODS = ['error', 'trace', 'warn'];
// React's custom built component stack strings match "\s{4}in"
// Chrome's prefix matches "\s{4}at"
const PREFIX_REGEX = /\s{4}(in|at)\s{1}/;
// Firefox and Safari have no prefix ("")
// but we can fallback to looking for location info (e.g. "foo.js:12:345")
const ROW_COLUMN_NUMBER_REGEX = /:\d+:\d+(\n|$)/;
export function isStringComponentStack(text: string): boolean {
return PREFIX_REGEX.test(text) || ROW_COLUMN_NUMBER_REGEX.test(text);
}
const STYLE_DIRECTIVE_REGEX = /^%c/;
// This function tells whether or not the arguments for a console
// method has been overridden by the patchForStrictMode function.
// If it has we'll need to do some special formatting of the arguments
// so the console color stays consistent
function isStrictModeOverride(args: Array<any>): boolean {
if (__IS_FIREFOX__) {
return (
args.length >= 2 &&
STYLE_DIRECTIVE_REGEX.test(args[0]) &&
args[1] === FIREFOX_CONSOLE_DIMMING_COLOR
);
} else {
return args.length >= 2 && args[0] === ANSI_STYLE_DIMMING_TEMPLATE;
}
}
// We add a suffix to some frames that older versions of React didn't do.
// To compare if it's equivalent we strip out the suffix to see if they're
// still equivalent. Similarly, we sometimes use [] and sometimes () so we
// strip them to for the comparison.
const frameDiffs = / \(\<anonymous\>\)$|\@unknown\:0\:0$|\(|\)|\[|\]/gm;
function areStackTracesEqual(a: string, b: string): boolean {
return a.replace(frameDiffs, '') === b.replace(frameDiffs, '');
}
function restorePotentiallyModifiedArgs(args: Array<any>): Array<any> {
// If the arguments don't have any styles applied, then just copy
if (!isStrictModeOverride(args)) {
return args.slice();
}
if (__IS_FIREFOX__) {
// Filter out %c from the start of the first argument and color as a second argument
return [args[0].slice(2)].concat(args.slice(2));
} else {
// Filter out the `\x1b...%s\x1b` template
return args.slice(1);
}
}
const injectedRenderers: Array<{
onErrorOrWarning: ?OnErrorOrWarning,
getComponentStack: ?GetComponentStack,
}> = [];
let targetConsole: Object = console;
let targetConsoleMethods: {[string]: $FlowFixMe} = {};
for (const method in console) {
// $FlowFixMe[invalid-computed-prop]
targetConsoleMethods[method] = console[method];
}
let unpatchFn: null | (() => void) = null;
// Enables e.g. Jest tests to inject a mock console object.
export function dangerous_setTargetConsoleForTesting(
targetConsoleForTesting: Object,
): void {
targetConsole = targetConsoleForTesting;
targetConsoleMethods = ({}: {[string]: $FlowFixMe});
for (const method in targetConsole) {
// $FlowFixMe[invalid-computed-prop]
targetConsoleMethods[method] = console[method];
}
}
// v16 renderers should use this method to inject internals necessary to generate a component stack.
// These internals will be used if the console is patched.
// Injecting them separately allows the console to easily be patched or un-patched later (at runtime).
export function registerRenderer(
onErrorOrWarning?: OnErrorOrWarning,
getComponentStack?: GetComponentStack,
): void {
injectedRenderers.push({
onErrorOrWarning,
getComponentStack,
});
}
const consoleSettingsRef: ConsolePatchSettings = {
appendComponentStack: false,
breakOnConsoleErrors: false,
showInlineWarningsAndErrors: false,
hideConsoleLogsInStrictMode: false,
};
// Patches console methods to append component stack for the current fiber.
// Call unpatch() to remove the injected behavior.
export function patch({
appendComponentStack,
breakOnConsoleErrors,
showInlineWarningsAndErrors,
hideConsoleLogsInStrictMode,
}: $ReadOnly<ConsolePatchSettings>): void {
// Settings may change after we've patched the console.
// Using a shared ref allows the patch function to read the latest values.
consoleSettingsRef.appendComponentStack = appendComponentStack;
consoleSettingsRef.breakOnConsoleErrors = breakOnConsoleErrors;
consoleSettingsRef.showInlineWarningsAndErrors = showInlineWarningsAndErrors;
consoleSettingsRef.hideConsoleLogsInStrictMode = hideConsoleLogsInStrictMode;
if (
appendComponentStack ||
breakOnConsoleErrors ||
showInlineWarningsAndErrors
) {
if (unpatchFn !== null) {
// Don't patch twice.
return;
}
const originalConsoleMethods: {[string]: $FlowFixMe} = {};
unpatchFn = () => {
for (const method in originalConsoleMethods) {
try {
targetConsole[method] = originalConsoleMethods[method];
} catch (error) {}
}
};
OVERRIDE_CONSOLE_METHODS.forEach(method => {
try {
const originalMethod = (originalConsoleMethods[method] = targetConsole[
method
].__REACT_DEVTOOLS_ORIGINAL_METHOD__
? targetConsole[method].__REACT_DEVTOOLS_ORIGINAL_METHOD__
: targetConsole[method]);
// $FlowFixMe[missing-local-annot]
const overrideMethod = (...args) => {
let alreadyHasComponentStack = false;
if (method !== 'log' && consoleSettingsRef.appendComponentStack) {
const lastArg = args.length > 0 ? args[args.length - 1] : null;
alreadyHasComponentStack =
typeof lastArg === 'string' && isStringComponentStack(lastArg); // The last argument should be a component stack.
}
const shouldShowInlineWarningsAndErrors =
consoleSettingsRef.showInlineWarningsAndErrors &&
(method === 'error' || method === 'warn');
// 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?)
for (let i = 0; i < injectedRenderers.length; i++) {
const renderer = injectedRenderers[i];
const {getComponentStack, onErrorOrWarning} = renderer;
try {
if (shouldShowInlineWarningsAndErrors) {
// patch() is called by two places: (1) the hook and (2) the renderer backend.
// The backend is what implements a message queue, so it's the only one that injects onErrorOrWarning.
if (onErrorOrWarning != null) {
onErrorOrWarning(
((method: any): 'error' | 'warn'),
// Restore and copy args before we mutate them (e.g. adding the component stack)
restorePotentiallyModifiedArgs(args),
);
}
}
} catch (error) {
// Don't let a DevTools or React internal error interfere with logging.
setTimeout(() => {
throw error;
}, 0);
}
try {
if (
consoleSettingsRef.appendComponentStack &&
getComponentStack != null
) {
// This needs to be directly in the wrapper so we can pop exactly one frame.
const topFrame = Error('react-stack-top-frame');
const match = getComponentStack(topFrame);
if (match !== null) {
const {enableOwnerStacks, componentStack} = match;
// Empty string means we have a match but no component stack.
// We don't need to look in other renderers but we also don't add anything.
if (componentStack !== '') {
// Create a fake Error so that when we print it we get native source maps. Every
// browser will print the .stack property of the error and then parse it back for source
// mapping. Rather than print the internal slot. So it doesn't matter that the internal
// slot doesn't line up.
const fakeError = new Error('');
// In Chromium, only the stack property is printed but in Firefox the <name>:<message>
// gets printed so to make the colon make sense, we name it so we print Stack:
// and similarly Safari leave an expandable slot.
if (__IS_CHROME__ || __IS_EDGE__) {
// Before sending the stack to Chrome DevTools for formatting,
// V8 will reconstruct this according to the template <name>: <message><stack-frames>
// https://source.chromium.org/chromium/chromium/src/+/main:v8/src/inspector/value-mirror.cc;l=252-311;drc=bdc48d1b1312cc40c00282efb1c9c5f41dcdca9a
// It has to start with ^[\w.]*Error\b to trigger stack formatting.
fakeError.name = enableOwnerStacks
? 'Error Stack'
: 'Error Component Stack'; // This gets printed
} else {
fakeError.name = enableOwnerStacks
? 'Stack'
: 'Component Stack'; // This gets printed
}
// In Chromium, the stack property needs to start with ^[\w.]*Error\b to trigger stack
// formatting. Otherwise it is left alone. So we prefix it. Otherwise we just override it
// to our own stack.
fakeError.stack =
__IS_CHROME__ || __IS_EDGE__ || __IS_NATIVE__
? (enableOwnerStacks
? 'Error Stack:'
: 'Error Component Stack:') + componentStack
: componentStack;
if (alreadyHasComponentStack) {
// Only modify the component stack if it matches what we would've added anyway.
// Otherwise we assume it was a non-React stack.
if (isStrictModeOverride(args)) {
// We do nothing to Strict Mode overrides that already has a stack
// because we have already lost some context for how to format it
// since we've already merged the stack into the log at this point.
} else if (
areStackTracesEqual(
args[args.length - 1],
componentStack,
)
) {
const firstArg = args[0];
if (
args.length > 1 &&
typeof firstArg === 'string' &&
firstArg.endsWith('%s')
) {
args[0] = firstArg.slice(0, firstArg.length - 2); // Strip the %s param
}
args[args.length - 1] = fakeError;
}
} else {
args.push(fakeError);
if (isStrictModeOverride(args)) {
if (__IS_FIREFOX__) {
args[0] = `${args[0]} %o`;
} else {
args[0] =
ANSI_STYLE_DIMMING_TEMPLATE_WITH_COMPONENT_STACK;
}
}
}
}
// Don't add stacks from other renderers.
break;
}
}
} catch (error) {
// Don't let a DevTools or React internal error interfere with logging.
setTimeout(() => {
throw error;
}, 0);
}
}
if (consoleSettingsRef.breakOnConsoleErrors) {
// --- Welcome to debugging with React DevTools ---
// This debugger statement means that you've enabled the "break on warnings" feature.
// Use the browser's Call Stack panel to step out of this override function-
// to where the original warning or error was logged.
// eslint-disable-next-line no-debugger
debugger;
}
originalMethod(...args);
};
overrideMethod.__REACT_DEVTOOLS_ORIGINAL_METHOD__ = originalMethod;
originalMethod.__REACT_DEVTOOLS_OVERRIDE_METHOD__ = overrideMethod;
targetConsole[method] = overrideMethod;
} catch (error) {}
});
} else {
unpatch();
}
}
// Removed component stack patch from console methods.
export function unpatch(): void {
if (unpatchFn !== null) {
unpatchFn();
unpatchFn = null;
}
}
let unpatchForStrictModeFn: null | (() => void) = null;
// NOTE: KEEP IN SYNC with src/hook.js:patchConsoleForInitialCommitInStrictMode
export function patchForStrictMode() {
const overrideConsoleMethods = [
'error',
'group',
'groupCollapsed',
'info',
'log',
'trace',
'warn',
];
if (unpatchForStrictModeFn !== null) {
// Don't patch twice.
return;
}
const originalConsoleMethods: {[string]: $FlowFixMe} = {};
unpatchForStrictModeFn = () => {
for (const method in originalConsoleMethods) {
try {
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]);
// $FlowFixMe[missing-local-annot]
const overrideMethod = (...args) => {
if (!consoleSettingsRef.hideConsoleLogsInStrictMode) {
// Dim the text color of the double logs if we're not hiding them.
if (__IS_FIREFOX__) {
originalMethod(
...formatWithStyles(args, FIREFOX_CONSOLE_DIMMING_COLOR),
);
} else {
originalMethod(
ANSI_STYLE_DIMMING_TEMPLATE,
...formatConsoleArguments(...args),
);
}
}
};
overrideMethod.__REACT_DEVTOOLS_STRICT_MODE_ORIGINAL_METHOD__ =
originalMethod;
originalMethod.__REACT_DEVTOOLS_STRICT_MODE_OVERRIDE_METHOD__ =
overrideMethod;
targetConsole[method] = overrideMethod;
} catch (error) {}
});
}
// NOTE: KEEP IN SYNC with src/hook.js:unpatchConsoleForInitialCommitInStrictMode
export function unpatchForStrictMode(): void {
if (unpatchForStrictModeFn !== null) {
unpatchForStrictModeFn();
unpatchForStrictModeFn = null;
}
}
export function patchConsoleUsingWindowValues() {
const appendComponentStack =
castBool(window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__) ?? true;
const breakOnConsoleErrors =
castBool(window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__) ?? false;
const showInlineWarningsAndErrors =
castBool(window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__) ?? true;
const hideConsoleLogsInStrictMode =
castBool(window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__) ??
false;
patch({
appendComponentStack,
breakOnConsoleErrors,
showInlineWarningsAndErrors,
hideConsoleLogsInStrictMode,
});
}
import type {ConsolePatchSettings} from './types';
// After receiving cached console patch settings from React Native, we set them on window.
// When the console is initially patched (in renderer.js and hook.js), these values are read.
@@ -433,10 +24,3 @@ export function writeConsolePatchSettingsToWindow(
window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ =
settings.hideConsoleLogsInStrictMode;
}
export function installConsoleFunctionsToWindow(): void {
window.__REACT_DEVTOOLS_CONSOLE_FUNCTIONS__ = {
patchConsoleUsingWindowValues,
registerRendererWithConsole: registerRenderer,
};
}
@@ -71,12 +71,6 @@ import {
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
} from '../../constants';
import {inspectHooksOfFiber} from 'react-debug-tools';
import {
patchConsoleUsingWindowValues,
registerRenderer as registerRendererWithConsole,
patchForStrictMode as patchConsoleForStrictMode,
unpatchForStrictMode as unpatchConsoleForStrictMode,
} from '../console';
import {
CONCURRENT_MODE_NUMBER,
CONCURRENT_MODE_SYMBOL_STRING,
@@ -1198,16 +1192,6 @@ export function attach(
needsToFlushComponentLogs = true;
}
// Patching the console enables DevTools to do a few useful things:
// * Append component stacks to warnings and error messages
// * Disable logging during re-renders to inspect hooks (see inspectHooksOfFiber)
registerRendererWithConsole(onErrorOrWarning, getComponentStack);
// The renderer interface can't read these preferences directly,
// because it is stored in localStorage within the context of the extension.
// It relies on the extension to pass the preference through via the global.
patchConsoleUsingWindowValues();
function debug(
name: string,
instance: DevToolsInstance,
@@ -5788,7 +5772,6 @@ export function attach(
hasElementWithId,
inspectElement,
logElementToConsole,
patchConsoleForStrictMode,
getComponentStack,
getElementAttributeByPath,
getElementSourceFunctionById,
@@ -5803,7 +5786,6 @@ export function attach(
startProfiling,
stopProfiling,
storeAsGlobal,
unpatchConsoleForStrictMode,
updateComponentFilters,
getEnvironmentNames,
};
@@ -19,11 +19,6 @@ import {componentInfoToComponentLogsMap} from '../shared/DevToolsServerComponent
import {formatConsoleArgumentsToSingleString} from 'react-devtools-shared/src/backend/utils';
import {
patchConsoleUsingWindowValues,
registerRenderer as registerRendererWithConsole,
} from '../console';
function supportsConsoleTasks(componentInfo: ReactComponentInfo): boolean {
// If this ReactComponentInfo supports native console.createTask then we are already running
// inside a native async stack trace if it's active - meaning the DevTools is open.
@@ -145,9 +140,6 @@ export function attach(
// The changes will be flushed later when we commit this tree to Fiber.
}
patchConsoleUsingWindowValues();
registerRendererWithConsole(onErrorOrWarning, getComponentStack);
return {
cleanup() {},
clearErrorsAndWarnings() {},
@@ -205,7 +197,6 @@ export function attach(
};
},
logElementToConsole() {},
patchConsoleForStrictMode() {},
getElementAttributeByPath() {},
getElementSourceFunctionById() {},
onErrorOrWarning,
@@ -219,7 +210,6 @@ export function attach(
startProfiling() {},
stopProfiling() {},
storeAsGlobal() {},
unpatchConsoleForStrictMode() {},
updateComponentFilters() {},
getEnvironmentNames() {
return [];
@@ -1103,10 +1103,6 @@ export function attach(
// Not implemented
}
function patchConsoleForStrictMode() {}
function unpatchConsoleForStrictMode() {}
function hasElementWithId(id: number): boolean {
return idToInternalInstanceMap.has(id);
}
@@ -1141,7 +1137,6 @@ export function attach(
overrideSuspense,
overrideValueAtPath,
renamePath,
patchConsoleForStrictMode,
getElementAttributeByPath,
getElementSourceFunctionById,
renderer,
@@ -1150,7 +1145,6 @@ export function attach(
startProfiling,
stopProfiling,
storeAsGlobal,
unpatchConsoleForStrictMode,
updateComponentFilters,
getEnvironmentNames,
};
-2
View File
@@ -404,7 +404,6 @@ export type RendererInterface = {
path: Array<string | number>,
value: any,
) => void,
patchConsoleForStrictMode: () => void,
getElementAttributeByPath: (
id: number,
path: Array<string | number>,
@@ -427,7 +426,6 @@ export type RendererInterface = {
path: Array<string | number>,
count: number,
) => void,
unpatchConsoleForStrictMode: () => void,
updateComponentFilters: (componentFilters: Array<ComponentFilter>) => void,
getEnvironmentNames: () => Array<string>,
+2 -2
View File
@@ -15,7 +15,7 @@ import type {
OwnersList,
ProfilingDataBackend,
RendererID,
ConsolePatchSettings,
DevToolsHookSettings,
} from 'react-devtools-shared/src/backend/types';
import type {StyleAndLayout as StyleAndLayoutPayload} from 'react-devtools-shared/src/backend/NativeStyleEditor/types';
@@ -241,7 +241,7 @@ type FrontendEvents = {
storeAsGlobal: [StoreAsGlobalParams],
updateComponentFilters: [Array<ComponentFilter>],
getEnvironmentNames: [],
updateConsolePatchSettings: [ConsolePatchSettings],
updateConsolePatchSettings: [DevToolsHookSettings],
viewAttributeSource: [ViewAttributeSourceParams],
viewElementSource: [ElementAndRendererID],
+282 -126
View File
@@ -21,10 +21,31 @@ import type {
import {
FIREFOX_CONSOLE_DIMMING_COLOR,
ANSI_STYLE_DIMMING_TEMPLATE,
ANSI_STYLE_DIMMING_TEMPLATE_WITH_COMPONENT_STACK,
} from 'react-devtools-shared/src/constants';
import attachRenderer from './attachRenderer';
declare var window: any;
// React's custom built component stack strings match "\s{4}in"
// Chrome's prefix matches "\s{4}at"
const PREFIX_REGEX = /\s{4}(in|at)\s{1}/;
// Firefox and Safari have no prefix ("")
// but we can fallback to looking for location info (e.g. "foo.js:12:345")
const ROW_COLUMN_NUMBER_REGEX = /:\d+:\d+(\n|$)/;
function isStringComponentStack(text: string): boolean {
return PREFIX_REGEX.test(text) || ROW_COLUMN_NUMBER_REGEX.test(text);
}
// We add a suffix to some frames that older versions of React didn't do.
// To compare if it's equivalent we strip out the suffix to see if they're
// still equivalent. Similarly, we sometimes use [] and sometimes () so we
// strip them to for the comparison.
const frameDiffs = / \(\<anonymous\>\)$|\@unknown\:0\:0$|\(|\)|\[|\]/gm;
function areStackTracesEqual(a: string, b: string): boolean {
return a.replace(frameDiffs, '') === b.replace(frameDiffs, '');
}
const targetConsole: Object = console;
export function installHook(
target: any,
@@ -36,25 +57,6 @@ export function installHook(
return null;
}
let targetConsole: Object = console;
let targetConsoleMethods: {[string]: $FlowFixMe} = {};
for (const method in console) {
// $FlowFixMe[invalid-computed-prop]
targetConsoleMethods[method] = console[method];
}
function dangerous_setTargetConsoleForTesting(
targetConsoleForTesting: Object,
): void {
targetConsole = targetConsoleForTesting;
targetConsoleMethods = ({}: {[string]: $FlowFixMe});
for (const method in targetConsole) {
// $FlowFixMe[invalid-computed-prop]
targetConsoleMethods[method] = console[method];
}
}
function detectReactBuildType(renderer: ReactRenderer) {
try {
if (typeof renderer.version === 'string') {
@@ -189,10 +191,7 @@ export function installHook(
}
// NOTE: KEEP IN SYNC with src/backend/utils.js
function formatWithStyles(
inputArgs: $ReadOnlyArray<any>,
style?: string,
): $ReadOnlyArray<any> {
function formatWithStyles(inputArgs: Array<any>, style?: string): Array<any> {
if (
inputArgs === undefined ||
inputArgs === null ||
@@ -285,85 +284,6 @@ export function installHook(
return [template, ...args];
}
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 patchConsoleForInitialCommitInStrictMode(
hideConsoleLogsInStrictMode: boolean,
) {
const overrideConsoleMethods = [
'error',
'group',
'groupCollapsed',
'info',
'log',
'trace',
'warn',
];
if (unpatchFn !== null) {
// Don't patch twice.
return;
}
const originalConsoleMethods: {[string]: $FlowFixMe} = {};
unpatchFn = () => {
for (const method in originalConsoleMethods) {
try {
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: $ReadOnlyArray<any>) => {
// Dim the text color of the double logs if we're not hiding them.
if (!hideConsoleLogsInStrictMode) {
// Firefox doesn't support ANSI escape sequences
if (__IS_FIREFOX__) {
originalMethod(
...formatWithStyles(args, FIREFOX_CONSOLE_DIMMING_COLOR),
);
} else {
originalMethod(
ANSI_STYLE_DIMMING_TEMPLATE,
...formatConsoleArguments(...args),
);
}
}
};
overrideMethod.__REACT_DEVTOOLS_STRICT_MODE_ORIGINAL_METHOD__ =
originalMethod;
originalMethod.__REACT_DEVTOOLS_STRICT_MODE_OVERRIDE_METHOD__ =
overrideMethod;
targetConsole[method] = overrideMethod;
} catch (error) {}
});
}
// NOTE: KEEP IN SYNC with src/backend/console.js:unpatchForStrictMode
function unpatchConsoleForInitialCommitInStrictMode() {
if (unpatchFn !== null) {
unpatchFn();
unpatchFn = null;
}
}
let uidCounter = 0;
function inject(renderer: ReactRenderer): number {
const id = ++uidCounter;
@@ -469,28 +389,85 @@ export function installHook(
}
}
function setStrictMode(rendererID: RendererID, isStrictMode: any) {
const rendererInterface = rendererInterfaces.get(rendererID);
if (rendererInterface != null) {
if (isStrictMode) {
rendererInterface.patchConsoleForStrictMode();
} else {
rendererInterface.unpatchConsoleForStrictMode();
}
} else {
// This should only happen during initial commit 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;
let isRunningDuringStrictModeInvocation = false;
function setStrictMode(rendererID: RendererID, isStrictMode: boolean) {
isRunningDuringStrictModeInvocation = isStrictMode;
patchConsoleForInitialCommitInStrictMode(hideConsoleLogsInStrictMode);
} else {
unpatchConsoleForInitialCommitInStrictMode();
}
if (isStrictMode) {
patchConsoleForStrictMode();
} else {
unpatchConsoleForStrictMode();
}
}
const unpatchConsoleCallbacks = [];
// For StrictMode we patch console once we are running in StrictMode and unpatch right after it
// So patching could happen multiple times during the runtime
// Notice how we don't patch error or warn methods, because they are already patched in patchConsoleForErrorsAndWarnings
// This will only happen once, when hook is installed
function patchConsoleForStrictMode() {
// Don't patch console in case settings were not injected
if (!hook.settings) {
return;
}
// Don't patch twice
if (unpatchConsoleCallbacks.length > 0) {
return;
}
// At this point 'error', 'warn', and 'trace' methods are already patched
// by React DevTools hook to append component stacks and other possible features.
const consoleMethodsToOverrideForStrictMode = [
'group',
'groupCollapsed',
'info',
'log',
];
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const method of consoleMethodsToOverrideForStrictMode) {
const originalMethod = targetConsole[method];
const overrideMethod: (...args: Array<any>) => void = (
...args: any[]
) => {
const settings = hook.settings;
// Something unexpected happened, fallback to just printing the console message.
if (settings == null) {
originalMethod(...args);
return;
}
if (settings.hideConsoleLogsInStrictMode) {
return;
}
// Dim the text color of the double logs if we're not hiding them.
// Firefox doesn't support ANSI escape sequences
if (__IS_FIREFOX__) {
originalMethod(
...formatWithStyles(args, FIREFOX_CONSOLE_DIMMING_COLOR),
);
} else {
originalMethod(
ANSI_STYLE_DIMMING_TEMPLATE,
...formatConsoleArguments(...args),
);
}
};
targetConsole[method] = overrideMethod;
unpatchConsoleCallbacks.push(() => {
targetConsole[method] = originalMethod;
});
}
}
function unpatchConsoleForStrictMode() {
unpatchConsoleCallbacks.forEach(callback => callback());
unpatchConsoleCallbacks.length = 0;
}
type StackFrameString = string;
const openModuleRangesStack: Array<StackFrameString> = [];
@@ -526,6 +503,188 @@ export function installHook(
}
}
// For Errors and Warnings we only patch console once
function patchConsoleForErrorsAndWarnings() {
// Don't patch console in case settings were not injected
if (!hook.settings) {
return;
}
const consoleMethodsToOverrideForErrorsAndWarnings = [
'error',
'trace',
'warn',
];
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const method of consoleMethodsToOverrideForErrorsAndWarnings) {
const originalMethod = targetConsole[method];
const overrideMethod: (...args: Array<any>) => void = (...args) => {
const settings = hook.settings;
// Something unexpected happened, fallback to just printing the console message.
if (settings == null) {
originalMethod(...args);
return;
}
if (
isRunningDuringStrictModeInvocation &&
settings.hideConsoleLogsInStrictMode
) {
return;
}
let injectedComponentStackAsFakeError = false;
let alreadyHasComponentStack = false;
if (settings.appendComponentStack) {
const lastArg = args.length > 0 ? args[args.length - 1] : null;
alreadyHasComponentStack =
typeof lastArg === 'string' && isStringComponentStack(lastArg); // The last argument should be a component stack.
}
const shouldShowInlineWarningsAndErrors =
settings.showInlineWarningsAndErrors &&
(method === 'error' || method === 'warn');
// 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?)
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const rendererInterface of hook.rendererInterfaces.values()) {
const {onErrorOrWarning, getComponentStack} = rendererInterface;
try {
if (shouldShowInlineWarningsAndErrors) {
// patch() is called by two places: (1) the hook and (2) the renderer backend.
// The backend is what implements a message queue, so it's the only one that injects onErrorOrWarning.
if (onErrorOrWarning != null) {
onErrorOrWarning(
((method: any): 'error' | 'warn'),
args.slice(),
);
}
}
} catch (error) {
// Don't let a DevTools or React internal error interfere with logging.
setTimeout(() => {
throw error;
}, 0);
}
try {
if (settings.appendComponentStack && getComponentStack != null) {
// This needs to be directly in the wrapper so we can pop exactly one frame.
const topFrame = Error('react-stack-top-frame');
const match = getComponentStack(topFrame);
if (match !== null) {
const {enableOwnerStacks, componentStack} = match;
// Empty string means we have a match but no component stack.
// We don't need to look in other renderers but we also don't add anything.
if (componentStack !== '') {
// Create a fake Error so that when we print it we get native source maps. Every
// browser will print the .stack property of the error and then parse it back for source
// mapping. Rather than print the internal slot. So it doesn't matter that the internal
// slot doesn't line up.
const fakeError = new Error('');
// In Chromium, only the stack property is printed but in Firefox the <name>:<message>
// gets printed so to make the colon make sense, we name it so we print Stack:
// and similarly Safari leave an expandable slot.
if (__IS_CHROME__ || __IS_EDGE__) {
// Before sending the stack to Chrome DevTools for formatting,
// V8 will reconstruct this according to the template <name>: <message><stack-frames>
// https://source.chromium.org/chromium/chromium/src/+/main:v8/src/inspector/value-mirror.cc;l=252-311;drc=bdc48d1b1312cc40c00282efb1c9c5f41dcdca9a
// It has to start with ^[\w.]*Error\b to trigger stack formatting.
fakeError.name = enableOwnerStacks
? 'Error Stack'
: 'Error Component Stack'; // This gets printed
} else {
fakeError.name = enableOwnerStacks
? 'Stack'
: 'Component Stack'; // This gets printed
}
// In Chromium, the stack property needs to start with ^[\w.]*Error\b to trigger stack
// formatting. Otherwise it is left alone. So we prefix it. Otherwise we just override it
// to our own stack.
fakeError.stack =
__IS_CHROME__ || __IS_EDGE__ || __IS_NATIVE__
? (enableOwnerStacks
? 'Error Stack:'
: 'Error Component Stack:') + componentStack
: componentStack;
if (alreadyHasComponentStack) {
// Only modify the component stack if it matches what we would've added anyway.
// Otherwise we assume it was a non-React stack.
if (
areStackTracesEqual(args[args.length - 1], componentStack)
) {
const firstArg = args[0];
if (
args.length > 1 &&
typeof firstArg === 'string' &&
firstArg.endsWith('%s')
) {
args[0] = firstArg.slice(0, firstArg.length - 2); // Strip the %s param
}
args[args.length - 1] = fakeError;
injectedComponentStackAsFakeError = true;
}
} else {
args.push(fakeError);
injectedComponentStackAsFakeError = true;
}
}
// Don't add stacks from other renderers.
break;
}
}
} catch (error) {
// Don't let a DevTools or React internal error interfere with logging.
setTimeout(() => {
throw error;
}, 0);
}
}
if (settings.breakOnConsoleErrors) {
// --- Welcome to debugging with React DevTools ---
// This debugger statement means that you've enabled the "break on warnings" feature.
// Use the browser's Call Stack panel to step out of this override function
// to where the original warning or error was logged.
// eslint-disable-next-line no-debugger
debugger;
}
if (isRunningDuringStrictModeInvocation) {
// Dim the text color of the double logs if we're not hiding them.
// Firefox doesn't support ANSI escape sequences
if (__IS_FIREFOX__) {
const argsWithCSSStyles = formatWithStyles(
args,
FIREFOX_CONSOLE_DIMMING_COLOR,
);
if (injectedComponentStackAsFakeError) {
argsWithCSSStyles[0] = `${argsWithCSSStyles[0]} %o`;
}
originalMethod(...argsWithCSSStyles);
} else {
originalMethod(
injectedComponentStackAsFakeError
? ANSI_STYLE_DIMMING_TEMPLATE_WITH_COMPONENT_STACK
: ANSI_STYLE_DIMMING_TEMPLATE,
...formatConsoleArguments(...args),
);
}
} else {
originalMethod(...args);
}
};
targetConsole[method] = overrideMethod;
}
}
// TODO: More meaningful names for "rendererInterfaces" and "renderers".
const fiberRoots: {[RendererID]: Set<mixed>} = {};
const rendererInterfaces = new Map<RendererID, RendererInterface>();
@@ -580,10 +739,12 @@ export function installHook(
showInlineWarningsAndErrors: true,
hideConsoleLogsInStrictMode: false,
};
patchConsoleForErrorsAndWarnings();
} else {
Promise.resolve(maybeSettingsOrSettingsPromise)
.then(settings => {
hook.settings = settings;
patchConsoleForErrorsAndWarnings();
})
.catch(() => {
targetConsole.error(
@@ -592,11 +753,6 @@ export function installHook(
});
}
if (__TEST__) {
hook.dangerous_setTargetConsoleForTesting =
dangerous_setTargetConsoleForTesting;
}
Object.defineProperty(
target,
'__REACT_DEVTOOLS_GLOBAL_HOOK__',