Repository: facebook/react
Pull request: [Flight] Add support for transporting `Error.cause`

## Summary

Adds support for transporting `Error.cause` (e.g. `new Error(message, { cause })`. React supports arbitrary values in `.cause` since `.cause` doesn't need to be an `Error` instance (see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause#description)

## How did you test this change?

- [x] Added test


Changed files:
filename: packages/react-client/src/ReactFlightClient.js
patch: @@ -8,6 +8,7 @@
  */
 
 import type {
+  JSONValue,
   Thenable,
   ReactDebugInfo,
   ReactDebugInfoEntry,
@@ -132,14 +133,6 @@ interface FlightStreamController {
 
 type UninitializedModel = string;
 
-export type JSONValue =
-  | number
-  | null
-  | boolean
-  | string
-  | {+[key: string]: JSONValue}
-  | $ReadOnlyArray<JSONValue>;
-
 type ProfilingResult = {
   track: number,
   endTime: number,
@@ -3527,6 +3520,18 @@ function resolveErrorDev(
   }
 
   let error;
+  const errorOptions =
+    'cause' in errorInfo
+      ? {
+          cause: reviveModel(
+            response,
+            // $FlowFixMe[incompatible-cast] -- Flow thinks `cause` in `cause?: JSONValue` can be undefined after `in` check.
+            (errorInfo.cause: JSONValue),
+            errorInfo,
+            'cause',
+          ),
+        }
+      : undefined;
   const callStack = buildFakeCallStack(
     response,
     stack,
@@ -3537,6 +3542,7 @@ function resolveErrorDev(
       null,
       message ||
         'An error occurred in the Server Components render but no message was provided',
+      errorOptions,
     ),
   );
 

filename: packages/react-client/src/__tests__/ReactFlight-test.js
patch: @@ -707,6 +707,139 @@ describe('ReactFlight', () => {
     }
   });
 
+  it('can transport Error.cause', async () => {
+    function renderError(error) {
+      if (!(error instanceof Error)) {
+        return `${JSON.stringify(error)}`;
+      }
+      return `
+        is error: ${error instanceof 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'}`;
+    }
+    function ComponentClient({error}) {
+      return renderError(error);
+    }
+    const Component = clientReference(ComponentClient);
+
+    function ServerComponent() {
+      const cause = new TypeError('root cause', {
+        cause: {type: 'object cause'},
+      });
+      const error = new Error('hello', {cause});
+      return <Component error={error} />;
+    }
+
+    const transport = ReactNoopFlightServer.render(<ServerComponent />, {
+      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: true
+        name: Error
+        message: hello
+        stack: Error: hello
+    in ServerComponent (at **)
+        environmentName: Server
+        cause: 
+        is error: true
+        name: TypeError
+        message: root cause
+        stack: TypeError: root cause
+    in ServerComponent (at **)
+        environmentName: Server
+        cause: {"type":"object cause"}`);
+    } else {
+      expect(ReactNoop).toMatchRenderedOutput(`
+        is error: true
+        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 Error.cause in thrown errors', async () => {
+    function renderError(error) {
+      if (!(error instanceof Error)) {
+        return `${JSON.stringify(error)}`;
+      }
+      return `
+        is error: true
+        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'}`;
+    }
+
+    function ServerComponent() {
+      const cause = new TypeError('root cause', {
+        cause: {type: 'object cause'},
+      });
+      const error = new Error('hello', {cause});
+      throw error;
+    }
+
+    const transport = ReactNoopFlightServer.render(<ServerComponent />, {
+      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: true
+        name: Error
+        message: hello
+        stack: Error: hello
+    in ServerComponent (at **)
+        environmentName: Server
+        cause: 
+        is error: true
+        name: TypeError
+        message: root cause
+        stack: TypeError: root cause
+    in ServerComponent (at **)
+        environmentName: Server
+        cause: {"type":"object cause"}`);
+    } else {
+      expect(renderError(error)).toEqual(`
+        is error: true
+        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);

filename: packages/react-server/src/ReactFlightServer.js
patch: @@ -467,14 +467,6 @@ function getCurrentStackInDEV(): string {
 
 const ObjectPrototype = Object.prototype;
 
-type JSONValue =
-  | string
-  | boolean
-  | number
-  | null
-  | {+[key: string]: JSONValue}
-  | $ReadOnlyArray<JSONValue>;
-
 const stringify = JSON.stringify;
 
 type ReactJSONValue =
@@ -498,6 +490,7 @@ export type ReactClientValue =
   | React$Element<string>
   | React$Element<ClientReference<any> & any>
   | ReactComponentInfo
+  | ReactErrorInfo
   | string
   | boolean
   | number
@@ -4171,6 +4164,11 @@ function serializeErrorValue(request: Request, error: Error): string {
       stack = [];
     }
     const errorInfo: ReactErrorInfoDev = {name, message, stack, env};
+    if ('cause' in error) {
+      const cause: ReactClientValue = (error.cause: any);
+      const causeId = outlineModel(request, cause);
+      errorInfo.cause = serializeByValueID(causeId);
+    }
     const id = outlineModel(request, errorInfo);
     return '$Z' + id.toString(16);
   } else {
@@ -4181,7 +4179,11 @@ function serializeErrorValue(request: Request, error: Error): string {
   }
 }
 
-function serializeDebugErrorValue(request: Request, error: Error): string {
+function serializeDebugErrorValue(
+  request: Request,
+  counter: {objectLimit: number},
+  error: Error,
+): string {
   if (__DEV__) {
     let name: string = 'Error';
     let message: string;
@@ -4203,6 +4205,12 @@ function serializeDebugErrorValue(request: Request, error: Error): string {
       stack = [];
     }
     const errorInfo: ReactErrorInfoDev = {name, message, stack, env};
+    if ('cause' in error) {
+      counter.objectLimit--;
+      const cause: ReactClientValue = (error.cause: any);
+      const causeId = outlineDebugModel(request, counter, cause);
+      errorInfo.cause = serializeByValueID(causeId);
+    }
     const id = outlineDebugModel(
       request,
       {objectLimit: stack.length * 2 + 1},
@@ -4231,6 +4239,7 @@ function emitErrorChunk(
     let message: string;
     let stack: ReactStackTrace;
     let env = (0, request.environmentName)();
+    let causeReference: null | string = null;
     try {
       if (error instanceof Error) {
         name = error.name;
@@ -4243,6 +4252,13 @@ function emitErrorChunk(
           // Keep the environment name.
           env = errorEnv;
         }
+        if ('cause' in error) {
+          const cause: ReactClientValue = (error.cause: any);
+          const causeId = debug
+            ? outlineDebugModel(request, {objectLimit: 5}, cause)
+            : outlineModel(request, cause);
+          causeReference = serializeByValueID(causeId);
+        }
       } else if (typeof error === 'object' && error !== null) {
         message = describeObjectForErrorMessage(error);
         stack = [];
@@ -4258,6 +4274,9 @@ function emitErrorChunk(
     const ownerRef =
       owner == null ? null : outlineComponentInfo(request, owner);
     errorInfo = {digest, name, message, stack, env, owner: ownerRef};
+    if (causeReference !== null) {
+      (errorInfo: ReactErrorInfoDev).cause = causeReference;
+    }
   } else {
     errorInfo = {digest};
   }
@@ -4969,7 +4988,7 @@ function renderDebugModel(
       return serializeDebugFormData(request, value);
     }
     if (value instanceof Error) {
-      return serializeDebugErrorValue(request, value);
+      return serializeDebugErrorValue(request, counter, value);
     }
     if (value instanceof ArrayBuffer) {
       return serializeDebugTypedArray(request, 'A', new Uint8Array(value));

filename: packages/shared/ReactTypes.js
patch: @@ -228,13 +228,22 @@ export type ReactErrorInfoProd = {
   +digest: string,
 };
 
+export type JSONValue =
+  | string
+  | boolean
+  | number
+  | null
+  | {+[key: string]: JSONValue}
+  | $ReadOnlyArray<JSONValue>;
+
 export type ReactErrorInfoDev = {
   +digest?: string,
   +name: string,
   +message: string,
   +stack: ReactStackTrace,
   +env: string,
   +owner?: null | string,
+  cause?: JSONValue,
 };
 
 export type ReactErrorInfo = ReactErrorInfoProd | ReactErrorInfoDev;