diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index d5c3fc7b5cf5..42f697c94a86 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -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): Iterator { // $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 () {}; } } diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 4c7aecc26955..5fb3c28600d6 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -3208,6 +3208,20 @@ 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, @@ -3215,6 +3229,8 @@ describe('ReactFlight', () => { 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 **)']); }); diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index c55e42cc9d50..eb64ce9d3de6 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -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; } diff --git a/packages/shared/ReactSerializationErrors.js b/packages/shared/ReactSerializationErrors.js index 6a571fd83257..36342412885a 100644 --- a/packages/shared/ReactSerializationErrors.js +++ b/packages/shared/ReactSerializationErrors.js @@ -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 {