[Flight] Support classes in renderDebugModel (#33590)

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.

---------

Co-authored-by: Hendrik Liebau <mail@hendrik-liebau.de>
This commit is contained in:
Sebastian Markbåge
2025-06-22 18:00:08 -04:00
committed by GitHub
co-authored by Hendrik Liebau
parent 1d1b26c701
commit 18ee505e77
4 changed files with 141 additions and 4 deletions
+56 -1
View File
@@ -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 () {};
}
}
+29
View File
@@ -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 **)']);
});
+42
View File
@@ -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;
}
+14 -3
View File
@@ -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 {