diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 1dbe46ebce1c8..44815dce4b5b9 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -193,7 +193,7 @@ function createBlockedChunk(response: Response): BlockedChunk { function createErrorChunk( response: Response, - error: Error, + error: ErrorWithDigest, ): ErroredChunk { // $FlowFixMe Flow doesn't support functions as constructors return new Chunk(ERRORED, null, error, response); @@ -628,21 +628,64 @@ export function resolveSymbol( chunks.set(id, createInitializedChunk(response, Symbol.for(name))); } -export function resolveError( +type ErrorWithDigest = Error & {digest?: string}; +export function resolveErrorProd( response: Response, id: number, + digest: string, +): void { + if (__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'resolveErrorProd should never be called in development mode. Use resolveErrorDev instead. This is a bug in React.', + ); + } + const error = new Error( + 'An error occurred in the Server Components render. The specific message is omitted in production' + + ' builds to avoid leaking sensitive details. A digest property is included on this error instance which' + + ' may provide additional details about the nature of the error.', + ); + error.stack = ''; + (error: any).digest = digest; + const errorWithDigest: ErrorWithDigest = (error: any); + const chunks = response._chunks; + const chunk = chunks.get(id); + if (!chunk) { + chunks.set(id, createErrorChunk(response, errorWithDigest)); + } else { + triggerErrorOnChunk(chunk, errorWithDigest); + } +} + +export function resolveErrorDev( + response: Response, + id: number, + digest: string, message: string, stack: string, ): void { + if (!__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'resolveErrorDev should never be called in production mode. Use resolveErrorProd instead. This is a bug in React.', + ); + } // eslint-disable-next-line react-internal/prod-error-codes - const error = new Error(message); + const error = new Error( + message || + 'An error occurred in the Server Components render but no message was provided', + ); error.stack = stack; + (error: any).digest = digest; + const errorWithDigest: ErrorWithDigest = (error: any); const chunks = response._chunks; const chunk = chunks.get(id); if (!chunk) { - chunks.set(id, createErrorChunk(response, error)); + chunks.set(id, createErrorChunk(response, errorWithDigest)); } else { - triggerErrorOnChunk(chunk, error); + triggerErrorOnChunk(chunk, errorWithDigest); } } diff --git a/packages/react-client/src/ReactFlightClientStream.js b/packages/react-client/src/ReactFlightClientStream.js index b6c61b0ba5de7..c5bf302a36160 100644 --- a/packages/react-client/src/ReactFlightClientStream.js +++ b/packages/react-client/src/ReactFlightClientStream.js @@ -16,7 +16,8 @@ import { resolveModel, resolveProvider, resolveSymbol, - resolveError, + resolveErrorProd, + resolveErrorDev, createResponse as createResponseBase, parseModelString, parseModelTuple, @@ -62,7 +63,17 @@ function processFullRow(response: Response, row: string): void { } case 'E': { const errorInfo = JSON.parse(text); - resolveError(response, id, errorInfo.message, errorInfo.stack); + if (__DEV__) { + resolveErrorDev( + response, + id, + errorInfo.digest, + errorInfo.message, + errorInfo.stack, + ); + } else { + resolveErrorProd(response, id, errorInfo.digest); + } return; } default: { diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index b03a7087656aa..4b72bdd85a4e1 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -45,7 +45,19 @@ describe('ReactFlight', () => { componentDidMount() { expect(this.state.hasError).toBe(true); expect(this.state.error).toBeTruthy(); - expect(this.state.error.message).toContain(this.props.expectedMessage); + if (__DEV__) { + expect(this.state.error.message).toContain( + this.props.expectedMessage, + ); + expect(this.state.error.digest).toBe('a dev digest'); + } else { + expect(this.state.error.message).toBe( + 'An error occurred in the Server Components render. The specific message is omitted in production' + + ' builds to avoid leaking sensitive details. A digest property is included on this error instance which' + + ' may provide additional details about the nature of the error.', + ); + expect(this.state.error.digest).toContain(this.props.expectedMessage); + } } render() { if (this.state.hasError) { @@ -371,8 +383,8 @@ describe('ReactFlight', () => { } const options = { - onError() { - // ignore + onError(x) { + return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; }, }; const event = ReactNoopFlightServer.render(, options); diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayClient.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayClient.js index ecee74598c579..85050f6558898 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayClient.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayClient.js @@ -16,7 +16,8 @@ import { resolveModel, resolveModule, resolveSymbol, - resolveError, + resolveErrorDev, + resolveErrorProd, close, getRoot, } from 'react-client/src/ReactFlightClient'; @@ -34,7 +35,20 @@ export function resolveRow(response: Response, chunk: RowEncoding): void { // $FlowFixMe: Flow doesn't support disjoint unions on tuples. resolveSymbol(response, chunk[1], chunk[2]); } else { - // $FlowFixMe: Flow doesn't support disjoint unions on tuples. - resolveError(response, chunk[1], chunk[2].message, chunk[2].stack); + if (__DEV__) { + resolveErrorDev( + response, + chunk[1], + // $FlowFixMe: Flow doesn't support disjoint unions on tuples. + chunk[2].digest, + // $FlowFixMe: Flow doesn't support disjoint unions on tuples. + chunk[2].message || '', + // $FlowFixMe: Flow doesn't support disjoint unions on tuples. + chunk[2].stack || '', + ); + } else { + // $FlowFixMe: Flow doesn't support disjoint unions on tuples. + resolveErrorProd(response, chunk[1], chunk[2].digest); + } } } diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js index d883068c9eaa7..ad6c48b2ebc4f 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js @@ -26,8 +26,9 @@ export type RowEncoding = 'E', number, { - message: string, - stack: string, + digest: string, + message?: string, + stack?: string, ... }, ]; diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js index 35a1b3e16d552..fc205a445013f 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js @@ -60,16 +60,48 @@ export function resolveModuleMetaData( export type Chunk = RowEncoding; -export function processErrorChunk( +export function processErrorChunkProd( request: Request, id: number, + digest: string, +): Chunk { + if (__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'processErrorChunkProd should never be called while in development mode. Use processErrorChunkDev instead. This is a bug in React.', + ); + } + + return [ + 'E', + id, + { + digest, + }, + ]; +} + +export function processErrorChunkDev( + request: Request, + id: number, + digest: string, message: string, stack: string, ): Chunk { + if (!__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'processErrorChunkDev should never be called while in production mode. Use processErrorChunkProd instead. This is a bug in React.', + ); + } + return [ 'E', id, { + digest, message, stack, }, diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index f8948c3604a58..4a835834152f3 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -332,7 +332,13 @@ describe('ReactFlightDOM', () => { function MyErrorBoundary({children}) { return ( -

{e.message}

}> + ( +

+ {__DEV__ ? e.message + ' + ' : null} + {e.digest} +

+ )}> {children}
); @@ -434,6 +440,7 @@ describe('ReactFlightDOM', () => { { onError(x) { reportedErrors.push(x); + return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; }, }, ); @@ -477,11 +484,14 @@ describe('ReactFlightDOM', () => { await act(async () => { rejectGames(theError); }); + const expectedGamesValue = __DEV__ + ? '

Game over + a dev digest

' + : '

digest("Game over")

'; expect(container.innerHTML).toBe( '
:name::avatar:
' + '

(loading sidebar)

' + '

(loading posts)

' + - '

Game over

', // TODO: should not have message in prod. + expectedGamesValue, ); expect(reportedErrors).toEqual([theError]); @@ -495,7 +505,7 @@ describe('ReactFlightDOM', () => { '
:name::avatar:
' + '
:photos::friends:
' + '

(loading posts)

' + - '

Game over

', // TODO: should not have message in prod. + expectedGamesValue, ); // Show everything. @@ -506,7 +516,7 @@ describe('ReactFlightDOM', () => { '
:name::avatar:
' + '
:photos::friends:
' + '
:posts:
' + - '

Game over

', // TODO: should not have message in prod. + expectedGamesValue, ); expect(reportedErrors).toEqual([]); @@ -611,6 +621,8 @@ describe('ReactFlightDOM', () => { { onError(x) { reportedErrors.push(x); + const message = typeof x === 'string' ? x : x.message; + return __DEV__ ? 'a dev digest' : `digest("${message}")`; }, }, ); @@ -626,7 +638,13 @@ describe('ReactFlightDOM', () => { await act(async () => { root.render( -

{e.message}

}> + ( +

+ {__DEV__ ? e.message + ' + ' : null} + {e.digest} +

+ )}> (loading)

}>
@@ -638,7 +656,13 @@ describe('ReactFlightDOM', () => { await act(async () => { abort('for reasons'); }); - expect(container.innerHTML).toBe('

Error: for reasons

'); + if (__DEV__) { + expect(container.innerHTML).toBe( + '

Error: for reasons + a dev digest

', + ); + } else { + expect(container.innerHTML).toBe('

digest("for reasons")

'); + } expect(reportedErrors).toEqual(['for reasons']); }); @@ -772,7 +796,8 @@ describe('ReactFlightDOM', () => { webpackMap, { onError(x) { - reportedErrors.push(x); + reportedErrors.push(x.message); + return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; }, }, ); @@ -789,15 +814,27 @@ describe('ReactFlightDOM', () => { await act(async () => { root.render( -

{e.message}

}> + ( +

+ {__DEV__ ? e.message + ' + ' : null} + {e.digest} +

+ )}> (loading)

}>
, ); }); - expect(container.innerHTML).toBe('

bug in the bundler

'); + if (__DEV__) { + expect(container.innerHTML).toBe( + '

bug in the bundler + a dev digest

', + ); + } else { + expect(container.innerHTML).toBe('

digest("bug in the bundler")

'); + } - expect(reportedErrors).toEqual([]); + expect(reportedErrors).toEqual(['bug in the bundler']); }); }); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 7ac0a2b4aa9d1..2ff81ce2e843d 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -173,11 +173,27 @@ describe('ReactFlightDOMBrowser', () => { } } + let errorBoundaryFn; + if (__DEV__) { + errorBoundaryFn = e => ( +

+ {e.message} + {e.digest} +

+ ); + } else { + errorBoundaryFn = e => { + expect(e.message).toBe( + 'An error occurred in the Server Components render. The specific message is omitted in production' + + ' builds to avoid leaking sensitive details. A digest property is included on this error instance which' + + ' may provide additional details about the nature of the error.', + ); + return

{e.digest}

; + }; + } + function MyErrorBoundary({children}) { return ( -

{e.message}

}> - {children} -
+ {children} ); } @@ -251,6 +267,7 @@ describe('ReactFlightDOMBrowser', () => { { onError(x) { reportedErrors.push(x); + return __DEV__ ? `a dev digest` : `digest("${x.message}")`; }, }, ); @@ -293,11 +310,16 @@ describe('ReactFlightDOMBrowser', () => { await act(async () => { rejectGames(theError); }); + + const gamesExpectedValue = __DEV__ + ? '

Game over + a dev digest

' + : '

digest("Game over")

'; + expect(container.innerHTML).toBe( '
:name::avatar:
' + '

(loading sidebar)

' + '

(loading posts)

' + - '

Game over

', // TODO: should not have message in prod. + gamesExpectedValue, ); expect(reportedErrors).toEqual([theError]); @@ -311,7 +333,7 @@ describe('ReactFlightDOMBrowser', () => { '
:name::avatar:
' + '
:photos::friends:
' + '

(loading posts)

' + - '

Game over

', // TODO: should not have message in prod. + gamesExpectedValue, ); // Show everything. @@ -322,7 +344,7 @@ describe('ReactFlightDOMBrowser', () => { '
:name::avatar:
' + '
:photos::friends:
' + '
:posts:
' + - '

Game over

', // TODO: should not have message in prod. + gamesExpectedValue, ); expect(reportedErrors).toEqual([]); @@ -489,6 +511,24 @@ describe('ReactFlightDOMBrowser', () => { it('should be able to complete after aborting and throw the reason client-side', async () => { const reportedErrors = []; + let errorBoundaryFn; + if (__DEV__) { + errorBoundaryFn = e => ( +

+ {e.message} + {e.digest} +

+ ); + } else { + errorBoundaryFn = e => { + expect(e.message).toBe( + 'An error occurred in the Server Components render. The specific message is omitted in production' + + ' builds to avoid leaking sensitive details. A digest property is included on this error instance which' + + ' may provide additional details about the nature of the error.', + ); + return

{e.digest}

; + }; + } + class ErrorBoundary extends React.Component { state = {hasError: false, error: null}; static getDerivedStateFromError(error) { @@ -514,7 +554,9 @@ describe('ReactFlightDOMBrowser', () => { { signal: controller.signal, onError(x) { + const message = typeof x === 'string' ? x : x.message; reportedErrors.push(x); + return __DEV__ ? 'a dev digest' : `digest("${message}")`; }, }, ); @@ -529,7 +571,7 @@ describe('ReactFlightDOMBrowser', () => { await act(async () => { root.render( -

{e.message}

}> + (loading)

}>
@@ -545,7 +587,10 @@ describe('ReactFlightDOMBrowser', () => { controller.signal.reason = 'for reasons'; controller.abort('for reasons'); }); - expect(container.innerHTML).toBe('

Error: for reasons

'); + const expectedValue = __DEV__ + ? '

Error: for reasons + a dev digest

' + : '

digest("for reasons")

'; + expect(container.innerHTML).toBe(expectedValue); expect(reportedErrors).toEqual(['for reasons']); }); @@ -665,6 +710,7 @@ describe('ReactFlightDOMBrowser', () => { { onError(x) { reportedErrors.push(x); + return __DEV__ ? 'a dev digest' : `digest("${x.message}")`; }, }, ); @@ -677,7 +723,9 @@ describe('ReactFlightDOMBrowser', () => { } render() { if (this.state.error) { - return this.state.error.message; + return __DEV__ + ? this.state.error.message + ' + ' + this.state.error.digest + : this.state.error.digest; } return this.props.children; } @@ -696,7 +744,9 @@ describe('ReactFlightDOMBrowser', () => {
, ); }); - expect(container.innerHTML).toBe('Oops!'); + expect(container.innerHTML).toBe( + __DEV__ ? 'Oops! + a dev digest' : 'digest("Oops!")', + ); expect(reportedErrors.length).toBe(1); expect(reportedErrors[0].message).toBe('Oops!'); }); diff --git a/packages/react-server-native-relay/src/ReactFlightNativeRelayClient.js b/packages/react-server-native-relay/src/ReactFlightNativeRelayClient.js index 087b93479d424..030040e404846 100644 --- a/packages/react-server-native-relay/src/ReactFlightNativeRelayClient.js +++ b/packages/react-server-native-relay/src/ReactFlightNativeRelayClient.js @@ -16,7 +16,8 @@ import { resolveModel, resolveModule, resolveSymbol, - resolveError, + resolveErrorDev, + resolveErrorProd, close, getRoot, } from 'react-client/src/ReactFlightClient'; @@ -34,7 +35,20 @@ export function resolveRow(response: Response, chunk: RowEncoding): void { // $FlowFixMe: Flow doesn't support disjoint unions on tuples. resolveSymbol(response, chunk[1], chunk[2]); } else { - // $FlowFixMe: Flow doesn't support disjoint unions on tuples. - resolveError(response, chunk[1], chunk[2].message, chunk[2].stack); + if (__DEV__) { + resolveErrorDev( + response, + chunk[1], + // $FlowFixMe: Flow doesn't support disjoint unions on tuples. + chunk[2].digest, + // $FlowFixMe: Flow doesn't support disjoint unions on tuples. + chunk[2].message || '', + // $FlowFixMe: Flow doesn't support disjoint unions on tuples. + chunk[2].stack || '', + ); + } else { + // $FlowFixMe: Flow doesn't support disjoint unions on tuples. + resolveErrorProd(response, chunk[1], chunk[2].digest); + } } } diff --git a/packages/react-server-native-relay/src/ReactFlightNativeRelayProtocol.js b/packages/react-server-native-relay/src/ReactFlightNativeRelayProtocol.js index 1c32ac0dd4d44..c161cccefd2f1 100644 --- a/packages/react-server-native-relay/src/ReactFlightNativeRelayProtocol.js +++ b/packages/react-server-native-relay/src/ReactFlightNativeRelayProtocol.js @@ -26,8 +26,9 @@ export type RowEncoding = 'E', number, { - message: string, - stack: string, + digest: string, + message?: string, + stack?: string, ... }, ]; diff --git a/packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js b/packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js index 65d60ec334ea1..e1817cebf53f6 100644 --- a/packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js +++ b/packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js @@ -57,16 +57,47 @@ export function resolveModuleMetaData( export type Chunk = RowEncoding; -export function processErrorChunk( +export function processErrorChunkProd( request: Request, id: number, + digest: string, +): Chunk { + if (__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'processErrorChunkProd should never be called while in development mode. Use processErrorChunkDev instead. This is a bug in React.', + ); + } + + return [ + 'E', + id, + { + digest, + }, + ]; +} +export function processErrorChunkDev( + request: Request, + id: number, + digest: string, message: string, stack: string, ): Chunk { + if (!__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'processErrorChunkDev should never be called while in production mode. Use processErrorChunkProd instead. This is a bug in React.', + ); + } + return [ 'E', id, { + digest, message, stack, }, diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 6f26f8b88f693..67014934e429d 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -35,7 +35,8 @@ import { processModuleChunk, processProviderChunk, processSymbolChunk, - processErrorChunk, + processErrorChunkProd, + processErrorChunkDev, processReferenceChunk, resolveModuleMetaData, getModuleKey, @@ -125,7 +126,7 @@ export type Request = { writtenProviders: Map, identifierPrefix: string, identifierCount: number, - onError: (error: mixed) => void, + onError: (error: mixed) => ?string, toJSON: (key: string, value: ReactModel) => ReactJSONValue, }; @@ -143,7 +144,7 @@ const CLOSED = 2; export function createRequest( model: ReactModel, bundlerConfig: BundlerConfig, - onError: void | ((error: mixed) => void), + onError: void | ((error: mixed) => ?string), context?: Array<[string, ServerContextJSONValue]>, identifierPrefix?: string, ): Request { @@ -364,7 +365,13 @@ function serializeModuleReference( } catch (x) { request.pendingChunks++; const errorId = request.nextChunkId++; - emitErrorChunk(request, errorId, x); + const digest = logRecoverableError(request, x); + if (__DEV__) { + const {message, stack} = getErrorMessageAndStackDev(x); + emitErrorChunkDev(request, errorId, digest, message, stack); + } else { + emitErrorChunkProd(request, errorId, digest); + } return serializeByValueID(errorId); } } @@ -629,7 +636,13 @@ export function resolveModelToJSON( // once it gets rendered. request.pendingChunks++; const errorId = request.nextChunkId++; - emitErrorChunk(request, errorId, x); + const digest = logRecoverableError(request, x); + if (__DEV__) { + const {message, stack} = getErrorMessageAndStackDev(x); + emitErrorChunkDev(request, errorId, digest, message, stack); + } else { + emitErrorChunkProd(request, errorId, digest); + } return serializeByRefID(errorId); } } @@ -797,9 +810,47 @@ export function resolveModelToJSON( ); } -function logRecoverableError(request: Request, error: mixed): void { +function logRecoverableError(request: Request, error: mixed): string { const onError = request.onError; - onError(error); + const errorDigest = onError(error); + if (errorDigest != null && typeof errorDigest !== 'string') { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + `onError returned something with a type other than "string". onError should return a string and may return null or undefined but must not return anything else. It received something of type "${typeof errorDigest}" instead`, + ); + } + return errorDigest || ''; +} + +function getErrorMessageAndStackDev( + error: mixed, +): {message: string, stack: string} { + if (__DEV__) { + let message; + let stack = ''; + try { + if (error instanceof Error) { + // eslint-disable-next-line react-internal/safe-string-coercion + message = String(error.message); + // eslint-disable-next-line react-internal/safe-string-coercion + stack = String(error.stack); + } else { + message = 'Error: ' + (error: any); + } + } catch (x) { + message = 'An error occurred but serializing the error message failed.'; + } + return { + message, + stack, + }; + } else { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'getErrorMessageAndStackDev should never be called from production mode. This is a bug in React.', + ); + } } function fatalError(request: Request, error: mixed): void { @@ -813,26 +864,29 @@ function fatalError(request: Request, error: mixed): void { } } -function emitErrorChunk(request: Request, id: number, error: mixed): void { - // TODO: We should not leak error messages to the client in prod. - // Give this an error code instead and log on the server. - // We can serialize the error in DEV as a convenience. - let message; - let stack = ''; - try { - if (error instanceof Error) { - // eslint-disable-next-line react-internal/safe-string-coercion - message = String(error.message); - // eslint-disable-next-line react-internal/safe-string-coercion - stack = String(error.stack); - } else { - message = 'Error: ' + (error: any); - } - } catch (x) { - message = 'An error occurred but serializing the error message failed.'; - } +function emitErrorChunkProd( + request: Request, + id: number, + digest: string, +): void { + const processedChunk = processErrorChunkProd(request, id, digest); + request.completedErrorChunks.push(processedChunk); +} - const processedChunk = processErrorChunk(request, id, message, stack); +function emitErrorChunkDev( + request: Request, + id: number, + digest: string, + message: string, + stack: string, +): void { + const processedChunk = processErrorChunkDev( + request, + id, + digest, + message, + stack, + ); request.completedErrorChunks.push(processedChunk); } @@ -935,9 +989,13 @@ function retryTask(request: Request, task: Task): void { } else { request.abortableTasks.delete(task); task.status = ERRORED; - logRecoverableError(request, x); - // This errored, we need to serialize this error to the - emitErrorChunk(request, task.id, x); + const digest = logRecoverableError(request, x); + if (__DEV__) { + const {message, stack} = getErrorMessageAndStackDev(x); + emitErrorChunkDev(request, task.id, digest, message, stack); + } else { + emitErrorChunkProd(request, task.id, digest); + } } } } @@ -1077,10 +1135,15 @@ export function abort(request: Request, reason: mixed): void { ? new Error('The render was aborted by the server without a reason.') : reason; - logRecoverableError(request, error); + const digest = logRecoverableError(request, error); request.pendingChunks++; const errorId = request.nextChunkId++; - emitErrorChunk(request, errorId, error); + if (__DEV__) { + const {message, stack} = getErrorMessageAndStackDev(error); + emitErrorChunkDev(request, errorId, digest, message, stack); + } else { + emitErrorChunkProd(request, errorId, digest); + } abortableTasks.forEach(task => abortTask(task, request, errorId)); abortableTasks.clear(); } diff --git a/packages/react-server/src/ReactFlightServerConfigStream.js b/packages/react-server/src/ReactFlightServerConfigStream.js index 6320f28d585b6..7944137288eb5 100644 --- a/packages/react-server/src/ReactFlightServerConfigStream.js +++ b/packages/react-server/src/ReactFlightServerConfigStream.js @@ -78,13 +78,40 @@ function serializeRowHeader(tag: string, id: number) { return tag + id.toString(16) + ':'; } -export function processErrorChunk( +export function processErrorChunkProd( request: Request, id: number, + digest: string, +): Chunk { + if (__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'processErrorChunkProd should never be called while in development mode. Use processErrorChunkDev instead. This is a bug in React.', + ); + } + + const errorInfo: any = {digest}; + const row = serializeRowHeader('E', id) + stringify(errorInfo) + '\n'; + return stringToChunk(row); +} + +export function processErrorChunkDev( + request: Request, + id: number, + digest: string, message: string, stack: string, ): Chunk { - const errorInfo = {message, stack}; + if (!__DEV__) { + // These errors should never make it into a build so we don't need to encode them in codes.json + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'processErrorChunkDev should never be called while in production mode. Use processErrorChunkProd instead. This is a bug in React.', + ); + } + + const errorInfo: any = {digest, message, stack}; const row = serializeRowHeader('E', id) + stringify(errorInfo) + '\n'; return stringToChunk(row); } diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 7a23215cc332a..4b50cbdcfcf44 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -425,5 +425,6 @@ "437": "the \"precedence\" prop for links to stylesheets expects to receive a string but received something of type \"%s\" instead.", "438": "An unsupported type was passed to use(): %s", "439": "We didn't expect to see a forward reference. This is a bug in the React Server.", - "440": "An event from useEvent was called during render." + "440": "An event from useEvent was called during render.", + "441": "An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error." }