Skip to content

Commit 18ee505

Browse files
[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 <[email protected]>
1 parent 1d1b26c commit 18ee505

File tree

4 files changed

+141
-4
lines changed

4 files changed

+141
-4
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1430,6 +1430,17 @@ function createFormData(
14301430
return formData;
14311431
}
14321432

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+
14331444
function extractIterator(response: Response, model: Array<any>): Iterator<any> {
14341445
// $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array.
14351446
return model[Symbol.iterator]();
@@ -1606,16 +1617,60 @@ function parseModelString(
16061617
// BigInt
16071618
return BigInt(value.slice(2));
16081619
}
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+
}
16091635
case 'E': {
16101636
if (__DEV__) {
16111637
// In DEV mode we allow indirect eval to produce functions for logging.
16121638
// This should not compile to eval() because then it has local scope access.
1639+
const code = value.slice(2);
16131640
try {
16141641
// eslint-disable-next-line no-eval
1615-
return (0, eval)(value.slice(2));
1642+
return (0, eval)(code);
16161643
} catch (x) {
16171644
// We currently use this to express functions so we fail parsing it,
16181645
// 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+
}
16191674
return function () {};
16201675
}
16211676
}

packages/react-client/src/__tests__/ReactFlight-test.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3208,13 +3208,29 @@ describe('ReactFlight', () => {
32083208
return 'hello';
32093209
}
32103210

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+
32113225
function ServerComponent() {
32123226
console.log('hi', {
32133227
prop: 123,
32143228
fn: foo,
32153229
map: new Map([['foo', foo]]),
32163230
promise: Promise.resolve('yo'),
32173231
infinitePromise: new Promise(() => {}),
3232+
Class: MyClass,
3233+
instance: new MyClass(),
32183234
});
32193235
throw new Error('err');
32203236
}
@@ -3304,6 +3320,19 @@ describe('ReactFlight', () => {
33043320
// This should not reject upon aborting the stream.
33053321
expect(resolved).toBe(false);
33063322

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+
33073336
expect(ownerStacks).toEqual(['\n in App (at **)']);
33083337
});
33093338

packages/react-server/src/ReactFlightServer.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ import {
139139

140140
import {
141141
describeObjectForErrorMessage,
142+
isGetter,
142143
isSimpleObject,
143144
jsxPropsParents,
144145
jsxChildrenParents,
@@ -148,6 +149,7 @@ import {
148149
import ReactSharedInternals from './ReactSharedInternalsServer';
149150
import isArray from 'shared/isArray';
150151
import getPrototypeOf from 'shared/getPrototypeOf';
152+
import hasOwnProperty from 'shared/hasOwnProperty';
151153
import binaryToComparableString from 'shared/binaryToComparableString';
152154

153155
import {SuspenseException, getSuspendedThenable} from './ReactFlightThenable';
@@ -3906,6 +3908,8 @@ function serializeEval(source: string): string {
39063908
return '$E' + source;
39073909
}
39083910

3911+
const CONSTRUCTOR_MARKER: symbol = __DEV__ ? Symbol() : (null: any);
3912+
39093913
let debugModelRoot: mixed = null;
39103914
let debugNoOutline: mixed = null;
39113915
// This is a forked version of renderModel which should never error, never suspend and is limited
@@ -3941,6 +3945,16 @@ function renderDebugModel(
39413945
(value: any),
39423946
);
39433947
}
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+
39443958
if (request.temporaryReferences !== undefined) {
39453959
const tempRef = resolveTemporaryReference(
39463960
request.temporaryReferences,
@@ -4141,6 +4155,34 @@ function renderDebugModel(
41414155
return Array.from((value: any));
41424156
}
41434157

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+
41444186
// $FlowFixMe[incompatible-return]
41454187
return value;
41464188
}

packages/shared/ReactSerializationErrors.js

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,18 @@ function isObjectPrototype(object: any): boolean {
5151
return true;
5252
}
5353

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+
5466
export function isSimpleObject(object: any): boolean {
5567
if (!isObjectPrototype(getPrototypeOf(object))) {
5668
return false;
@@ -80,9 +92,8 @@ export function isSimpleObject(object: any): boolean {
8092
export function objectName(object: mixed): string {
8193
// $FlowFixMe[method-unbinding]
8294
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);
8697
}
8798

8899
function describeKeyForErrorMessage(key: string): string {

0 commit comments

Comments
 (0)