Skip to content

Commit 400e822

Browse files
authored
Remove Component Stack from React Logged Warnings and Error Reporting (facebook#30308)
React transpiles some of its own `console.error` calls into a helper that appends component stacks to those calls. However, this doesn't cover user space `console.error` calls - which includes React helpers that React has moved into third parties like createClass and prop-types. The idea is that any user space component can add a warning just like React can which is why React DevTools adds them too if they don't already exist. Having them appended in both places is tricky because now you have to know whether to remove them from React's logs. Similarly it's often common for server-side frameworks to forget to cover the `console.error` logs from other sources since React DevTools isn't active there. However, it's also annoying to get component stacks clogging the terminal - depending on where the log came from. In the future `console.createTask()` will cover this use case natively and when available we don't append them at all. The new strategy relies on either: - React DevTools existing to add them to React logs as well as third parties. - `console.createTask` being supported and surfaced. - A third party framework showing the component stack either in an Error Dialog or appended to terminal output. For a third party to be able to implement this they need to be able to get the component stack. To get the component stack from within a `console.error` call you need to use the `React.captureOwnerStack()` helper which is only available in `enableOwnerStacks` flag. However, it's possible to polyfill with parent stacks using internals as a stop gap. There's a question of whether React 19 should just go out with `enableOwnerStacks` to expose this but regardless I think it's best it doesn't include component stacks from the runtime for consistency. In practice it's not really a regression though because typically either of the other options exists and error dialogs don't implement `console.error` overrides anyway yet. SSR terminals might miss them but they'd only have them in DEV warnings to begin with an a subset of React warnings. Typically those are either going to happen on the client anyway or replayed. Our tests are written to assert that component stacks work in various scenarios all over the place. To ensure that this keeps working I implement a "polyfill" that is similar to that expected a server framework might do - in `assertConsoleErrorDev` and `toErrorDev`. This PR doesn't yet change www or RN since they have their own forks of consoleWithStackDev for now.
1 parent 1e8efe7 commit 400e822

File tree

12 files changed

+544
-183
lines changed

12 files changed

+544
-183
lines changed

packages/internal-test-utils/__tests__/ReactInternalTestUtils-test.js

+370-72
Large diffs are not rendered by default.

packages/internal-test-utils/consoleMock.js

+43-2
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,34 @@ const patchConsoleMethod = (
4444
return;
4545
}
4646

47+
// Append Component Stacks. Simulates a framework or DevTools appending them.
48+
if (
49+
typeof format === 'string' &&
50+
(methodName === 'error' || methodName === 'warn')
51+
) {
52+
const React = require('react');
53+
if (React.captureOwnerStack) {
54+
// enableOwnerStacks enabled. When it's always on, we can assume this case.
55+
const stack = React.captureOwnerStack();
56+
if (stack) {
57+
format += '%s';
58+
args.push(stack);
59+
}
60+
} else {
61+
// Otherwise we have to use internals to emulate parent stacks.
62+
const ReactSharedInternals =
63+
React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE ||
64+
React.__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
65+
if (ReactSharedInternals && ReactSharedInternals.getCurrentStack) {
66+
const stack = ReactSharedInternals.getCurrentStack();
67+
if (stack !== '') {
68+
format += '%s';
69+
args.push(stack);
70+
}
71+
}
72+
}
73+
}
74+
4775
// Capture the call stack now so we can warn about it later.
4876
// The call stack has helpful information for the test author.
4977
// Don't throw yet though b'c it might be accidentally caught and suppressed.
@@ -204,7 +232,7 @@ export function assertConsoleLogsCleared() {
204232
if (warnings.length > 0) {
205233
message += `\nconsole.warn was called without assertConsoleWarnDev:\n${diff(
206234
'',
207-
warnings.join('\n'),
235+
warnings.map(normalizeComponentStack).join('\n'),
208236
{
209237
omitAnnotationLines: true,
210238
},
@@ -213,7 +241,7 @@ export function assertConsoleLogsCleared() {
213241
if (errors.length > 0) {
214242
message += `\nconsole.error was called without assertConsoleErrorDev:\n${diff(
215243
'',
216-
errors.join('\n'),
244+
errors.map(normalizeComponentStack).join('\n'),
217245
{
218246
omitAnnotationLines: true,
219247
},
@@ -249,6 +277,19 @@ function normalizeCodeLocInfo(str) {
249277
});
250278
}
251279

280+
function normalizeComponentStack(entry) {
281+
if (
282+
typeof entry[0] === 'string' &&
283+
entry[0].endsWith('%s') &&
284+
isLikelyAComponentStack(entry[entry.length - 1])
285+
) {
286+
const clone = entry.slice(0);
287+
clone[clone.length - 1] = normalizeCodeLocInfo(entry[entry.length - 1]);
288+
return clone;
289+
}
290+
return entry;
291+
}
292+
252293
const isLikelyAComponentStack = message =>
253294
typeof message === 'string' &&
254295
(message.indexOf('<component stack>') > -1 ||

packages/react-client/src/__tests__/ReactFlight-test.js

+21-5
Original file line numberDiff line numberDiff line change
@@ -1436,12 +1436,23 @@ describe('ReactFlight', () => {
14361436

14371437
it('should warn in DEV a child is missing keys on server component', () => {
14381438
function NoKey({children}) {
1439-
return <div key="this has a key but parent doesn't" />;
1439+
return ReactServer.createElement('div', {
1440+
key: "this has a key but parent doesn't",
1441+
});
14401442
}
14411443
expect(() => {
1444+
// While we're on the server we need to have the Server version active to track component stacks.
1445+
jest.resetModules();
1446+
jest.mock('react', () => ReactServer);
14421447
const transport = ReactNoopFlightServer.render(
1443-
<div>{Array(6).fill(<NoKey />)}</div>,
1448+
ReactServer.createElement(
1449+
'div',
1450+
null,
1451+
Array(6).fill(ReactServer.createElement(NoKey)),
1452+
),
14441453
);
1454+
jest.resetModules();
1455+
jest.mock('react', () => React);
14451456
ReactNoopFlightClient.read(transport);
14461457
}).toErrorDev('Each child in a list should have a unique "key" prop.');
14471458
});
@@ -2814,7 +2825,7 @@ describe('ReactFlight', () => {
28142825
});
28152826

28162827
// @gate (enableOwnerStacks && enableServerComponentLogs) || !__DEV__
2817-
it('should not include component stacks in replayed logs (unless DevTools add them)', () => {
2828+
it('should include only one component stack in replayed logs (if DevTools or polyfill adds them)', () => {
28182829
class MyError extends Error {
28192830
toJSON() {
28202831
return 123;
@@ -2839,6 +2850,9 @@ describe('ReactFlight', () => {
28392850
return ReactServer.createElement(Bar);
28402851
}
28412852

2853+
// While we're on the server we need to have the Server version active to track component stacks.
2854+
jest.resetModules();
2855+
jest.mock('react', () => ReactServer);
28422856
const transport = ReactNoopFlightServer.render(
28432857
ReactServer.createElement(App),
28442858
);
@@ -2857,6 +2871,8 @@ describe('ReactFlight', () => {
28572871
]);
28582872

28592873
// Replay logs on the client
2874+
jest.resetModules();
2875+
jest.mock('react', () => React);
28602876
ReactNoopFlightClient.read(transport);
28612877
assertConsoleErrorDev(
28622878
[
@@ -2866,8 +2882,8 @@ describe('ReactFlight', () => {
28662882
' <div>Womp womp: {Error}</div>\n' +
28672883
' ^^^^^^^',
28682884
],
2869-
// We should not have a stack in the replay because that should be added either by console.createTask
2870-
// or React DevTools on the client. Neither of which we do here.
2885+
// We should have a stack in the replay but we don't yet set the owner from the Flight replaying
2886+
// so our simulated polyfill doesn't end up getting any component stacks yet.
28712887
{withoutStack: true},
28722888
);
28732889
});

packages/react-dom/src/__tests__/ReactDOMConsoleErrorReporting-test.js

+12-6
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
143143
expect.stringContaining('%s'),
144144
expect.stringContaining('An error occurred in the <Foo> component'),
145145
expect.stringContaining('Consider adding an error boundary'),
146-
expect.stringContaining('Foo'),
146+
// The component stack is not added without the polyfill/devtools.
147+
// expect.stringContaining('Foo'),
147148
],
148149
]);
149150
} else {
@@ -208,7 +209,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
208209
'The above error occurred in the <Foo> component',
209210
),
210211
expect.stringContaining('ErrorBoundary'),
211-
expect.stringContaining('Foo'),
212+
// The component stack is not added without the polyfill/devtools.
213+
// expect.stringContaining('Foo'),
212214
],
213215
]);
214216
} else {
@@ -274,7 +276,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
274276
expect.stringContaining('%s'),
275277
expect.stringContaining('An error occurred in the <Foo> component'),
276278
expect.stringContaining('Consider adding an error boundary'),
277-
expect.stringContaining('Foo'),
279+
// The component stack is not added without the polyfill/devtools.
280+
// expect.stringContaining('Foo'),
278281
],
279282
]);
280283
} else {
@@ -344,7 +347,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
344347
'The above error occurred in the <Foo> component',
345348
),
346349
expect.stringContaining('ErrorBoundary'),
347-
expect.stringContaining('Foo'),
350+
// The component stack is not added without the polyfill/devtools.
351+
// expect.stringContaining('Foo'),
348352
],
349353
]);
350354
} else {
@@ -410,7 +414,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
410414
expect.stringContaining('%s'),
411415
expect.stringContaining('An error occurred in the <Foo> component'),
412416
expect.stringContaining('Consider adding an error boundary'),
413-
expect.stringContaining('Foo'),
417+
// The component stack is not added without the polyfill/devtools.
418+
// expect.stringContaining('Foo'),
414419
],
415420
]);
416421
} else {
@@ -478,7 +483,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
478483
'The above error occurred in the <Foo> component',
479484
),
480485
expect.stringContaining('ErrorBoundary'),
481-
expect.stringContaining('Foo'),
486+
// The component stack is not added without the polyfill/devtools.
487+
// expect.stringContaining('Foo'),
482488
],
483489
]);
484490
} else {

packages/react-dom/src/__tests__/ReactDOMConsoleErrorReportingLegacy-test.js

+12-6
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
162162
// Addendum by React:
163163
expect.stringContaining('An error occurred in the <Foo> component'),
164164
expect.stringContaining('Consider adding an error boundary'),
165-
expect.stringContaining('Foo'),
165+
// The component stack is not added without the polyfill/devtools.
166+
// expect.stringContaining('Foo'),
166167
],
167168
]);
168169

@@ -239,7 +240,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
239240
'The above error occurred in the <Foo> component',
240241
),
241242
expect.stringContaining('ErrorBoundary'),
242-
expect.stringContaining('Foo'),
243+
// The component stack is not added without the polyfill/devtools.
244+
// expect.stringContaining('Foo'),
243245
],
244246
]);
245247
} else {
@@ -309,7 +311,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
309311
// Addendum by React:
310312
expect.stringContaining('An error occurred in the <Foo> component'),
311313
expect.stringContaining('Consider adding an error boundary'),
312-
expect.stringContaining('Foo'),
314+
// The component stack is not added without the polyfill/devtools.
315+
// expect.stringContaining('Foo'),
313316
],
314317
]);
315318

@@ -390,7 +393,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
390393
'The above error occurred in the <Foo> component',
391394
),
392395
expect.stringContaining('ErrorBoundary'),
393-
expect.stringContaining('Foo'),
396+
// The component stack is not added without the polyfill/devtools.
397+
// expect.stringContaining('Foo'),
394398
],
395399
]);
396400
} else {
@@ -460,7 +464,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
460464
// Addendum by React:
461465
expect.stringContaining('An error occurred in the <Foo> component'),
462466
expect.stringContaining('Consider adding an error boundary'),
463-
expect.stringContaining('Foo'),
467+
// The component stack is not added without the polyfill/devtools.
468+
// expect.stringContaining('Foo'),
464469
],
465470
]);
466471

@@ -540,7 +545,8 @@ describe('ReactDOMConsoleErrorReporting', () => {
540545
'The above error occurred in the <Foo> component',
541546
),
542547
expect.stringContaining('ErrorBoundary'),
543-
expect.stringContaining('Foo'),
548+
// The component stack is not added without the polyfill/devtools.
549+
// expect.stringContaining('Foo'),
544550
],
545551
]);
546552
} else {

packages/react-dom/src/__tests__/ReactUpdates-test.js

+9-8
Original file line numberDiff line numberDiff line change
@@ -1857,12 +1857,14 @@ describe('ReactUpdates', () => {
18571857
}
18581858

18591859
let error = null;
1860-
let stack = null;
1860+
let ownerStack = null;
18611861
let nativeStack = null;
18621862
const originalConsoleError = console.error;
1863-
console.error = (e, s) => {
1863+
console.error = e => {
18641864
error = e;
1865-
stack = s;
1865+
ownerStack = gate(flags => flags.enableOwnerStacks)
1866+
? React.captureOwnerStack()
1867+
: null;
18661868
nativeStack = new Error().stack;
18671869
Scheduler.log('stop');
18681870
};
@@ -1878,12 +1880,11 @@ describe('ReactUpdates', () => {
18781880
expect(error).toContain('Maximum update depth exceeded');
18791881
// The currently executing effect should be on the native stack
18801882
expect(nativeStack).toContain('at myEffect');
1881-
if (!gate(flags => flags.enableOwnerStacks)) {
1882-
// The currently running component's name is not in the owner
1883-
// stack because it's just its JSX callsite.
1884-
expect(stack).toContain('at NonTerminating');
1883+
if (gate(flags => flags.enableOwnerStacks)) {
1884+
expect(ownerStack).toContain('at App');
1885+
} else {
1886+
expect(ownerStack).toBe(null);
18851887
}
1886-
expect(stack).toContain('at App');
18871888
});
18881889

18891890
it('can have nested updates if they do not cross the limit', async () => {

packages/react-reconciler/src/__tests__/ReactIncrementalErrorLogging-test.js

+39-35
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,16 @@ describe('ReactIncrementalErrorLogging', () => {
9191
'Consider adding an error boundary to your tree ' +
9292
'to customize error handling behavior.',
9393
),
94-
expect.stringMatching(
95-
new RegExp(
96-
gate(flags => flags.enableOwnerStacks)
97-
? '\\s+(in|at) ErrorThrowingComponent'
98-
: '\\s+(in|at) ErrorThrowingComponent (.*)\n' +
99-
'\\s+(in|at) span(.*)\n' +
100-
'\\s+(in|at) div(.*)',
101-
),
102-
),
94+
// The component stack is not added without the polyfill/devtools.
95+
// expect.stringMatching(
96+
// new RegExp(
97+
// gate(flags => flags.enableOwnerStacks)
98+
// ? '\\s+(in|at) ErrorThrowingComponent'
99+
// : '\\s+(in|at) ErrorThrowingComponent (.*)\n' +
100+
// '\\s+(in|at) span(.*)\n' +
101+
// '\\s+(in|at) div(.*)',
102+
// ),
103+
// ),
103104
);
104105
}
105106
});
@@ -139,15 +140,16 @@ describe('ReactIncrementalErrorLogging', () => {
139140
'Consider adding an error boundary to your tree ' +
140141
'to customize error handling behavior.',
141142
),
142-
expect.stringMatching(
143-
new RegExp(
144-
gate(flags => flags.enableOwnerStacks)
145-
? '\\s+(in|at) ErrorThrowingComponent'
146-
: '\\s+(in|at) ErrorThrowingComponent (.*)\n' +
147-
'\\s+(in|at) span(.*)\n' +
148-
'\\s+(in|at) div(.*)',
149-
),
150-
),
143+
// The component stack is not added without the polyfill/devtools.
144+
// expect.stringMatching(
145+
// new RegExp(
146+
// gate(flags => flags.enableOwnerStacks)
147+
// ? '\\s+(in|at) ErrorThrowingComponent'
148+
// : '\\s+(in|at) ErrorThrowingComponent (.*)\n' +
149+
// '\\s+(in|at) span(.*)\n' +
150+
// '\\s+(in|at) div(.*)',
151+
// ),
152+
// ),
151153
);
152154
}
153155
});
@@ -199,16 +201,17 @@ describe('ReactIncrementalErrorLogging', () => {
199201
'React will try to recreate this component tree from scratch ' +
200202
'using the error boundary you provided, ErrorBoundary.',
201203
),
202-
expect.stringMatching(
203-
new RegExp(
204-
gate(flags => flags.enableOwnerStacks)
205-
? '\\s+(in|at) ErrorThrowingComponent'
206-
: '\\s+(in|at) ErrorThrowingComponent (.*)\n' +
207-
'\\s+(in|at) span(.*)\n' +
208-
'\\s+(in|at) ErrorBoundary(.*)\n' +
209-
'\\s+(in|at) div(.*)',
210-
),
211-
),
204+
// The component stack is not added without the polyfill/devtools.
205+
// expect.stringMatching(
206+
// new RegExp(
207+
// gate(flags => flags.enableOwnerStacks)
208+
// ? '\\s+(in|at) ErrorThrowingComponent'
209+
// : '\\s+(in|at) ErrorThrowingComponent (.*)\n' +
210+
// '\\s+(in|at) span(.*)\n' +
211+
// '\\s+(in|at) ErrorBoundary(.*)\n' +
212+
// '\\s+(in|at) div(.*)',
213+
// ),
214+
// ),
212215
);
213216
} else {
214217
expect(logCapturedErrorCalls[0]).toEqual(
@@ -282,13 +285,14 @@ describe('ReactIncrementalErrorLogging', () => {
282285
'React will try to recreate this component tree from scratch ' +
283286
'using the error boundary you provided, ErrorBoundary.',
284287
),
285-
expect.stringMatching(
286-
gate(flag => flag.enableOwnerStacks)
287-
? new RegExp('\\s+(in|at) Foo')
288-
: new RegExp(
289-
'\\s+(in|at) Foo (.*)\n' + '\\s+(in|at) ErrorBoundary(.*)',
290-
),
291-
),
288+
// The component stack is not added without the polyfill/devtools.
289+
// expect.stringMatching(
290+
// gate(flag => flag.enableOwnerStacks)
291+
// ? new RegExp('\\s+(in|at) Foo')
292+
// : new RegExp(
293+
// '\\s+(in|at) Foo (.*)\n' + '\\s+(in|at) ErrorBoundary(.*)',
294+
// ),
295+
// ),
292296
);
293297
} else {
294298
expect(console.error).toHaveBeenCalledWith(

0 commit comments

Comments
 (0)