[DevTools] Improve Layering Between Console and Renderer (#30925)
The console instrumentation should not know about things like Fibers. Only the renderer bindings should know about that stuff. We can improve the layering by just moving all that stuff behind a `getComponentStack` helper that gets injected by the renderer. This sets us up for the Flight renderer #30906 to have its own implementation of this function.
Sebastian Markbåge committed
Sep 9, 2024 at 15:33 UTC
0dbacf204168cedaf1b430084b5fc3820f1c6dfa
5 files changed
+145
-137
packages/react-devtools-shared/src/__tests__/console-test.js
+20
-13
@@ -54,14 +54,6 @@ describe('console', () => {
54
fakeConsole,
55
);
56
57
- const inject = global.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject;
58
- global.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = internals => {
59
- rendererID = inject(internals);
60
-
61
- Console.registerRenderer(internals);
62
- return rendererID;
63
- };
64
-
57
React = require('react');
58
if (
59
React.version.startsWith('19') &&
@@ -1100,9 +1092,17 @@ describe('console error', () => {
1092
global.__REACT_DEVTOOLS_GLOBAL_HOOK__.inject = internals => {
1093
inject(internals);
1094
1103
- Console.registerRenderer(internals, () => {
1104
- throw Error('foo');
1105
- });
1095
+ Console.registerRenderer(
1096
+ () => {
1097
+ throw Error('foo');
1098
+ },
1099
+ () => {
1100
+ return {
1101
+ enableOwnerStacks: true,
1102
+ componentStack: '\n at FakeStack (fake-file)',
1103
+ };
1104
+ },
1105
+ );
1106
};
1107
1108
React = require('react');
@@ -1142,11 +1142,18 @@ describe('console error', () => {
1142
expect(mockLog.mock.calls[0][0]).toBe('log');
1143
1144
expect(mockWarn).toHaveBeenCalledTimes(1);
1145
- expect(mockWarn.mock.calls[0]).toHaveLength(1);
1145
+ expect(mockWarn.mock.calls[0]).toHaveLength(2);
1146
expect(mockWarn.mock.calls[0][0]).toBe('warn');
1147
+ // An error in showInlineWarningsAndErrors doesn't need to break component stacks.
1148
+ expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe(
1149
+ '\n in FakeStack (at **)',
1150
+ );
1151
1152
expect(mockError).toHaveBeenCalledTimes(1);
1149
- expect(mockError.mock.calls[0]).toHaveLength(1);
1153
+ expect(mockError.mock.calls[0]).toHaveLength(2);
1154
expect(mockError.mock.calls[0][0]).toBe('error');
1155
+ expect(normalizeCodeLocInfo(mockError.mock.calls[0][1])).toBe(
1156
+ '\n in FakeStack (at **)',
1157
+ );
1158
});
1159
});
packages/react-devtools-shared/src/backend/console.js
+56
-102
@@ -7,14 +7,7 @@
7
* @flow
8
*/
9
10
-import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
11
-import type {
12
- LegacyDispatcherRef,
13
- CurrentDispatcherRef,
14
- ReactRenderer,
15
- WorkTagMap,
16
- ConsolePatchSettings,
17
-} from './types';
10
+import type {ConsolePatchSettings} from './types';
11
12
import {
13
formatConsoleArguments,
@@ -25,14 +18,6 @@ import {
18
ANSI_STYLE_DIMMING_TEMPLATE,
19
ANSI_STYLE_DIMMING_TEMPLATE_WITH_COMPONENT_STACK,
20
} from 'react-devtools-shared/src/constants';
28
-import {getInternalReactConstants, getDispatcherRef} from './fiber/renderer';
29
-import {
30
- getStackByFiberInDevAndProd,
31
- getOwnerStackByFiberInDev,
32
- supportsOwnerStacks,
33
- supportsConsoleTasks,
34
-} from './fiber/DevToolsFiberComponentStack';
35
-import {formatOwnerStack} from './shared/DevToolsOwnerStack';
21
import {castBool, castBrowserTheme} from '../utils';
22
23
const OVERRIDE_CONSOLE_METHODS = ['error', 'trace', 'warn'];
@@ -90,21 +75,15 @@ function restorePotentiallyModifiedArgs(args: Array<any>): Array<any> {
75
}
76
}
77
93
-type OnErrorOrWarning = (
94
- fiber: Fiber,
95
- type: 'error' | 'warn',
96
- args: Array<any>,
97
-) => void;
98
-
99
-const injectedRenderers: Map<
100
- ReactRenderer,
101
- {
102
- currentDispatcherRef: LegacyDispatcherRef | CurrentDispatcherRef,
103
- getCurrentFiber: () => Fiber | null,
104
- onErrorOrWarning: ?OnErrorOrWarning,
105
- workTagMap: WorkTagMap,
106
- },
107
-> = new Map();
78
+type OnErrorOrWarning = (type: 'error' | 'warn', args: Array<any>) => void;
79
+type GetComponentStack = (
80
+ topFrame: Error,
81
+) => null | {enableOwnerStacks: boolean, componentStack: string};
82
+
83
+const injectedRenderers: Array<{
84
+ onErrorOrWarning: ?OnErrorOrWarning,
85
+ getComponentStack: ?GetComponentStack,
86
+}> = [];
87
88
let targetConsole: Object = console;
89
let targetConsoleMethods: {[string]: $FlowFixMe} = {};
@@ -132,23 +111,13 @@ export function dangerous_setTargetConsoleForTesting(
111
// These internals will be used if the console is patched.
112
// Injecting them separately allows the console to easily be patched or un-patched later (at runtime).
113
export function registerRenderer(
135
- renderer: ReactRenderer,
114
onErrorOrWarning?: OnErrorOrWarning,
115
+ getComponentStack?: GetComponentStack,
116
): void {
138
- const {currentDispatcherRef, getCurrentFiber, version} = renderer;
139
-
140
- // currentDispatcherRef gets injected for v16.8+ to support hooks inspection.
141
- // getCurrentFiber gets injected for v16.9+.
142
- if (currentDispatcherRef != null && typeof getCurrentFiber === 'function') {
143
- const {ReactTypeOfWork} = getInternalReactConstants(version);
144
-
145
- injectedRenderers.set(renderer, {
146
- currentDispatcherRef,
147
- getCurrentFiber,
148
- workTagMap: ReactTypeOfWork,
149
- onErrorOrWarning,
150
- });
151
- }
117
+ injectedRenderers.push({
118
+ onErrorOrWarning,
119
+ getComponentStack,
120
+ });
121
}
122
123
const consoleSettingsRef: ConsolePatchSettings = {
@@ -219,55 +188,39 @@ export function patch({
188
189
// Search for the first renderer that has a current Fiber.
190
// We don't handle the edge case of stacks for more than one (e.g. interleaved renderers?)
222
- // eslint-disable-next-line no-for-of-loops/no-for-of-loops
223
- for (const renderer of injectedRenderers.values()) {
224
- const currentDispatcherRef = getDispatcherRef(renderer);
225
- const {getCurrentFiber, onErrorOrWarning, workTagMap} = renderer;
226
- const current: ?Fiber = getCurrentFiber();
227
- if (current != null) {
228
- try {
229
- if (shouldShowInlineWarningsAndErrors) {
230
- // patch() is called by two places: (1) the hook and (2) the renderer backend.
231
- // The backend is what implements a message queue, so it's the only one that injects onErrorOrWarning.
232
- if (typeof onErrorOrWarning === 'function') {
233
- onErrorOrWarning(
234
- current,
235
- ((method: any): 'error' | 'warn'),
236
- // Restore and copy args before we mutate them (e.g. adding the component stack)
237
- restorePotentiallyModifiedArgs(args),
238
- );
239
- }
191
+ for (let i = 0; i < injectedRenderers.length; i++) {
192
+ const renderer = injectedRenderers[i];
193
+ const {getComponentStack, onErrorOrWarning} = renderer;
194
+ try {
195
+ if (shouldShowInlineWarningsAndErrors) {
196
+ // patch() is called by two places: (1) the hook and (2) the renderer backend.
197
+ // The backend is what implements a message queue, so it's the only one that injects onErrorOrWarning.
198
+ if (onErrorOrWarning != null) {
199
+ onErrorOrWarning(
200
+ ((method: any): 'error' | 'warn'),
201
+ // Restore and copy args before we mutate them (e.g. adding the component stack)
202
+ restorePotentiallyModifiedArgs(args),
203
+ );
204
}
241
-
242
- if (
243
- consoleSettingsRef.appendComponentStack &&
244
- !supportsConsoleTasks(current)
245
- ) {
246
- const enableOwnerStacks = supportsOwnerStacks(current);
247
- let componentStack = '';
248
- if (enableOwnerStacks) {
249
- // Prefix the owner stack with the current stack. I.e. what called
250
- // console.error. While this will also be part of the native stack,
251
- // it is hidden and not presented alongside this argument so we print
252
- // them all together.
253
- const topStackFrames = formatOwnerStack(
254
- new Error('react-stack-top-frame'),
255
- );
256
- if (topStackFrames) {
257
- componentStack += '\n' + topStackFrames;
258
- }
259
- componentStack += getOwnerStackByFiberInDev(
260
- workTagMap,
261
- current,
262
- (currentDispatcherRef: any),
263
- );
264
- } else {
265
- componentStack = getStackByFiberInDevAndProd(
266
- workTagMap,
267
- current,
268
- (currentDispatcherRef: any),
269
- );
270
- }
205
+ }
206
+ } catch (error) {
207
+ // Don't let a DevTools or React internal error interfere with logging.
208
+ setTimeout(() => {
209
+ throw error;
210
+ }, 0);
211
+ }
212
+ try {
213
+ if (
214
+ consoleSettingsRef.appendComponentStack &&
215
+ getComponentStack != null
216
+ ) {
217
+ // This needs to be directly in the wrapper so we can pop exactly one frame.
218
+ const topFrame = Error('react-stack-top-frame');
219
+ const match = getComponentStack(topFrame);
220
+ if (match !== null) {
221
+ const {enableOwnerStacks, componentStack} = match;
222
+ // Empty string means we have a match but no component stack.
223
+ // We don't need to look in other renderers but we also don't add anything.
224
if (componentStack !== '') {
225
// Create a fake Error so that when we print it we get native source maps. Every
226
// browser will print the .stack property of the error and then parse it back for source
@@ -275,7 +228,7 @@ export function patch({
228
// slot doesn't line up.
229
const fakeError = new Error('');
230
// In Chromium, only the stack property is printed but in Firefox the <name>:<message>
278
- // gets printed so to make the colon make sense, we name it so we print Component Stack:
231
+ // gets printed so to make the colon make sense, we name it so we print Stack:
232
// and similarly Safari leave an expandable slot.
233
fakeError.name = enableOwnerStacks
234
? 'Stack'
@@ -289,6 +242,7 @@ export function patch({
242
? 'Error Stack:'
243
: 'Error Component Stack:') + componentStack
244
: componentStack;
245
+
246
if (alreadyHasComponentStack) {
247
// Only modify the component stack if it matches what we would've added anyway.
248
// Otherwise we assume it was a non-React stack.
@@ -324,15 +278,15 @@ export function patch({
278
}
279
}
280
}
281
+ // Don't add stacks from other renderers.
282
+ break;
283
}
328
- } catch (error) {
329
- // Don't let a DevTools or React internal error interfere with logging.
330
- setTimeout(() => {
331
- throw error;
332
- }, 0);
333
- } finally {
334
- break;
284
}
285
+ } catch (error) {
286
+ // Don't let a DevTools or React internal error interfere with logging.
287
+ setTimeout(() => {
288
+ throw error;
289
+ }, 0);
290
}
291
}
292
packages/react-devtools-shared/src/backend/fiber/renderer.js
+68
-2
@@ -106,6 +106,13 @@ import {componentInfoToComponentLogsMap} from '../shared/DevToolsServerComponent
106
import is from 'shared/objectIs';
107
import hasOwnProperty from 'shared/hasOwnProperty';
108
109
+import {
110
+ getStackByFiberInDevAndProd,
111
+ getOwnerStackByFiberInDev,
112
+ supportsOwnerStacks,
113
+ supportsConsoleTasks,
114
+} from './DevToolsFiberComponentStack';
115
+
116
// $FlowFixMe[method-unbinding]
117
const toString = Object.prototype.toString;
118
@@ -912,6 +919,7 @@ export function attach(
919
setErrorHandler,
920
setSuspenseHandler,
921
scheduleUpdate,
922
+ getCurrentFiber,
923
} = renderer;
924
const supportsTogglingError =
925
typeof setErrorHandler === 'function' &&
@@ -1067,12 +1075,70 @@ export function attach(
1075
}
1076
}
1077
1078
+ function getComponentStack(
1079
+ topFrame: Error,
1080
+ ): null | {enableOwnerStacks: boolean, componentStack: string} {
1081
+ if (getCurrentFiber === undefined) {
1082
+ // Expected this to be part of the renderer. Ignore.
1083
+ return null;
1084
+ }
1085
+ const current = getCurrentFiber();
1086
+ if (current === null) {
1087
+ // Outside of our render scope.
1088
+ return null;
1089
+ }
1090
+
1091
+ if (supportsConsoleTasks(current)) {
1092
+ // This will be handled natively by console.createTask. No need for
1093
+ // DevTools to add it.
1094
+ return null;
1095
+ }
1096
+
1097
+ const dispatcherRef = getDispatcherRef(renderer);
1098
+ if (dispatcherRef === undefined) {
1099
+ return null;
1100
+ }
1101
+
1102
+ const enableOwnerStacks = supportsOwnerStacks(current);
1103
+ let componentStack = '';
1104
+ if (enableOwnerStacks) {
1105
+ // Prefix the owner stack with the current stack. I.e. what called
1106
+ // console.error. While this will also be part of the native stack,
1107
+ // it is hidden and not presented alongside this argument so we print
1108
+ // them all together.
1109
+ const topStackFrames = formatOwnerStack(topFrame);
1110
+ if (topStackFrames) {
1111
+ componentStack += '\n' + topStackFrames;
1112
+ }
1113
+ componentStack += getOwnerStackByFiberInDev(
1114
+ ReactTypeOfWork,
1115
+ current,
1116
+ dispatcherRef,
1117
+ );
1118
+ } else {
1119
+ componentStack = getStackByFiberInDevAndProd(
1120
+ ReactTypeOfWork,
1121
+ current,
1122
+ dispatcherRef,
1123
+ );
1124
+ }
1125
+ return {enableOwnerStacks, componentStack};
1126
+ }
1127
+
1128
// Called when an error or warning is logged during render, commit, or passive (including unmount functions).
1129
function onErrorOrWarning(
1072
- fiber: Fiber,
1130
type: 'error' | 'warn',
1131
args: $ReadOnlyArray<any>,
1132
): void {
1133
+ if (getCurrentFiber === undefined) {
1134
+ // Expected this to be part of the renderer. Ignore.
1135
+ return;
1136
+ }
1137
+ const fiber = getCurrentFiber();
1138
+ if (fiber === null) {
1139
+ // Outside of our render scope.
1140
+ return;
1141
+ }
1142
if (type === 'error') {
1143
// if this is an error simulated by us to trigger error boundary, ignore
1144
if (
@@ -1135,7 +1201,7 @@ export function attach(
1201
// Patching the console enables DevTools to do a few useful things:
1202
// * Append component stacks to warnings and error messages
1203
// * Disable logging during re-renders to inspect hooks (see inspectHooksOfFiber)
1138
- registerRendererWithConsole(renderer, onErrorOrWarning);
1204
+ registerRendererWithConsole(onErrorOrWarning, getComponentStack);
1205
1206
// The renderer interface can't read these preferences directly,
1207
// because it is stored in localStorage within the context of the extension.
packages/react-devtools-shared/src/backend/flight/renderer.js
+1
-1
@@ -21,7 +21,7 @@ export function attach(
21
global: Object,
22
): RendererInterface {
23
patchConsoleUsingWindowValues();
24
- registerRendererWithConsole(renderer);
24
+ registerRendererWithConsole(); // TODO: Fill in the impl
25
26
return {
27
cleanup() {},
packages/react-devtools-shared/src/hook.js
-19
@@ -367,25 +367,6 @@ export function installHook(target: any): DevToolsHook | null {
367
? 'deadcode'
368
: detectReactBuildType(renderer);
369
370
- // Patching the console enables DevTools to do a few useful things:
371
- // * Append component stacks to warnings and error messages
372
- // * Disabling or marking logs during a double render in Strict Mode
373
- // * Disable logging during re-renders to inspect hooks (see inspectHooksOfFiber)
374
- //
375
- // Allow patching console early (during injection) to
376
- // provide developers with components stacks even if they don't run DevTools.
377
- if (target.hasOwnProperty('__REACT_DEVTOOLS_CONSOLE_FUNCTIONS__')) {
378
- const {registerRendererWithConsole, patchConsoleUsingWindowValues} =
379
- target.__REACT_DEVTOOLS_CONSOLE_FUNCTIONS__;
380
- if (
381
- typeof registerRendererWithConsole === 'function' &&
382
- typeof patchConsoleUsingWindowValues === 'function'
383
- ) {
384
- registerRendererWithConsole(renderer);
385
- patchConsoleUsingWindowValues();
386
- }
387
- }
388
-
370
// If we have just reloaded to profile, we need to inject the renderer interface before the app loads.
371
// Otherwise the renderer won't yet exist and we can skip this step.
372
const attach = target.__REACT_DEVTOOLS_ATTACH__;