@samitouri / QOS-React-2 / commits / 18ee505e77

[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 {