Repository: facebook/react
Pull request: [Flight] Support classes in renderDebugModel

This adds better support for serializing class instances as Debug values.

It adds a new marker on the object `{ "": "$P...", ... }` which indicates which constructor's prototype to use for this object's prototype. It doesn't encode arbitrary prototypes and it doesn't encode any of the properties on the prototype. It might get some of the properties from the prototype by virtue of `toString` on a `class` constructor will include the whole class's body.

This will ensure that the instance gets the right name in logs.

Additionally, this now also invokes getters if they're enumerable on the prototype. This lets us reify values that can only be read from native classes.

Changed files:
filename: packages/react-client/src/ReactFlightClient.js
patch: @@ -1430,6 +1430,17 @@ function createFormData(
   return formData;
 }
 
+function applyConstructor(
+  response: Response,
+  model: Function,
+  parentObject: Object,
+  key: string,
+): void {
+  Object.setPrototypeOf(parentObject, model.prototype);
+  // Delete the property. It was just a placeholder.
+  return undefined;
+}
+
 function extractIterator(response: Response, model: Array<any>): Iterator<any> {
   // $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array.
   return model[Symbol.iterator]();
@@ -1606,16 +1617,60 @@ function parseModelString(
         // BigInt
         return BigInt(value.slice(2));
       }
+      case 'P': {
+        if (__DEV__) {
+          // In DEV mode we allow debug objects to specify themselves as instances of
+          // another constructor.
+          const ref = value.slice(2);
+          return getOutlinedModel(
+            response,
+            ref,
+            parentObject,
+            key,
+            applyConstructor,
+          );
+        }
+        //Fallthrough
+      }
       case 'E': {
         if (__DEV__) {
           // In DEV mode we allow indirect eval to produce functions for logging.
           // This should not compile to eval() because then it has local scope access.
+          const code = value.slice(2);
           try {
             // eslint-disable-next-line no-eval
-            return (0, eval)(value.slice(2));
+            return (0, eval)(code);
           } catch (x) {
             // We currently use this to express functions so we fail parsing it,
             // let's just return a blank function as a place holder.
+            if (code.startsWith('(async function')) {
+              const idx = code.indexOf('(', 15);
+              if (idx !== -1) {
+                const name = code.slice(15, idx).trim();
+                // eslint-disable-next-line no-eval
+                return (0, eval)(
+                  '({' + JSON.stringify(name) + ':async function(){}})',
+                )[name];
+              }
+            } else if (code.startsWith('(function')) {
+              const idx = code.indexOf('(', 9);
+              if (idx !== -1) {
+                const name = code.slice(9, idx).trim();
+                // eslint-disable-next-line no-eval
+                return (0, eval)(
+                  '({' + JSON.stringify(name) + ':function(){}})',
+                )[name];
+              }
+            } else if (code.startsWith('(class')) {
+              const idx = code.indexOf('{', 6);
+              if (idx !== -1) {
+                const name = code.slice(6, idx).trim();
+                // eslint-disable-next-line no-eval
+                return (0, eval)('({' + JSON.stringify(name) + ':class{}})')[
+                  name
+                ];
+              }
+            }
             return function () {};
           }
         }

filename: packages/react-client/src/__tests__/ReactFlight-test.js
patch: @@ -3208,13 +3208,29 @@ describe('ReactFlight', () => {
       return 'hello';
     }
 
+    class MyClass {
+      constructor() {
+        this.x = 1;
+      }
+      method() {}
+      get y() {
+        return this.x + 1;
+      }
+      get z() {
+        return this.x + 5;
+      }
+    }
+    Object.defineProperty(MyClass.prototype, 'y', {enumerable: true});
+
     function ServerComponent() {
       console.log('hi', {
         prop: 123,
         fn: foo,
         map: new Map([['foo', foo]]),
         promise: Promise.resolve('yo'),
         infinitePromise: new Promise(() => {}),
+        Class: MyClass,
+        instance: new MyClass(),
       });
       throw new Error('err');
     }
@@ -3304,6 +3320,19 @@ describe('ReactFlight', () => {
     // This should not reject upon aborting the stream.
     expect(resolved).toBe(false);
 
+    const Class = mockConsoleLog.mock.calls[0][1].Class;
+    const instance = mockConsoleLog.mock.calls[0][1].instance;
+    expect(typeof Class).toBe('function');
+    expect(Class.prototype.constructor).toBe(Class);
+    expect(instance instanceof Class).toBe(true);
+    expect(Object.getPrototypeOf(instance)).toBe(Class.prototype);
+    expect(instance.x).toBe(1);
+    expect(instance.hasOwnProperty('y')).toBe(true);
+    expect(instance.y).toBe(2); // Enumerable getter was reified
+    expect(instance.hasOwnProperty('z')).toBe(false);
+    expect(instance.z).toBe(6); // Not enumerable getter was transferred as part of the toString() of the class
+    expect(typeof instance.method).toBe('function'); // Methods are included only if they're part of the toString()
+
     expect(ownerStacks).toEqual(['\n    in App (at **)']);
   });
 

filename: packages/react-server/src/ReactFlightServer.js
patch: @@ -139,6 +139,7 @@ import {
 
 import {
   describeObjectForErrorMessage,
+  isGetter,
   isSimpleObject,
   jsxPropsParents,
   jsxChildrenParents,
@@ -148,6 +149,7 @@ import {
 import ReactSharedInternals from './ReactSharedInternalsServer';
 import isArray from 'shared/isArray';
 import getPrototypeOf from 'shared/getPrototypeOf';
+import hasOwnProperty from 'shared/hasOwnProperty';
 import binaryToComparableString from 'shared/binaryToComparableString';
 
 import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable';
@@ -3906,6 +3908,8 @@ function serializeEval(source: string): string {
   return '$E' + source;
 }
 
+const CONSTRUCTOR_MARKER: symbol = __DEV__ ? Symbol() : (null: any);
+
 let debugModelRoot: mixed = null;
 let debugNoOutline: mixed = null;
 // This is a forked version of renderModel which should never error, never suspend and is limited
@@ -3941,6 +3945,16 @@ function renderDebugModel(
         (value: any),
       );
     }
+    if (value.$$typeof === CONSTRUCTOR_MARKER) {
+      const constructor: Function = (value: any).constructor;
+      let ref = request.writtenDebugObjects.get(constructor);
+      if (ref === undefined) {
+        const id = outlineDebugModel(request, counter, constructor);
+        ref = serializeByValueID(id);
+      }
+      return '$P' + ref.slice(1);
+    }
+
     if (request.temporaryReferences !== undefined) {
       const tempRef = resolveTemporaryReference(
         request.temporaryReferences,
@@ -4141,6 +4155,34 @@ function renderDebugModel(
       return Array.from((value: any));
     }
 
+    const proto = getPrototypeOf(value);
+    if (proto !== ObjectPrototype && proto !== null) {
+      const object: Object = value;
+      const instanceDescription: Object = Object.create(null);
+      for (const propName in object) {
+        if (hasOwnProperty.call(value, propName) || isGetter(proto, propName)) {
+          // We intentionally invoke getters on the prototype to read any enumerable getters.
+          instanceDescription[propName] = object[propName];
+        }
+      }
+      const constructor = proto.constructor;
+      if (
+        typeof constructor === 'function' &&
+        constructor.prototype === proto
+      ) {
+        // This is a simple class shape.
+        if (hasOwnProperty.call(object, '') || isGetter(proto, '')) {
+          // This object already has an empty property name. Skip encoding its prototype.
+        } else {
+          instanceDescription[''] = {
+            $$typeof: CONSTRUCTOR_MARKER,
+            constructor: constructor,
+          };
+        }
+      }
+      return instanceDescription;
+    }
+
     // $FlowFixMe[incompatible-return]
     return value;
   }

filename: packages/shared/ReactSerializationErrors.js
patch: @@ -51,6 +51,18 @@ function isObjectPrototype(object: any): boolean {
   return true;
 }
 
+export function isGetter(object: any, name: string): boolean {
+  const ObjectPrototype = Object.prototype;
+  if (object === ObjectPrototype || object === null) {
+    return false;
+  }
+  const descriptor = Object.getOwnPropertyDescriptor(object, name);
+  if (descriptor === undefined) {
+    return isGetter(getPrototypeOf(object), name);
+  }
+  return typeof descriptor.get === 'function';
+}
+
 export function isSimpleObject(object: any): boolean {
   if (!isObjectPrototype(getPrototypeOf(object))) {
     return false;
@@ -80,9 +92,8 @@ export function isSimpleObject(object: any): boolean {
 export function objectName(object: mixed): string {
   // $FlowFixMe[method-unbinding]
   const name = Object.prototype.toString.call(object);
-  return name.replace(/^\[object (.*)\]$/, function (m, p0) {
-    return p0;
-  });
+  // Extract 'Object' from '[object Object]':
+  return name.slice(8, name.length - 1);
 }
 
 function describeKeyForErrorMessage(key: string): string {