[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>
Sebastian Markbåge committed
Jun 22, 2025 at 18:00 UTC
18ee505e7791f2bb55f0e520667c51588df7ba48
4 files changed
+141
-4
packages/react-client/src/ReactFlightClient.js
+56
-1
@@ -1430,6 +1430,17 @@ function createFormData(
1430
return formData;
1431
}
1432
1433
+function applyConstructor(
1434
+ response: Response,
1435
+ model: Function,
1436
+ parentObject: Object,
1437
+ key: string,
1438
+): void {
1439
+ Object.setPrototypeOf(parentObject, model.prototype);
1440
+ // Delete the property. It was just a placeholder.
1441
+ return undefined;
1442
+}
1443
+
1444
function extractIterator(response: Response, model: Array<any>): Iterator<any> {
1445
// $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array.
1446
return model[Symbol.iterator]();
@@ -1606,16 +1617,60 @@ function parseModelString(
1617
// BigInt
1618
return BigInt(value.slice(2));
1619
}
1620
+ case 'P': {
1621
+ if (__DEV__) {
1622
+ // In DEV mode we allow debug objects to specify themselves as instances of
1623
+ // another constructor.
1624
+ const ref = value.slice(2);
1625
+ return getOutlinedModel(
1626
+ response,
1627
+ ref,
1628
+ parentObject,
1629
+ key,
1630
+ applyConstructor,
1631
+ );
1632
+ }
1633
+ //Fallthrough
1634
+ }
1635
case 'E': {
1636
if (__DEV__) {
1637
// In DEV mode we allow indirect eval to produce functions for logging.
1638
// This should not compile to eval() because then it has local scope access.
1639
+ const code = value.slice(2);
1640
try {
1641
// eslint-disable-next-line no-eval
1615
- return (0, eval)(value.slice(2));
1642
+ return (0, eval)(code);
1643
} catch (x) {
1644
// We currently use this to express functions so we fail parsing it,
1645
// let's just return a blank function as a place holder.
1646
+ if (code.startsWith('(async function')) {
1647
+ const idx = code.indexOf('(', 15);
1648
+ if (idx !== -1) {
1649
+ const name = code.slice(15, idx).trim();
1650
+ // eslint-disable-next-line no-eval
1651
+ return (0, eval)(
1652
+ '({' + JSON.stringify(name) + ':async function(){}})',
1653
+ )[name];
1654
+ }
1655
+ } else if (code.startsWith('(function')) {
1656
+ const idx = code.indexOf('(', 9);
1657
+ if (idx !== -1) {
1658
+ const name = code.slice(9, idx).trim();
1659
+ // eslint-disable-next-line no-eval
1660
+ return (0, eval)(
1661
+ '({' + JSON.stringify(name) + ':function(){}})',
1662
+ )[name];
1663
+ }
1664
+ } else if (code.startsWith('(class')) {
1665
+ const idx = code.indexOf('{', 6);
1666
+ if (idx !== -1) {
1667
+ const name = code.slice(6, idx).trim();
1668
+ // eslint-disable-next-line no-eval
1669
+ return (0, eval)('({' + JSON.stringify(name) + ':class{}})')[
1670
+ name
1671
+ ];
1672
+ }
1673
+ }
1674
return function () {};
1675
}
1676
}
packages/react-client/src/__tests__/ReactFlight-test.js
+29
@@ -3208,6 +3208,20 @@ describe('ReactFlight', () => {
3208
return 'hello';
3209
}
3210
3211
+ class MyClass {
3212
+ constructor() {
3213
+ this.x = 1;
3214
+ }
3215
+ method() {}
3216
+ get y() {
3217
+ return this.x + 1;
3218
+ }
3219
+ get z() {
3220
+ return this.x + 5;
3221
+ }
3222
+ }
3223
+ Object.defineProperty(MyClass.prototype, 'y', {enumerable: true});
3224
+
3225
function ServerComponent() {
3226
console.log('hi', {
3227
prop: 123,
@@ -3215,6 +3229,8 @@ describe('ReactFlight', () => {
3229
map: new Map([['foo', foo]]),
3230
promise: Promise.resolve('yo'),
3231
infinitePromise: new Promise(() => {}),
3232
+ Class: MyClass,
3233
+ instance: new MyClass(),
3234
});
3235
throw new Error('err');
3236
}
@@ -3304,6 +3320,19 @@ describe('ReactFlight', () => {
3320
// This should not reject upon aborting the stream.
3321
expect(resolved).toBe(false);
3322
3323
+ const Class = mockConsoleLog.mock.calls[0][1].Class;
3324
+ const instance = mockConsoleLog.mock.calls[0][1].instance;
3325
+ expect(typeof Class).toBe('function');
3326
+ expect(Class.prototype.constructor).toBe(Class);
3327
+ expect(instance instanceof Class).toBe(true);
3328
+ expect(Object.getPrototypeOf(instance)).toBe(Class.prototype);
3329
+ expect(instance.x).toBe(1);
3330
+ expect(instance.hasOwnProperty('y')).toBe(true);
3331
+ expect(instance.y).toBe(2); // Enumerable getter was reified
3332
+ expect(instance.hasOwnProperty('z')).toBe(false);
3333
+ expect(instance.z).toBe(6); // Not enumerable getter was transferred as part of the toString() of the class
3334
+ expect(typeof instance.method).toBe('function'); // Methods are included only if they're part of the toString()
3335
+
3336
expect(ownerStacks).toEqual(['\n in App (at **)']);
3337
});
3338
packages/react-server/src/ReactFlightServer.js
+42
@@ -139,6 +139,7 @@ import {
139
140
import {
141
describeObjectForErrorMessage,
142
+ isGetter,
143
isSimpleObject,
144
jsxPropsParents,
145
jsxChildrenParents,
@@ -148,6 +149,7 @@ import {
149
import ReactSharedInternals from './ReactSharedInternalsServer';
150
import isArray from 'shared/isArray';
151
import getPrototypeOf from 'shared/getPrototypeOf';
152
+import hasOwnProperty from 'shared/hasOwnProperty';
153
import binaryToComparableString from 'shared/binaryToComparableString';
154
155
import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable';
@@ -3906,6 +3908,8 @@ function serializeEval(source: string): string {
3908
return '$E' + source;
3909
}
3910
3911
+const CONSTRUCTOR_MARKER: symbol = __DEV__ ? Symbol() : (null: any);
3912
+
3913
let debugModelRoot: mixed = null;
3914
let debugNoOutline: mixed = null;
3915
// This is a forked version of renderModel which should never error, never suspend and is limited
@@ -3941,6 +3945,16 @@ function renderDebugModel(
3945
(value: any),
3946
);
3947
}
3948
+ if (value.$$typeof === CONSTRUCTOR_MARKER) {
3949
+ const constructor: Function = (value: any).constructor;
3950
+ let ref = request.writtenDebugObjects.get(constructor);
3951
+ if (ref === undefined) {
3952
+ const id = outlineDebugModel(request, counter, constructor);
3953
+ ref = serializeByValueID(id);
3954
+ }
3955
+ return '$P' + ref.slice(1);
3956
+ }
3957
+
3958
if (request.temporaryReferences !== undefined) {
3959
const tempRef = resolveTemporaryReference(
3960
request.temporaryReferences,
@@ -4141,6 +4155,34 @@ function renderDebugModel(
4155
return Array.from((value: any));
4156
}
4157
4158
+ const proto = getPrototypeOf(value);
4159
+ if (proto !== ObjectPrototype && proto !== null) {
4160
+ const object: Object = value;
4161
+ const instanceDescription: Object = Object.create(null);
4162
+ for (const propName in object) {
4163
+ if (hasOwnProperty.call(value, propName) || isGetter(proto, propName)) {
4164
+ // We intentionally invoke getters on the prototype to read any enumerable getters.
4165
+ instanceDescription[propName] = object[propName];
4166
+ }
4167
+ }
4168
+ const constructor = proto.constructor;
4169
+ if (
4170
+ typeof constructor === 'function' &&
4171
+ constructor.prototype === proto
4172
+ ) {
4173
+ // This is a simple class shape.
4174
+ if (hasOwnProperty.call(object, '') || isGetter(proto, '')) {
4175
+ // This object already has an empty property name. Skip encoding its prototype.
4176
+ } else {
4177
+ instanceDescription[''] = {
4178
+ $$typeof: CONSTRUCTOR_MARKER,
4179
+ constructor: constructor,
4180
+ };
4181
+ }
4182
+ }
4183
+ return instanceDescription;
4184
+ }
4185
+
4186
// $FlowFixMe[incompatible-return]
4187
return value;
4188
}
packages/shared/ReactSerializationErrors.js
+14
-3
@@ -51,6 +51,18 @@ function isObjectPrototype(object: any): boolean {
51
return true;
52
}
53
54
+export function isGetter(object: any, name: string): boolean {
55
+ const ObjectPrototype = Object.prototype;
56
+ if (object === ObjectPrototype || object === null) {
57
+ return false;
58
+ }
59
+ const descriptor = Object.getOwnPropertyDescriptor(object, name);
60
+ if (descriptor === undefined) {
61
+ return isGetter(getPrototypeOf(object), name);
62
+ }
63
+ return typeof descriptor.get === 'function';
64
+}
65
+
66
export function isSimpleObject(object: any): boolean {
67
if (!isObjectPrototype(getPrototypeOf(object))) {
68
return false;
@@ -80,9 +92,8 @@ export function isSimpleObject(object: any): boolean {
92
export function objectName(object: mixed): string {
93
// $FlowFixMe[method-unbinding]
94
const name = Object.prototype.toString.call(object);
83
- return name.replace(/^\[object (.*)\]$/, function (m, p0) {
84
- return p0;
85
- });
95
+ // Extract 'Object' from '[object Object]':
96
+ return name.slice(8, name.length - 1);
97
}
98
99
function describeKeyForErrorMessage(key: string): string {