@samitouri / QOS-React / commits / 2ba3065527

[Flight] Add support for transporting `Error.cause` (#35810)

Sebastian "Sebbie" Silbermann committed Feb 19, 2026 at 15:50 UTC 2ba3065527cbabc9778363e78a411653cd4cd215
4 files changed +185 -18
packages/react-client/src/ReactFlightClient.js
+14 -8
@@ -8,6 +8,7 @@
8 */
9
10 import type {
11 + JSONValue,
12 Thenable,
13 ReactDebugInfo,
14 ReactDebugInfoEntry,
@@ -132,14 +133,6 @@ interface FlightStreamController {
133
134 type UninitializedModel = string;
135
135 -export type JSONValue =
136 - | number
137 - | null
138 - | boolean
139 - | string
140 - | {+[key: string]: JSONValue}
141 - | $ReadOnlyArray<JSONValue>;
142 -
136 type ProfilingResult = {
137 track: number,
138 endTime: number,
@@ -3527,6 +3520,18 @@ function resolveErrorDev(
3520 }
3521
3522 let error;
3523 + const errorOptions =
3524 + 'cause' in errorInfo
3525 + ? {
3526 + cause: reviveModel(
3527 + response,
3528 + // $FlowFixMe[incompatible-cast] -- Flow thinks `cause` in `cause?: JSONValue` can be undefined after `in` check.
3529 + (errorInfo.cause: JSONValue),
3530 + errorInfo,
3531 + 'cause',
3532 + ),
3533 + }
3534 + : undefined;
3535 const callStack = buildFakeCallStack(
3536 response,
3537 stack,
@@ -3537,6 +3542,7 @@ function resolveErrorDev(
3542 null,
3543 message ||
3544 'An error occurred in the Server Components render but no message was provided',
3545 + errorOptions,
3546 ),
3547 );
3548
packages/react-client/src/__tests__/ReactFlight-test.js
+133
@@ -707,6 +707,139 @@ describe('ReactFlight', () => {
707 }
708 });
709
710 + it('can transport Error.cause', async () => {
711 + function renderError(error) {
712 + if (!(error instanceof Error)) {
713 + return `${JSON.stringify(error)}`;
714 + }
715 + return `
716 + is error: ${error instanceof Error}
717 + name: ${error.name}
718 + message: ${error.message}
719 + stack: ${normalizeCodeLocInfo(error.stack).split('\n').slice(0, 2).join('\n')}
720 + environmentName: ${error.environmentName}
721 + cause: ${'cause' in error ? renderError(error.cause) : 'no cause'}`;
722 + }
723 + function ComponentClient({error}) {
724 + return renderError(error);
725 + }
726 + const Component = clientReference(ComponentClient);
727 +
728 + function ServerComponent() {
729 + const cause = new TypeError('root cause', {
730 + cause: {type: 'object cause'},
731 + });
732 + const error = new Error('hello', {cause});
733 + return <Component error={error} />;
734 + }
735 +
736 + const transport = ReactNoopFlightServer.render(<ServerComponent />, {
737 + onError(x) {
738 + if (__DEV__) {
739 + return 'a dev digest';
740 + }
741 + return `digest("${x.message}")`;
742 + },
743 + });
744 +
745 + await act(() => {
746 + ReactNoop.render(ReactNoopFlightClient.read(transport));
747 + });
748 +
749 + if (__DEV__) {
750 + expect(ReactNoop).toMatchRenderedOutput(`
751 + is error: true
752 + name: Error
753 + message: hello
754 + stack: Error: hello
755 + in ServerComponent (at **)
756 + environmentName: Server
757 + cause:
758 + is error: true
759 + name: TypeError
760 + message: root cause
761 + stack: TypeError: root cause
762 + in ServerComponent (at **)
763 + environmentName: Server
764 + cause: {"type":"object cause"}`);
765 + } else {
766 + expect(ReactNoop).toMatchRenderedOutput(`
767 + is error: true
768 + name: Error
769 + 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.
770 + 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.
771 + environmentName: undefined
772 + cause: no cause`);
773 + }
774 + });
775 +
776 + it('includes Error.cause in thrown errors', async () => {
777 + function renderError(error) {
778 + if (!(error instanceof Error)) {
779 + return `${JSON.stringify(error)}`;
780 + }
781 + return `
782 + is error: true
783 + name: ${error.name}
784 + message: ${error.message}
785 + stack: ${normalizeCodeLocInfo(error.stack).split('\n').slice(0, 2).join('\n')}
786 + environmentName: ${error.environmentName}
787 + cause: ${'cause' in error ? renderError(error.cause) : 'no cause'}`;
788 + }
789 +
790 + function ServerComponent() {
791 + const cause = new TypeError('root cause', {
792 + cause: {type: 'object cause'},
793 + });
794 + const error = new Error('hello', {cause});
795 + throw error;
796 + }
797 +
798 + const transport = ReactNoopFlightServer.render(<ServerComponent />, {
799 + onError(x) {
800 + if (__DEV__) {
801 + return 'a dev digest';
802 + }
803 + return `digest("${x.message}")`;
804 + },
805 + });
806 +
807 + let error;
808 + try {
809 + await act(() => {
810 + ReactNoop.render(ReactNoopFlightClient.read(transport));
811 + });
812 + } catch (x) {
813 + error = x;
814 + }
815 +
816 + if (__DEV__) {
817 + expect(renderError(error)).toEqual(`
818 + is error: true
819 + name: Error
820 + message: hello
821 + stack: Error: hello
822 + in ServerComponent (at **)
823 + environmentName: Server
824 + cause:
825 + is error: true
826 + name: TypeError
827 + message: root cause
828 + stack: TypeError: root cause
829 + in ServerComponent (at **)
830 + environmentName: Server
831 + cause: {"type":"object cause"}`);
832 + } else {
833 + expect(renderError(error)).toEqual(`
834 + is error: true
835 + name: Error
836 + 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.
837 + 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.
838 + environmentName: undefined
839 + cause: no cause`);
840 + }
841 + });
842 +
843 it('can transport cyclic objects', async () => {
844 function ComponentClient({prop}) {
845 expect(prop.obj.obj.obj).toBe(prop.obj.obj);
packages/react-server/src/ReactFlightServer.js
+29 -10
@@ -467,14 +467,6 @@ function getCurrentStackInDEV(): string {
467
468 const ObjectPrototype = Object.prototype;
469
470 -type JSONValue =
471 - | string
472 - | boolean
473 - | number
474 - | null
475 - | {+[key: string]: JSONValue}
476 - | $ReadOnlyArray<JSONValue>;
477 -
470 const stringify = JSON.stringify;
471
472 type ReactJSONValue =
@@ -498,6 +490,7 @@ export type ReactClientValue =
490 | React$Element<string>
491 | React$Element<ClientReference<any> & any>
492 | ReactComponentInfo
493 + | ReactErrorInfo
494 | string
495 | boolean
496 | number
@@ -4171,6 +4164,11 @@ function serializeErrorValue(request: Request, error: Error): string {
4164 stack = [];
4165 }
4166 const errorInfo: ReactErrorInfoDev = {name, message, stack, env};
4167 + if ('cause' in error) {
4168 + const cause: ReactClientValue = (error.cause: any);
4169 + const causeId = outlineModel(request, cause);
4170 + errorInfo.cause = serializeByValueID(causeId);
4171 + }
4172 const id = outlineModel(request, errorInfo);
4173 return '$Z' + id.toString(16);
4174 } else {
@@ -4181,7 +4179,11 @@ function serializeErrorValue(request: Request, error: Error): string {
4179 }
4180 }
4181
4184 -function serializeDebugErrorValue(request: Request, error: Error): string {
4182 +function serializeDebugErrorValue(
4183 + request: Request,
4184 + counter: {objectLimit: number},
4185 + error: Error,
4186 +): string {
4187 if (__DEV__) {
4188 let name: string = 'Error';
4189 let message: string;
@@ -4203,6 +4205,12 @@ function serializeDebugErrorValue(request: Request, error: Error): string {
4205 stack = [];
4206 }
4207 const errorInfo: ReactErrorInfoDev = {name, message, stack, env};
4208 + if ('cause' in error) {
4209 + counter.objectLimit--;
4210 + const cause: ReactClientValue = (error.cause: any);
4211 + const causeId = outlineDebugModel(request, counter, cause);
4212 + errorInfo.cause = serializeByValueID(causeId);
4213 + }
4214 const id = outlineDebugModel(
4215 request,
4216 {objectLimit: stack.length * 2 + 1},
@@ -4231,6 +4239,7 @@ function emitErrorChunk(
4239 let message: string;
4240 let stack: ReactStackTrace;
4241 let env = (0, request.environmentName)();
4242 + let causeReference: null | string = null;
4243 try {
4244 if (error instanceof Error) {
4245 name = error.name;
@@ -4243,6 +4252,13 @@ function emitErrorChunk(
4252 // Keep the environment name.
4253 env = errorEnv;
4254 }
4255 + if ('cause' in error) {
4256 + const cause: ReactClientValue = (error.cause: any);
4257 + const causeId = debug
4258 + ? outlineDebugModel(request, {objectLimit: 5}, cause)
4259 + : outlineModel(request, cause);
4260 + causeReference = serializeByValueID(causeId);
4261 + }
4262 } else if (typeof error === 'object' && error !== null) {
4263 message = describeObjectForErrorMessage(error);
4264 stack = [];
@@ -4258,6 +4274,9 @@ function emitErrorChunk(
4274 const ownerRef =
4275 owner == null ? null : outlineComponentInfo(request, owner);
4276 errorInfo = {digest, name, message, stack, env, owner: ownerRef};
4277 + if (causeReference !== null) {
4278 + (errorInfo: ReactErrorInfoDev).cause = causeReference;
4279 + }
4280 } else {
4281 errorInfo = {digest};
4282 }
@@ -4969,7 +4988,7 @@ function renderDebugModel(
4988 return serializeDebugFormData(request, value);
4989 }
4990 if (value instanceof Error) {
4972 - return serializeDebugErrorValue(request, value);
4991 + return serializeDebugErrorValue(request, counter, value);
4992 }
4993 if (value instanceof ArrayBuffer) {
4994 return serializeDebugTypedArray(request, 'A', new Uint8Array(value));
packages/shared/ReactTypes.js
+9
@@ -228,6 +228,14 @@ export type ReactErrorInfoProd = {
228 +digest: string,
229 };
230
231 +export type JSONValue =
232 + | string
233 + | boolean
234 + | number
235 + | null
236 + | {+[key: string]: JSONValue}
237 + | $ReadOnlyArray<JSONValue>;
238 +
239 export type ReactErrorInfoDev = {
240 +digest?: string,
241 +name: string,
@@ -235,6 +243,7 @@ export type ReactErrorInfoDev = {
243 +stack: ReactStackTrace,
244 +env: string,
245 +owner?: null | string,
246 + cause?: JSONValue,
247 };
248
249 export type ReactErrorInfo = ReactErrorInfoProd | ReactErrorInfoDev;