From 01a027d1f21be89eb5bd9c4e38e04a7f08aaa264 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Fri, 27 Mar 2026 09:54:17 -0700 Subject: [PATCH 1/3] Don't lint against AggregateError --- .eslintrc.js | 1 + packages/internal-test-utils/ReactInternalTestUtils.js | 1 - packages/internal-test-utils/internalAct.js | 1 - packages/react-dom/src/__tests__/ReactDOMSelect-test.js | 1 - packages/react-reconciler/src/__tests__/ReactFlushSync-test.js | 1 - packages/react/src/ReactAct.js | 1 - 6 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 16a8050ee75..813c99dbece 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -566,6 +566,7 @@ module.exports = { CallSite: 'readonly', ConsoleTask: 'readonly', // TOOD: Figure out what the official name of this will be. ReturnType: 'readonly', + AggregateError: 'readonly', AnimationFrameID: 'readonly', WeakRef: 'readonly', // For Flow type annotation. Only `BigInt` is valid at runtime. diff --git a/packages/internal-test-utils/ReactInternalTestUtils.js b/packages/internal-test-utils/ReactInternalTestUtils.js index ed9b822457a..eab69112d4a 100644 --- a/packages/internal-test-utils/ReactInternalTestUtils.js +++ b/packages/internal-test-utils/ReactInternalTestUtils.js @@ -122,7 +122,6 @@ ${diff(expectedLog, actualLog)} function aggregateErrors(errors: Array): mixed { if (errors.length > 1 && typeof AggregateError === 'function') { - // eslint-disable-next-line no-undef return new AggregateError(errors); } return errors[0]; diff --git a/packages/internal-test-utils/internalAct.js b/packages/internal-test-utils/internalAct.js index 1a420bcf203..c2c4afb21ad 100644 --- a/packages/internal-test-utils/internalAct.js +++ b/packages/internal-test-utils/internalAct.js @@ -34,7 +34,6 @@ async function waitForMicrotasks() { function aggregateErrors(errors: Array): mixed { if (errors.length > 1 && typeof AggregateError === 'function') { - // eslint-disable-next-line no-undef return new AggregateError(errors); } return errors[0]; diff --git a/packages/react-dom/src/__tests__/ReactDOMSelect-test.js b/packages/react-dom/src/__tests__/ReactDOMSelect-test.js index f05fb3e3726..c90455307d0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMSelect-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMSelect-test.js @@ -1485,7 +1485,6 @@ describe('ReactDOMSelect', () => { ); }), ).rejects.toThrowError( - // eslint-disable-next-line no-undef new AggregateError([ new TypeError('prod message'), new TypeError('prod message'), diff --git a/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js b/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js index 679a9370dd3..aa8ec0f1dd2 100644 --- a/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js +++ b/packages/react-reconciler/src/__tests__/ReactFlushSync-test.js @@ -337,7 +337,6 @@ describe('ReactFlushSync', () => { expect(getVisibleChildren(container3)).toEqual('aww'); // Because there were multiple errors, React threw an AggregateError. - // eslint-disable-next-line no-undef expect(error).toBeInstanceOf(AggregateError); expect(error.errors.length).toBe(2); expect(error.errors[0]).toBe(aahh); diff --git a/packages/react/src/ReactAct.js b/packages/react/src/ReactAct.js index c4c8a98d531..1e74d222a29 100644 --- a/packages/react/src/ReactAct.js +++ b/packages/react/src/ReactAct.js @@ -23,7 +23,6 @@ let didWarnNoAwaitAct = false; function aggregateErrors(errors: Array): mixed { if (errors.length > 1 && typeof AggregateError === 'function') { - // eslint-disable-next-line no-undef return new AggregateError(errors); } return errors[0]; From f3350c4e1618c4eca796ac077fe1ff136ef815bc Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Fri, 27 Mar 2026 09:21:52 -0700 Subject: [PATCH 2/3] [Flight] Never deserialize `Error.cause` in dev --- packages/react-client/src/ReactFlightClient.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index fa89cf69ad6..02d0eae4c2e 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -3525,7 +3525,8 @@ function resolveErrorDev( let error; const errorOptions = - 'cause' in errorInfo + // We don't serialize Error.cause in prod so we never need to deserialize + __DEV__ && 'cause' in errorInfo ? { cause: reviveModel( response, From 9cd92e4373c3b345e35f516fe04c72e1929c5253 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Fri, 27 Mar 2026 09:20:45 -0700 Subject: [PATCH 3/3] [Flight] Transport `AggregateErrors.errors` --- .../react-client/src/ReactFlightClient.js | 36 +++- .../src/__tests__/ReactFlight-test.js | 198 ++++++++++++++++++ .../react-server/src/ReactFlightServer.js | 31 +++ packages/shared/ReactTypes.js | 1 + 4 files changed, 259 insertions(+), 7 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 02d0eae4c2e..f67d99ba71d 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -3537,18 +3537,40 @@ function resolveErrorDev( ), } : undefined; + const isAggregateError = + typeof AggregateError !== 'undefined' && 'errors' in errorInfo; + const revivedErrors = + // We don't serialize AggregateError.errors in prod so we never need to deserialize + __DEV__ && isAggregateError + ? reviveModel( + response, + // $FlowFixMe[incompatible-cast] + (errorInfo.errors: JSONValue), + errorInfo, + 'errors', + ) + : null; const callStack = buildFakeCallStack( response, stack, env, false, - // $FlowFixMe[incompatible-use] - Error.bind( - null, - message || - 'An error occurred in the Server Components render but no message was provided', - errorOptions, - ), + isAggregateError + ? // $FlowFixMe[incompatible-use] + AggregateError.bind( + null, + revivedErrors, + message || + 'An error occurred in the Server Components render but no message was provided', + errorOptions, + ) + : // $FlowFixMe[incompatible-use] + Error.bind( + null, + message || + 'An error occurred in the Server Components render but no message was provided', + errorOptions, + ), ); let ownerTask: null | ConsoleTask = null; diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index e25b8c87a9b..8736b0585f1 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -840,6 +840,204 @@ describe('ReactFlight', () => { } }); + it('can transport AggregateError', async () => { + function renderError(error) { + if (!(error instanceof Error)) { + return `${JSON.stringify(error)}`; + } + let result = ` + is error: ${error instanceof AggregateError ? 'AggregateError' : 'Error'} + name: ${error.name} + message: ${error.message} + stack: ${normalizeCodeLocInfo(error.stack).split('\n').slice(0, 2).join('\n')} + environmentName: ${error.environmentName} + cause: ${'cause' in error ? renderError(error.cause) : 'no cause'}`; + if ('errors' in error) { + result += ` + errors: [${error.errors.map(e => renderError(e)).join(',\n')}]`; + } + return result; + } + function ComponentClient({error}) { + return renderError(error); + } + const Component = clientReference(ComponentClient); + + function ServerComponent() { + const error1 = new TypeError('first error'); + const error2 = new RangeError('second error'); + const error = new AggregateError([error1, error2], 'aggregate'); + return ; + } + + const transport = ReactNoopFlightServer.render(, { + onError(x) { + if (__DEV__) { + return 'a dev digest'; + } + return `digest("${x.message}")`; + }, + }); + + await act(() => { + ReactNoop.render(ReactNoopFlightClient.read(transport)); + }); + + if (__DEV__) { + expect(ReactNoop).toMatchRenderedOutput(` + is error: AggregateError + name: AggregateError + message: aggregate + stack: AggregateError: aggregate + in ServerComponent (at **) + environmentName: Server + cause: no cause + errors: [ + is error: Error + name: TypeError + message: first error + stack: TypeError: first error + in ServerComponent (at **) + environmentName: Server + cause: no cause, + + is error: Error + name: RangeError + message: second error + stack: RangeError: second error + in ServerComponent (at **) + environmentName: Server + cause: no cause]`); + } else { + expect(ReactNoop).toMatchRenderedOutput(` + is error: Error + name: Error + message: 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. + stack: 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. + environmentName: undefined + cause: no cause`); + } + }); + + it('includes AggregateError.errors in thrown errors', async () => { + function renderError(error) { + if (!(error instanceof Error)) { + return `${JSON.stringify(error)}`; + } + let result = ` + is error: ${error instanceof AggregateError ? 'AggregateError' : 'Error'} + name: ${error.name} + message: ${error.message} + stack: ${normalizeCodeLocInfo(error.stack).split('\n').slice(0, 2).join('\n')} + environmentName: ${error.environmentName} + cause: ${'cause' in error ? renderError(error.cause) : 'no cause'}`; + if ('errors' in error) { + result += ` + errors: [${error.errors.map(e => renderError(e)).join(',\n')}]`; + } + return result; + } + + function ServerComponent() { + const error1 = new TypeError('first error'); + const error2 = new RangeError('second error'); + const error3 = new Error('third error'); + const error4 = new Error('fourth error'); + const error5 = new Error('fifth error'); + const error6 = new Error('sixth error'); + const error = new AggregateError( + [error1, error2, error3, error4, error5, error6], + 'aggregate', + ); + throw error; + } + + const transport = ReactNoopFlightServer.render(, { + onError(x) { + if (__DEV__) { + return 'a dev digest'; + } + return `digest("${x.message}")`; + }, + }); + + let error; + try { + await act(() => { + ReactNoop.render(ReactNoopFlightClient.read(transport)); + }); + } catch (x) { + error = x; + } + + if (__DEV__) { + expect(renderError(error)).toEqual(` + is error: AggregateError + name: AggregateError + message: aggregate + stack: AggregateError: aggregate + in ServerComponent (at **) + environmentName: Server + cause: no cause + errors: [ + is error: Error + name: TypeError + message: first error + stack: TypeError: first error + in ServerComponent (at **) + environmentName: Server + cause: no cause, + + is error: Error + name: RangeError + message: second error + stack: RangeError: second error + in ServerComponent (at **) + environmentName: Server + cause: no cause, + + is error: Error + name: Error + message: third error + stack: Error: third error + in ServerComponent (at **) + environmentName: Server + cause: no cause, + + is error: Error + name: Error + message: fourth error + stack: Error: fourth error + in ServerComponent (at **) + environmentName: Server + cause: no cause, + + is error: Error + name: Error + message: fifth error + stack: Error: fifth error + in ServerComponent (at **) + environmentName: Server + cause: no cause, + + is error: Error + name: Error + message: sixth error + stack: Error: sixth error + in ServerComponent (at **) + environmentName: Server + cause: no cause]`); + } else { + expect(renderError(error)).toEqual(` + is error: Error + name: Error + message: 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. + stack: 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. + environmentName: undefined + cause: no cause`); + } + }); + it('can transport cyclic objects', async () => { function ComponentClient({prop}) { expect(prop.obj.obj.obj).toBe(prop.obj.obj); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 4c50f6a7d20..7f6376821b9 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -4169,6 +4169,14 @@ function serializeErrorValue(request: Request, error: Error): string { const causeId = outlineModel(request, cause); errorInfo.cause = serializeByValueID(causeId); } + if ( + typeof AggregateError !== 'undefined' && + error instanceof AggregateError + ) { + const errors: ReactClientValue = (error.errors: any); + const errorsId = outlineModel(request, errors); + errorInfo.errors = serializeByValueID(errorsId); + } const id = outlineModel(request, errorInfo); return '$Z' + id.toString(16); } else { @@ -4211,6 +4219,15 @@ function serializeDebugErrorValue( const causeId = outlineDebugModel(request, counter, cause); errorInfo.cause = serializeByValueID(causeId); } + if ( + typeof AggregateError !== 'undefined' && + error instanceof AggregateError + ) { + counter.objectLimit--; + const errors: ReactClientValue = (error.errors: any); + const errorsId = outlineDebugModel(request, counter, errors); + errorInfo.errors = serializeByValueID(errorsId); + } const id = outlineDebugModel( request, {objectLimit: stack.length * 2 + 1}, @@ -4240,6 +4257,7 @@ function emitErrorChunk( let stack: ReactStackTrace; let env = (0, request.environmentName)(); let causeReference: null | string = null; + let errorsReference: null | string = null; try { if (error instanceof Error) { name = error.name; @@ -4259,6 +4277,16 @@ function emitErrorChunk( : outlineModel(request, cause); causeReference = serializeByValueID(causeId); } + if ( + typeof AggregateError !== 'undefined' && + error instanceof AggregateError + ) { + const errors: ReactClientValue = (error.errors: any); + const errorsId = debug + ? outlineDebugModel(request, {objectLimit: 5}, errors) + : outlineModel(request, errors); + errorsReference = serializeByValueID(errorsId); + } } else if (typeof error === 'object' && error !== null) { message = describeObjectForErrorMessage(error); stack = []; @@ -4277,6 +4305,9 @@ function emitErrorChunk( if (causeReference !== null) { (errorInfo: ReactErrorInfoDev).cause = causeReference; } + if (errorsReference !== null) { + (errorInfo: ReactErrorInfoDev).errors = errorsReference; + } } else { errorInfo = {digest}; } diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index c8658278a5b..032d70c3ce3 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -244,6 +244,7 @@ export type ReactErrorInfoDev = { +env: string, +owner?: null | string, cause?: JSONValue, + errors?: JSONValue, }; export type ReactErrorInfo = ReactErrorInfoProd | ReactErrorInfoDev;