Files
react/packages/react-devtools-shared/src/backend/console.js
T
Sebastian Markbåge d1afcb43fd [DevTools] Track all public HostInstances in a Map (#30831)
This lets us get from a HostInstance to the nearest DevToolsInstance
without relying on `findFiberByHostInstance` and
`fiberToDevToolsInstanceMap`. We already did the equivalent of this for
Resources in HostHoistables.

One issue before was that we'd ideally get away from the
`fiberToDevToolsInstanceMap` map in general since we should ideally not
treat Fibers as stateful but they could be replaced by something else
stateful in principle.

This PR also addresses Virtual Instances. Now you can select a DOM node
and have it select a Virtual Instance if that's the nearest parent since
the parent doesn't have to be a Fiber anymore.

However, the other reason for this change is that I'd like to get rid of
the need for the `findFiberByHostInstance` from being injected. A
renderer should not need to store a reference back from its instance to
a Fiber. Without the Synthetic Event system this wouldn't be needed by
the renderer so we should be able to remove it. We also don't really
need it since we have all the information by just walking the commit to
collect the nodes if we just maintain our own Map.

There's one subtle nuance that the different renderers do. Typically a
HostInstance is the same thing as a PublicInstance in React but
technically in Fabric they're not the same. So we need to translate
between PublicInstance and HostInstance. I just hardcoded the Fabric
implementation of this since it's the only known one that does this but
could feature detect other ones too if necessary. On one hand it's more
resilient to refactors to not rely on injected helpers and on hand it
doesn't follow changes to things like this.

For the conflict resolution I added in #30494 I had to make that
specific to DOM so we can move the DOM traversal to the backend instead
of the injected helper.
2024-09-03 17:28:05 -04:00

487 lines
18 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
import type {
LegacyDispatcherRef,
CurrentDispatcherRef,
ReactRenderer,
WorkTagMap,
ConsolePatchSettings,
} 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 {getInternalReactConstants, getDispatcherRef} from './fiber/renderer';
import {
getStackByFiberInDevAndProd,
getOwnerStackByFiberInDev,
supportsOwnerStacks,
supportsConsoleTasks,
} from './fiber/DevToolsFiberComponentStack';
import {formatOwnerStack} from './shared/DevToolsOwnerStack';
import {castBool, castBrowserTheme} 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);
}
}
type OnErrorOrWarning = (
fiber: Fiber,
type: 'error' | 'warn',
args: Array<any>,
) => void;
const injectedRenderers: Map<
ReactRenderer,
{
currentDispatcherRef: LegacyDispatcherRef | CurrentDispatcherRef,
getCurrentFiber: () => Fiber | null,
onErrorOrWarning: ?OnErrorOrWarning,
workTagMap: WorkTagMap,
},
> = new Map();
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(
renderer: ReactRenderer,
onErrorOrWarning?: OnErrorOrWarning,
): void {
const {currentDispatcherRef, getCurrentFiber, version} = renderer;
// currentDispatcherRef gets injected for v16.8+ to support hooks inspection.
// getCurrentFiber gets injected for v16.9+.
if (currentDispatcherRef != null && typeof getCurrentFiber === 'function') {
const {ReactTypeOfWork} = getInternalReactConstants(version);
injectedRenderers.set(renderer, {
currentDispatcherRef,
getCurrentFiber,
workTagMap: ReactTypeOfWork,
onErrorOrWarning,
});
}
}
const consoleSettingsRef: ConsolePatchSettings = {
appendComponentStack: false,
breakOnConsoleErrors: false,
showInlineWarningsAndErrors: false,
hideConsoleLogsInStrictMode: false,
browserTheme: 'dark',
};
// 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,
browserTheme,
}: 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;
consoleSettingsRef.browserTheme = browserTheme;
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?)
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const renderer of injectedRenderers.values()) {
const currentDispatcherRef = getDispatcherRef(renderer);
const {getCurrentFiber, onErrorOrWarning, workTagMap} = renderer;
const current: ?Fiber = getCurrentFiber();
if (current != null) {
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 (typeof onErrorOrWarning === 'function') {
onErrorOrWarning(
current,
((method: any): 'error' | 'warn'),
// Restore and copy args before we mutate them (e.g. adding the component stack)
restorePotentiallyModifiedArgs(args),
);
}
}
if (
consoleSettingsRef.appendComponentStack &&
!supportsConsoleTasks(current)
) {
const enableOwnerStacks = supportsOwnerStacks(current);
let componentStack = '';
if (enableOwnerStacks) {
// Prefix the owner stack with the current stack. I.e. what called
// console.error. While this will also be part of the native stack,
// it is hidden and not presented alongside this argument so we print
// them all together.
const topStackFrames = formatOwnerStack(
new Error('react-stack-top-frame'),
);
if (topStackFrames) {
componentStack += '\n' + topStackFrames;
}
componentStack += getOwnerStackByFiberInDev(
workTagMap,
current,
(currentDispatcherRef: any),
);
} else {
componentStack = getStackByFiberInDevAndProd(
workTagMap,
current,
(currentDispatcherRef: any),
);
}
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 Component Stack:
// and similarly Safari leave an expandable slot.
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;
}
}
}
}
}
} catch (error) {
// Don't let a DevTools or React internal error interfere with logging.
setTimeout(() => {
throw error;
}, 0);
} finally {
break;
}
}
}
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;
const browserTheme =
castBrowserTheme(window.__REACT_DEVTOOLS_BROWSER_THEME__) ?? 'dark';
patch({
appendComponentStack,
breakOnConsoleErrors,
showInlineWarningsAndErrors,
hideConsoleLogsInStrictMode,
browserTheme,
});
}
// 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.
// The browser extension (etc.) sets these values on window, but through another method.
export function writeConsolePatchSettingsToWindow(
settings: ConsolePatchSettings,
): void {
window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ =
settings.appendComponentStack;
window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ =
settings.breakOnConsoleErrors;
window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ =
settings.showInlineWarningsAndErrors;
window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ =
settings.hideConsoleLogsInStrictMode;
window.__REACT_DEVTOOLS_BROWSER_THEME__ = settings.browserTheme;
}
export function installConsoleFunctionsToWindow(): void {
window.__REACT_DEVTOOLS_CONSOLE_FUNCTIONS__ = {
patchConsoleUsingWindowValues,
registerRendererWithConsole: registerRenderer,
};
}