Skip to content

Commit

Permalink
Badge Environment Name on Thrown Errors from the Server (#29846)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
sebmarkbage authored Jun 26, 2024
1 parent 7045700 commit 349a99a
Show file tree
Hide file tree
Showing 34 changed files with 134 additions and 36 deletions.
17 changes: 11 additions & 6 deletions packages/internal-test-utils/consoleMock.js
Original file line number Diff line number Diff line change
Expand Up @@ -418,13 +418,18 @@ export function createLogAssertion(
let argIndex = 0;
// console.* could have been called with a non-string e.g. `console.error(new Error())`
// eslint-disable-next-line react-internal/safe-string-coercion
String(format).replace(/%s/g, () => argIndex++);
String(format).replace(/%s|%c/g, () => argIndex++);
if (argIndex !== args.length) {
logsMismatchingFormat.push({
format,
args,
expectedArgCount: argIndex,
});
if (format.includes('%c%s')) {
// We intentionally use mismatching formatting when printing badging because we don't know
// the best default to use for different types because the default varies by platform.
} else {
logsMismatchingFormat.push({
format,
args,
expectedArgCount: argIndex,
});
}
}

// Check for extra component stacks
Expand Down
4 changes: 4 additions & 0 deletions packages/internal-test-utils/shouldIgnoreConsoleError.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
module.exports = function shouldIgnoreConsoleError(format, args) {
if (__DEV__) {
if (typeof format === 'string') {
if (format.startsWith('%c%s')) {
// Looks like a badged error message
args.splice(0, 3);
}
if (
args[0] != null &&
((typeof args[0] === 'object' &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* @flow
*/

import {warn, error} from 'shared/consoleWithStackDev';

const badgeFormat = '%c%s%c ';
// Same badge styling as DevTools.
const badgeStyle =
Expand Down Expand Up @@ -63,7 +65,12 @@ export function printToConsole(
);
}

// eslint-disable-next-line react-internal/no-production-logging
console[methodName].apply(console, newArgs);
return;
if (methodName === 'error') {
error.apply(console, newArgs);
} else if (methodName === 'warn') {
warn.apply(console, newArgs);
} else {
// eslint-disable-next-line react-internal/no-production-logging
console[methodName].apply(console, newArgs);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* @flow
*/

import {warn, error} from 'shared/consoleWithStackDev';

const badgeFormat = '[%s] ';
const pad = ' ';

Expand Down Expand Up @@ -44,7 +46,12 @@ export function printToConsole(
newArgs.splice(offset, 0, badgeFormat, pad + badgeName + pad);
}

// eslint-disable-next-line react-internal/no-production-logging
console[methodName].apply(console, newArgs);
return;
if (methodName === 'error') {
error.apply(console, newArgs);
} else if (methodName === 'warn') {
warn.apply(console, newArgs);
} else {
// eslint-disable-next-line react-internal/no-production-logging
console[methodName].apply(console, newArgs);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* @flow
*/

import {warn, error} from 'shared/consoleWithStackDev';

// This flips color using ANSI, then sets a color styling, then resets.
const badgeFormat = '\x1b[0m\x1b[7m%c%s\x1b[0m%c ';
// Same badge styling as DevTools.
Expand Down Expand Up @@ -64,7 +66,12 @@ export function printToConsole(
);
}

// eslint-disable-next-line react-internal/no-production-logging
console[methodName].apply(console, newArgs);
return;
if (methodName === 'error') {
error.apply(console, newArgs);
} else if (methodName === 'warn') {
warn.apply(console, newArgs);
} else {
// eslint-disable-next-line react-internal/no-production-logging
console[methodName].apply(console, newArgs);
}
}
5 changes: 5 additions & 0 deletions packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -1730,6 +1730,7 @@ function resolveErrorDev(
digest: string,
message: string,
stack: string,
env: string,
): void {
if (!__DEV__) {
// These errors should never make it into a build so we don't need to encode them in codes.json
Expand Down Expand Up @@ -1769,6 +1770,7 @@ function resolveErrorDev(
}

(error: any).digest = digest;
(error: any).environmentName = env;
const errorWithDigest: ErrorWithDigest = (error: any);
const chunks = response._chunks;
const chunk = chunks.get(id);
Expand Down Expand Up @@ -2056,6 +2058,8 @@ function resolveConsoleEntry(
task.run(callStack);
return;
}
// TODO: Set the current owner so that consoleWithStackDev adds the component
// stack during the replay - if needed.
}
const rootTask = response._debugRootTask;
if (rootTask != null) {
Expand Down Expand Up @@ -2198,6 +2202,7 @@ function processFullRow(
errorInfo.digest,
errorInfo.message,
errorInfo.stack,
errorInfo.env,
);
} else {
resolveErrorProd(response, id, errorInfo.digest);
Expand Down
2 changes: 2 additions & 0 deletions packages/react-client/src/__tests__/ReactFlight-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ describe('ReactFlight', () => {
this.props.expectedMessage,
);
expect(this.state.error.digest).toBe('a dev digest');
expect(this.state.error.environmentName).toBe('Server');
} else {
expect(this.state.error.message).toBe(
'An error occurred in the Server Components render. The specific message is omitted in production' +
Expand All @@ -143,6 +144,7 @@ describe('ReactFlight', () => {
expectedDigest = '[]';
}
expect(this.state.error.digest).toContain(expectedDigest);
expect(this.state.error.environmentName).toBe(undefined);
expect(this.state.error.stack).toBe(
'Error: ' + this.state.error.message,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';
export * from 'react-server-dom-esm/src/ReactFlightClientConfigBundlerESM';
export * from 'react-server-dom-esm/src/ReactFlightClientConfigTargetESMBrowser';
export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';
export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack';
export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackBrowser';
export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackBrowser';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';
export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack';
export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackBrowser';
export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackBrowser';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
export * from 'react-client/src/ReactFlightClientConsoleConfigPlain';
export * from 'react-client/src/ReactClientConsoleConfigPlain';
export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';

export type Response = any;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
export * from 'react-client/src/ReactClientConsoleConfigServer';
export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack';
export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackServer';
export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
export * from 'react-client/src/ReactClientConsoleConfigServer';
export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack';
export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackServer';
export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export * from 'react-client/src/ReactFlightClientStreamConfigWeb';
export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';
export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';

export type Response = any;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export * from 'react-client/src/ReactFlightClientStreamConfigNode';
export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
export * from 'react-client/src/ReactClientConsoleConfigServer';
export * from 'react-server-dom-esm/src/ReactFlightClientConfigBundlerESM';
export * from 'react-server-dom-esm/src/ReactFlightClientConfigTargetESMServer';
export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export * from 'react-client/src/ReactFlightClientStreamConfigNode';
export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
export * from 'react-client/src/ReactClientConsoleConfigServer';
export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack';
export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackServer';
export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export * from 'react-client/src/ReactFlightClientStreamConfigNode';
export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
export * from 'react-client/src/ReactClientConsoleConfigServer';
export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerNode';
export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer';
export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export * from 'react-client/src/ReactFlightClientStreamConfigNode';
export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
export * from 'react-client/src/ReactClientConsoleConfigServer';
export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack';
export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackServer';
export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*/

export * from 'react-client/src/ReactFlightClientStreamConfigNode';
export * from 'react-client/src/ReactFlightClientConsoleConfigServer';
export * from 'react-client/src/ReactClientConsoleConfigServer';
export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode';
export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer';
export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM';
Expand Down
5 changes: 5 additions & 0 deletions packages/react-noop-renderer/src/createReactNoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
NotPendingTransition: (null: TransitionStatus),

resetFormInstance(form: Instance) {},

printToConsole(methodName, args, badgeName) {
// eslint-disable-next-line react-internal/no-production-logging
console[methodName].apply(console, args);
},
};

const hostConfig = useMutation
Expand Down
36 changes: 29 additions & 7 deletions packages/react-reconciler/src/ReactFiberErrorLogger.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import ReactSharedInternals from 'shared/ReactSharedInternals';

import {enableOwnerStacks} from 'shared/ReactFeatureFlags';

import {printToConsole} from './ReactFiberConfig';

// Side-channel since I'm not sure we want to make this part of the public API
let componentName: null | string = null;
let errorBoundaryName: null | string = null;
Expand Down Expand Up @@ -94,13 +96,33 @@ export function defaultOnCaughtError(
}.`;

if (enableOwnerStacks) {
console.error(
'%o\n\n%s\n\n%s\n',
error,
componentNameMessage,
recreateMessage,
// We let our consoleWithStackDev wrapper add the component stack to the end.
);
if (
typeof error === 'object' &&
error !== null &&
typeof error.environmentName === 'string'
) {
// This was a Server error. We print the environment name in a badge just like we do with
// replays of console logs to indicate that the source of this throw as actually the Server.
printToConsole(
'error',
[
'%o\n\n%s\n\n%s\n',
error,
componentNameMessage,
recreateMessage,
// We let our consoleWithStackDev wrapper add the component stack to the end.
],
error.environmentName,
);
} else {
console.error(
'%o\n\n%s\n\n%s\n',
error,
componentNameMessage,
recreateMessage,
// We let our consoleWithStackDev wrapper add the component stack to the end.
);
}
} else {
// The current Fiber is disconnected at this point which means that console printing
// cannot add a component stack since it terminates at the deletion node. This is not
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
*/

export * from 'react-art/src/ReactFiberConfigART';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const suspendInstance = $$$config.suspendInstance;
export const waitForCommitToBeReady = $$$config.waitForCommitToBeReady;
export const NotPendingTransition = $$$config.NotPendingTransition;
export const resetFormInstance = $$$config.resetFormInstance;
export const printToConsole = $$$config.printToConsole;

// -------------------
// Microtasks
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
*/

export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM';
export * from 'react-client/src/ReactClientConsoleConfigBrowser';
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
*/

export * from 'react-native-renderer/src/ReactFiberConfigFabric';
export * from 'react-client/src/ReactClientConsoleConfigPlain';
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
*/

export * from 'react-native-renderer/src/ReactFiberConfigNative';
export * from 'react-client/src/ReactClientConsoleConfigPlain';
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
*/

export * from 'react-test-renderer/src/ReactFiberConfigTestHost';
export * from 'react-client/src/ReactClientConsoleConfigPlain';
13 changes: 12 additions & 1 deletion packages/react-server/src/ReactFizzServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
resetResumableState,
completeResumableState,
emitEarlyPreloads,
printToConsole,
} from './ReactFizzConfig';
import {
constructClassInstance,
Expand Down Expand Up @@ -363,7 +364,17 @@ export opaque type Request = {
const DEFAULT_PROGRESSIVE_CHUNK_SIZE = 12800;

function defaultErrorHandler(error: mixed) {
console['error'](error); // Don't transform to our wrapper
if (
typeof error === 'object' &&
error !== null &&
typeof error.environmentName === 'string'
) {
// This was a Server error. We print the environment name in a badge just like we do with
// replays of console logs to indicate that the source of this throw as actually the Server.
printToConsole('error', [error], error.environmentName);
} else {
console['error'](error); // Don't transform to our wrapper
}
return null;
}

Expand Down
Loading

0 comments on commit 349a99a

Please sign in to comment.