@samitouri / QOS-React / commits / 349a99a7a3

Badge Environment Name on Thrown Errors from the Server (#29846)

When we replay logs we badge them with e.g. `[Server]`. That way it's easy to identify that the source of the log actually happened on the Server (RSC). However, when we threw an error we didn't have any such thing. The error was rethrown on the client and then handled just like any other client error. This transfers the `environmentName` in DEV to our restored Error "sub-class" (conceptually) along with `digest`. That way you can read `error.environmentName` to print this in your own UI. I also updated our default for `onCaughtError` (and `onError` in Fizz) to use the `printToConsole` helper that the Flight Client uses to log it with the badge format. So by default you get the same experience as console.error for caught errors: <img width="810" alt="Screenshot 2024-06-10 at 9 25 12 PM" src="https://github.com/facebook/react/assets/63648/8490fedc-09f6-4286-9332-fbe6b0faa2d3"> <img width="815" alt="Screenshot 2024-06-10 at 9 39 30 PM" src="https://github.com/facebook/react/assets/63648/bdcfc554-504a-4b1d-82bf-b717e74975ac"> Unfortunately I can't do the same thing for `onUncaughtError` nor `onRecoverableError` because they use `reportError` which doesn't have custom formatting (unless we also prevented default on window.onerror). However maybe that's ok because 1) you should always have an error boundary 2) it's not likely that an RSC error can actually recover because it's not going to be rendered again so shouldn't really happen outside some parent conditionally rendering maybe. The other problem with this approach is that the default is no longer trivial - so reimplementing the default in user space is trickier and ideally we shouldn't expose our default to be called.

Sebastian Markbåge committed Jun 26, 2024 at 19:27 UTC 349a99a7a347f280ce40e9297cac5a3bd796901e
34 files changed +134 -36
packages/internal-test-utils/consoleMock.js
+11 -6
@@ -418,13 +418,18 @@ export function createLogAssertion(
418 let argIndex = 0;
419 // console.* could have been called with a non-string e.g. `console.error(new Error())`
420 // eslint-disable-next-line react-internal/safe-string-coercion
421 - String(format).replace(/%s/g, () => argIndex++);
421 + String(format).replace(/%s|%c/g, () => argIndex++);
422 if (argIndex !== args.length) {
423 - logsMismatchingFormat.push({
424 - format,
425 - args,
426 - expectedArgCount: argIndex,
427 - });
423 + if (format.includes('%c%s')) {
424 + // We intentionally use mismatching formatting when printing badging because we don't know
425 + // the best default to use for different types because the default varies by platform.
426 + } else {
427 + logsMismatchingFormat.push({
428 + format,
429 + args,
430 + expectedArgCount: argIndex,
431 + });
432 + }
433 }
434
435 // Check for extra component stacks
packages/internal-test-utils/shouldIgnoreConsoleError.js
+4
@@ -3,6 +3,10 @@
3 module.exports = function shouldIgnoreConsoleError(format, args) {
4 if (__DEV__) {
5 if (typeof format === 'string') {
6 + if (format.startsWith('%c%s')) {
7 + // Looks like a badged error message
8 + args.splice(0, 3);
9 + }
10 if (
11 args[0] != null &&
12 ((typeof args[0] === 'object' &&
packages/react-client/src/ReactClientConsoleConfigBrowser.js renamed
+10 -3
@@ -7,6 +7,8 @@
7 * @flow
8 */
9
10 +import {warn, error} from 'shared/consoleWithStackDev';
11 +
12 const badgeFormat = '%c%s%c ';
13 // Same badge styling as DevTools.
14 const badgeStyle =
@@ -63,7 +65,12 @@ export function printToConsole(
65 );
66 }
67
66 - // eslint-disable-next-line react-internal/no-production-logging
67 - console[methodName].apply(console, newArgs);
68 - return;
68 + if (methodName === 'error') {
69 + error.apply(console, newArgs);
70 + } else if (methodName === 'warn') {
71 + warn.apply(console, newArgs);
72 + } else {
73 + // eslint-disable-next-line react-internal/no-production-logging
74 + console[methodName].apply(console, newArgs);
75 + }
76 }
packages/react-client/src/ReactClientConsoleConfigPlain.js renamed
+10 -3
@@ -7,6 +7,8 @@
7 * @flow
8 */
9
10 +import {warn, error} from 'shared/consoleWithStackDev';
11 +
12 const badgeFormat = '[%s] ';
13 const pad = ' ';
14
@@ -44,7 +46,12 @@ export function printToConsole(
46 newArgs.splice(offset, 0, badgeFormat, pad + badgeName + pad);
47 }
48
47 - // eslint-disable-next-line react-internal/no-production-logging
48 - console[methodName].apply(console, newArgs);
49 - return;
49 + if (methodName === 'error') {
50 + error.apply(console, newArgs);
51 + } else if (methodName === 'warn') {
52 + warn.apply(console, newArgs);
53 + } else {
54 + // eslint-disable-next-line react-internal/no-production-logging
55 + console[methodName].apply(console, newArgs);
56 + }
57 }
packages/react-client/src/ReactClientConsoleConfigServer.js renamed
+10 -3
@@ -7,6 +7,8 @@
7 * @flow
8 */
9
10 +import {warn, error} from 'shared/consoleWithStackDev';
11 +
12 // This flips color using ANSI, then sets a color styling, then resets.
13 const badgeFormat = '\x1b[0m\x1b[7m%c%s\x1b[0m%c ';
14 // Same badge styling as DevTools.
@@ -64,7 +66,12 @@ export function printToConsole(
66 );
67 }
68
67 - // eslint-disable-next-line react-internal/no-production-logging
68 - console[methodName].apply(console, newArgs);
69 - return;
69 + if (methodName === 'error') {
70 + error.apply(console, newArgs);
71 + } else if (methodName === 'warn') {
72 + warn.apply(console, newArgs);
73 + } else {
74 + // eslint-disable-next-line react-internal/no-production-logging
75 + console[methodName].apply(console, newArgs);
76 + }
77 }
packages/react-client/src/ReactFlightClient.js
+5
@@ -1730,6 +1730,7 @@ function resolveErrorDev(
1730 digest: string,
1731 message: string,
1732 stack: string,
1733 + env: string,
1734 ): void {
1735 if (!__DEV__) {
1736 // These errors should never make it into a build so we don't need to encode them in codes.json
@@ -1769,6 +1770,7 @@ function resolveErrorDev(
1770 }
1771
1772 (error: any).digest = digest;
1773 + (error: any).environmentName = env;
1774 const errorWithDigest: ErrorWithDigest = (error: any);
1775 const chunks = response._chunks;
1776 const chunk = chunks.get(id);
@@ -2056,6 +2058,8 @@ function resolveConsoleEntry(
2058 task.run(callStack);
2059 return;
2060 }
2061 + // TODO: Set the current owner so that consoleWithStackDev adds the component
2062 + // stack during the replay - if needed.
2063 }
2064 const rootTask = response._debugRootTask;
2065 if (rootTask != null) {
@@ -2198,6 +2202,7 @@ function processFullRow(
2202 errorInfo.digest,
2203 errorInfo.message,
2204 errorInfo.stack,
2205 + errorInfo.env,
2206 );
2207 } else {
2208 resolveErrorProd(response, id, errorInfo.digest);
packages/react-client/src/__tests__/ReactFlight-test.js
+2
@@ -127,6 +127,7 @@ describe('ReactFlight', () => {
127 this.props.expectedMessage,
128 );
129 expect(this.state.error.digest).toBe('a dev digest');
130 + expect(this.state.error.environmentName).toBe('Server');
131 } else {
132 expect(this.state.error.message).toBe(
133 'An error occurred in the Server Components render. The specific message is omitted in production' +
@@ -143,6 +144,7 @@ describe('ReactFlight', () => {
144 expectedDigest = '[]';
145 }
146 expect(this.state.error.digest).toContain(expectedDigest);
147 + expect(this.state.error.environmentName).toBe(undefined);
148 expect(this.state.error.stack).toBe(
149 'Error: ' + this.state.error.message,
150 );
packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js
+1 -1
@@ -8,7 +8,7 @@
8 */
9
10 export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
11 -export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser';
11 +export * from 'react-client/src/ReactClientConsoleConfigBrowser';
12 export * from 'react-server-dom-esm/src/ReactFlightClientConfigBundlerESM';
13 export * from 'react-server-dom-esm/src/ReactFlightClientConfigTargetESMBrowser';
14 export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';
packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js
+1 -1
@@ -8,7 +8,7 @@
8 */
9
10 export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
11 -export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser';
11 +export * from 'react-client/src/ReactClientConsoleConfigBrowser';
12 export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack';
13 export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackBrowser';
14 export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackBrowser';
packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js
+1 -1
@@ -8,7 +8,7 @@
8 */
9
10 export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
11 -export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser';
11 +export * from 'react-client/src/ReactClientConsoleConfigBrowser';
12 export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack';
13 export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackBrowser';
14 export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackBrowser';
packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js
+1 -1
@@ -8,7 +8,7 @@
8 */
9
10 export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
11 -export * from 'react-client/src/ReactFlightClientConsoleConfigPlain';
11 +export * from 'react-client/src/ReactClientConsoleConfigPlain';
12 export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';
13
14 export type Response = any;
packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js
+1 -1
@@ -8,7 +8,7 @@
8 */
9
10 export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
11 -export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
11 +export * from 'react-client/src/ReactClientConsoleConfigServer';
12 export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack';
13 export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackServer';
14 export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer';
packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js
+1 -1
@@ -8,7 +8,7 @@
8 */
9
10 export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
11 -export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
11 +export * from 'react-client/src/ReactClientConsoleConfigServer';
12 export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack';
13 export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackServer';
14 export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer';
packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js
+1 -1
@@ -8,7 +8,7 @@
8 */
9
10 export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
11 -export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser';
11 +export * from 'react-client/src/ReactClientConsoleConfigBrowser';
12 export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';
13
14 export type Response = any;
packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js
+1 -1
@@ -8,7 +8,7 @@
8 */
9
10 export * from 'react-client/src/ReactFlightClientStreamConfigNode';
11 -export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
11 +export * from 'react-client/src/ReactClientConsoleConfigServer';
12 export * from 'react-server-dom-esm/src/ReactFlightClientConfigBundlerESM';
13 export * from 'react-server-dom-esm/src/ReactFlightClientConfigTargetESMServer';
14 export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';
packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js
+1 -1
@@ -8,7 +8,7 @@
8 */
9
10 export * from 'react-client/src/ReactFlightClientStreamConfigNode';
11 -export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
11 +export * from 'react-client/src/ReactClientConsoleConfigServer';
12 export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack';
13 export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackServer';
14 export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer';
packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js
+1 -1
@@ -8,7 +8,7 @@
8 */
9
10 export * from 'react-client/src/ReactFlightClientStreamConfigNode';
11 -export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
11 +export * from 'react-client/src/ReactClientConsoleConfigServer';
12 export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerNode';
13 export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer';
14 export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';
packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js
+1 -1
@@ -8,7 +8,7 @@
8 */
9
10 export * from 'react-client/src/ReactFlightClientStreamConfigNode';
11 -export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
11 +export * from 'react-client/src/ReactClientConsoleConfigServer';
12 export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack';
13 export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackServer';
14 export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer';
packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js
+1 -1
@@ -8,7 +8,7 @@
8 */
9
10 export * from 'react-client/src/ReactFlightClientStreamConfigNode';
11 -export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
11 +export * from 'react-client/src/ReactClientConsoleConfigServer';
12 export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode';
13 export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer';
14 export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';
packages/react-noop-renderer/src/createReactNoop.js
+5
@@ -635,6 +635,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
635 NotPendingTransition: (null: TransitionStatus),
636
637 resetFormInstance(form: Instance) {},
638 +
639 + printToConsole(methodName, args, badgeName) {
640 + // eslint-disable-next-line react-internal/no-production-logging
641 + console[methodName].apply(console, args);
642 + },
643 };
644
645 const hostConfig = useMutation
packages/react-reconciler/src/ReactFiberErrorLogger.js
+29 -7
@@ -20,6 +20,8 @@ import ReactSharedInternals from 'shared/ReactSharedInternals';
20
21 import {enableOwnerStacks} from 'shared/ReactFeatureFlags';
22
23 +import {printToConsole} from './ReactFiberConfig';
24 +
25 // Side-channel since I'm not sure we want to make this part of the public API
26 let componentName: null | string = null;
27 let errorBoundaryName: null | string = null;
@@ -94,13 +96,33 @@ export function defaultOnCaughtError(
96 }.`;
97
98 if (enableOwnerStacks) {
97 - console.error(
98 - '%o\n\n%s\n\n%s\n',
99 - error,
100 - componentNameMessage,
101 - recreateMessage,
102 - // We let our consoleWithStackDev wrapper add the component stack to the end.
103 - );
99 + if (
100 + typeof error === 'object' &&
101 + error !== null &&
102 + typeof error.environmentName === 'string'
103 + ) {
104 + // This was a Server error. We print the environment name in a badge just like we do with
105 + // replays of console logs to indicate that the source of this throw as actually the Server.
106 + printToConsole(
107 + 'error',
108 + [
109 + '%o\n\n%s\n\n%s\n',
110 + error,
111 + componentNameMessage,
112 + recreateMessage,
113 + // We let our consoleWithStackDev wrapper add the component stack to the end.
114 + ],
115 + error.environmentName,
116 + );
117 + } else {
118 + console.error(
119 + '%o\n\n%s\n\n%s\n',
120 + error,
121 + componentNameMessage,
122 + recreateMessage,
123 + // We let our consoleWithStackDev wrapper add the component stack to the end.
124 + );
125 + }
126 } else {
127 // The current Fiber is disconnected at this point which means that console printing
128 // cannot add a component stack since it terminates at the deletion node. This is not
packages/react-reconciler/src/forks/ReactFiberConfig.art.js
+1
@@ -8,3 +8,4 @@
8 */
9
10 export * from 'react-art/src/ReactFiberConfigART';
11 +export * from 'react-client/src/ReactClientConsoleConfigBrowser';
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+1
@@ -80,6 +80,7 @@ export const suspendInstance = $$$config.suspendInstance;
80 export const waitForCommitToBeReady = $$$config.waitForCommitToBeReady;
81 export const NotPendingTransition = $$$config.NotPendingTransition;
82 export const resetFormInstance = $$$config.resetFormInstance;
83 +export const printToConsole = $$$config.printToConsole;
84
85 // -------------------
86 // Microtasks
packages/react-reconciler/src/forks/ReactFiberConfig.dom.js
+1
@@ -8,3 +8,4 @@
8 */
9
10 export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM';
11 +export * from 'react-client/src/ReactClientConsoleConfigBrowser';
packages/react-reconciler/src/forks/ReactFiberConfig.fabric.js
+1
@@ -8,3 +8,4 @@
8 */
9
10 export * from 'react-native-renderer/src/ReactFiberConfigFabric';
11 +export * from 'react-client/src/ReactClientConsoleConfigPlain';
packages/react-reconciler/src/forks/ReactFiberConfig.native.js
+1
@@ -8,3 +8,4 @@
8 */
9
10 export * from 'react-native-renderer/src/ReactFiberConfigNative';
11 +export * from 'react-client/src/ReactClientConsoleConfigPlain';
packages/react-reconciler/src/forks/ReactFiberConfig.test.js
+1
@@ -8,3 +8,4 @@
8 */
9
10 export * from 'react-test-renderer/src/ReactFiberConfigTestHost';
11 +export * from 'react-client/src/ReactClientConsoleConfigPlain';
packages/react-server/src/ReactFizzServer.js
+12 -1
@@ -78,6 +78,7 @@ import {
78 resetResumableState,
79 completeResumableState,
80 emitEarlyPreloads,
81 + printToConsole,
82 } from './ReactFizzConfig';
83 import {
84 constructClassInstance,
@@ -363,7 +364,17 @@ export opaque type Request = {
364 const DEFAULT_PROGRESSIVE_CHUNK_SIZE = 12800;
365
366 function defaultErrorHandler(error: mixed) {
366 - console['error'](error); // Don't transform to our wrapper
367 + if (
368 + typeof error === 'object' &&
369 + error !== null &&
370 + typeof error.environmentName === 'string'
371 + ) {
372 + // This was a Server error. We print the environment name in a badge just like we do with
373 + // replays of console logs to indicate that the source of this throw as actually the Server.
374 + printToConsole('error', [error], error.environmentName);
375 + } else {
376 + console['error'](error); // Don't transform to our wrapper
377 + }
378 return null;
379 }
380
packages/react-server/src/ReactFlightServer.js
+8 -1
@@ -2774,11 +2774,18 @@ function emitErrorChunk(
2774 if (__DEV__) {
2775 let message;
2776 let stack = '';
2777 + let env = request.environmentName();
2778 try {
2779 if (error instanceof Error) {
2780 // eslint-disable-next-line react-internal/safe-string-coercion
2781 message = String(error.message);
2782 stack = getStack(error);
2783 + const errorEnv = (error: any).environmentName;
2784 + if (typeof errorEnv === 'string') {
2785 + // This probably came from another FlightClient as a pass through.
2786 + // Keep the environment name.
2787 + env = errorEnv;
2788 + }
2789 } else if (typeof error === 'object' && error !== null) {
2790 message = describeObjectForErrorMessage(error);
2791 } else {
@@ -2788,7 +2795,7 @@ function emitErrorChunk(
2795 } catch (x) {
2796 message = 'An error occurred but serializing the error message failed.';
2797 }
2791 - errorInfo = {digest, message, stack};
2798 + errorInfo = {digest, message, stack, env};
2799 } else {
2800 errorInfo = {digest};
2801 }
packages/react-server/src/forks/ReactFizzConfig.custom.js
+2
@@ -40,6 +40,8 @@ export const isPrimaryRenderer = false;
40 export const supportsRequestStorage = false;
41 export const requestStorage: AsyncLocalStorage<Request | void> = (null: any);
42
43 +export const printToConsole = $$$config.printToConsole;
44 +
45 export const resetResumableState = $$$config.resetResumableState;
46 export const completeResumableState = $$$config.completeResumableState;
47 export const getChildFormatContext = $$$config.getChildFormatContext;
packages/react-server/src/forks/ReactFizzConfig.dom-edge.js
+2
@@ -10,6 +10,8 @@ import type {Request} from 'react-server/src/ReactFizzServer';
10
11 export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
12
13 +export * from 'react-client/src/ReactClientConsoleConfigServer';
14 +
15 // For now, we get this from the global scope, but this will likely move to a module.
16 export const supportsRequestStorage = typeof AsyncLocalStorage === 'function';
17 export const requestStorage: AsyncLocalStorage<Request | void> =
packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js
+2
@@ -10,5 +10,7 @@ import type {Request} from 'react-server/src/ReactFizzServer';
10
11 export * from 'react-dom-bindings/src/server/ReactFizzConfigDOMLegacy';
12
13 +export * from 'react-client/src/ReactClientConsoleConfigPlain';
14 +
15 export const supportsRequestStorage = false;
16 export const requestStorage: AsyncLocalStorage<Request | void> = (null: any);
packages/react-server/src/forks/ReactFizzConfig.dom-node.js
+2
@@ -13,6 +13,8 @@ import type {Request} from 'react-server/src/ReactFizzServer';
13
14 export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
15
16 +export * from 'react-client/src/ReactClientConsoleConfigServer';
17 +
18 export const supportsRequestStorage = true;
19 export const requestStorage: AsyncLocalStorage<Request | void> =
20 new AsyncLocalStorage();
packages/react-server/src/forks/ReactFizzConfig.dom.js
+2
@@ -10,5 +10,7 @@ import type {Request} from 'react-server/src/ReactFizzServer';
10
11 export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM';
12
13 +export * from 'react-client/src/ReactClientConsoleConfigBrowser';
14 +
15 export const supportsRequestStorage = false;
16 export const requestStorage: AsyncLocalStorage<Request | void> = (null: any);