Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion packages/react-client/src/ReactFlightClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]();
Expand Down Expand Up @@ -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 () {};
}
}
Expand Down
29 changes: 29 additions & 0 deletions packages/react-client/src/__tests__/ReactFlight-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3208,13 +3208,29 @@ 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,
fn: foo,
map: new Map([['foo', foo]]),
promise: Promise.resolve('yo'),
infinitePromise: new Promise(() => {}),
Class: MyClass,
instance: new MyClass(),
});
throw new Error('err');
}
Expand Down Expand Up @@ -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 **)']);
});

Expand Down
42 changes: 42 additions & 0 deletions packages/react-server/src/ReactFlightServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ import {

import {
describeObjectForErrorMessage,
isGetter,
isSimpleObject,
jsxPropsParents,
jsxChildrenParents,
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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];
Copy link
Collaborator

@unstubbable unstubbable Jun 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit concerned that this might trigger unwanted side effects, like the getters on the "exotic" promises in Next.js.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, but for them to be discovered they have to be enumerable so the trick is to make them non-enumerable.

Getters are non-enumerable by default when defined by syntax and when added with defineProperty.

If anything the concerning bit might be the enumerability trap but that's a concern regardless.

}
}
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;
}
Expand Down
17 changes: 14 additions & 3 deletions packages/shared/ReactSerializationErrors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading